mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-01-19 00:27:25 +01:00
Compare commits
316 Commits
http-respo
...
utf8-heade
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35eac5b9ad | ||
|
|
6b1f72fec9 | ||
|
|
824ec39d46 | ||
|
|
cfa8d92af1 | ||
|
|
91d2603fe0 | ||
|
|
6be95f8285 | ||
|
|
4783cb1211 | ||
|
|
113ff55426 | ||
|
|
f2f4bbdbd5 | ||
|
|
d931ce8acc | ||
|
|
b1c0d57fb9 | ||
|
|
b3d11f09ba | ||
|
|
1ccf659781 | ||
|
|
3ad639daed | ||
|
|
dc5dbdf6e5 | ||
|
|
e3998d5fce | ||
|
|
8ad1089053 | ||
|
|
1a6b076e87 | ||
|
|
9db9678952 | ||
|
|
037d1d647d | ||
|
|
cb9be5b732 | ||
|
|
99b9792875 | ||
|
|
9471429cb3 | ||
|
|
ea538338cf | ||
|
|
5825f20e98 | ||
|
|
35ad4a0c03 | ||
|
|
b5b4997957 | ||
|
|
69dcc380a3 | ||
|
|
8e04eeaacd | ||
|
|
c63ca95867 | ||
|
|
d6c0ae130f | ||
|
|
e1339ccde7 | ||
|
|
7c1d892779 | ||
|
|
5f2e238a30 | ||
|
|
f69065ca79 | ||
|
|
1c731a3cef | ||
|
|
6cd72683ad | ||
|
|
e86bdf46db | ||
|
|
0adbd87387 | ||
|
|
286ae43d1a | ||
|
|
a75fb08ef1 | ||
|
|
58a0c2a6c6 | ||
|
|
d050956007 | ||
|
|
bdae48afba | ||
|
|
cb5c4c5483 | ||
|
|
e91f07a081 | ||
|
|
7d96be6fb3 | ||
|
|
46c798c71a | ||
|
|
037a51a9d0 | ||
|
|
4596e4bcab | ||
|
|
9b30ada880 | ||
|
|
96d711e19e | ||
|
|
5af5565fb1 | ||
|
|
29c9551548 | ||
|
|
23c5d4e345 | ||
|
|
ff5bf4acd0 | ||
|
|
34c42c55f6 | ||
|
|
07e5b28868 | ||
|
|
06a0654a5a | ||
|
|
8cc23117fe | ||
|
|
f8c4f20a8f | ||
|
|
8053e992e4 | ||
|
|
9db96140e2 | ||
|
|
502d0a0abd | ||
|
|
80b0a94f7e | ||
|
|
338cab1660 | ||
|
|
b8836d674a | ||
|
|
c6a96d19e2 | ||
|
|
bcb24aecd3 | ||
|
|
d72ae47d1f | ||
|
|
a5d2fc172b | ||
|
|
bbab81a1a2 | ||
|
|
78a1ca81e3 | ||
|
|
f090d1313e | ||
|
|
afa4efa140 | ||
|
|
d2b88005f0 | ||
|
|
9eb1f6a186 | ||
|
|
2d8d5b3b95 | ||
|
|
844f4a3931 | ||
|
|
8aaec62d7f | ||
|
|
d97c3d2afc | ||
|
|
29ddd2a4b5 | ||
|
|
73069ae9a0 | ||
|
|
05d7c65e42 | ||
|
|
d11d7b13e6 | ||
|
|
14285a95e5 | ||
|
|
c3ec809727 | ||
|
|
e72a2703db | ||
|
|
e20fd0f84f | ||
|
|
6989643a49 | ||
|
|
ca9fed7b67 | ||
|
|
358b344916 | ||
|
|
b51294dc2c | ||
|
|
bb3fe4f830 | ||
|
|
84d5fde24b | ||
|
|
fe731d43cd | ||
|
|
835dad9eba | ||
|
|
77eb898528 | ||
|
|
ad9f8a5400 | ||
|
|
ceba7503a4 | ||
|
|
754b456320 | ||
|
|
6903e1677d | ||
|
|
8de26a7fdf | ||
|
|
6d672a7a71 | ||
|
|
d7b7bea701 | ||
|
|
b1916b5066 | ||
|
|
13a90172c2 | ||
|
|
394bca0ca6 | ||
|
|
c2af85b894 | ||
|
|
8ebc70261f | ||
|
|
390e8d18c7 | ||
|
|
284d992fb8 | ||
|
|
e808cace29 | ||
|
|
762dc8449c | ||
|
|
385bb5634d | ||
|
|
1aaa82b631 | ||
|
|
e0bc2f13f0 | ||
|
|
6ab974e50f | ||
|
|
75217bf61b | ||
|
|
2ee2395bd0 | ||
|
|
db7baf73c0 | ||
|
|
c6bfdd45be | ||
|
|
f953302c27 | ||
|
|
b69b4490bb | ||
|
|
92d9c28a70 | ||
|
|
fd6e470f3c | ||
|
|
6f312dad07 | ||
|
|
bd2dc5376c | ||
|
|
823963b934 | ||
|
|
d30c5acf0d | ||
|
|
961b62ad87 | ||
|
|
3f0cc828f2 | ||
|
|
394a30784b | ||
|
|
d887e41cf7 | ||
|
|
2565802721 | ||
|
|
d4a044366d | ||
|
|
9370acbcfe | ||
|
|
e5e8003ee0 | ||
|
|
3777feae8f | ||
|
|
2783a52cad | ||
|
|
3f754f2d02 | ||
|
|
ee97e1110d | ||
|
|
758eb3f371 | ||
|
|
1797dec2ba | ||
|
|
25be5b47e4 | ||
|
|
bc0e72e3ef | ||
|
|
0b854286f5 | ||
|
|
e633a40ef1 | ||
|
|
fc75937072 | ||
|
|
5e0d8ab9f8 | ||
|
|
323ce6274a | ||
|
|
79281fdd21 | ||
|
|
e7d58ccdf2 | ||
|
|
0328ba2a32 | ||
|
|
477c9d3ed5 | ||
|
|
e44f0ef6e7 | ||
|
|
6f4b260035 | ||
|
|
bb7a751e58 | ||
|
|
97c9266cc8 | ||
|
|
a139a3df89 | ||
|
|
346d8d7967 | ||
|
|
3eeeac2c13 | ||
|
|
94f6d2d5b5 | ||
|
|
1c4420bca8 | ||
|
|
ecff7258ba | ||
|
|
72d4f67524 | ||
|
|
1ce92714c4 | ||
|
|
1c6c2cf332 | ||
|
|
9d42ee9391 | ||
|
|
b62204054f | ||
|
|
166dc6b4fa | ||
|
|
02a1e99db2 | ||
|
|
250637cf92 | ||
|
|
b46de7402d | ||
|
|
9334a94886 | ||
|
|
9b9aa4306a | ||
|
|
90db1283dd | ||
|
|
8cc00a6ac6 | ||
|
|
315034c8cd | ||
|
|
23ac9d44a1 | ||
|
|
70db2f994c | ||
|
|
64b3c3c2fa | ||
|
|
983afb2b45 | ||
|
|
4d22ccc7f6 | ||
|
|
cd3429842b | ||
|
|
d89df315e4 | ||
|
|
fe3a225f8f | ||
|
|
f862341997 | ||
|
|
8ca08ce868 | ||
|
|
ba46630138 | ||
|
|
a3087047b6 | ||
|
|
217ca81b17 | ||
|
|
7edcebad1f | ||
|
|
0af3e29ce1 | ||
|
|
dd6462de13 | ||
|
|
52f18d048c | ||
|
|
c522ee1dd8 | ||
|
|
33e3f7ae46 | ||
|
|
87f9f88e32 | ||
|
|
0fe1e109ed | ||
|
|
90b04417cf | ||
|
|
221004af39 | ||
|
|
c3f6077f95 | ||
|
|
4f9227f100 | ||
|
|
ae6f649a06 | ||
|
|
26f9eddfc4 | ||
|
|
00879d11d3 | ||
|
|
f1bcc26cfe | ||
|
|
0967414f79 | ||
|
|
f4772b0c75 | ||
|
|
8215b66db3 | ||
|
|
d0a98afc49 | ||
|
|
da3a5681d9 | ||
|
|
f7f343fe55 | ||
|
|
0606fbe60a | ||
|
|
b2bedafae7 | ||
|
|
c108e8d856 | ||
|
|
5b5509d07c | ||
|
|
0d7aba9487 | ||
|
|
fbbfa2bbc1 | ||
|
|
2f5cfab01c | ||
|
|
70cd267ff5 | ||
|
|
d5052d79e6 | ||
|
|
a372eb99b7 | ||
|
|
199933b752 | ||
|
|
45928ddc47 | ||
|
|
bfc3983d06 | ||
|
|
2329695a47 | ||
|
|
ab1dbb04bd | ||
|
|
1fe19e41fb | ||
|
|
a47ac2a5b5 | ||
|
|
8eae44ea61 | ||
|
|
57e1104afb | ||
|
|
ede957973b | ||
|
|
697c09e146 | ||
|
|
ab59d81d08 | ||
|
|
c8d3b665f5 | ||
|
|
422ad0cc5d | ||
|
|
0c3d832c5f | ||
|
|
483410c4a2 | ||
|
|
bdeec4d297 | ||
|
|
21b27b5dbe | ||
|
|
29340e7e24 | ||
|
|
4ab450309f | ||
|
|
2ac63c4327 | ||
|
|
c31b9236a1 | ||
|
|
1da4187405 | ||
|
|
41282e2c73 | ||
|
|
3d40acc26b | ||
|
|
f7ed0eb4e7 | ||
|
|
9eadaf4c3a | ||
|
|
ce7d447f16 | ||
|
|
ef9d6d9f6c | ||
|
|
0e4044b747 | ||
|
|
bc3d897d7a | ||
|
|
1655f584f9 | ||
|
|
07afaf961d | ||
|
|
2b2a1eca9c | ||
|
|
3dd964f42c | ||
|
|
44aa7f4053 | ||
|
|
965fc2016d | ||
|
|
fd470702ab | ||
|
|
d17d86da95 | ||
|
|
f8a70c6025 | ||
|
|
587cc48b24 | ||
|
|
0c430c37bc | ||
|
|
273b911ccf | ||
|
|
a51228b374 | ||
|
|
568b336913 | ||
|
|
ab5fc36fb7 | ||
|
|
ff78ecc195 | ||
|
|
bf2acbf617 | ||
|
|
f18b98d75b | ||
|
|
16c5c74923 | ||
|
|
3586fc90ca | ||
|
|
67b45455b8 | ||
|
|
d92d1ad974 | ||
|
|
0177016fbc | ||
|
|
36685e9df9 | ||
|
|
61f403bff4 | ||
|
|
83d7dd99e8 | ||
|
|
224eae2d2d | ||
|
|
cf6997797e | ||
|
|
33e75375fd | ||
|
|
b0540c1162 | ||
|
|
4093a8ea5b | ||
|
|
e892b994c3 | ||
|
|
5f75e98861 | ||
|
|
e9b05e8ed7 | ||
|
|
1edcc239e5 | ||
|
|
61d09cf033 | ||
|
|
227ea8ecc5 | ||
|
|
7e4fb3caed | ||
|
|
152dfbbb54 | ||
|
|
c3f29bdc41 | ||
|
|
fb727fc84a | ||
|
|
9377c265a8 | ||
|
|
59b59fda98 | ||
|
|
96439ac41f | ||
|
|
c9a5d00b89 | ||
|
|
9efc1ec4f6 | ||
|
|
85fc16b016 | ||
|
|
5287fa1c94 | ||
|
|
1c54be3581 | ||
|
|
484fd91452 | ||
|
|
9ff3bb0c87 | ||
|
|
38e7801b41 | ||
|
|
0e1082b09c | ||
|
|
a95d1f9200 | ||
|
|
b9badee6db | ||
|
|
c6b64df662 | ||
|
|
7c5b9c0e62 | ||
|
|
6bfe4a9779 | ||
|
|
fb2fa4c478 | ||
|
|
28b654ae27 | ||
|
|
d686e1ee77 |
26
.github/ISSUE_TEMPLATE/1_bug_report.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/1_bug_report.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
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
Normal file
26
.github/ISSUE_TEMPLATE/2_enhancement_request.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
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
Normal file
21
.github/ISSUE_TEMPLATE/3_tech_support.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
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
Normal file
21
.github/ISSUE_TEMPLATE/4_question.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
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 :) -->
|
||||
4
.github/workflows/build.yaml
vendored
4
.github/workflows/build.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '17'
|
||||
node-version: '18'
|
||||
-
|
||||
name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
~/go/bin
|
||||
~/.npm
|
||||
web/node_modules
|
||||
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
||||
key: ${{ runner.os }}-ntfy-${{ hashFiles('go.sum', 'web/package.lock') }}
|
||||
restore-keys: ${{ runner.os }}-ntfy-
|
||||
-
|
||||
name: Install dependencies
|
||||
|
||||
4
.github/workflows/release.yaml
vendored
4
.github/workflows/release.yaml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '17'
|
||||
node-version: '18'
|
||||
-
|
||||
name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
~/go/bin
|
||||
~/.npm
|
||||
web/node_modules
|
||||
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
||||
key: ${{ runner.os }}-ntfy-${{ hashFiles('go.sum', 'web/package.lock') }}
|
||||
restore-keys: ${{ runner.os }}-ntfy-
|
||||
-
|
||||
name: Docker login
|
||||
|
||||
4
.github/workflows/test.yaml
vendored
4
.github/workflows/test.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '17'
|
||||
node-version: '18'
|
||||
-
|
||||
name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
~/go/bin
|
||||
~/.npm
|
||||
web/node_modules
|
||||
key: ${{ runner.os }}-ntfy-${{ hashFiles('**/go.sum', '**/package.lock') }}
|
||||
key: ${{ runner.os }}-ntfy-${{ hashFiles('go.sum', 'web/package.lock') }}
|
||||
restore-keys: ${{ runner.os }}-ntfy-
|
||||
-
|
||||
name: Install dependencies
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ secrets/
|
||||
*.iml
|
||||
node_modules/
|
||||
.DS_Store
|
||||
__pycache__
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,9 +1,15 @@
|
||||
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
|
||||
|
||||
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
|
||||
ENTRYPOINT ["ntfy"]
|
||||
|
||||
22
Makefile
22
Makefile
@@ -141,25 +141,25 @@ web-deps-update:
|
||||
# Main server/client build
|
||||
|
||||
cli: cli-deps
|
||||
goreleaser build --snapshot --rm-dist
|
||||
goreleaser build --snapshot --clean
|
||||
|
||||
cli-linux-amd64: cli-deps-static-sites
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_linux_amd64
|
||||
goreleaser build --snapshot --clean --id ntfy_linux_amd64
|
||||
|
||||
cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_linux_armv6
|
||||
goreleaser build --snapshot --clean --id ntfy_linux_armv6
|
||||
|
||||
cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_linux_armv7
|
||||
goreleaser build --snapshot --clean --id ntfy_linux_armv7
|
||||
|
||||
cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_linux_arm64
|
||||
goreleaser build --snapshot --clean --id ntfy_linux_arm64
|
||||
|
||||
cli-windows-amd64: cli-deps-static-sites
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_windows_amd64
|
||||
goreleaser build --snapshot --clean --id ntfy_windows_amd64
|
||||
|
||||
cli-darwin-all: cli-deps-static-sites
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_darwin_all
|
||||
goreleaser build --snapshot --clean --id ntfy_darwin_all
|
||||
|
||||
cli-linux-server: cli-deps-static-sites
|
||||
# This is a target to build the CLI (including the server) manually.
|
||||
@@ -277,11 +277,11 @@ staticcheck: .PHONY
|
||||
|
||||
# Releasing targets
|
||||
|
||||
release: clean update cli-deps release-checks docs web check
|
||||
goreleaser release --rm-dist
|
||||
release: clean cli-deps release-checks docs web check
|
||||
goreleaser release --clean
|
||||
|
||||
release-snapshot: clean update cli-deps docs web check
|
||||
goreleaser release --snapshot --skip-publish --rm-dist
|
||||
release-snapshot: clean cli-deps docs web check
|
||||
goreleaser release --snapshot --skip-publish --clean
|
||||
|
||||
release-checks:
|
||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
||||
|
||||
22
README.md
22
README.md
@@ -13,11 +13,14 @@
|
||||
[](https://ntfy.statuspage.io/)
|
||||
[](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
|
||||
|
||||
**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
|
||||
It allows you to **send notifications to your phone or desktop via scripts** from any computer, entirely **without signup or cost**.
|
||||
It's also open source (as you can plainly see) if you want to run your own.
|
||||
**ntfy** (pronounced "*notify*") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern)
|
||||
notification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer,
|
||||
**without having to sign up or pay any fees**. If you'd like to run your own instance of the service, you can easily do
|
||||
so since ntfy is open source.
|
||||
|
||||
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)).
|
||||
You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There is also an [open source Android app](https://github.com/binwiederhier/ntfy-android)
|
||||
available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/),
|
||||
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>
|
||||
<img src="web/public/static/img/screenshot-curl.png" height="180">
|
||||
@@ -117,6 +120,17 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
|
||||
<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>
|
||||
|
||||
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:
|
||||
|
||||
10
SECURITY.md
Normal file
10
SECURITY.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# 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`).
|
||||
@@ -5,10 +5,12 @@
|
||||
#
|
||||
# default-host: https://ntfy.sh
|
||||
|
||||
# Default username and password will be used with "ntfy publish" if no credentials are provided on command line
|
||||
# Default username and password will be used with "ntfy subscribe" if no credentials are provided in subscription below
|
||||
# For an empty password, use empty double-quotes ("")
|
||||
#
|
||||
# Default credentials will be used with "ntfy publish" and "ntfy subscribe" if no other credentials are provided.
|
||||
# You can set a default token to use or a default user:password combination, but not both. For an empty password,
|
||||
# use empty double-quotes ("")
|
||||
|
||||
# default-token:
|
||||
|
||||
# default-user:
|
||||
# default-password:
|
||||
|
||||
@@ -30,6 +32,8 @@
|
||||
# command: 'notify-send "$m"'
|
||||
# user: phill
|
||||
# password: mypass
|
||||
# - topic: token_topic
|
||||
# token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
#
|
||||
# Variables:
|
||||
# Variable Aliases Description
|
||||
|
||||
@@ -12,17 +12,22 @@ const (
|
||||
|
||||
// Config is the config struct for a Client
|
||||
type Config struct {
|
||||
DefaultHost string `yaml:"default-host"`
|
||||
DefaultUser string `yaml:"default-user"`
|
||||
DefaultPassword *string `yaml:"default-password"`
|
||||
DefaultCommand string `yaml:"default-command"`
|
||||
Subscribe []struct {
|
||||
Topic string `yaml:"topic"`
|
||||
User string `yaml:"user"`
|
||||
Password *string `yaml:"password"`
|
||||
Command string `yaml:"command"`
|
||||
If map[string]string `yaml:"if"`
|
||||
} `yaml:"subscribe"`
|
||||
DefaultHost string `yaml:"default-host"`
|
||||
DefaultUser string `yaml:"default-user"`
|
||||
DefaultPassword *string `yaml:"default-password"`
|
||||
DefaultToken string `yaml:"default-token"`
|
||||
DefaultCommand string `yaml:"default-command"`
|
||||
Subscribe []Subscribe `yaml:"subscribe"`
|
||||
}
|
||||
|
||||
// Subscribe is the struct for a Subscription within Config
|
||||
type Subscribe struct {
|
||||
Topic string `yaml:"topic"`
|
||||
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
|
||||
@@ -31,6 +36,7 @@ func NewConfig() *Config {
|
||||
DefaultHost: DefaultBaseURL,
|
||||
DefaultUser: "",
|
||||
DefaultPassword: nil,
|
||||
DefaultToken: "",
|
||||
DefaultCommand: "",
|
||||
Subscribe: nil,
|
||||
}
|
||||
|
||||
@@ -116,3 +116,25 @@ subscribe:
|
||||
require.Equal(t, "phil", conf.Subscribe[0].User)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ var flagsPublish = append(
|
||||
&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-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"},
|
||||
)
|
||||
|
||||
@@ -155,8 +154,7 @@ func execPublish(c *cli.Context) error {
|
||||
}
|
||||
if token != "" {
|
||||
options = append(options, client.WithBearerAuth(token))
|
||||
}
|
||||
if user != "" {
|
||||
} else if user != "" {
|
||||
var pass string
|
||||
parts := strings.SplitN(user, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
@@ -172,6 +170,8 @@ func execPublish(c *cli.Context) error {
|
||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
||||
}
|
||||
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 {
|
||||
options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/test"
|
||||
"heckel.io/ntfy/util"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -130,11 +133,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())
|
||||
|
||||
// Tests with NTFY_TOPIC set ////
|
||||
require.Nil(t, os.Setenv("NTFY_TOPIC", topic))
|
||||
t.Setenv("NTFY_TOPIC", topic)
|
||||
|
||||
// Test: Successful command with NTFY_TOPIC
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--cmd", "echo", "hi there"}))
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--cmd", "echo", "hi there"}))
|
||||
m = toMessage(t, stdout.String())
|
||||
require.Equal(t, "mytopic", m.Topic)
|
||||
|
||||
@@ -143,7 +146,155 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
|
||||
require.Nil(t, sleep.Start())
|
||||
go sleep.Wait() // Must be called to release resources
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--wait-pid", strconv.Itoa(sleep.Process.Pid)}))
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid)}))
|
||||
m = toMessage(t, stdout.String())
|
||||
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())
|
||||
}
|
||||
|
||||
24
cmd/serve.go
24
cmd/serve.go
@@ -37,8 +37,8 @@ var flagsServe = append(
|
||||
append([]cli.Flag{}, flagsDefault...),
|
||||
&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: "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 to as HTTPS 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 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-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.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"}),
|
||||
@@ -81,9 +81,14 @@ 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-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.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.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: "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{
|
||||
@@ -148,6 +153,7 @@ func execServe(c *cli.Context) error {
|
||||
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
|
||||
totalTopicLimit := c.Int("global-topic-limit")
|
||||
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
||||
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
|
||||
visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit")
|
||||
visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
|
||||
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
||||
@@ -159,6 +165,10 @@ func execServe(c *cli.Context) error {
|
||||
behindProxy := c.Bool("behind-proxy")
|
||||
stripeSecretKey := c.String("stripe-secret-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
|
||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||
@@ -175,8 +185,8 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("if set, certificate file must exist")
|
||||
} else if listenHTTPS != "" && (keyFile == "" || certFile == "") {
|
||||
return errors.New("if listen-https is set, both key-file and cert-file must be set")
|
||||
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderUser == "" || smtpSenderPass == "" || smtpSenderFrom == "") {
|
||||
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 smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") {
|
||||
return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set")
|
||||
} else if smtpServerListen != "" && smtpServerDomain == "" {
|
||||
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
|
||||
} else if attachmentCacheDir != "" && baseURL == "" {
|
||||
@@ -250,6 +260,7 @@ func execServe(c *cli.Context) error {
|
||||
|
||||
// Stripe things
|
||||
if stripeSecretKey != "" {
|
||||
stripe.EnableTelemetry = false // Whoa!
|
||||
stripe.Key = stripeSecretKey
|
||||
}
|
||||
|
||||
@@ -301,13 +312,18 @@ func execServe(c *cli.Context) error {
|
||||
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
|
||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
|
||||
conf.BehindProxy = behindProxy
|
||||
conf.StripeSecretKey = stripeSecretKey
|
||||
conf.StripeWebhookKey = stripeWebhookKey
|
||||
conf.BillingContact = billingContact
|
||||
conf.EnableWeb = enableWeb
|
||||
conf.EnableSignup = enableSignup
|
||||
conf.EnableLogin = enableLogin
|
||||
conf.EnableReservations = enableReservations
|
||||
conf.EnableMetrics = enableMetrics
|
||||
conf.MetricsListenHTTP = metricsListenHTTP
|
||||
conf.ProfileListenHTTP = profileListenHTTP
|
||||
conf.Version = c.App.Version
|
||||
|
||||
// Set up hot-reloading of config
|
||||
|
||||
@@ -30,6 +30,7 @@ var flagsSubscribe = append(
|
||||
&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: "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: "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"},
|
||||
@@ -97,11 +98,18 @@ func execSubscribe(c *cli.Context) error {
|
||||
cl := client.New(conf)
|
||||
since := c.String("since")
|
||||
user := c.String("user")
|
||||
token := c.String("token")
|
||||
poll := c.Bool("poll")
|
||||
scheduled := c.Bool("scheduled")
|
||||
fromConfig := c.Bool("from-config")
|
||||
topic := c.Args().Get(0)
|
||||
command := c.Args().Get(1)
|
||||
|
||||
// Checks
|
||||
if user != "" && token != "" {
|
||||
return errors.New("cannot set both --user and --token")
|
||||
}
|
||||
|
||||
if !fromConfig {
|
||||
conf.Subscribe = nil // wipe if --from-config not passed
|
||||
}
|
||||
@@ -109,7 +117,9 @@ func execSubscribe(c *cli.Context) error {
|
||||
if since != "" {
|
||||
options = append(options, client.WithSince(since))
|
||||
}
|
||||
if user != "" {
|
||||
if token != "" {
|
||||
options = append(options, client.WithBearerAuth(token))
|
||||
} else if user != "" {
|
||||
var pass string
|
||||
parts := strings.SplitN(user, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
@@ -125,9 +135,10 @@ func execSubscribe(c *cli.Context) error {
|
||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
||||
}
|
||||
options = append(options, client.WithBasicAuth(user, pass))
|
||||
}
|
||||
if poll {
|
||||
options = append(options, client.WithPoll())
|
||||
} else if conf.DefaultToken != "" {
|
||||
options = append(options, client.WithBearerAuth(conf.DefaultToken))
|
||||
} else if conf.DefaultUser != "" && conf.DefaultPassword != nil {
|
||||
options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))
|
||||
}
|
||||
if scheduled {
|
||||
options = append(options, client.WithScheduled())
|
||||
@@ -145,6 +156,9 @@ func execSubscribe(c *cli.Context) 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
|
||||
if auth := maybeAddAuthHeader(s, conf); auth != nil {
|
||||
options = append(options, auth)
|
||||
}
|
||||
if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -175,21 +189,11 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
|
||||
for filter, value := range s.If {
|
||||
topicOptions = append(topicOptions, client.WithFilter(filter, value))
|
||||
}
|
||||
var user string
|
||||
var password *string
|
||||
if s.User != "" {
|
||||
user = s.User
|
||||
} else if conf.DefaultUser != "" {
|
||||
user = conf.DefaultUser
|
||||
}
|
||||
if s.Password != nil {
|
||||
password = s.Password
|
||||
} else if conf.DefaultPassword != nil {
|
||||
password = conf.DefaultPassword
|
||||
}
|
||||
if user != "" && password != nil {
|
||||
topicOptions = append(topicOptions, client.WithBasicAuth(user, *password))
|
||||
|
||||
if auth := maybeAddAuthHeader(s, conf); auth != nil {
|
||||
topicOptions = append(topicOptions, auth)
|
||||
}
|
||||
|
||||
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
|
||||
if s.Command != "" {
|
||||
cmds[subscriptionID] = s.Command
|
||||
@@ -214,6 +218,25 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
|
||||
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) {
|
||||
if command != "" {
|
||||
runCommand(c, command, m)
|
||||
|
||||
361
cmd/subscribe_test.go
Normal file
361
cmd/subscribe_test.go
Normal file
@@ -0,0 +1,361 @@
|
||||
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()))
|
||||
}
|
||||
71
cmd/tier.go
71
cmd/tier.go
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/user"
|
||||
"heckel.io/ntfy/util"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -17,12 +16,12 @@ func init() {
|
||||
|
||||
const (
|
||||
defaultMessageLimit = 5000
|
||||
defaultMessageExpiryDuration = 12 * time.Hour
|
||||
defaultMessageExpiryDuration = "12h"
|
||||
defaultEmailLimit = 20
|
||||
defaultReservationLimit = 3
|
||||
defaultAttachmentFileSizeLimit = "15M"
|
||||
defaultAttachmentTotalSizeLimit = "100M"
|
||||
defaultAttachmentExpiryDuration = 6 * time.Hour
|
||||
defaultAttachmentExpiryDuration = "6h"
|
||||
defaultAttachmentBandwidthLimit = "1G"
|
||||
)
|
||||
|
||||
@@ -47,14 +46,15 @@ var cmdTier = &cli.Command{
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "name", Usage: "tier name"},
|
||||
&cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"},
|
||||
&cli.DurationFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
|
||||
&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: "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.DurationFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"},
|
||||
&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-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
&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.
|
||||
@@ -89,14 +89,15 @@ Examples:
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "name", Usage: "tier name"},
|
||||
&cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"},
|
||||
&cli.DurationFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
|
||||
&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: "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.DurationFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"},
|
||||
&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-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
&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.
|
||||
|
||||
@@ -110,7 +111,8 @@ 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-price-id=price_1234 \
|
||||
--stripe-monthly-price-id=price_1234 \
|
||||
--stripe-monthly-price-id=price_5678 \
|
||||
pro
|
||||
`,
|
||||
},
|
||||
@@ -166,6 +168,10 @@ func execTierAdd(c *cli.Context) error {
|
||||
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 {
|
||||
@@ -182,6 +188,10 @@ func execTierAdd(c *cli.Context) error {
|
||||
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
|
||||
@@ -194,19 +204,24 @@ func execTierAdd(c *cli.Context) error {
|
||||
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: c.Duration("message-expiry-duration"),
|
||||
MessageExpiryDuration: messageExpiryDuration,
|
||||
EmailLimit: c.Int64("email-limit"),
|
||||
ReservationLimit: c.Int64("reservation-limit"),
|
||||
AttachmentFileSizeLimit: attachmentFileSizeLimit,
|
||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit,
|
||||
AttachmentExpiryDuration: c.Duration("attachment-expiry-duration"),
|
||||
AttachmentExpiryDuration: attachmentExpiryDuration,
|
||||
AttachmentBandwidthLimit: attachmentBandwidthLimit,
|
||||
StripePriceID: c.String("stripe-price-id"),
|
||||
StripeMonthlyPriceID: c.String("stripe-monthly-price-id"),
|
||||
StripeYearlyPriceID: c.String("stripe-yearly-price-id"),
|
||||
}
|
||||
if err := manager.AddTier(tier); err != nil {
|
||||
return err
|
||||
@@ -244,7 +259,10 @@ func execTierChange(c *cli.Context) error {
|
||||
tier.MessageLimit = c.Int64("message-limit")
|
||||
}
|
||||
if c.IsSet("message-expiry-duration") {
|
||||
tier.MessageExpiryDuration = c.Duration("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")
|
||||
@@ -265,7 +283,10 @@ func execTierChange(c *cli.Context) error {
|
||||
}
|
||||
}
|
||||
if c.IsSet("attachment-expiry-duration") {
|
||||
tier.AttachmentExpiryDuration = c.Duration("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"))
|
||||
@@ -273,8 +294,16 @@ func execTierChange(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IsSet("stripe-price-id") {
|
||||
tier.StripePriceID = c.String("stripe-price-id")
|
||||
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
|
||||
@@ -319,9 +348,9 @@ func execTierList(c *cli.Context) error {
|
||||
}
|
||||
|
||||
func printTier(c *cli.Context, tier *user.Tier) {
|
||||
stripePriceID := tier.StripePriceID
|
||||
if stripePriceID == "" {
|
||||
stripePriceID = "(none)"
|
||||
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)
|
||||
@@ -333,5 +362,5 @@ func printTier(c *cli.Context, tier *user.Tier) {
|
||||
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 price: %s\n", stripePriceID)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices)
|
||||
}
|
||||
|
||||
@@ -29,24 +29,25 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "change",
|
||||
"--message-limit=999",
|
||||
"--message-expiry-duration=99h",
|
||||
"--message-expiry-duration=2d",
|
||||
"--email-limit=91",
|
||||
"--reservation-limit=98",
|
||||
"--attachment-file-size-limit=100m",
|
||||
"--attachment-expiry-duration=7h",
|
||||
"--attachment-expiry-duration=1d",
|
||||
"--attachment-total-size-limit=10G",
|
||||
"--attachment-bandwidth-limit=100G",
|
||||
"--stripe-price-id=price_991",
|
||||
"--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: 99h")
|
||||
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: 7h")
|
||||
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 price: price_991")
|
||||
require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "remove", "pro"))
|
||||
|
||||
50
docs/_overrides/main.html
Normal file
50
docs/_overrides/main.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% 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 it 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>
|
||||
<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 %}
|
||||
@@ -839,6 +839,8 @@ config options:
|
||||
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
|
||||
@@ -849,6 +851,7 @@ Here's an example:
|
||||
``` yaml
|
||||
stripe-secret-key: "sk_test_ZmhzZGtmbGhkc2tqZmhzYcO2a2hmbGtnaHNkbGtnaGRsc2hnbG"
|
||||
stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR"
|
||||
billing-contact: "phil@example.com"
|
||||
```
|
||||
|
||||
## Rate limiting
|
||||
@@ -929,6 +932,25 @@ 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
|
||||
```
|
||||
|
||||
### 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
|
||||
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**.
|
||||
@@ -1067,6 +1089,60 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
|
||||
maxretry = 10
|
||||
```
|
||||
|
||||
## Health checks
|
||||
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.
|
||||
|
||||
@@ -1178,12 +1254,14 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `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-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) |
|
||||
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up 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_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-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 *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
||||
@@ -1267,6 +1345,7 @@ OPTIONS:
|
||||
--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)
|
||||
```
|
||||
|
||||
|
||||
3636
docs/emojis.md
3636
docs/emojis.md
File diff suppressed because it is too large
Load Diff
@@ -572,4 +572,27 @@ Example `template.html`:
|
||||
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>
|
||||
```
|
||||
|
||||
@@ -43,9 +43,9 @@ of the app and [self-host your own ntfy server](install.md).
|
||||
## 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,
|
||||
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), 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). There has been a ton of testing and improvement around this. I think it's pretty
|
||||
decent now.
|
||||
or you use *instant delivery* (Android only), or install from F-droid ([which does not support FCM](https://f-droid.org/docs/Inclusion_Policy/)),
|
||||
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).
|
||||
There has been a ton of testing and improvement around this. I think it's pretty 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
|
||||
|
||||
6
docs/hooks.py
Normal file
6
docs/hooks.py
Normal file
@@ -0,0 +1,6 @@
|
||||
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'))
|
||||
@@ -26,37 +26,37 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_2.0.0_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_2.0.0_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_2.3.1_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_2.3.1_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.3.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.0.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.0.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.3.1_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.3.1_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.3.1_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.0.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.0.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.3.1_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.3.1_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.3.1_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.0.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.0.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.3.1_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.3.1_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.3.1_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -106,7 +106,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -114,7 +114,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -122,7 +122,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -130,7 +130,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -140,28 +140,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -189,30 +189,36 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
|
||||
|
||||
## macOS
|
||||
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.0.0/ntfy_2.0.0_macOS_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_macOS_all.tar.gz),
|
||||
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
|
||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||
|
||||
```bash
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_macOS_all.tar.gz > ntfy_2.0.0_macOS_all.tar.gz
|
||||
tar zxvf ntfy_2.0.0_macOS_all.tar.gz
|
||||
sudo cp -a ntfy_2.0.0_macOS_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_macOS_all.tar.gz > ntfy_2.3.1_macOS_all.tar.gz
|
||||
tar zxvf ntfy_2.3.1_macOS_all.tar.gz
|
||||
sudo cp -a ntfy_2.3.1_macOS_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.0.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.3.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
!!! info
|
||||
There is a [GitHub issue](https://github.com/binwiederhier/ntfy/issues/286) about making ntfy installable via
|
||||
[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.
|
||||
Check out the [build instructions](develop.md) for details.
|
||||
Only the ntfy CLI is supported on macOS. ntfy server is currently not supported, but you can build and run it for
|
||||
development as well. Check out the [build instructions](develop.md) for details.
|
||||
|
||||
## Homebrew
|
||||
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
|
||||
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.0.0/ntfy_2.0.0_windows_x86_64.zip),
|
||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_windows_x86_64.zip),
|
||||
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).
|
||||
@@ -266,7 +272,7 @@ docker run \
|
||||
serve
|
||||
```
|
||||
|
||||
Using docker-compose with non-root user:
|
||||
Using docker-compose with non-root user and healthchecks enabled:
|
||||
```yaml
|
||||
version: "2.1"
|
||||
|
||||
@@ -284,6 +290,12 @@ services:
|
||||
- /etc/ntfy:/etc/ntfy
|
||||
ports:
|
||||
- 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
|
||||
```
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ ntfy community. Thanks to everyone running a public server. **You guys rock!**
|
||||
| [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**.
|
||||
@@ -33,8 +35,12 @@ and uptime of third party servers, so use of each server is **at your own discre
|
||||
- [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
|
||||
- [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
|
||||
- [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
|
||||
|
||||
## [UnifiedPush](https://unifiedpush.org/users/apps/) integrations
|
||||
|
||||
@@ -58,6 +64,7 @@ and uptime of third party servers, so use of each server is **at your own discre
|
||||
- [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](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
|
||||
|
||||
@@ -73,6 +80,7 @@ and uptime of third party servers, so use of each server is **at your own discre
|
||||
|
||||
- [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-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)
|
||||
@@ -82,6 +90,7 @@ and uptime of third party servers, so use of each server is **at your own discre
|
||||
- [ntfy-server-status](https://github.com/filip2cz/ntfy-server-status) - Checking if server is online and reporting through ntfy (C)
|
||||
- [borg-based backup](https://github.com/davidhi7/backup) - Simple borg-based backup script with notifications based on ntfy.sh or Discord webhooks (Python/Shell)
|
||||
- [ntfy.sh *arr script](https://github.com/agent-squirrel/nfty-arr-script) - Quick and hacky script to get sonarr/radarr to notify the ntfy.sh service (Shell)
|
||||
- [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)
|
||||
- [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)
|
||||
@@ -111,9 +120,25 @@ and uptime of third party servers, so use of each server is **at your own discre
|
||||
- [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
|
||||
- [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)
|
||||
|
||||
## Blog + forum posts
|
||||
|
||||
- [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
|
||||
@@ -122,10 +147,12 @@ and uptime of third party servers, so use of each server is **at your own discre
|
||||
- [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 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
|
||||
- [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
|
||||
- [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
|
||||
- [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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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 now clear to me what is causing it.
|
||||
clueless on how to fix it, sadly, as it is ephemeral and not clear to me what is causing it.
|
||||
|
||||
Please send experienced iOS developers my way to help me figure this out.
|
||||
|
||||
|
||||
553
docs/publish.md
553
docs/publish.md
@@ -38,7 +38,12 @@ Here's an example showing how to publish a simple message using a POST request:
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
Invoke-RestMethod -Method 'Post' -Uri https://ntfy.sh/mytopic -Body "Backup successful" -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/mytopic"
|
||||
Body = "Backup successful"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -124,12 +129,17 @@ a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/phil_alerts"
|
||||
$headers = @{ Title="Unauthorized access detected"
|
||||
Priority="urgent"
|
||||
Tags="warning,skull" }
|
||||
$body = "Remote access to phils-laptop detected. Act right away."
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/phil_alerts"
|
||||
Headers = @{
|
||||
Title = "Unauthorized access detected"
|
||||
Priority = "urgent"
|
||||
Tags = "warning,skull"
|
||||
}
|
||||
Body = "Remote access to phils-laptop detected. Act right away."
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -242,18 +252,21 @@ an [external image attachment](#attach-file-from-a-url) and [email publishing](#
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/mydoorbell"
|
||||
$headers = @{ Click="https://home.nest.com/"
|
||||
Attach="https://nest.com/view/yAxkasd.jpg"
|
||||
Actions="http, Open door, https://api.nest.com/open/yAxkasd, clear=true"
|
||||
Email="phil@example.com" }
|
||||
$body = @'
|
||||
There's someone at the door. 🐶
|
||||
|
||||
Please check if it's a good boy or a hooman.
|
||||
Doggies have been known to ring the doorbell.
|
||||
'@
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/mydoorbell"
|
||||
Headers = @{
|
||||
Click = "https://home.nest.com"
|
||||
Attach = "https://nest.com/view/yAxksd.jpg"
|
||||
Actions = "http, Open door, https://api.nest.com/open/yAxkasd, clear=true"
|
||||
Email = "phil@example.com"
|
||||
}
|
||||
Body = "There's someone at the door. 🐶`n
|
||||
`n
|
||||
Please check if it's a good boy or a hooman.`n
|
||||
Doggies have been known to ring the doorbell.`n"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -342,10 +355,15 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/controversial"
|
||||
$headers = @{ Title="Dogs are better than cats" }
|
||||
$body = "Oh my ..."
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/controversial"
|
||||
Headers = @{
|
||||
Title = "Dogs are better than cats"
|
||||
}
|
||||
Body = "Oh my ..."
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -373,6 +391,12 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`).
|
||||
<figcaption>Detail view of notification with title</figcaption>
|
||||
</figure>
|
||||
|
||||
!!! info
|
||||
ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).
|
||||
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the `X-Title` or `X-Message`
|
||||
header as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
|
||||
or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
|
||||
|
||||
## Message priority
|
||||
_Supported on:_ :material-android: :material-apple: :material-firefox:
|
||||
|
||||
@@ -432,10 +456,14 @@ You can set the priority with the header `X-Priority` (or any of its aliases: `P
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/phil_alerts"
|
||||
$headers = @{ Priority="5" }
|
||||
$body = "An urgent message"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
$Request = @{
|
||||
URI = "https://ntfy.sh/phil_alerts"
|
||||
Headers = @{
|
||||
Priority = "5"
|
||||
}
|
||||
Body = "An urgent message"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -553,10 +581,15 @@ them with a comma, e.g. `tag1,tag2,tag3`.
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/backups"
|
||||
$headers = @{ Tags="warning,mailsrv13,daily-backup" }
|
||||
$body = "Backup of mailsrv13 failed"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/backups"
|
||||
Headers = @{
|
||||
Tags = "warning,mailsrv13,daily-backup"
|
||||
}
|
||||
Body = "Backup of mailsrv13 failed"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -645,10 +678,15 @@ to be delivered in 3 days, it'll remain in the cache for 3 days and 12 hours. Al
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/hello"
|
||||
$headers = @{ At="tomorrow, 10am" }
|
||||
$body = "Good morning"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/hello"
|
||||
Headers = @{
|
||||
At = "tomorrow, 10am"
|
||||
}
|
||||
Body = "Good morning"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -729,7 +767,7 @@ For instance, assuming your topic is `mywebhook`, you can simply call `/mywebhoo
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
Invoke-RestMethod -Method 'Get' -Uri "ntfy.sh/mywebhook/trigger"
|
||||
Invoke-RestMethod "ntfy.sh/mywebhook/trigger"
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -778,7 +816,7 @@ Here's an example with a custom message, tags and a priority:
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
Invoke-RestMethod -Method 'Get' -Uri "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull"
|
||||
Invoke-RestMethod "ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull"
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -883,25 +921,29 @@ is the only required one:
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh"
|
||||
$body = @{
|
||||
topic = "mytopic"
|
||||
title = "Low disk space alert"
|
||||
message = "Disk space is low at 5.1 GB"
|
||||
priority = 4
|
||||
attach = "https://filesrv.lan/space.jpg"
|
||||
filename = "diskspace.jpg"
|
||||
tags = @("warning", "cd")
|
||||
click = "https://homecamera.lan/xasds1h2xsSsa/"
|
||||
actions = @(
|
||||
@{
|
||||
action = "view"
|
||||
label = "Admin panel"
|
||||
url = "https://filesrv.lan/admin"
|
||||
}
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh"
|
||||
Body = @{
|
||||
Topic = "mytopic"
|
||||
Title = "Low disk space alert"
|
||||
Message = "Disk space is low at 5.1 GB"
|
||||
Priority = 4
|
||||
Attach = "https://filesrv.lan/space.jpg"
|
||||
FileName = "diskspace.jpg"
|
||||
Tags = @("warning", "cd")
|
||||
Click = "https://homecamera.lan/xasds1h2xsSsa/"
|
||||
Actions = ConvertTo-JSON @(
|
||||
@{
|
||||
Action = "view"
|
||||
Label = "Admin panel"
|
||||
URL = "https://filesrv.lan/admin"
|
||||
}
|
||||
)
|
||||
} | ConvertTo-Json
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
|
||||
}
|
||||
ContentType = "application/json"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -1061,10 +1103,15 @@ As an example, here's how you can create the above notification using this forma
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/myhome"
|
||||
$headers = @{ Actions="view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'" }
|
||||
$body = "You left the house. Turn down the A/C?"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/myhome"
|
||||
Headers = @{
|
||||
Actions="view, Open portal, https://home.nest.com/, clear=true; http, Turn down, https://api.nest.com/, body='{\"temperature\": 65}'"
|
||||
}
|
||||
Body = "You left the house. Turn down the A/C?"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -1214,26 +1261,30 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh"
|
||||
$body = @{
|
||||
topic = "myhome"
|
||||
message = "You left the house. Turn down the A/C?"
|
||||
actions = @(
|
||||
@{
|
||||
action = "view"
|
||||
label = "Open portal"
|
||||
url = "https://home.nest.com/"
|
||||
clear = $true
|
||||
},
|
||||
@{
|
||||
action = "http"
|
||||
label = "Turn down"
|
||||
url = "https://api.nest.com/"
|
||||
body = '{"temperature": 65}'
|
||||
}
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh"
|
||||
Body = ConvertTo-JSON @{
|
||||
Topic = "myhome"
|
||||
Message = "You left the house. Turn down the A/C?"
|
||||
Actions = @(
|
||||
@{
|
||||
Action = "view"
|
||||
Label = "Open portal"
|
||||
URL = "https://home.nest.com/"
|
||||
Clear = $true
|
||||
},
|
||||
@{
|
||||
Action = "http"
|
||||
Label = "Turn down"
|
||||
URL = "https://api.nest.com/"
|
||||
Body = '{"temperature": 65}'
|
||||
}
|
||||
)
|
||||
} | ConvertTo-Json
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
|
||||
}
|
||||
ContentType = "application/json"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -1292,7 +1343,7 @@ Alternatively, the same actions can be defined as **JSON array**, if the notific
|
||||
```
|
||||
|
||||
The required/optional fields for each action depend on the type of the action itself. Please refer to
|
||||
[`view` action](#open-websiteapp), [`broadcasst` action](#send-android-broadcast), and [`http` action](#send-http-request)
|
||||
[`view` action](#open-websiteapp), [`broadcast` action](#send-android-broadcast), and [`http` action](#send-http-request)
|
||||
for details.
|
||||
|
||||
### Open website/app
|
||||
@@ -1358,10 +1409,15 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/myhome"
|
||||
$headers = @{ Actions="view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392" }
|
||||
$body = "Somebody retweeted your tweet."
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/myhome"
|
||||
Headers = @{
|
||||
Actions = "view, Open Twitter, https://twitter.com/binwiederhier/status/1467633927951163392"
|
||||
}
|
||||
Body = "Somebody retweeted your tweet."
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -1474,19 +1530,23 @@ And the same example using [JSON publishing](#publish-as-json):
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh"
|
||||
$body = @{
|
||||
topic = "myhome"
|
||||
message = "Somebody retweeted your tweet."
|
||||
actions = @(
|
||||
@{
|
||||
"action"="view"
|
||||
"label"="Open Twitter"
|
||||
"url"="https://twitter.com/binwiederhier/status/1467633927951163392"
|
||||
}
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh"
|
||||
Body = ConvertTo-JSON @{
|
||||
Topic = "myhome"
|
||||
Message = "Somebody retweeted your tweet."
|
||||
Actions = @(
|
||||
@{
|
||||
Action = "view"
|
||||
Label = "Open Twitter"
|
||||
URL = "https://twitter.com/binwiederhier/status/1467633927951163392"
|
||||
}
|
||||
)
|
||||
} | ConvertTo-Json
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
|
||||
}
|
||||
ContentType = "application/json"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -1600,10 +1660,15 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/wifey"
|
||||
$headers = @{ Actions="broadcast, Take picture, extras.cmd=pic, extras.camera=front" }
|
||||
$body = "Your wife requested you send a picture of yourself."
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/wifey"
|
||||
Headers = @{
|
||||
Actions = "broadcast, Take picture, extras.cmd=pic, extras.camera=front"
|
||||
}
|
||||
Body = "Your wife requested you send a picture of yourself."
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -1733,23 +1798,26 @@ And the same example using [JSON publishing](#publish-as-json):
|
||||
``` powershell
|
||||
# Powershell requires the 'Depth' argument to equal 3 here to expand 'Extras',
|
||||
# otherwise it will read System.Collections.Hashtable in the returned JSON
|
||||
|
||||
$uri = "https://ntfy.sh"
|
||||
$body = @{
|
||||
topic = "wifey"
|
||||
message = "Your wife requested you send a picture of yourself."
|
||||
actions = @(
|
||||
@{
|
||||
action = "broadcast"
|
||||
label = "Take picture"
|
||||
extras = @{
|
||||
cmd ="pic"
|
||||
camera = "front"
|
||||
}
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh"
|
||||
Body = @{
|
||||
Topic = "wifey"
|
||||
Message = "Your wife requested you send a picture of yourself."
|
||||
Actions = ConvertTo-Json -Depth 3 @(
|
||||
@{
|
||||
Action = "broadcast"
|
||||
Label = "Take picture"
|
||||
Extras = @{
|
||||
CMD ="pic"
|
||||
Camera = "front"
|
||||
}
|
||||
}
|
||||
)
|
||||
} | ConvertTo-Json -Depth 3
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
|
||||
}
|
||||
ContentType = "application/json"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -1861,10 +1929,15 @@ Here's an example using the [`X-Actions` header](#using-a-header):
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/myhome"
|
||||
$headers = @{ Actions="http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}" }
|
||||
$body = "Garage door has been open for 15 minutes. Close it?"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/myhome"
|
||||
Headers = @{
|
||||
Actions="http, Close door, https://api.mygarage.lan/, method=PUT, headers.Authorization=Bearer zAzsx1sk.., body={\"action\": \"close\"}"
|
||||
}
|
||||
Body = "Garage door has been open for 15 minutes. Close it?"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -2005,24 +2078,28 @@ And the same example using [JSON publishing](#publish-as-json):
|
||||
# Powershell requires the 'Depth' argument to equal 3 here to expand 'headers',
|
||||
# otherwise it will read System.Collections.Hashtable in the returned JSON
|
||||
|
||||
$uri = "https://ntfy.sh"
|
||||
$body = @{
|
||||
topic = "myhome"
|
||||
message = "Garage door has been open for 15 minutes. Close it?"
|
||||
actions = @(
|
||||
@{
|
||||
action = "http"
|
||||
label = "Close door"
|
||||
url = "https://api.mygarage.lan/"
|
||||
method = "PUT"
|
||||
headers = @{
|
||||
Authorization = "Bearer zAzsx1sk.."
|
||||
}
|
||||
body = '{"action": "close"}'
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh"
|
||||
Body = @{
|
||||
Topic = "myhome"
|
||||
Message = "Garage door has been open for 15 minutes. Close it?"
|
||||
Actions = ConvertTo-Json -Depth 3 @(
|
||||
@{
|
||||
Action = "http"
|
||||
Label = "Close door"
|
||||
URL = "https://api.mygarage.lan/"
|
||||
Method = "PUT"
|
||||
Headers = @{
|
||||
Authorization = "Bearer zAzsx1sk.."
|
||||
}
|
||||
Body = ConvertTo-JSON @{Action = "close"}
|
||||
}
|
||||
)
|
||||
} | ConvertTo-Json -Depth 3
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -ContentType "application/json" -UseBasicParsing
|
||||
}
|
||||
ContentType = "application/json"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -2149,10 +2226,13 @@ Here's an example that will open Reddit when the notification is clicked:
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/reddit_alerts"
|
||||
$headers = @{ Click="https://www.reddit.com/message/messages" }
|
||||
$body = "New messages on Reddit"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/reddit_alerts"
|
||||
Headers = @{ Click="https://www.reddit.com/message/messages" }
|
||||
Body = "New messages on Reddit"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -2321,9 +2401,12 @@ Here's an example showing how to attach an APK file:
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/mydownloads"
|
||||
$headers = @{ Attach="https://f-droid.org/F-Droid.apk" }
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/mydownloads"
|
||||
Headers = @{ Attach="https://f-droid.org/F-Droid.apk" }
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -2414,12 +2497,17 @@ Here's an example showing how to include an icon:
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/tvshows"
|
||||
$headers = @{ Title"="Kodi: Resuming Playback"
|
||||
Tags="arrow_forward"
|
||||
Icon="https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png" }
|
||||
$body = "The Wire, S01E01"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Headers $headers -Body $body -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/tvshows"
|
||||
Headers = @{
|
||||
Title = "Kodi: Resuming Playback"
|
||||
Tags = "arrow_forward"
|
||||
Icon = "https://styles.redditmedia.com/t5_32uhe/styles/communityIcon_xnt6chtnr2j21.png"
|
||||
}
|
||||
Body = "The Wire, S01E01"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -2525,13 +2613,18 @@ that, your IP address appears in the e-mail body. This is to prevent abuse.
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/alerts"
|
||||
$headers = @{ Title"="Low disk space alert"
|
||||
Priority="high"
|
||||
Tags="warning,skull,backup-host,ssh-login")
|
||||
Email="phil@example.com" }
|
||||
$body = "Unknown login from 5.31.23.83 to backups.example.com"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/alerts"
|
||||
Headers = @{
|
||||
Title = "Low disk space alert"
|
||||
Priority = "high"
|
||||
Tags = "warning,skull,backup-host,ssh-login")
|
||||
Email = "phil@example.com"
|
||||
}
|
||||
Body = "Unknown login from 5.31.23.83 to backups.example.com"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -2582,6 +2675,11 @@ format is:
|
||||
ntfy-$topic@ntfy.sh
|
||||
```
|
||||
|
||||
If [access control](config.md#access-control) is enabled, and the target topic does not support anonymous writes, e-mail publishing won't work without providing an authorized access token. That will change the format of the e-mail's recipient address to
|
||||
```
|
||||
ntfy-$topic+$token@ntfy.sh
|
||||
```
|
||||
|
||||
As of today, e-mail publishing only supports adding a [message title](#message-title) (the e-mail subject). Tags, priority,
|
||||
delay and other features are not supported (yet). Here's an example that will publish a message with the
|
||||
title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://ntfy.sh/sometopic)):
|
||||
@@ -2652,14 +2750,36 @@ Here's an example with a user `testuser` and password `fakepassword`:
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
|
||||
=== "PowerShell"
|
||||
=== "PowerShell 7+"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.example.com/mysecrets"
|
||||
$credentials = 'testuser:fakepassword'
|
||||
$encodedCredentials = [convert]::ToBase64String([text.Encoding]::UTF8.GetBytes($credentials))
|
||||
$headers = @{Authorization="Basic $encodedCredentials"}
|
||||
$message = "Look ma, with auth"
|
||||
Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing
|
||||
# Get the credentials from the user
|
||||
$Credential = Get-Credential testuser
|
||||
|
||||
# Alternatively, create a PSCredential object with the password from scratch
|
||||
$Credential = [PSCredential]::new("testuser", (ConvertTo-SecureString "password" -AsPlainText -Force))
|
||||
|
||||
# Note that the Authentication parameter requires PowerShell 7 or later
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.example.com/mysecrets"
|
||||
Authentication = "Basic"
|
||||
Credential = $Credential
|
||||
Body = "Look ma, with auth"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "PowerShell 5 and earlier"
|
||||
# With PowerShell 5 or earlier, we need to create the base64 username:password string ourselves
|
||||
$CredentialString = "$($Credential.Username):$($Credential.GetNetworkCredential().Password)"
|
||||
$EncodedCredential = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CredentialString))
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.example.com/mysecrets"
|
||||
Headers = @{ Authorization = "Basic $EncodedCredential"}
|
||||
Body = "Look ma, with auth"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -2756,12 +2876,29 @@ with the token `tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2`:
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
|
||||
=== "PowerShell"
|
||||
=== "PowerShell 7+"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.example.com/mysecrets"
|
||||
$headers = @{Authorization="Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"}
|
||||
$message = "Look ma, with auth"
|
||||
Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing
|
||||
# With PowerShell 7 or greater, we can use the Authentication and Token parameters
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.example.com/mysecrets"
|
||||
Authorization = "Bearer"
|
||||
Token = "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"
|
||||
Body = "Look ma, with auth"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "PowerShell 5 and earlier"
|
||||
``` powershell
|
||||
# In PowerShell 5 and below, we can only send the Bearer token as a string in the Headers
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.example.com/mysecrets"
|
||||
Headers = @{ Authorization = "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" }
|
||||
Body = "Look ma, with auth"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -2836,10 +2973,16 @@ access token. This is primarily useful to make `curl` calls easier, e.g. `curl -
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.example.com/mysecrets"
|
||||
$headers = @{Authorization="Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy"}
|
||||
$message = "Look ma, with auth"
|
||||
Invoke-RestMethod -Uri $uri -Body $message -Headers $headers -Method "Post" -UseBasicParsing
|
||||
# Note that PSCredentials *must* have a username, so we fall back to placing the authorization in the Headers as with PowerShell 5
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.example.com/mysecrets"
|
||||
Headers = @{
|
||||
Authorization = "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy"
|
||||
}
|
||||
Body = "Look ma, with auth"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -2908,9 +3051,12 @@ Here's an example using the `auth` query parameter:
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw"
|
||||
$message = "Look ma, with auth"
|
||||
Invoke-RestMethod -Uri $uri -Body $message -Method "Post" -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.example.com/mysecrets?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw"
|
||||
Body = "Look ma, with auth"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -2930,7 +3076,7 @@ Here's an example using the `auth` query parameter:
|
||||
]));
|
||||
```
|
||||
|
||||
To generate the value of the `auth` parameter, encode the value of the `Authorization` header (see anove) using
|
||||
To generate the value of the `auth` parameter, encode the value of the `Authorization` header (see above) using
|
||||
**raw base64 encoding** (like base64, but strip any trailing `=`). Here's some pseudo-code that hopefully
|
||||
explains it better:
|
||||
|
||||
@@ -3007,10 +3153,13 @@ are still delivered to connected subscribers, but [`since=`](subscribe/api.md#fe
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/mytopic"
|
||||
$headers = @{ Cache="no" }
|
||||
$body = "This message won't be stored server-side"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/mytopic"
|
||||
Headers = @{ Cache="no" }
|
||||
Body = "This message won't be stored server-side"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -3087,10 +3236,13 @@ to `no`. This will instruct the server not to forward messages to Firebase.
|
||||
|
||||
=== "PowerShell"
|
||||
``` powershell
|
||||
$uri = "https://ntfy.sh/mytopic"
|
||||
$headers = @{ Firebase="no" }
|
||||
$body = "This message won't be forwarded to FCM"
|
||||
Invoke-RestMethod -Method 'Post' -Uri $uri -Body $body -Headers $headers -UseBasicParsing
|
||||
$Request = @{
|
||||
Method = "POST"
|
||||
URI = "https://ntfy.sh/mytopic"
|
||||
Headers = @{ Firebase="no" }
|
||||
Body = "This message won't be forwarded to FCM"
|
||||
}
|
||||
Invoke-RestMethod @Request
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
@@ -3156,22 +3308,33 @@ There are a few limitations to the API to prevent abuse and to keep the server h
|
||||
are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into,
|
||||
but just in case, let's list them all:
|
||||
|
||||
| Limit | Description |
|
||||
|----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
|
||||
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. |
|
||||
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. |
|
||||
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
|
||||
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. |
|
||||
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
|
||||
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. |
|
||||
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
|
||||
| Limit | Description |
|
||||
|---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
|
||||
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. |
|
||||
| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 1,000. |
|
||||
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 10. |
|
||||
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
|
||||
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 5 MB, and the per-visitor total is 50 MB. |
|
||||
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
|
||||
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. |
|
||||
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
|
||||
|
||||
These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing
|
||||
a higher tier. ntfy.sh offers multiple paid tiers, which allows for much hier limits than the ones listed above.
|
||||
|
||||
## List of all parameters
|
||||
The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**,
|
||||
and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form.
|
||||
The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**
|
||||
when used in **HTTP headers**, and must be **lowercase** when used as **query parameters in the URL**. They are listed in the
|
||||
table in their canonical form.
|
||||
|
||||
| Parameter | Aliases (case-insensitive) | Description |
|
||||
!!! info
|
||||
ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/).
|
||||
If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the `X-Title` or `X-Message`
|
||||
header as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)),
|
||||
or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)).
|
||||
|
||||
| Parameter | Aliases | Description |
|
||||
|-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------|
|
||||
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
|
||||
| `X-Title` | `Title`, `t` | [Message title](#message-title) |
|
||||
|
||||
203
docs/releases.md
203
docs/releases.md
@@ -2,6 +2,161 @@
|
||||
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).
|
||||
|
||||
### 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
|
||||
|
||||
@@ -64,6 +219,11 @@ going. It'll only make ntfy better.
|
||||
* 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
|
||||
|
||||
@@ -95,11 +255,6 @@ breaking-change upgrade, which required some work to get working again.
|
||||
|
||||
* 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
|
||||
Released December 23, 2022 🎅
|
||||
|
||||
@@ -991,3 +1146,41 @@ Released Dec 28, 2021
|
||||
## Older 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).
|
||||
|
||||
## 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)
|
||||
|
||||
**Additional languages:**
|
||||
|
||||
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/))
|
||||
|
||||
### ntfy server v2.4.0 (UNRELEASED)
|
||||
|
||||
**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/))
|
||||
|
||||
71
docs/static/css/extra.css
vendored
71
docs/static/css/extra.css
vendored
@@ -2,13 +2,15 @@
|
||||
--md-primary-fg-color: #338574;
|
||||
--md-primary-fg-color--light: #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) {
|
||||
width: unset !important;
|
||||
}
|
||||
|
||||
|
||||
.md-header__topic:first-child {
|
||||
font-weight: 400;
|
||||
}
|
||||
@@ -69,7 +71,18 @@ figure video {
|
||||
}
|
||||
|
||||
.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 */
|
||||
@@ -147,3 +160,57 @@ figure video {
|
||||
.lightbox .close-lightbox:hover::before {
|
||||
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-mono-v22-latin-regular.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-mono-v22-latin-regular.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/static/fonts/roboto-v30-latin-300.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-v30-latin-300.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/static/fonts/roboto-v30-latin-500.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-v30-latin-500.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/static/fonts/roboto-v30-latin-700.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-v30-latin-700.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/static/fonts/roboto-v30-latin-italic.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-v30-latin-italic.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/static/fonts/roboto-v30-latin-regular.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-v30-latin-regular.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/static/img/android-screenshot-logs.jpg
vendored
Normal file
BIN
docs/static/img/android-screenshot-logs.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
BIN
docs/static/img/grafana-dashboard.png
vendored
Normal file
BIN
docs/static/img/grafana-dashboard.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
BIN
docs/static/img/web-logs.png
vendored
Normal file
BIN
docs/static/img/web-logs.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -319,7 +319,7 @@ format of the message. It's very straight forward:
|
||||
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `expires` | ✔️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted |
|
||||
| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
|
||||
| `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 |
|
||||
| `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>
|
||||
</figure>
|
||||
|
||||
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
|
||||
`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
|
||||
override the defaults.
|
||||
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).
|
||||
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
|
||||
will be used, otherwise, the subscription settings will override the defaults.
|
||||
|
||||
!!! warning
|
||||
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),
|
||||
be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password.
|
||||
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
|
||||
require authentication), be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password.
|
||||
|
||||
### 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))
|
||||
|
||||
131
docs/troubleshooting.md
Normal file
131
docs/troubleshooting.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# 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.
|
||||
2400
examples/grafana-dashboard/ntfy-grafana.json
Normal file
2400
examples/grafana-dashboard/ntfy-grafana.json
Normal file
File diff suppressed because it is too large
Load Diff
51
go.mod
51
go.mod
@@ -4,62 +4,71 @@ go 1.18
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.9.0 // indirect
|
||||
cloud.google.com/go/storage v1.29.0 // indirect
|
||||
cloud.google.com/go/storage v1.30.1 // indirect
|
||||
github.com/BurntSushi/toml v1.2.1 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
github.com/emersion/go-smtp v0.16.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.1
|
||||
github.com/gabriel-vasile/mimetype v1.4.2
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/mattn/go-sqlite3 v1.14.16
|
||||
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
|
||||
github.com/stretchr/testify v1.8.1
|
||||
github.com/urfave/cli/v2 v2.24.4
|
||||
golang.org/x/crypto v0.6.0
|
||||
golang.org/x/oauth2 v0.5.0 // indirect
|
||||
github.com/urfave/cli/v2 v2.25.1
|
||||
golang.org/x/crypto v0.8.0
|
||||
golang.org/x/oauth2 v0.7.0 // indirect
|
||||
golang.org/x/sync v0.1.0
|
||||
golang.org/x/term v0.5.0
|
||||
golang.org/x/term v0.7.0
|
||||
golang.org/x/time v0.3.0
|
||||
google.golang.org/api v0.110.0
|
||||
google.golang.org/api v0.119.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require github.com/pkg/errors v0.9.1 // indirect
|
||||
|
||||
require (
|
||||
firebase.google.com/go/v4 v4.10.0
|
||||
github.com/stripe/stripe-go/v74 v74.7.0
|
||||
firebase.google.com/go/v4 v4.11.0
|
||||
github.com/prometheus/client_golang v1.15.0
|
||||
github.com/stripe/stripe-go/v74 v74.15.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.110.0 // indirect
|
||||
cloud.google.com/go/compute v1.18.0 // indirect
|
||||
cloud.google.com/go/compute v1.19.1 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
||||
cloud.google.com/go/iam v0.10.0 // indirect
|
||||
cloud.google.com/go/iam v1.0.0 // indirect
|
||||
cloud.google.com/go/longrunning v0.4.1 // indirect
|
||||
github.com/AlekSi/pointer v1.2.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/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
github.com/google/s2a-go v0.1.2 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.8.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.3.0 // indirect
|
||||
github.com/prometheus/common v0.42.0 // indirect
|
||||
github.com/prometheus/procfs v0.9.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/stretchr/objx v0.5.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
golang.org/x/net v0.7.0 // indirect
|
||||
golang.org/x/sys v0.5.0 // indirect
|
||||
golang.org/x/text v0.7.0 // indirect
|
||||
golang.org/x/net v0.9.0 // indirect
|
||||
golang.org/x/sys v0.7.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.2 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc // indirect
|
||||
google.golang.org/grpc v1.53.0 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.3 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
|
||||
google.golang.org/grpc v1.54.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
180
go.sum
180
go.sum
@@ -1,20 +1,27 @@
|
||||
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.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys=
|
||||
cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY=
|
||||
cloud.google.com/go/compute v1.18.0 h1:FEigFqoDbys2cvFkZ9Fjq4gnHBP55anJ0yQyau2f9oY=
|
||||
cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs=
|
||||
cloud.google.com/go/compute v1.19.0 h1:+9zda3WGgW1ZSTlVppLCYFIr48Pa35q1uG2N1itbCEQ=
|
||||
cloud.google.com/go/compute v1.19.0/go.mod h1:rikpw2y+UMidAe9tISo04EHNOIf42RLYF/q8Bs93scU=
|
||||
cloud.google.com/go/compute v1.19.1 h1:am86mquDUgjGNWxiGn+5PGLbmgiWXlE/yNWpIpNvuXY=
|
||||
cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE=
|
||||
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/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
|
||||
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
|
||||
cloud.google.com/go/iam v0.10.0 h1:fpP/gByFs6US1ma53v7VxhvbJpO2Aapng6wabJ99MuI=
|
||||
cloud.google.com/go/iam v0.10.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM=
|
||||
cloud.google.com/go/iam v0.13.0 h1:+CmB+K0J/33d0zSQ9SlFWUeCCEn5XJA0ZMZ3pHE9u8k=
|
||||
cloud.google.com/go/iam v0.13.0/go.mod h1:ljOg+rcNfzZ5d6f1nAUJ8ZIxOaZUVoS14bKCtaLZ/D0=
|
||||
cloud.google.com/go/iam v1.0.0 h1:hlQJMovyJJwYjZcTohUH4o1L8Z8kYz+E+W/zktiLCBc=
|
||||
cloud.google.com/go/iam v1.0.0/go.mod h1:ikbQ4f1r91wTmBmmOtBCOtuEOei6taatNXytzB7Cxew=
|
||||
cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
|
||||
cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo=
|
||||
cloud.google.com/go/storage v1.29.0 h1:6weCgzRvMg7lzuUurI4697AqIRPU1SvzHhynwpW31jI=
|
||||
cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4=
|
||||
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
|
||||
cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
|
||||
firebase.google.com/go/v4 v4.10.0 h1:dgK/8uwfJbzc5LZK/GyRRfIkZEDObN9q0kgEXsjlXN4=
|
||||
firebase.google.com/go/v4 v4.10.0/go.mod h1:m0gLwPY9fxKggizzglgCNWOGnFnVPifLpqZzo5u3e/A=
|
||||
firebase.google.com/go/v4 v4.11.0 h1:szjBoiF33A2FavRLIDZjW1mw+OsW/XAtHoYNIqWOjRk=
|
||||
firebase.google.com/go/v4 v4.11.0/go.mod h1:60c36dWLK4+j05Vw5XMllek3b3PCynU3BfI46OSwsUE=
|
||||
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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
@@ -22,11 +29,23 @@ github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak
|
||||
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/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/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/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/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -38,12 +57,15 @@ github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVR
|
||||
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.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/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
|
||||
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
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.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
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-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
@@ -52,16 +74,20 @@ 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.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.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||
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.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/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
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.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.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.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
@@ -72,69 +98,114 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
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/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
|
||||
github.com/google/s2a-go v0.1.2 h1:WVtYAYuYxKeYajAmThMRYWP6K3wXkcqbGHeUgeubUHY=
|
||||
github.com/google/s2a-go v0.1.2/go.mod h1:OJpEgntRZo8ugHpF9hkoLJbS5dSI20XZeXJ9JVywLlM=
|
||||
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/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.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
|
||||
github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ=
|
||||
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
|
||||
github.com/googleapis/gax-go/v2 v2.8.0 h1:UBtEZqx1bjXtOQ5BVTkuYghXrr3N4V123VKJK67vJZc=
|
||||
github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI=
|
||||
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/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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/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/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
|
||||
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
|
||||
github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM=
|
||||
github.com/prometheus/client_golang v1.15.0/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.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
|
||||
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
|
||||
github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
|
||||
github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
|
||||
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.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
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/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.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||
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.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.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stripe/stripe-go/v74 v74.7.0 h1:KHlyslQj9YOv62b1sycQ31LFj7KlqR+seHsSowAWrjc=
|
||||
github.com/stripe/stripe-go/v74 v74.7.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
|
||||
github.com/urfave/cli/v2 v2.24.4 h1:0gyJJEBYtCV87zI/x2nZCPyDxD51K6xM8SkwjHFCNEU=
|
||||
github.com/urfave/cli/v2 v2.24.4/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
||||
github.com/stripe/stripe-go/v74 v74.14.0 h1:hB1Ocu/m3BUZ+PrTePsPSv8TKcXTrleCL5Y5JfB8zCo=
|
||||
github.com/stripe/stripe-go/v74 v74.14.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
||||
github.com/stripe/stripe-go/v74 v74.15.0 h1:P3ZYrY4CdZeV8Pc/205utqjur+5gcTef+9hgtj8P8IY=
|
||||
github.com/stripe/stripe-go/v74 v74.15.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
||||
github.com/urfave/cli/v2 v2.25.1 h1:zw8dSP7ghX0Gmm8vugrs6q9Ku0wzweqPyshy+syu9Gw=
|
||||
github.com/urfave/cli/v2 v2.25.1/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/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/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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
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-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
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-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-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-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s=
|
||||
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw=
|
||||
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
|
||||
golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g=
|
||||
golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
|
||||
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-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-20210220032951-036812b2e83c/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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -142,19 +213,32 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-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-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
|
||||
golang.org/x/sys v0.7.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.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.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.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
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/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -162,29 +246,43 @@ 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-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-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-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/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||
google.golang.org/api v0.110.0 h1:l+rh0KYUooe9JGbGVx71tbFo4SMbMTXK3I3ia2QSEeU=
|
||||
google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
|
||||
google.golang.org/api v0.114.0 h1:1xQPji6cO2E2vLiI+C/XiFAnsn1WV3mjaEwGLhi3grE=
|
||||
google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg=
|
||||
google.golang.org/api v0.119.0 h1:Dzq+ARD6+8jmd5wknJE1crpuzu1JiovEU6gCp9PkoKA=
|
||||
google.golang.org/api v0.119.0/go.mod h1:CrSvlNEFCFLae9ZUtL1z+61+rEBD7J/aCYwVYKZoWFU=
|
||||
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.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine/v2 v2.0.2 h1:MSqyWy2shDLwG7chbwBJ5uMyw6SNqJzhJHNDwYB0Akk=
|
||||
google.golang.org/appengine/v2 v2.0.2/go.mod h1:PkgRUWz4o1XOvbqtWTkBtCitEJ5Tp4HoVEdMMYQR/8E=
|
||||
google.golang.org/appengine/v2 v2.0.3 h1:AyY/mipuqiyCIAqOevfmu5fMDc5/9P/QggWfCQYdkSA=
|
||||
google.golang.org/appengine/v2 v2.0.3/go.mod h1:2Z0TTdcXxnHdXzmp8drrmOExUDM2WQgyT33c6JDUlJM=
|
||||
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-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-20230209215440-0dfe4f8abfcc h1:ijGwO+0vL2hJt5gaygqP2j6PfflOBrRot0IczKbmtio=
|
||||
google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
|
||||
google.golang.org/genproto v0.0.0-20230330200707-38013875ee22 h1:n3ThVoQnHbCbnkhZZ1fx3+3fBAisViSwrpbtLV7vydY=
|
||||
google.golang.org/genproto v0.0.0-20230330200707-38013875ee22/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak=
|
||||
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
|
||||
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
|
||||
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.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
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.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=
|
||||
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
|
||||
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
|
||||
google.golang.org/grpc v1.54.0 h1:EhTqbhiYeixwWQtAEZAxmV9MGqcjEU2mFx52xCzNyag=
|
||||
google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g=
|
||||
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-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
@@ -196,10 +294,12 @@ 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.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.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||
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=
|
||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
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/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
57
log/event.go
57
log/event.go
@@ -3,6 +3,7 @@ package log
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/util"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
@@ -11,11 +12,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
tagField = "tag"
|
||||
errorField = "error"
|
||||
timeTakenField = "time_taken_ms"
|
||||
exitCodeField = "exit_code"
|
||||
timestampFormat = "2006-01-02T15:04:05.999Z07:00"
|
||||
fieldTag = "tag"
|
||||
fieldError = "error"
|
||||
fieldTimeTaken = "time_taken_ms"
|
||||
fieldExitCode = "exit_code"
|
||||
tagStdLog = "stdlog"
|
||||
)
|
||||
|
||||
// Event represents a single log event
|
||||
@@ -40,7 +41,7 @@ func newEvent() *Event {
|
||||
|
||||
// Fatal logs the event as FATAL, and exits the program with exit code 1
|
||||
func (e *Event) Fatal(message string, v ...any) {
|
||||
e.Field(exitCodeField, 1).maybeLog(FatalLevel, message, v...)
|
||||
e.Field(fieldExitCode, 1).maybeLog(FatalLevel, message, v...)
|
||||
fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -72,7 +73,7 @@ func (e *Event) Trace(message string, v ...any) {
|
||||
|
||||
// Tag adds a "tag" field to the log event
|
||||
func (e *Event) Tag(tag string) *Event {
|
||||
return e.Field(tagField, tag)
|
||||
return e.Field(fieldTag, tag)
|
||||
}
|
||||
|
||||
// Time sets the time field
|
||||
@@ -85,7 +86,7 @@ func (e *Event) Time(t time.Time) *Event {
|
||||
func (e *Event) Timing(f func()) *Event {
|
||||
start := time.Now()
|
||||
f()
|
||||
return e.Field(timeTakenField, time.Since(start).Milliseconds())
|
||||
return e.Field(fieldTimeTaken, time.Since(start).Milliseconds())
|
||||
}
|
||||
|
||||
// Err adds an "error" field to the log event
|
||||
@@ -95,7 +96,7 @@ func (e *Event) Err(err error) *Event {
|
||||
} else if c, ok := err.(Contexter); ok {
|
||||
return e.With(c)
|
||||
}
|
||||
return e.Field(errorField, err.Error())
|
||||
return e.Field(fieldError, err.Error())
|
||||
}
|
||||
|
||||
// Field adds a custom field and value to the log event
|
||||
@@ -118,38 +119,44 @@ func (e *Event) Fields(fields Context) *Event {
|
||||
return e
|
||||
}
|
||||
|
||||
// With adds the fields of the given Contexter structs to the log event by calling their With method
|
||||
func (e *Event) With(contexts ...Contexter) *Event {
|
||||
// With adds the fields of the given Contexter structs to the log event by calling their Context method
|
||||
func (e *Event) With(contexters ...Contexter) *Event {
|
||||
if e.contexters == nil {
|
||||
e.contexters = contexts
|
||||
e.contexters = contexters
|
||||
} else {
|
||||
e.contexters = append(e.contexters, contexts...)
|
||||
e.contexters = append(e.contexters, contexters...)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// maybeLog logs the event to the defined output. The event is only logged, if
|
||||
// either the global log level is >= l, or if the log level in one of the overrides matches
|
||||
// Render returns the rendered log event as a string, or an empty string. The event is only rendered,
|
||||
// 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) maybeLog(l Level, message string, v ...any) {
|
||||
func (e *Event) Render(l Level, message string, v ...any) string {
|
||||
appliedContexters := e.maybeApplyContexters()
|
||||
if !e.shouldLog(l) {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
e.Message = fmt.Sprintf(message, v...)
|
||||
e.Level = l
|
||||
e.Timestamp = e.time.Format(timestampFormat)
|
||||
e.Timestamp = util.FormatTime(e.time)
|
||||
if !appliedContexters {
|
||||
e.applyContexters()
|
||||
}
|
||||
if CurrentFormat() == JSONFormat {
|
||||
log.Println(e.JSON())
|
||||
} else {
|
||||
log.Println(e.String())
|
||||
return e.JSON()
|
||||
}
|
||||
return e.String()
|
||||
}
|
||||
|
||||
// maybeLog logs the event to the defined output, or does nothing if Render returns an empty string
|
||||
func (e *Event) maybeLog(l Level, message string, v ...any) {
|
||||
if m := e.Render(l, message, v...); m != "" {
|
||||
log.Println(m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,11 +210,13 @@ func (e *Event) globalLevelWithOverride() Level {
|
||||
if e.fields == nil {
|
||||
return l
|
||||
}
|
||||
for field, override := range ov {
|
||||
for field, fieldOverrides := range ov {
|
||||
value, exists := e.fields[field]
|
||||
if exists {
|
||||
if override.value == "" || override.value == value || override.value == fmt.Sprintf("%v", value) {
|
||||
return override.level
|
||||
for _, o := range fieldOverrides {
|
||||
if o.value == "" || o.value == value || o.value == fmt.Sprintf("%v", value) {
|
||||
return o.level
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
57
log/log.go
57
log/log.go
@@ -4,6 +4,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -12,17 +13,26 @@ import (
|
||||
var (
|
||||
DefaultLevel = InfoLevel
|
||||
DefaultFormat = TextFormat
|
||||
DefaultOutput = os.Stderr
|
||||
DefaultOutput = &peekLogWriter{os.Stderr}
|
||||
)
|
||||
|
||||
var (
|
||||
level = DefaultLevel
|
||||
format = DefaultFormat
|
||||
overrides = make(map[string]*levelOverride)
|
||||
overrides = make(map[string][]*levelOverride)
|
||||
output io.Writer = DefaultOutput
|
||||
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
|
||||
func Fatal(message string, v ...any) {
|
||||
newEvent().Fatal(message, v...)
|
||||
@@ -101,14 +111,17 @@ func SetLevel(newLevel Level) {
|
||||
func SetLevelOverride(field string, value string, level Level) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
overrides[field] = &levelOverride{value: value, level: level}
|
||||
if _, ok := overrides[field]; !ok {
|
||||
overrides[field] = make([]*levelOverride, 0)
|
||||
}
|
||||
overrides[field] = append(overrides[field], &levelOverride{value: value, level: level})
|
||||
}
|
||||
|
||||
// ResetLevelOverrides removes all log level overrides
|
||||
func ResetLevelOverrides() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
overrides = make(map[string]*levelOverride)
|
||||
overrides = make(map[string][]*levelOverride)
|
||||
}
|
||||
|
||||
// CurrentFormat returns the current log format
|
||||
@@ -132,28 +145,27 @@ func SetFormat(newFormat Format) {
|
||||
func SetOutput(w io.Writer) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
log.SetOutput(w)
|
||||
output = w
|
||||
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()
|
||||
if f, ok := output.(*os.File); ok {
|
||||
return f.Name()
|
||||
}
|
||||
return ""
|
||||
return filename
|
||||
}
|
||||
|
||||
// IsFile returns true if the output is a non-default file
|
||||
func IsFile() bool {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
if _, ok := output.(*os.File); ok && output != DefaultOutput {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return filename != ""
|
||||
}
|
||||
|
||||
// DisableDates disables the date/time prefix
|
||||
@@ -175,3 +187,20 @@ func IsTrace() bool {
|
||||
func IsDebug() bool {
|
||||
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"))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -170,6 +173,72 @@ func TestLog_LevelOverrideAny(t *testing.T) {
|
||||
{"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_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 {
|
||||
|
||||
@@ -102,6 +102,13 @@ type Contexter interface {
|
||||
// Context represents an object's state in the form of key-value pairs
|
||||
type Context map[string]any
|
||||
|
||||
// Merge merges other into this context
|
||||
func (c Context) Merge(other Context) {
|
||||
for k, v := range other {
|
||||
c[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
type levelOverride struct {
|
||||
value string
|
||||
level Level
|
||||
|
||||
@@ -9,7 +9,9 @@ edit_uri: blob/main/docs/
|
||||
|
||||
theme:
|
||||
name: material
|
||||
font: false
|
||||
language: en
|
||||
custom_dir: docs/_overrides
|
||||
logo: static/img/ntfy.png
|
||||
favicon: static/img/favicon.png
|
||||
include_search_page: false
|
||||
@@ -69,6 +71,9 @@ plugins:
|
||||
- search
|
||||
- minify:
|
||||
minify_html: true
|
||||
- mkdocs-simple-hooks:
|
||||
hooks:
|
||||
on_post_build: "docs.hooks:copy_fonts"
|
||||
|
||||
nav:
|
||||
- "Getting started": index.md
|
||||
@@ -88,6 +93,7 @@ nav:
|
||||
- "Integrations + projects": integrations.md
|
||||
- "Release notes": releases.md
|
||||
- "Emojis 🥳 🎉": emojis.md
|
||||
- "Troubleshooting": troubleshooting.md
|
||||
- "Known issues": known-issues.md
|
||||
- "Deprecation notices": deprecations.md
|
||||
- "Development": develop.md
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# The documentation uses 'mkdocs', which is written in Python
|
||||
mkdocs-material
|
||||
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
|
||||
[tagging and emojis page](../publish/#tags-emojis).
|
||||
|
||||
<table class="remove-md-box"><tr>
|
||||
<table class=\"remove-md-box emoji-table\"><tr>
|
||||
" > "$1"
|
||||
|
||||
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
|
||||
from="$(($col * $percolumn + 1))"
|
||||
to="$(($col * $percolumn + 1 + $percolumn))"
|
||||
echo "<td><table><thead><tr><th>Tag</th><th>Emoji</th></tr></thead><tbody>" >> "$1"
|
||||
echo "<td><table><thead><tr><th>Tag</th><th style='text-align: center'>Emoji</th></tr></thead><tbody>" >> "$1"
|
||||
cat "$SCRIPTDIR/emoji.json" \
|
||||
| jq -r '.[] | "<tr><td><code>" + .aliases[0] + "</code></td><td>" + .emoji + "</td></tr>"' \
|
||||
| jq -r '.[] | "<tr><td class=c><code>" + .aliases[0] + "</code></td><td class=e>" + .emoji + "</td></tr>"' \
|
||||
| sed -n "${from},${to}p" >> "$1"
|
||||
echo "</tbody></table></td>" >> "$1"
|
||||
done
|
||||
|
||||
@@ -49,7 +49,7 @@ const (
|
||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||
DefaultVisitorAccountCreationLimitBurst = 3
|
||||
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
|
||||
DefaultVisitorAuthFailureLimitBurst = 10
|
||||
DefaultVisitorAuthFailureLimitBurst = 30
|
||||
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
||||
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
||||
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
||||
@@ -61,7 +61,7 @@ var (
|
||||
|
||||
// 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", "account", "settings", "signup", "login", "v1"}
|
||||
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.
|
||||
@@ -105,6 +105,9 @@ type Config struct {
|
||||
SMTPServerListen string
|
||||
SMTPServerDomain string
|
||||
SMTPServerAddrPrefix string
|
||||
MetricsEnable bool
|
||||
MetricsListenHTTP string
|
||||
ProfileListenHTTP string
|
||||
MessageLimit int
|
||||
MinDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
@@ -124,14 +127,17 @@ type Config struct {
|
||||
VisitorAuthFailureLimitBurst int
|
||||
VisitorAuthFailureLimitReplenish time.Duration
|
||||
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
|
||||
StripeSecretKey string
|
||||
StripeWebhookKey string
|
||||
StripePriceCacheDuration time.Duration
|
||||
BillingContact string
|
||||
EnableWeb bool
|
||||
EnableSignup bool // Enable creation of accounts via API and UI
|
||||
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
|
||||
Version string // injected by App
|
||||
}
|
||||
@@ -197,10 +203,12 @@ func NewConfig() *Config {
|
||||
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
|
||||
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
|
||||
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
||||
VisitorSubscriberRateLimiting: false,
|
||||
BehindProxy: false,
|
||||
StripeSecretKey: "",
|
||||
StripeWebhookKey: "",
|
||||
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
||||
BillingContact: "",
|
||||
EnableWeb: true,
|
||||
EnableSignup: false,
|
||||
EnableLogin: false,
|
||||
|
||||
150
server/errors.go
150
server/errors.go
@@ -13,6 +13,7 @@ type errHTTP struct {
|
||||
HTTPCode int `json:"http"`
|
||||
Message string `json:"error"`
|
||||
Link string `json:"link,omitempty"`
|
||||
context log.Context
|
||||
}
|
||||
|
||||
func (e errHTTP) Error() string {
|
||||
@@ -25,71 +26,106 @@ func (e errHTTP) JSON() string {
|
||||
}
|
||||
|
||||
func (e errHTTP) Context() log.Context {
|
||||
return log.Context{
|
||||
context := log.Context{
|
||||
"error": e.Message,
|
||||
"error_code": e.Code,
|
||||
"http_status": e.HTTPCode,
|
||||
}
|
||||
for k, v := range e.context {
|
||||
context[k] = v
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
func wrapErrHTTP(err *errHTTP, message string, args ...any) *errHTTP {
|
||||
return &errHTTP{
|
||||
Code: err.Code,
|
||||
HTTPCode: err.HTTPCode,
|
||||
Message: fmt.Sprintf("%s, %s", err.Message, fmt.Sprintf(message, args...)),
|
||||
Link: err.Link,
|
||||
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 (
|
||||
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"}
|
||||
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""}
|
||||
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"}
|
||||
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"}
|
||||
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"}
|
||||
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", ""}
|
||||
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", ""}
|
||||
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"}
|
||||
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"}
|
||||
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"}
|
||||
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"}
|
||||
errHTTPBadRequestMatrixPushkeyBaseURLMismatch = &errHTTP{40020, http.StatusBadRequest, "invalid request: push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"}
|
||||
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons"}
|
||||
errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config"}
|
||||
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", ""}
|
||||
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", ""}
|
||||
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", ""}
|
||||
errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", ""}
|
||||
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""}
|
||||
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""}
|
||||
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
|
||||
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", ""}
|
||||
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", ""}
|
||||
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", ""}
|
||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""}
|
||||
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", ""}
|
||||
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
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"}
|
||||
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
|
||||
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", ""}
|
||||
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
|
||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
||||
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", ""}
|
||||
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}
|
||||
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", "", nil}
|
||||
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", "", nil}
|
||||
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", "", nil}
|
||||
errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "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", nil}
|
||||
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
||||
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority", nil}
|
||||
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil}
|
||||
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil}
|
||||
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil}
|
||||
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", "", nil}
|
||||
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil}
|
||||
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil}
|
||||
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
||||
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets", nil}
|
||||
errHTTPBadRequestMessageJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json", nil}
|
||||
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons", nil}
|
||||
errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway", nil}
|
||||
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons", nil}
|
||||
errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config", nil}
|
||||
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", "", nil}
|
||||
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", "", nil}
|
||||
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", "", nil}
|
||||
errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", "", nil}
|
||||
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil}
|
||||
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil}
|
||||
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil}
|
||||
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil}
|
||||
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil}
|
||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
||||
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil}
|
||||
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
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
|
||||
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}
|
||||
)
|
||||
|
||||
@@ -67,6 +67,7 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (in
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.totalSizeCurrent += size
|
||||
mset(metricAttachmentsTotalSize, c.totalSizeCurrent)
|
||||
c.mu.Unlock()
|
||||
return size, nil
|
||||
}
|
||||
@@ -89,6 +90,7 @@ func (c *fileCache) Remove(ids ...string) error {
|
||||
c.mu.Lock()
|
||||
c.totalSizeCurrent = size
|
||||
c.mu.Unlock()
|
||||
mset(metricAttachmentsTotalSize, size)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ const (
|
||||
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
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
var (
|
||||
errUnexpectedMessageType = errors.New("unexpected message type")
|
||||
errMessageNotFound = errors.New("message not found")
|
||||
errNoRows = errors.New("no rows found")
|
||||
)
|
||||
|
||||
// Messages cache
|
||||
@@ -54,6 +55,11 @@ const (
|
||||
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 TABLE IF NOT EXISTS stats (
|
||||
key TEXT PRIMARY KEY,
|
||||
value INT
|
||||
);
|
||||
INSERT INTO stats (key, value) VALUES ('messages', 0);
|
||||
COMMIT;
|
||||
`
|
||||
insertMessageQuery = `
|
||||
@@ -108,11 +114,14 @@ const (
|
||||
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 >= ?`
|
||||
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
|
||||
const (
|
||||
currentSchemaVersion = 10
|
||||
currentSchemaVersion = 11
|
||||
createSchemaVersionTableQuery = `
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
@@ -222,20 +231,30 @@ const (
|
||||
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
||||
`
|
||||
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 (
|
||||
migrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{
|
||||
0: migrateFrom0,
|
||||
1: migrateFrom1,
|
||||
2: migrateFrom2,
|
||||
3: migrateFrom3,
|
||||
4: migrateFrom4,
|
||||
5: migrateFrom5,
|
||||
6: migrateFrom6,
|
||||
7: migrateFrom7,
|
||||
8: migrateFrom8,
|
||||
9: migrateFrom9,
|
||||
0: migrateFrom0,
|
||||
1: migrateFrom1,
|
||||
2: migrateFrom2,
|
||||
3: migrateFrom3,
|
||||
4: migrateFrom4,
|
||||
5: migrateFrom5,
|
||||
6: migrateFrom6,
|
||||
7: migrateFrom7,
|
||||
8: migrateFrom8,
|
||||
9: migrateFrom9,
|
||||
10: migrateFrom10,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -536,7 +555,7 @@ func (c *messageCache) ExpireMessages(topics ...string) error {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, t := range topics {
|
||||
if _, err := tx.Exec(updateMessagesForTopicExpiryQuery, time.Now().Unix(), t); err != nil {
|
||||
if _, err := tx.Exec(updateMessagesForTopicExpiryQuery, time.Now().Unix()-1, t); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -706,6 +725,26 @@ func readMessage(rows *sql.Rows) (*message, error) {
|
||||
}, 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 {
|
||||
return c.db.Close()
|
||||
}
|
||||
@@ -889,3 +928,19 @@ func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func migrateFrom10(db *sql.DB, cacheDuration time.Duration) error {
|
||||
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()
|
||||
}
|
||||
|
||||
439
server/server.go
439
server/server.go
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/user"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -37,6 +39,8 @@ type Server struct {
|
||||
config *Config
|
||||
httpServer *http.Server
|
||||
httpsServer *http.Server
|
||||
httpMetricsServer *http.Server
|
||||
httpProfileServer *http.Server
|
||||
unixListener net.Listener
|
||||
smtpServer *smtp.Server
|
||||
smtpServerBackend *smtpBackend
|
||||
@@ -44,14 +48,16 @@ type Server struct {
|
||||
topics map[string]*topic
|
||||
visitors map[string]*visitor // ip:<ip> or user:<user>
|
||||
firebaseClient *firebaseClient
|
||||
messages int64
|
||||
userManager *user.Manager // Might be nil!
|
||||
messageCache *messageCache // Database that stores the messages
|
||||
fileCache *fileCache // File system based cache that stores attachments
|
||||
stripe stripeAPI // Stripe API, can be replaced with a mock
|
||||
priceCache *util.LookupCache[map[string]string] // Stripe price ID -> formatted price
|
||||
messages int64 // Total number of messages (persisted if messageCache enabled)
|
||||
messagesHistory []int64 // Last n values of the messages counter, used to determine rate
|
||||
userManager *user.Manager // Might be nil!
|
||||
messageCache *messageCache // Database that stores the messages
|
||||
fileCache *fileCache // File system based cache that stores attachments
|
||||
stripe stripeAPI // Stripe API, can be replaced with a mock
|
||||
priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
|
||||
metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set
|
||||
closeChan chan bool
|
||||
mu sync.Mutex
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// handleFunc extends the normal http.HandlerFunc to be able to easily return errors
|
||||
@@ -72,8 +78,10 @@ var (
|
||||
webConfigPath = "/config.js"
|
||||
accountPath = "/account"
|
||||
matrixPushPath = "/_matrix/push/v1/notify"
|
||||
metricsPath = "/metrics"
|
||||
apiHealthPath = "/v1/health"
|
||||
apiTiers = "/v1/tiers"
|
||||
apiStatsPath = "/v1/stats"
|
||||
apiTiersPath = "/v1/tiers"
|
||||
apiAccountPath = "/v1/account"
|
||||
apiAccountTokenPath = "/v1/account/token"
|
||||
apiAccountPasswordPath = "/v1/account/password"
|
||||
@@ -110,7 +118,10 @@ const (
|
||||
newMessageBody = "New message" // Used in poll requests as generic message
|
||||
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
||||
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
|
||||
jsonBodyBytesLimit = 16384
|
||||
jsonBodyBytesLimit = 16384 // Max number of bytes for a JSON request body
|
||||
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
||||
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
||||
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
||||
)
|
||||
|
||||
// WebSocket constants
|
||||
@@ -140,6 +151,10 @@ func New(conf *Config) (*Server, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages, err := messageCache.Stats()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fileCache *fileCache
|
||||
if conf.AttachmentCacheDir != "" {
|
||||
fileCache, err = newFileCache(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit)
|
||||
@@ -160,18 +175,26 @@ func New(conf *Config) (*Server, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firebaseClient = newFirebaseClient(sender, userManager)
|
||||
// This awkward logic is required because Go is weird about nil types and interfaces.
|
||||
// See issue #641, and https://go.dev/play/p/uur1flrv1t3 for an example
|
||||
var auther user.Auther
|
||||
if userManager != nil {
|
||||
auther = userManager
|
||||
}
|
||||
firebaseClient = newFirebaseClient(sender, auther)
|
||||
}
|
||||
s := &Server{
|
||||
config: conf,
|
||||
messageCache: messageCache,
|
||||
fileCache: fileCache,
|
||||
firebaseClient: firebaseClient,
|
||||
smtpSender: mailer,
|
||||
topics: topics,
|
||||
userManager: userManager,
|
||||
visitors: make(map[string]*visitor),
|
||||
stripe: stripe,
|
||||
config: conf,
|
||||
messageCache: messageCache,
|
||||
fileCache: fileCache,
|
||||
firebaseClient: firebaseClient,
|
||||
smtpSender: mailer,
|
||||
topics: topics,
|
||||
userManager: userManager,
|
||||
messages: messages,
|
||||
messagesHistory: []int64{messages},
|
||||
visitors: make(map[string]*visitor),
|
||||
stripe: stripe,
|
||||
}
|
||||
s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration)
|
||||
return s, nil
|
||||
@@ -202,6 +225,12 @@ func (s *Server) Run() error {
|
||||
if s.config.SMTPServerListen != "" {
|
||||
listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen)
|
||||
}
|
||||
if s.config.MetricsListenHTTP != "" {
|
||||
listenStr += fmt.Sprintf(" %s[http/metrics]", s.config.MetricsListenHTTP)
|
||||
}
|
||||
if s.config.ProfileListenHTTP != "" {
|
||||
listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP)
|
||||
}
|
||||
log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String())
|
||||
if log.IsFile() {
|
||||
fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.Version)
|
||||
@@ -248,6 +277,28 @@ func (s *Server) Run() error {
|
||||
errChan <- httpServer.Serve(s.unixListener)
|
||||
}()
|
||||
}
|
||||
if s.config.MetricsListenHTTP != "" {
|
||||
initMetrics()
|
||||
s.httpMetricsServer = &http.Server{Addr: s.config.MetricsListenHTTP, Handler: promhttp.Handler()}
|
||||
go func() {
|
||||
errChan <- s.httpMetricsServer.ListenAndServe()
|
||||
}()
|
||||
} else if s.config.EnableMetrics {
|
||||
initMetrics()
|
||||
s.metricsHandler = promhttp.Handler()
|
||||
}
|
||||
if s.config.ProfileListenHTTP != "" {
|
||||
profileMux := http.NewServeMux()
|
||||
profileMux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
profileMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
profileMux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
profileMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
profileMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
s.httpProfileServer = &http.Server{Addr: s.config.ProfileListenHTTP, Handler: profileMux}
|
||||
go func() {
|
||||
errChan <- s.httpProfileServer.ListenAndServe()
|
||||
}()
|
||||
}
|
||||
if s.config.SMTPServerListen != "" {
|
||||
go func() {
|
||||
errChan <- s.runSMTPServer()
|
||||
@@ -291,7 +342,6 @@ func (s *Server) closeDatabases() {
|
||||
|
||||
// handle is the main entry point for all HTTP requests
|
||||
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
w = newHTTPResponseWriter(w) // Avoid logging "superfluous response.WriteHeader call" warning
|
||||
v, err := s.maybeAuthenticate(r) // Note: Always returns v, even when error is returned
|
||||
if err != nil {
|
||||
s.handleError(w, r, v, err)
|
||||
@@ -309,6 +359,9 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleError(w, r, v, err)
|
||||
return
|
||||
}
|
||||
if metricHTTPRequests != nil {
|
||||
metricHTTPRequests.WithLabelValues("200", "20000", r.Method).Inc()
|
||||
}
|
||||
}).
|
||||
Debug("HTTP request finished")
|
||||
}
|
||||
@@ -318,25 +371,31 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
|
||||
if !ok {
|
||||
httpErr = errHTTPInternalError
|
||||
}
|
||||
isNormalError := strings.Contains(err.Error(), "i/o timeout") || util.Contains([]int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized}, httpErr.HTTPCode)
|
||||
if metricHTTPRequests != nil {
|
||||
metricHTTPRequests.WithLabelValues(fmt.Sprintf("%d", httpErr.HTTPCode), fmt.Sprintf("%d", httpErr.Code), r.Method).Inc()
|
||||
}
|
||||
isRateLimiting := util.Contains(rateLimitingErrorCodes, httpErr.HTTPCode)
|
||||
isNormalError := strings.Contains(err.Error(), "i/o timeout") || util.Contains(normalErrorCodes, httpErr.HTTPCode)
|
||||
ev := logvr(v, r).Err(err)
|
||||
if websocket.IsWebSocketUpgrade(r) {
|
||||
ev.Tag(tagWebsocket).Fields(websocketErrorContext(err))
|
||||
if isNormalError {
|
||||
logvr(v, r).Tag(tagWebsocket).Err(err).Fields(websocketErrorContext(err)).Debug("WebSocket error (this error is okay, it happens a lot): %s", err.Error())
|
||||
ev.Debug("WebSocket error (this error is okay, it happens a lot): %s", err.Error())
|
||||
} else {
|
||||
logvr(v, r).Tag(tagWebsocket).Err(err).Fields(websocketErrorContext(err)).Info("WebSocket error: %s", err.Error())
|
||||
ev.Info("WebSocket error: %s", err.Error())
|
||||
}
|
||||
return // Do not attempt to write to upgraded connection
|
||||
}
|
||||
if matrixErr, ok := err.(*errMatrix); ok {
|
||||
if err := writeMatrixError(w, r, v, matrixErr); err != nil {
|
||||
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Writing Matrix error failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
if isNormalError {
|
||||
logvr(v, r).Err(httpErr).Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
|
||||
ev.Debug("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
|
||||
} else {
|
||||
logvr(v, r).Err(httpErr).Info("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
|
||||
ev.Info("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
|
||||
}
|
||||
if isRateLimiting && s.config.StripeSecretKey != "" {
|
||||
u := v.User()
|
||||
if u == nil || u.Tier == nil {
|
||||
httpErr = httpErr.Wrap("increase your limits with a paid plan, see %s", s.config.BaseURL)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
@@ -391,10 +450,14 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath {
|
||||
return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe!
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiTiers {
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath {
|
||||
return s.handleStats(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
|
||||
return s.ensurePaymentsEnabled(s.handleBillingTiersGet)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
|
||||
return s.handleMatrixDiscovery(w)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
|
||||
return s.handleMetrics(w, r, v)
|
||||
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
||||
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
|
||||
@@ -404,13 +467,13 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
} else if r.Method == http.MethodOptions {
|
||||
return s.limitRequests(s.handleOptions)(w, r, v) // Should work even if the web app is not enabled, see #598
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" {
|
||||
return s.limitRequests(s.transformBodyJSON(s.authorizeTopicWrite(s.handlePublish)))(w, r, v)
|
||||
return s.transformBodyJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish)))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath {
|
||||
return s.limitRequests(s.transformMatrixJSON(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
|
||||
return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequests(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequests(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeJSON))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && ssePathRegex.MatchString(r.URL.Path) {
|
||||
@@ -475,6 +538,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||
EnableSignup: s.config.EnableSignup,
|
||||
EnablePayments: s.config.StripeSecretKey != "",
|
||||
EnableReservations: s.config.EnableReservations,
|
||||
BillingContact: s.config.BillingContact,
|
||||
DisallowedTopics: s.config.DisallowedTopics,
|
||||
}
|
||||
b, err := json.MarshalIndent(response, "", " ")
|
||||
@@ -486,17 +550,41 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||
return err
|
||||
}
|
||||
|
||||
// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,
|
||||
// and listen-metrics-http is not set.
|
||||
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
s.metricsHandler.ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStatic returns all static resources (excluding the docs), including the web app
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
r.URL.Path = webSiteDir + r.URL.Path
|
||||
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleDocs returns static resources related to the docs
|
||||
func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
util.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStats returns the publicly available server stats
|
||||
func (s *Server) handleStats(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
s.mu.RLock()
|
||||
messages, n, rate := s.messages, len(s.messagesHistory), float64(0)
|
||||
if n > 1 {
|
||||
rate = float64(s.messagesHistory[n-1]-s.messagesHistory[0]) / (float64(n-1) * s.config.ManagerInterval.Seconds())
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
response := &apiStatsResponse{
|
||||
Messages: messages,
|
||||
MessagesRate: rate,
|
||||
}
|
||||
return s.writeJSON(w, response)
|
||||
}
|
||||
|
||||
// handleFile processes the download of attachment files. The method handles GET and HEAD requests against a file.
|
||||
// Before streaming the file to a client, it locates uploader (m.Sender or m.User) in the message cache, so it
|
||||
// can associate the download bandwidth with the uploader.
|
||||
@@ -512,7 +600,10 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
||||
file := filepath.Join(s.config.AttachmentCacheDir, messageID)
|
||||
stat, err := os.Stat(file)
|
||||
if err != nil {
|
||||
return errHTTPNotFound
|
||||
return errHTTPNotFound.Fields(log.Context{
|
||||
"message_id": messageID,
|
||||
"error_context": "filesystem",
|
||||
})
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
||||
@@ -533,7 +624,10 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
||||
}, s.config.CacheBatchTimeout, 100*time.Millisecond, 300*time.Millisecond, 600*time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
return errHTTPNotFound
|
||||
return errHTTPNotFound.Fields(log.Context{
|
||||
"message_id": messageID,
|
||||
"error_context": "message_cache",
|
||||
})
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
@@ -549,7 +643,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
||||
bandwidthVisitor = s.visitor(m.Sender, nil)
|
||||
}
|
||||
if !bandwidthVisitor.BandwidthAllowed(stat.Size()) {
|
||||
return errHTTPTooManyRequestsLimitAttachmentBandwidth
|
||||
return errHTTPTooManyRequestsLimitAttachmentBandwidth.With(m)
|
||||
}
|
||||
// Actually send file
|
||||
f, err := os.Open(file)
|
||||
@@ -568,29 +662,44 @@ func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
|
||||
return writeMatrixDiscoveryResponse(w)
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*message, error) {
|
||||
t, err := s.topicFromPath(r.URL.Path)
|
||||
func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, error) {
|
||||
start := time.Now()
|
||||
t, err := fromContext[*topic](r, contextTopic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !v.MessageAllowed() {
|
||||
return nil, errHTTPTooManyRequestsLimitMessages
|
||||
vrate, err := fromContext[*visitor](r, contextRateVisitor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := util.Peek(r.Body, s.config.MessageLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := newDefaultMessage(t.ID, "")
|
||||
cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
cache, firebase, email, unifiedpush, e := s.parsePublishParams(r, m)
|
||||
if e != nil {
|
||||
return nil, e.With(t)
|
||||
}
|
||||
if unifiedpush && s.config.VisitorSubscriberRateLimiting && t.RateVisitor() == nil {
|
||||
// UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting (see
|
||||
// Rate-Topics header). The 5xx response is because some app servers (in particular Mastodon) will remove
|
||||
// the subscription as invalid if any 400-499 code (except 429/408) is returned.
|
||||
// See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46
|
||||
return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
|
||||
} else if !util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) && !vrate.MessageAllowed() {
|
||||
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
|
||||
} else if email != "" && !vrate.EmailAllowed() {
|
||||
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
|
||||
}
|
||||
if m.PollID != "" {
|
||||
m = newPollRequestMessage(t.ID, m.PollID)
|
||||
}
|
||||
m.Sender = v.IP()
|
||||
m.User = v.MaybeUserID()
|
||||
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
|
||||
if cache {
|
||||
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
|
||||
}
|
||||
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -600,6 +709,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
|
||||
delayed := m.Time > time.Now().Unix()
|
||||
ev := logvrm(v, r, m).
|
||||
Tag(tagPublish).
|
||||
With(t).
|
||||
Fields(log.Context{
|
||||
"message_delayed": delayed,
|
||||
"message_firebase": firebase,
|
||||
@@ -640,41 +750,70 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
|
||||
s.mu.Lock()
|
||||
s.messages++
|
||||
s.mu.Unlock()
|
||||
if unifiedpush {
|
||||
minc(metricUnifiedPushPublishedSuccess)
|
||||
}
|
||||
mset(metricMessagePublishDurationMillis, time.Since(start).Milliseconds())
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
m, err := s.handlePublishWithoutResponse(r, v)
|
||||
m, err := s.handlePublishInternal(r, v)
|
||||
if err != nil {
|
||||
minc(metricMessagesPublishedFailure)
|
||||
return err
|
||||
}
|
||||
minc(metricMessagesPublishedSuccess)
|
||||
return s.writeJSON(w, m)
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
_, err := s.handlePublishWithoutResponse(r, v)
|
||||
_, err := s.handlePublishInternal(r, v)
|
||||
if err != nil {
|
||||
return &errMatrix{pushKey: r.Header.Get(matrixPushKeyHeader), err: err}
|
||||
minc(metricMessagesPublishedFailure)
|
||||
minc(metricMatrixPublishedFailure)
|
||||
if e, ok := err.(*errHTTP); ok && e.HTTPCode == errHTTPInsufficientStorageUnifiedPush.HTTPCode {
|
||||
topic, err := fromContext[*topic](r, contextTopic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pushKey, err := fromContext[string](r, contextMatrixPushKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if time.Since(topic.LastAccess()) > matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter {
|
||||
return writeMatrixResponse(w, pushKey)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
minc(metricMessagesPublishedSuccess)
|
||||
minc(metricMatrixPublishedSuccess)
|
||||
return writeMatrixSuccess(w)
|
||||
}
|
||||
|
||||
func (s *Server) sendToFirebase(v *visitor, m *message) {
|
||||
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
|
||||
if err := s.firebaseClient.Send(v, m); err != nil {
|
||||
minc(metricFirebasePublishedFailure)
|
||||
if err == errFirebaseTemporarilyBanned {
|
||||
logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error())
|
||||
} else {
|
||||
logvm(v, m).Tag(tagFirebase).Err(err).Warn("Unable to publish to Firebase: %v", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
minc(metricFirebasePublishedSuccess)
|
||||
}
|
||||
|
||||
func (s *Server) sendEmail(v *visitor, m *message, email string) {
|
||||
logvm(v, m).Tag(tagEmail).Field("email", email).Debug("Sending email to %s", email)
|
||||
if err := s.smtpSender.Send(v, m, email); err != nil {
|
||||
logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error())
|
||||
minc(metricEmailsPublishedFailure)
|
||||
return
|
||||
}
|
||||
minc(metricEmailsPublishedSuccess)
|
||||
}
|
||||
|
||||
func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||
@@ -701,10 +840,10 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) {
|
||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err *errHTTP) {
|
||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||
m.Title = readParam(r, "x-title", "title", "t")
|
||||
m.Title = maybeDecodeHeader(readParam(r, "x-title", "title", "t"))
|
||||
m.Click = readParam(r, "x-click", "click")
|
||||
icon := readParam(r, "x-icon", "icon")
|
||||
filename := readParam(r, "x-filename", "filename", "file", "f")
|
||||
@@ -740,29 +879,19 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||
m.Icon = icon
|
||||
}
|
||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||
if email != "" {
|
||||
if !v.EmailAllowed() {
|
||||
return false, false, "", false, errHTTPTooManyRequestsLimitEmails
|
||||
}
|
||||
}
|
||||
if s.smtpSender == nil && email != "" {
|
||||
return false, false, "", false, errHTTPBadRequestEmailDisabled
|
||||
}
|
||||
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
||||
if messageStr != "" {
|
||||
m.Message = messageStr
|
||||
m.Message = maybeDecodeHeader(messageStr)
|
||||
}
|
||||
m.Priority, err = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||
if err != nil {
|
||||
var e error
|
||||
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||
if e != nil {
|
||||
return false, false, "", false, errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
tagsStr := readParam(r, "x-tags", "tags", "tag", "ta")
|
||||
if tagsStr != "" {
|
||||
m.Tags = make([]string, 0)
|
||||
for _, s := range util.SplitNoEmpty(tagsStr, ",") {
|
||||
m.Tags = append(m.Tags, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||
if delayStr != "" {
|
||||
if !cache {
|
||||
@@ -783,9 +912,9 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
|
||||
}
|
||||
actionsStr := readParam(r, "x-actions", "actions", "action")
|
||||
if actionsStr != "" {
|
||||
m.Actions, err = parseActions(actionsStr)
|
||||
if err != nil {
|
||||
return false, false, "", false, wrapErrHTTP(errHTTPBadRequestActionsInvalid, err.Error())
|
||||
m.Actions, e = parseActions(actionsStr)
|
||||
if e != nil {
|
||||
return false, false, "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
|
||||
}
|
||||
}
|
||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||
@@ -849,7 +978,7 @@ func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeekedRead
|
||||
|
||||
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser) error {
|
||||
if !utf8.Valid(body.PeekedBytes) {
|
||||
return errHTTPBadRequestMessageNotUTF8
|
||||
return errHTTPBadRequestMessageNotUTF8.With(m)
|
||||
}
|
||||
if len(body.PeekedBytes) > 0 { // Empty body should not override message (publish via GET!)
|
||||
m.Message = strings.TrimSpace(string(body.PeekedBytes)) // Truncates the message to the peek limit if required
|
||||
@@ -862,7 +991,7 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
|
||||
|
||||
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
|
||||
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
|
||||
return errHTTPBadRequestAttachmentsDisallowed
|
||||
return errHTTPBadRequestAttachmentsDisallowed.With(m)
|
||||
}
|
||||
vinfo, err := v.Info()
|
||||
if err != nil {
|
||||
@@ -870,13 +999,17 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||
}
|
||||
attachmentExpiry := time.Now().Add(vinfo.Limits.AttachmentExpiryDuration).Unix()
|
||||
if m.Time > attachmentExpiry {
|
||||
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
|
||||
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery.With(m)
|
||||
}
|
||||
contentLengthStr := r.Header.Get("Content-Length")
|
||||
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
|
||||
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
||||
if err == nil && (contentLength > vinfo.Stats.AttachmentTotalSizeRemaining || contentLength > vinfo.Limits.AttachmentFileSizeLimit) {
|
||||
return errHTTPEntityTooLargeAttachment
|
||||
return errHTTPEntityTooLargeAttachment.With(m).Fields(log.Context{
|
||||
"message_content_length": contentLength,
|
||||
"attachment_total_size_remaining": vinfo.Stats.AttachmentTotalSizeRemaining,
|
||||
"attachment_file_size_limit": vinfo.Limits.AttachmentFileSizeLimit,
|
||||
})
|
||||
}
|
||||
}
|
||||
if m.Attachment == nil {
|
||||
@@ -899,7 +1032,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||
}
|
||||
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)
|
||||
if err == util.ErrLimitReached {
|
||||
return errHTTPEntityTooLargeAttachment
|
||||
return errHTTPEntityTooLargeAttachment.With(m)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -952,7 +1085,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
poll, since, scheduled, filters, err := parseSubscribeParams(r)
|
||||
poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -982,9 +1115,15 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset!
|
||||
if poll {
|
||||
for _, t := range topics {
|
||||
t.Keepalive()
|
||||
}
|
||||
return s.sendOldMessages(topics, since, scheduled, v, sub)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -1011,8 +1150,16 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
case <-r.Context().Done():
|
||||
return nil
|
||||
case <-time.After(s.config.KeepaliveInterval):
|
||||
logvr(v, r).Tag(tagSubscribe).Trace("Sending keepalive message")
|
||||
ev := logvr(v, r).Tag(tagSubscribe)
|
||||
if len(topics) == 1 {
|
||||
ev.With(topics[0]).Trace("Sending keepalive message to %s", topics[0].ID)
|
||||
} else {
|
||||
ev.Trace("Sending keepalive message to %d topics", len(topics))
|
||||
}
|
||||
v.Keepalive()
|
||||
for _, t := range topics {
|
||||
t.Keepalive()
|
||||
}
|
||||
if err := sub(v, newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message
|
||||
return err
|
||||
}
|
||||
@@ -1034,7 +1181,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
poll, since, scheduled, filters, err := parseSubscribeParams(r)
|
||||
poll, since, scheduled, filters, rateTopics, err := parseSubscribeParams(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1100,6 +1247,9 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
return &websocket.CloseError{Code: websocket.CloseNormalClosure, Text: "subscription was canceled"}
|
||||
case <-time.After(s.config.KeepaliveInterval):
|
||||
v.Keepalive()
|
||||
for _, t := range topics {
|
||||
t.Keepalive()
|
||||
}
|
||||
if err := ping(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1117,8 +1267,14 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
}
|
||||
return conn.WriteJSON(msg)
|
||||
}
|
||||
if err := s.maybeSetRateVisitors(r, v, topics, rateTopics); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
if poll {
|
||||
for _, t := range topics {
|
||||
t.Keepalive()
|
||||
}
|
||||
return s.sendOldMessages(topics, since, scheduled, v, sub)
|
||||
}
|
||||
subscriberIDs := make([]int, 0)
|
||||
@@ -1144,7 +1300,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
return err
|
||||
}
|
||||
|
||||
func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, err error) {
|
||||
func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, rateTopics []string, err error) {
|
||||
poll = readBoolParam(r, false, "x-poll", "poll", "po")
|
||||
scheduled = readBoolParam(r, false, "x-scheduled", "scheduled", "sched")
|
||||
since, err = parseSince(r, poll)
|
||||
@@ -1155,9 +1311,73 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rateTopics = readCommaSeparatedParam(r, "x-rate-topics", "rate-topics")
|
||||
return
|
||||
}
|
||||
|
||||
// maybeSetRateVisitors sets the rate visitor on a topic (v.SetRateVisitor), indicating that all messages published
|
||||
// to that topic will be rate limited against the rate visitor instead of the publishing visitor.
|
||||
//
|
||||
// Setting the rate visitor is ony allowed if the `visitor-subscriber-rate-limiting` setting is enabled, AND
|
||||
// - auth-file is not set (everything is open by default)
|
||||
// - or the topic is reserved, and v.user is the owner
|
||||
// - or the topic is not reserved, and v.user has write access
|
||||
//
|
||||
// Note: This TEMPORARILY also registers all topics starting with "up" (= UnifiedPush). This is to ease the transition
|
||||
// until the Android app will send the "Rate-Topics" header.
|
||||
func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic, rateTopics []string) error {
|
||||
// Bail out if not enabled
|
||||
if !s.config.VisitorSubscriberRateLimiting {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Make a list of topics that we'll actually set the RateVisitor on
|
||||
eligibleRateTopics := make([]*topic, 0)
|
||||
for _, t := range topics {
|
||||
if (strings.HasPrefix(t.ID, unifiedPushTopicPrefix) && len(t.ID) == unifiedPushTopicLength) || util.Contains(rateTopics, t.ID) {
|
||||
eligibleRateTopics = append(eligibleRateTopics, t)
|
||||
}
|
||||
}
|
||||
if len(eligibleRateTopics) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If access controls are turned off, v has access to everything, and we can set the rate visitor
|
||||
if s.userManager == nil {
|
||||
return s.setRateVisitors(r, v, eligibleRateTopics)
|
||||
}
|
||||
|
||||
// If access controls are enabled, only set rate visitor if
|
||||
// - topic is reserved, and v.user is the owner
|
||||
// - topic is not reserved, and v.user has write access
|
||||
writableRateTopics := make([]*topic, 0)
|
||||
for _, t := range topics {
|
||||
ownerUserID, err := s.userManager.ReservationOwner(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ownerUserID == "" {
|
||||
if err := s.userManager.Authorize(v.User(), t.ID, user.PermissionWrite); err == nil {
|
||||
writableRateTopics = append(writableRateTopics, t)
|
||||
}
|
||||
} else if ownerUserID == v.MaybeUserID() {
|
||||
writableRateTopics = append(writableRateTopics, t)
|
||||
}
|
||||
}
|
||||
return s.setRateVisitors(r, v, writableRateTopics)
|
||||
}
|
||||
|
||||
func (s *Server) setRateVisitors(r *http.Request, v *visitor, rateTopics []*topic) error {
|
||||
for _, t := range rateTopics {
|
||||
logvr(v, r).
|
||||
Tag(tagSubscribe).
|
||||
With(t).
|
||||
Debug("Setting visitor as rate visitor for topic %s", t.ID)
|
||||
t.SetRateVisitor(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the
|
||||
// marker, returning only messages that are newer than the marker.
|
||||
func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, v *visitor, sub subscriber) error {
|
||||
@@ -1337,8 +1557,14 @@ func (s *Server) runFirebaseKeepaliver() {
|
||||
select {
|
||||
case <-time.After(s.config.FirebaseKeepaliveInterval):
|
||||
s.sendToFirebase(v, newKeepaliveMessage(firebaseControlTopic))
|
||||
case <-time.After(s.config.FirebasePollInterval):
|
||||
s.sendToFirebase(v, newKeepaliveMessage(firebasePollTopic))
|
||||
/*
|
||||
FIXME: Disable iOS polling entirely for now due to thundering herd problem (see #677)
|
||||
To solve this, we'd have to shard the iOS poll topics to spread out the polling evenly.
|
||||
Given that it's not really necessary to poll, turning it off for now should not have any impact.
|
||||
|
||||
case <-time.After(s.config.FirebasePollInterval):
|
||||
s.sendToFirebase(v, newKeepaliveMessage(firebasePollTopic))
|
||||
*/
|
||||
case <-s.closeChan:
|
||||
return
|
||||
}
|
||||
@@ -1366,7 +1592,7 @@ func (s *Server) sendDelayedMessages() error {
|
||||
for _, m := range messages {
|
||||
var u *user.User
|
||||
if s.userManager != nil && m.User != "" {
|
||||
u, err = s.userManager.User(m.User)
|
||||
u, err = s.userManager.UserByID(m.User)
|
||||
if err != nil {
|
||||
log.With(m).Err(err).Warn("Error sending delayed message")
|
||||
continue
|
||||
@@ -1382,9 +1608,9 @@ func (s *Server) sendDelayedMessages() error {
|
||||
|
||||
func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
|
||||
logvm(v, m).Debug("Sending delayed message")
|
||||
s.mu.Lock()
|
||||
s.mu.RLock()
|
||||
t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
|
||||
s.mu.Unlock()
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
go func() {
|
||||
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler
|
||||
@@ -1463,11 +1689,15 @@ func (s *Server) transformMatrixJSON(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageLimit)
|
||||
if err != nil {
|
||||
logvr(v, r).Tag(tagMatrix).Err(err).Trace("Invalid Matrix request")
|
||||
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Invalid Matrix request")
|
||||
if e, ok := err.(*errMatrixPushkeyRejected); ok {
|
||||
return writeMatrixResponse(w, e.rejectedPushKey)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := next(w, newRequest, v); err != nil {
|
||||
return &errMatrix{pushKey: newRequest.Header.Get(matrixPushKeyHeader), err: err}
|
||||
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Error handling Matrix request")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1493,8 +1723,8 @@ func (s *Server) autorizeTopic(next handleFunc, perm user.Permission) handleFunc
|
||||
u := v.User()
|
||||
for _, t := range topics {
|
||||
if err := s.userManager.Authorize(u, t.ID, perm); err != nil {
|
||||
logvr(v, r).Err(err).Field("message_topic", t.ID).Debug("Access to topic %s not authorized", t.ID)
|
||||
return errHTTPForbidden
|
||||
logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID)
|
||||
return errHTTPForbidden.With(t)
|
||||
}
|
||||
}
|
||||
return next(w, r, v)
|
||||
@@ -1504,7 +1734,9 @@ func (s *Server) autorizeTopic(next handleFunc, perm user.Permission) handleFunc
|
||||
// maybeAuthenticate reads the "Authorization" header and will try to authenticate the user
|
||||
// if it is set.
|
||||
//
|
||||
// - If the header is not set, an IP-based visitor is returned
|
||||
// - If auth-file is not configured, immediately return an IP-based visitor
|
||||
// - If the header is not set or not supported (anything non-Basic and non-Bearer),
|
||||
// an IP-based visitor is returned
|
||||
// - If the header is set, authenticate will be called to check the username/password (Basic auth),
|
||||
// or the token (Bearer auth), and read the user from the database
|
||||
//
|
||||
@@ -1514,13 +1746,14 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
|
||||
// Read "Authorization" header value, and exit out early if it's not set
|
||||
ip := extractIPAddress(r, s.config.BehindProxy)
|
||||
vip := s.visitor(ip, nil)
|
||||
if s.userManager == nil {
|
||||
return vip, nil
|
||||
}
|
||||
header, err := readAuthHeader(r)
|
||||
if err != nil {
|
||||
return vip, err
|
||||
} else if header == "" {
|
||||
} else if !supportedAuthHeader(header) {
|
||||
return vip, nil
|
||||
} else if s.userManager == nil {
|
||||
return vip, errHTTPUnauthorized
|
||||
}
|
||||
// If we're trying to auth, check the rate limiter first
|
||||
if !vip.AuthAllowed() {
|
||||
@@ -1562,6 +1795,14 @@ func readAuthHeader(r *http.Request) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// supportedAuthHeader returns true only if the Authorization header value starts
|
||||
// with "Basic" or "Bearer". In particular, an empty value is not supported, and neither
|
||||
// are things like "WebPush", or "vapid" (see #629).
|
||||
func supportedAuthHeader(value string) bool {
|
||||
value = strings.ToLower(value)
|
||||
return strings.HasPrefix(value, "basic ") || strings.HasPrefix(value, "bearer ")
|
||||
}
|
||||
|
||||
func (s *Server) authenticateBasicAuth(r *http.Request, value string) (user *user.User, err error) {
|
||||
r.Header.Set("Authorization", value)
|
||||
username, password, ok := r.BasicAuth()
|
||||
@@ -1608,3 +1849,17 @@ func (s *Server) writeJSON(w http.ResponseWriter, v any) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateAndWriteStats(messagesCount int64) {
|
||||
s.mu.Lock()
|
||||
s.messagesHistory = append(s.messagesHistory, messagesCount)
|
||||
if len(s.messagesHistory) > messagesHistoryMax {
|
||||
s.messagesHistory = s.messagesHistory[1:]
|
||||
}
|
||||
s.mu.Unlock()
|
||||
go func() {
|
||||
if err := s.messageCache.UpdateStats(messagesCount); err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Cannot write messages stats")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -117,18 +117,19 @@
|
||||
# attachment-expiry-duration: "3h"
|
||||
|
||||
# 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. As of today, only
|
||||
# SMTP servers with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings
|
||||
# below (visitor-email-limit-burst & visitor-email-limit-burst).
|
||||
# messages will additionally be sent out as e-mail using an external SMTP server.
|
||||
#
|
||||
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTLS are supported.
|
||||
# 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-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-user/smtp-sender-pass are the username and password of the SMTP user (leave blank for no auth)
|
||||
#
|
||||
# smtp-sender-addr:
|
||||
# smtp-sender-from:
|
||||
# smtp-sender-user:
|
||||
# smtp-sender-pass:
|
||||
# smtp-sender-from:
|
||||
|
||||
# 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.
|
||||
@@ -234,15 +235,54 @@
|
||||
# visitor-attachment-total-size-limit: "100M"
|
||||
# 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
|
||||
#
|
||||
# - 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.
|
||||
# - 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.
|
||||
# - 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-webhook-key:
|
||||
# billing-contact:
|
||||
|
||||
# Metrics
|
||||
#
|
||||
# ntfy can expose Prometheus-style metrics via a /metrics endpoint, or on a dedicated listen IP/port.
|
||||
# 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"
|
||||
#
|
||||
# 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
|
||||
#
|
||||
|
||||
@@ -100,6 +100,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||
Customer: true,
|
||||
Subscription: u.Billing.StripeSubscriptionID != "",
|
||||
Status: string(u.Billing.StripeSubscriptionStatus),
|
||||
Interval: string(u.Billing.StripeSubscriptionInterval),
|
||||
PaidUntil: u.Billing.StripeSubscriptionPaidUntil.Unix(),
|
||||
CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(),
|
||||
}
|
||||
@@ -506,6 +507,7 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi
|
||||
if err := s.messageCache.ExpireMessages(topics...); err != nil {
|
||||
return err
|
||||
}
|
||||
go s.pruneMessages()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -290,6 +290,7 @@ func TestAccount_ChangePassword_NoAccount(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccount_ExtendToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
@@ -611,6 +612,7 @@ func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||
t.Parallel()
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.AuthDefault = user.PermissionReadWrite
|
||||
s := newTestServer(t, conf)
|
||||
@@ -655,6 +657,17 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||
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",
|
||||
@@ -670,9 +683,13 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||
|
||||
// Verify that messages and attachments were deleted
|
||||
// This does not explicitly call the manager!
|
||||
time.Sleep(time.Second)
|
||||
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)
|
||||
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))
|
||||
@@ -684,91 +701,10 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
|
||||
}
|
||||
|
||||
func TestAccount_Reservation_Add_Kills_Other_Subscribers(t *testing.T) {
|
||||
/*func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.AuthDefault = user.PermissionReadWrite
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create user with tier
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 20,
|
||||
ReservationLimit: 2,
|
||||
}))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
|
||||
// Subscribe anonymously
|
||||
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"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// 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"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Kill user Go routine
|
||||
s.topics["mytopic"].CancelSubscribers("<invalid>")
|
||||
|
||||
select {
|
||||
case <-userCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Waiting for user subscription to be killed failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.AuthDefault = user.PermissionReadWrite
|
||||
conf.AuthStatsQueueWriterInterval = 200 * time.Millisecond
|
||||
conf.AuthStatsQueueWriterInterval = 300 * time.Millisecond
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
@@ -790,13 +726,12 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Wait for stats queue writer
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Verify that message stats were persisted
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(1), u.Stats.Messages)
|
||||
// Wait for stats queue writer, verify that message stats were persisted
|
||||
waitFor(t, func() bool {
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
return int64(1) == u.Stats.Messages
|
||||
})
|
||||
|
||||
// Change tier, make a request (to reset limiters)
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
@@ -814,10 +749,11 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Verify that message stats were persisted
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
u, err = s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(2), u.Stats.Messages) // v.EnqueueUserStats had run!
|
||||
waitFor(t, func() bool {
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
return int64(2) == u.Stats.Messages // v.EnqueueUserStats had run!
|
||||
})
|
||||
|
||||
// Stats keep counting
|
||||
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||
@@ -826,5 +762,4 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
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!
|
||||
|
||||
}
|
||||
}*/
|
||||
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -34,16 +35,20 @@ func (s *Server) execManager() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, t := range s.topics {
|
||||
subs := t.SubscribersCount()
|
||||
log.Tag(tagManager).Trace("- topic %s: %d subscribers", t.ID, subs)
|
||||
msgs, exists := messageCounts[t.ID]
|
||||
if subs == 0 && (!exists || msgs == 0) {
|
||||
log.Tag(tagManager).Trace("Deleting empty topic %s", t.ID)
|
||||
subs, lastAccess := t.Stats()
|
||||
ev := log.Tag(tagManager).With(t)
|
||||
if t.Stale() {
|
||||
if ev.IsTrace() {
|
||||
ev.Trace("- topic %s: Deleting stale topic (%d subscribers, accessed %s)", t.ID, subs, util.FormatTime(lastAccess))
|
||||
}
|
||||
emptyTopics++
|
||||
delete(s.topics, t.ID)
|
||||
continue
|
||||
} else {
|
||||
if ev.IsTrace() {
|
||||
ev.Trace("- topic %s: %d subscribers, accessed %s", t.ID, subs, util.FormatTime(lastAccess))
|
||||
}
|
||||
subscribers += subs
|
||||
}
|
||||
subscribers += subs
|
||||
}
|
||||
}).
|
||||
Debug("Removed %d empty topic(s)", emptyTopics)
|
||||
@@ -58,10 +63,24 @@ func (s *Server) execManager() {
|
||||
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
|
||||
}
|
||||
|
||||
// Users
|
||||
var usersCount int64
|
||||
if s.userManager != nil {
|
||||
usersCount, err = s.userManager.UsersCount()
|
||||
if err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error counting users")
|
||||
}
|
||||
}
|
||||
|
||||
// Print stats
|
||||
s.mu.Lock()
|
||||
s.mu.RLock()
|
||||
messagesCount, topicsCount, visitorsCount := s.messages, len(s.topics), len(s.visitors)
|
||||
s.mu.Unlock()
|
||||
s.mu.RUnlock()
|
||||
|
||||
// Update stats
|
||||
s.updateAndWriteStats(messagesCount)
|
||||
|
||||
// Log stats
|
||||
log.
|
||||
Tag(tagManager).
|
||||
Fields(log.Context{
|
||||
@@ -70,6 +89,7 @@ func (s *Server) execManager() {
|
||||
"topics_active": topicsCount,
|
||||
"subscribers": subscribers,
|
||||
"visitors": visitorsCount,
|
||||
"users": usersCount,
|
||||
"emails_received": receivedMailTotal,
|
||||
"emails_received_success": receivedMailSuccess,
|
||||
"emails_received_failure": receivedMailFailure,
|
||||
@@ -78,6 +98,11 @@ func (s *Server) execManager() {
|
||||
"emails_sent_failure": sentMailFailure,
|
||||
}).
|
||||
Info("Server stats")
|
||||
mset(metricMessagesCached, messagesCached)
|
||||
mset(metricVisitors, visitorsCount)
|
||||
mset(metricUsers, usersCount)
|
||||
mset(metricSubscribers, subscribers)
|
||||
mset(metricTopics, topicsCount)
|
||||
}
|
||||
|
||||
func (s *Server) pruneVisitors() {
|
||||
@@ -116,29 +141,30 @@ func (s *Server) pruneTokens() {
|
||||
}
|
||||
|
||||
func (s *Server) pruneAttachments() {
|
||||
if s.fileCache != nil {
|
||||
log.
|
||||
Tag(tagManager).
|
||||
Timing(func() {
|
||||
ids, err := s.messageCache.AttachmentsExpired()
|
||||
if err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments")
|
||||
} else if len(ids) > 0 {
|
||||
if log.Tag(tagManager).IsDebug() {
|
||||
log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", "))
|
||||
}
|
||||
if err := s.fileCache.Remove(ids...); err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error deleting attachments")
|
||||
}
|
||||
if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")
|
||||
}
|
||||
} else {
|
||||
log.Tag(tagManager).Debug("No expired attachments to delete")
|
||||
}
|
||||
}).
|
||||
Debug("Deleted expired attachments")
|
||||
if s.fileCache == nil {
|
||||
return
|
||||
}
|
||||
log.
|
||||
Tag(tagManager).
|
||||
Timing(func() {
|
||||
ids, err := s.messageCache.AttachmentsExpired()
|
||||
if err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments")
|
||||
} else if len(ids) > 0 {
|
||||
if log.Tag(tagManager).IsDebug() {
|
||||
log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", "))
|
||||
}
|
||||
if err := s.fileCache.Remove(ids...); err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error deleting attachments")
|
||||
}
|
||||
if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")
|
||||
}
|
||||
} else {
|
||||
log.Tag(tagManager).Debug("No expired attachments to delete")
|
||||
}
|
||||
}).
|
||||
Debug("Deleted expired attachments")
|
||||
}
|
||||
|
||||
func (s *Server) pruneMessages() {
|
||||
@@ -149,8 +175,10 @@ func (s *Server) pruneMessages() {
|
||||
if err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error retrieving expired messages")
|
||||
} else if len(expiredMessageIDs) > 0 {
|
||||
if err := s.fileCache.Remove(expiredMessageIDs...); err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error deleting attachments for expired messages")
|
||||
if s.fileCache != nil {
|
||||
if err := s.fileCache.Remove(expiredMessageIDs...); err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error deleting attachments for expired messages")
|
||||
}
|
||||
}
|
||||
if err := s.messageCache.DeleteMessages(expiredMessageIDs...); err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")
|
||||
|
||||
28
server/server_manager_test.go
Normal file
28
server/server_manager_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServer_Manager_Prune_Messages_Without_Attachments_DoesNotPanic(t *testing.T) {
|
||||
// Tests that the manager runs without attachment-cache-dir set, see #617
|
||||
c := newTestConfig(t)
|
||||
c.AttachmentCacheDir = ""
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Publish a message
|
||||
rr := request(t, s, "POST", "/mytopic", "hi", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
m := toMessage(t, rr.Body.String())
|
||||
|
||||
// Expire message
|
||||
require.Nil(t, s.messageCache.ExpireMessages("mytopic"))
|
||||
|
||||
// Does not panic
|
||||
s.pruneMessages()
|
||||
|
||||
// Actually deleted
|
||||
_, err := s.messageCache.Message(m.ID)
|
||||
require.Equal(t, errMessageNotFound, err)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Matrix Push Gateway / UnifiedPush / ntfy integration:
|
||||
@@ -71,25 +72,27 @@ type matrixResponse struct {
|
||||
Rejected []string `json:"rejected"`
|
||||
}
|
||||
|
||||
// errMatrix represents an error when handing Matrix gateway messages
|
||||
type errMatrix struct {
|
||||
pushKey string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e errMatrix) Error() string {
|
||||
if e.err != nil {
|
||||
return fmt.Sprintf("message with push key %s rejected: %s", e.pushKey, e.err.Error())
|
||||
}
|
||||
return fmt.Sprintf("message with push key %s rejected", e.pushKey)
|
||||
}
|
||||
|
||||
const (
|
||||
// matrixPushKeyHeader is a header that's used internally to pass the Matrix push key (from the matrixRequest)
|
||||
// along with the request. The push key is only used if an error occurs down the line.
|
||||
matrixPushKeyHeader = "X-Matrix-Pushkey"
|
||||
// matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter is the time after which a Matrix response
|
||||
// will return an HTTP 200 with the push key (i.e. "rejected":["<pushkey>"]}), if no rate visitor has been set on
|
||||
// the topic. Rejecting the push key will instruct the Matrix server to invalidate the pushkey and stop sending
|
||||
// messages to it. This must be longer than topicExpungeAfter. See https://spec.matrix.org/v1.6/push-gateway-api/
|
||||
matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter = 12 * time.Hour
|
||||
)
|
||||
|
||||
// errMatrixPushkeyRejected represents an error when handing Matrix gateway messages
|
||||
//
|
||||
// If the push key is set, the app server will remove it and will never send messages using the same
|
||||
// push key again, until the user repairs it.
|
||||
type errMatrixPushkeyRejected struct {
|
||||
rejectedPushKey string
|
||||
configuredBaseURL string
|
||||
}
|
||||
|
||||
func (e errMatrixPushkeyRejected) Error() string {
|
||||
return fmt.Sprintf("push key must be prefixed with base URL, received push key: %s, configured base URL: %s", e.rejectedPushKey, e.configuredBaseURL)
|
||||
}
|
||||
|
||||
// newRequestFromMatrixJSON reads the request body as a Matrix JSON message, parses the "pushkey", and creates a new
|
||||
// HTTP request that looks like a normal ntfy request from it.
|
||||
//
|
||||
@@ -122,17 +125,19 @@ func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int)
|
||||
}
|
||||
pushKey := m.Notification.Devices[0].PushKey // We ignore other devices for now, see discussion in #316
|
||||
if !strings.HasPrefix(pushKey, baseURL+"/") {
|
||||
return nil, &errMatrix{pushKey: pushKey, err: wrapErrHTTP(errHTTPBadRequestMatrixPushkeyBaseURLMismatch, "received push key: %s, configured base URL: %s", pushKey, baseURL)}
|
||||
return nil, &errMatrixPushkeyRejected{rejectedPushKey: pushKey, configuredBaseURL: baseURL}
|
||||
}
|
||||
newRequest, err := http.NewRequest(http.MethodPost, pushKey, io.NopCloser(bytes.NewReader(body.PeekedBytes)))
|
||||
if err != nil {
|
||||
return nil, &errMatrix{pushKey: pushKey, err: err}
|
||||
return nil, err
|
||||
}
|
||||
newRequest.RemoteAddr = r.RemoteAddr // Not strictly necessary, since visitor was already extracted
|
||||
if r.Header.Get("X-Forwarded-For") != "" {
|
||||
newRequest.Header.Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
|
||||
}
|
||||
newRequest.Header.Set(matrixPushKeyHeader, pushKey)
|
||||
newRequest = withContext(newRequest, map[contextKey]any{
|
||||
contextMatrixPushKey: pushKey,
|
||||
})
|
||||
return newRequest, nil
|
||||
}
|
||||
|
||||
@@ -144,12 +149,6 @@ func writeMatrixDiscoveryResponse(w http.ResponseWriter) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// writeMatrixError logs and writes the errMatrix to the given http.ResponseWriter as a matrixResponse
|
||||
func writeMatrixError(w http.ResponseWriter, r *http.Request, v *visitor, err *errMatrix) error {
|
||||
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Matrix gateway error")
|
||||
return writeMatrixResponse(w, err.pushKey)
|
||||
}
|
||||
|
||||
// writeMatrixSuccess writes a successful matrixResponse (no rejected push key) to the given http.ResponseWriter
|
||||
func writeMatrixSuccess(w http.ResponseWriter) error {
|
||||
return writeMatrixResponse(w, "")
|
||||
|
||||
@@ -3,7 +3,6 @@ package server
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -19,7 +18,6 @@ func TestMatrix_NewRequestFromMatrixJSON_Success(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "POST", newRequest.Method)
|
||||
require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.URL.String())
|
||||
require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.Header.Get("X-Matrix-Pushkey"))
|
||||
require.Equal(t, body, readAll(t, newRequest.Body))
|
||||
}
|
||||
|
||||
@@ -56,10 +54,10 @@ func TestMatrix_NewRequestFromMatrixJSON_MismatchingPushKey(t *testing.T) {
|
||||
body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.example.com/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}`
|
||||
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
|
||||
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
|
||||
matrixErr, ok := err.(*errMatrix)
|
||||
matrixErr, ok := err.(*errMatrixPushkeyRejected)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "invalid request: push key must be prefixed with base URL, received push key: https://ntfy.example.com/upABCDEFGHI?up=1, configured base URL: https://ntfy.sh", matrixErr.err.Error())
|
||||
require.Equal(t, "https://ntfy.example.com/upABCDEFGHI?up=1", matrixErr.pushKey)
|
||||
require.Equal(t, "push key must be prefixed with base URL, received push key: https://ntfy.example.com/upABCDEFGHI?up=1, configured base URL: https://ntfy.sh", matrixErr.Error())
|
||||
require.Equal(t, "https://ntfy.example.com/upABCDEFGHI?up=1", matrixErr.rejectedPushKey)
|
||||
}
|
||||
|
||||
func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) {
|
||||
@@ -71,9 +69,7 @@ func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) {
|
||||
|
||||
func TestMatrix_WriteMatrixError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", nil)
|
||||
v := newVisitor(newTestConfig(t), nil, nil, netip.MustParseAddr("1.2.3.4"), nil)
|
||||
require.Nil(t, writeMatrixError(w, r, v, &errMatrix{"https://ntfy.example.com/upABCDEFGHI?up=1", errHTTPBadRequestMatrixPushkeyBaseURLMismatch}))
|
||||
require.Nil(t, writeMatrixResponse(w, "https://ntfy.example.com/upABCDEFGHI?up=1"))
|
||||
require.Equal(t, 200, w.Result().StatusCode)
|
||||
require.Equal(t, `{"rejected":["https://ntfy.example.com/upABCDEFGHI?up=1"]}`+"\n", w.Body.String())
|
||||
}
|
||||
|
||||
122
server/server_metrics.go
Normal file
122
server/server_metrics.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
metricMessagesPublishedSuccess prometheus.Counter
|
||||
metricMessagesPublishedFailure prometheus.Counter
|
||||
metricMessagesCached prometheus.Gauge
|
||||
metricMessagePublishDurationMillis prometheus.Gauge
|
||||
metricFirebasePublishedSuccess prometheus.Counter
|
||||
metricFirebasePublishedFailure prometheus.Counter
|
||||
metricEmailsPublishedSuccess prometheus.Counter
|
||||
metricEmailsPublishedFailure prometheus.Counter
|
||||
metricEmailsReceivedSuccess prometheus.Counter
|
||||
metricEmailsReceivedFailure prometheus.Counter
|
||||
metricUnifiedPushPublishedSuccess prometheus.Counter
|
||||
metricMatrixPublishedSuccess prometheus.Counter
|
||||
metricMatrixPublishedFailure prometheus.Counter
|
||||
metricAttachmentsTotalSize prometheus.Gauge
|
||||
metricVisitors prometheus.Gauge
|
||||
metricSubscribers prometheus.Gauge
|
||||
metricTopics prometheus.Gauge
|
||||
metricUsers prometheus.Gauge
|
||||
metricHTTPRequests *prometheus.CounterVec
|
||||
)
|
||||
|
||||
func initMetrics() {
|
||||
metricMessagesPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_messages_published_success",
|
||||
})
|
||||
metricMessagesPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_messages_published_failure",
|
||||
})
|
||||
metricMessagesCached = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_messages_cached_total",
|
||||
})
|
||||
metricMessagePublishDurationMillis = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_message_publish_duration_ms",
|
||||
})
|
||||
metricFirebasePublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_firebase_published_success",
|
||||
})
|
||||
metricFirebasePublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_firebase_published_failure",
|
||||
})
|
||||
metricEmailsPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_sent_success",
|
||||
})
|
||||
metricEmailsPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_sent_failure",
|
||||
})
|
||||
metricEmailsReceivedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_received_success",
|
||||
})
|
||||
metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_received_failure",
|
||||
})
|
||||
metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_unifiedpush_published_success",
|
||||
})
|
||||
metricMatrixPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_matrix_published_success",
|
||||
})
|
||||
metricMatrixPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_matrix_published_failure",
|
||||
})
|
||||
metricAttachmentsTotalSize = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_attachments_total_size",
|
||||
})
|
||||
metricVisitors = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_visitors_total",
|
||||
})
|
||||
metricUsers = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_users_total",
|
||||
})
|
||||
metricSubscribers = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_subscribers_total",
|
||||
})
|
||||
metricTopics = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_topics_total",
|
||||
})
|
||||
metricHTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ntfy_http_requests_total",
|
||||
}, []string{"http_code", "ntfy_code", "http_method"})
|
||||
prometheus.MustRegister(
|
||||
metricMessagesPublishedSuccess,
|
||||
metricMessagesPublishedFailure,
|
||||
metricMessagesCached,
|
||||
metricMessagePublishDurationMillis,
|
||||
metricFirebasePublishedSuccess,
|
||||
metricFirebasePublishedFailure,
|
||||
metricEmailsPublishedSuccess,
|
||||
metricEmailsPublishedFailure,
|
||||
metricEmailsReceivedSuccess,
|
||||
metricEmailsReceivedFailure,
|
||||
metricUnifiedPushPublishedSuccess,
|
||||
metricMatrixPublishedSuccess,
|
||||
metricMatrixPublishedFailure,
|
||||
metricAttachmentsTotalSize,
|
||||
metricVisitors,
|
||||
metricUsers,
|
||||
metricSubscribers,
|
||||
metricTopics,
|
||||
metricHTTPRequests,
|
||||
)
|
||||
}
|
||||
|
||||
// minc increments a prometheus.Counter if it is non-nil
|
||||
func minc(counter prometheus.Counter) {
|
||||
if counter != nil {
|
||||
counter.Inc()
|
||||
}
|
||||
}
|
||||
|
||||
// mset sets a prometheus.Gauge if it is non-nil
|
||||
func mset[T int | int64 | float64](gauge prometheus.Gauge, value T) {
|
||||
if gauge != nil {
|
||||
gauge.Set(float64(value))
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"heckel.io/ntfy/util"
|
||||
"net/http"
|
||||
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
contextRateVisitor contextKey = iota + 2586
|
||||
contextTopic
|
||||
contextMatrixPushKey
|
||||
)
|
||||
|
||||
func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
@@ -16,6 +25,30 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// limitRequestsWithTopic limits requests with a topic and stores the rate-limiting-subscriber and topic into request.Context
|
||||
func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
t, err := s.topicFromPath(r.URL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vrate := v
|
||||
if rateVisitor := t.RateVisitor(); rateVisitor != nil {
|
||||
vrate = rateVisitor
|
||||
}
|
||||
r = withContext(r, map[contextKey]any{
|
||||
contextRateVisitor: vrate,
|
||||
contextTopic: t,
|
||||
})
|
||||
if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) {
|
||||
return next(w, r, v)
|
||||
} else if !vrate.RequestAllowed() {
|
||||
return errHTTPTooManyRequestsLimitRequests
|
||||
}
|
||||
return next(w, r, v)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if !s.config.EnableWeb {
|
||||
|
||||
@@ -80,14 +80,17 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
|
||||
return err
|
||||
}
|
||||
for _, tier := range tiers {
|
||||
priceStr, ok := prices[tier.StripePriceID]
|
||||
if tier.StripePriceID == "" || !ok {
|
||||
priceMonth, priceYear := prices[tier.StripeMonthlyPriceID], prices[tier.StripeYearlyPriceID]
|
||||
if priceMonth == 0 || priceYear == 0 { // Only allow tiers that have both prices!
|
||||
continue
|
||||
}
|
||||
response = append(response, &apiAccountBillingTier{
|
||||
Code: tier.Code,
|
||||
Name: tier.Name,
|
||||
Price: priceStr,
|
||||
Code: tier.Code,
|
||||
Name: tier.Name,
|
||||
Prices: &apiAccountBillingPrices{
|
||||
Month: priceMonth,
|
||||
Year: priceYear,
|
||||
},
|
||||
Limits: &apiAccountLimits{
|
||||
Basis: string(visitorLimitBasisTier),
|
||||
Messages: tier.MessageLimit,
|
||||
@@ -117,11 +120,21 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
|
||||
tier, err := s.userManager.Tier(req.Tier)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if tier.StripePriceID == "" {
|
||||
}
|
||||
var priceID string
|
||||
if req.Interval == string(stripe.PriceRecurringIntervalMonth) && tier.StripeMonthlyPriceID != "" {
|
||||
priceID = tier.StripeMonthlyPriceID
|
||||
} else if req.Interval == string(stripe.PriceRecurringIntervalYear) && tier.StripeYearlyPriceID != "" {
|
||||
priceID = tier.StripeYearlyPriceID
|
||||
} else {
|
||||
return errNotAPaidTier
|
||||
}
|
||||
logvr(v, r).
|
||||
With(tier).
|
||||
Fields(log.Context{
|
||||
"stripe_price_id": priceID,
|
||||
"stripe_subscription_interval": req.Interval,
|
||||
}).
|
||||
Tag(tagStripe).
|
||||
Info("Creating Stripe checkout flow")
|
||||
var stripeCustomerID *string
|
||||
@@ -143,7 +156,7 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
|
||||
AllowPromotionCodes: stripe.Bool(true),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(tier.StripePriceID),
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
@@ -175,15 +188,16 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
|
||||
if err != nil {
|
||||
return err
|
||||
} else if sess.Customer == nil || sess.Subscription == nil || sess.ClientReferenceID == "" {
|
||||
return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "customer or subscription not found")
|
||||
return errHTTPBadRequestBillingRequestInvalid.Wrap("customer or subscription not found")
|
||||
}
|
||||
sub, err := s.stripe.GetSubscription(sess.Subscription.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if sub.Items == nil || len(sub.Items.Data) != 1 || sub.Items.Data[0].Price == nil {
|
||||
return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "more than one line item in existing subscription")
|
||||
} else if sub.Items == nil || len(sub.Items.Data) != 1 || sub.Items.Data[0].Price == nil || sub.Items.Data[0].Price.Recurring == nil {
|
||||
return errHTTPBadRequestBillingRequestInvalid.Wrap("more than one line item in existing subscription")
|
||||
}
|
||||
tier, err := s.userManager.TierByStripePrice(sub.Items.Data[0].Price.ID)
|
||||
priceID, interval := sub.Items.Data[0].Price.ID, sub.Items.Data[0].Price.Recurring.Interval
|
||||
tier, err := s.userManager.TierByStripePrice(priceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -197,8 +211,10 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
|
||||
Tag(tagStripe).
|
||||
Fields(log.Context{
|
||||
"stripe_customer_id": sess.Customer.ID,
|
||||
"stripe_price_id": priceID,
|
||||
"stripe_subscription_id": sub.ID,
|
||||
"stripe_subscription_status": string(sub.Status),
|
||||
"stripe_subscription_interval": string(interval),
|
||||
"stripe_subscription_paid_until": sub.CurrentPeriodEnd,
|
||||
}).
|
||||
Info("Stripe checkout flow succeeded, updating user tier and subscription")
|
||||
@@ -213,7 +229,7 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
|
||||
if _, err := s.stripe.UpdateCustomer(sess.Customer.ID, customerParams); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
|
||||
if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
|
||||
return err
|
||||
}
|
||||
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
|
||||
@@ -235,28 +251,37 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var priceID string
|
||||
if req.Interval == string(stripe.PriceRecurringIntervalMonth) && tier.StripeMonthlyPriceID != "" {
|
||||
priceID = tier.StripeMonthlyPriceID
|
||||
} else if req.Interval == string(stripe.PriceRecurringIntervalYear) && tier.StripeYearlyPriceID != "" {
|
||||
priceID = tier.StripeYearlyPriceID
|
||||
} else {
|
||||
return errNotAPaidTier
|
||||
}
|
||||
logvr(v, r).
|
||||
Tag(tagStripe).
|
||||
Fields(log.Context{
|
||||
"new_tier_id": tier.ID,
|
||||
"new_tier_name": tier.Name,
|
||||
"new_tier_stripe_price_id": tier.StripePriceID,
|
||||
"new_tier_id": tier.ID,
|
||||
"new_tier_code": tier.Code,
|
||||
"new_tier_stripe_price_id": priceID,
|
||||
"new_tier_stripe_subscription_interval": req.Interval,
|
||||
// Other stripe_* fields filled by visitor context
|
||||
}).
|
||||
Info("Changing Stripe subscription and billing tier to %s/%s (price %s)", tier.ID, tier.Name, tier.StripePriceID)
|
||||
Info("Changing Stripe subscription and billing tier to %s/%s (price %s, %s)", tier.ID, tier.Name, priceID, req.Interval)
|
||||
sub, err := s.stripe.GetSubscription(u.Billing.StripeSubscriptionID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if sub.Items == nil || len(sub.Items.Data) != 1 {
|
||||
return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "no items, or more than one item")
|
||||
return errHTTPBadRequestBillingRequestInvalid.Wrap("no items, or more than one item")
|
||||
}
|
||||
params := &stripe.SubscriptionParams{
|
||||
CancelAtPeriodEnd: stripe.Bool(false),
|
||||
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
|
||||
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)),
|
||||
Items: []*stripe.SubscriptionItemsParams{
|
||||
{
|
||||
ID: stripe.String(sub.Items.Data[0].ID),
|
||||
Price: stripe.String(tier.StripePriceID),
|
||||
Price: stripe.String(priceID),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -345,20 +370,22 @@ func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(r *http.Request,
|
||||
ev, err := util.UnmarshalJSON[apiStripeSubscriptionUpdatedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw)))
|
||||
if err != nil {
|
||||
return err
|
||||
} else if ev.ID == "" || ev.Customer == "" || ev.Status == "" || ev.CurrentPeriodEnd == 0 || ev.Items == nil || len(ev.Items.Data) != 1 || ev.Items.Data[0].Price == nil || ev.Items.Data[0].Price.ID == "" {
|
||||
} else if ev.ID == "" || ev.Customer == "" || ev.Status == "" || ev.CurrentPeriodEnd == 0 || ev.Items == nil || len(ev.Items.Data) != 1 || ev.Items.Data[0].Price == nil || ev.Items.Data[0].Price.ID == "" || ev.Items.Data[0].Price.Recurring == nil {
|
||||
logvr(v, r).Tag(tagStripe).Field("stripe_request", fmt.Sprintf("%#v", ev)).Warn("Unexpected request from Stripe")
|
||||
return errHTTPBadRequestBillingRequestInvalid
|
||||
}
|
||||
subscriptionID, priceID := ev.ID, ev.Items.Data[0].Price.ID
|
||||
subscriptionID, priceID, interval := ev.ID, ev.Items.Data[0].Price.ID, ev.Items.Data[0].Price.Recurring.Interval
|
||||
logvr(v, r).
|
||||
Tag(tagStripe).
|
||||
Fields(log.Context{
|
||||
"stripe_webhook_type": event.Type,
|
||||
"stripe_customer_id": ev.Customer,
|
||||
"stripe_price_id": priceID,
|
||||
"stripe_subscription_id": ev.ID,
|
||||
"stripe_subscription_status": ev.Status,
|
||||
"stripe_subscription_interval": interval,
|
||||
"stripe_subscription_paid_until": ev.CurrentPeriodEnd,
|
||||
"stripe_subscription_cancel_at": ev.CancelAt,
|
||||
"stripe_price_id": priceID,
|
||||
}).
|
||||
Info("Updating subscription to status %s, with price %s", ev.Status, priceID)
|
||||
userFn := func() (*user.User, error) {
|
||||
@@ -376,7 +403,7 @@ func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(r *http.Request,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.updateSubscriptionAndTier(r, v, u, tier, ev.Customer, subscriptionID, ev.Status, ev.CurrentPeriodEnd, ev.CancelAt); err != nil {
|
||||
if err := s.updateSubscriptionAndTier(r, v, u, tier, ev.Customer, subscriptionID, ev.Status, string(interval), ev.CurrentPeriodEnd, ev.CancelAt); err != nil {
|
||||
return err
|
||||
}
|
||||
s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u))
|
||||
@@ -399,14 +426,14 @@ func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(r *http.Request,
|
||||
Tag(tagStripe).
|
||||
Field("stripe_webhook_type", event.Type).
|
||||
Info("Subscription deleted, downgrading to unpaid tier")
|
||||
if err := s.updateSubscriptionAndTier(r, v, u, nil, ev.Customer, "", "", 0, 0); err != nil {
|
||||
if err := s.updateSubscriptionAndTier(r, v, u, nil, ev.Customer, "", "", "", 0, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.User, tier *user.Tier, customerID, subscriptionID, status string, paidUntil, cancelAt int64) error {
|
||||
func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.User, tier *user.Tier, customerID, subscriptionID, status, interval string, paidUntil, cancelAt int64) error {
|
||||
reservationsLimit := visitorDefaultReservationsLimit
|
||||
if tier != nil {
|
||||
reservationsLimit = tier.ReservationLimit
|
||||
@@ -423,9 +450,8 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.
|
||||
logvr(v, r).
|
||||
Tag(tagStripe).
|
||||
Fields(log.Context{
|
||||
"new_tier_id": tier.ID,
|
||||
"new_tier_name": tier.Name,
|
||||
"new_tier_stripe_price_id": tier.StripePriceID,
|
||||
"new_tier_id": tier.ID,
|
||||
"new_tier_code": tier.Code,
|
||||
}).
|
||||
Info("Changing tier to tier %s (%s) for user %s", tier.ID, tier.Name, u.Name)
|
||||
if err := s.userManager.ChangeTier(u.Name, tier.Code); err != nil {
|
||||
@@ -437,6 +463,7 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.
|
||||
StripeCustomerID: customerID,
|
||||
StripeSubscriptionID: subscriptionID,
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatus(status),
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringInterval(interval),
|
||||
StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0),
|
||||
StripeSubscriptionCancelAt: time.Unix(cancelAt, 0),
|
||||
}
|
||||
@@ -448,20 +475,16 @@ func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.
|
||||
|
||||
// fetchStripePrices contacts the Stripe API to retrieve all prices. This is used by the server to cache the prices
|
||||
// in memory, and ultimately for the web app to display the price table.
|
||||
func (s *Server) fetchStripePrices() (map[string]string, error) {
|
||||
func (s *Server) fetchStripePrices() (map[string]int64, error) {
|
||||
log.Debug("Caching prices from Stripe API")
|
||||
priceMap := make(map[string]string)
|
||||
priceMap := make(map[string]int64)
|
||||
prices, err := s.stripe.ListPrices(&stripe.PriceListParams{Active: stripe.Bool(true)})
|
||||
if err != nil {
|
||||
log.Warn("Fetching Stripe prices failed: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range prices {
|
||||
if p.UnitAmount%100 == 0 {
|
||||
priceMap[p.ID] = fmt.Sprintf("$%d", p.UnitAmount/100)
|
||||
} else {
|
||||
priceMap[p.ID] = fmt.Sprintf("$%.2f", float64(p.UnitAmount)/100)
|
||||
}
|
||||
priceMap[p.ID] = p.UnitAmount
|
||||
log.Trace("- Caching price %s = %v", p.ID, priceMap[p.ID])
|
||||
}
|
||||
return priceMap, nil
|
||||
|
||||
@@ -37,7 +37,9 @@ func TestPayments_Tiers(t *testing.T) {
|
||||
On("ListPrices", mock.Anything).
|
||||
Return([]*stripe.Price{
|
||||
{ID: "price_123", UnitAmount: 500},
|
||||
{ID: "price_124", UnitAmount: 5000},
|
||||
{ID: "price_456", UnitAmount: 1000},
|
||||
{ID: "price_457", UnitAmount: 10000},
|
||||
{ID: "price_999", UnitAmount: 9999},
|
||||
}, nil)
|
||||
|
||||
@@ -58,7 +60,8 @@ func TestPayments_Tiers(t *testing.T) {
|
||||
AttachmentFileSizeLimit: 999,
|
||||
AttachmentTotalSizeLimit: 888,
|
||||
AttachmentExpiryDuration: time.Minute,
|
||||
StripePriceID: "price_123",
|
||||
StripeMonthlyPriceID: "price_123",
|
||||
StripeYearlyPriceID: "price_124",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_444",
|
||||
@@ -71,7 +74,8 @@ func TestPayments_Tiers(t *testing.T) {
|
||||
AttachmentFileSizeLimit: 999111,
|
||||
AttachmentTotalSizeLimit: 888111,
|
||||
AttachmentExpiryDuration: time.Hour,
|
||||
StripePriceID: "price_456",
|
||||
StripeMonthlyPriceID: "price_456",
|
||||
StripeYearlyPriceID: "price_457",
|
||||
}))
|
||||
response := request(t, s, "GET", "/v1/tiers", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
@@ -98,6 +102,8 @@ func TestPayments_Tiers(t *testing.T) {
|
||||
require.Equal(t, "pro", tier.Code)
|
||||
require.Equal(t, "Pro", tier.Name)
|
||||
require.Equal(t, "tier", tier.Limits.Basis)
|
||||
require.Equal(t, int64(500), tier.Prices.Month)
|
||||
require.Equal(t, int64(5000), tier.Prices.Year)
|
||||
require.Equal(t, int64(777), tier.Limits.Reservations)
|
||||
require.Equal(t, int64(1000), tier.Limits.Messages)
|
||||
require.Equal(t, int64(3600), tier.Limits.MessagesExpiryDuration)
|
||||
@@ -109,6 +115,8 @@ func TestPayments_Tiers(t *testing.T) {
|
||||
tier = tiers[2]
|
||||
require.Equal(t, "business", tier.Code)
|
||||
require.Equal(t, "Business", tier.Name)
|
||||
require.Equal(t, int64(1000), tier.Prices.Month)
|
||||
require.Equal(t, int64(10000), tier.Prices.Year)
|
||||
require.Equal(t, "tier", tier.Limits.Basis)
|
||||
require.Equal(t, int64(777333), tier.Limits.Reservations)
|
||||
require.Equal(t, int64(2000), tier.Limits.Messages)
|
||||
@@ -136,14 +144,14 @@ func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) {
|
||||
|
||||
// Create tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_123",
|
||||
Code: "pro",
|
||||
StripePriceID: "price_123",
|
||||
ID: "ti_123",
|
||||
Code: "pro",
|
||||
StripeMonthlyPriceID: "price_123",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
|
||||
// Create subscription
|
||||
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro"}`, map[string]string{
|
||||
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
@@ -172,9 +180,9 @@ func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) {
|
||||
|
||||
// Create tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_123",
|
||||
Code: "pro",
|
||||
StripePriceID: "price_123",
|
||||
ID: "ti_123",
|
||||
Code: "pro",
|
||||
StripeMonthlyPriceID: "price_123",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
|
||||
@@ -187,7 +195,7 @@ func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) {
|
||||
require.Nil(t, s.userManager.ChangeBilling(u.Name, billing))
|
||||
|
||||
// Create subscription
|
||||
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro"}`, map[string]string{
|
||||
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro", "interval": "month"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
@@ -214,9 +222,9 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
|
||||
|
||||
// Create tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_123",
|
||||
Code: "pro",
|
||||
StripePriceID: "price_123",
|
||||
ID: "ti_123",
|
||||
Code: "pro",
|
||||
StripeMonthlyPriceID: "price_123",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
|
||||
@@ -267,7 +275,7 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_123",
|
||||
Code: "starter",
|
||||
StripePriceID: "price_1234",
|
||||
StripeMonthlyPriceID: "price_1234",
|
||||
ReservationLimit: 1,
|
||||
MessageLimit: 220, // 220 * 5% = 11 requests before rate limiting kicks in
|
||||
MessageExpiryDuration: time.Hour,
|
||||
@@ -298,7 +306,12 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
|
||||
Items: &stripe.SubscriptionItemList{
|
||||
Data: []*stripe.SubscriptionItem{
|
||||
{
|
||||
Price: &stripe.Price{ID: "price_1234"},
|
||||
Price: &stripe.Price{
|
||||
ID: "price_1234",
|
||||
Recurring: &stripe.PriceRecurring{
|
||||
Interval: stripe.PriceRecurringIntervalMonth,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -333,6 +346,7 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
|
||||
require.Equal(t, "", u.Billing.StripeCustomerID)
|
||||
require.Equal(t, "", u.Billing.StripeSubscriptionID)
|
||||
require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
||||
require.Equal(t, stripe.PriceRecurringInterval(""), u.Billing.StripeSubscriptionInterval)
|
||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
||||
require.Equal(t, int64(0), u.Stats.Messages) // Messages and emails are not persisted for no-tier users!
|
||||
@@ -349,6 +363,7 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
|
||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
||||
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus)
|
||||
require.Equal(t, stripe.PriceRecurringIntervalMonth, u.Billing.StripeSubscriptionInterval)
|
||||
require.Equal(t, int64(123456789), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
||||
require.Equal(t, int64(0), u.Stats.Messages)
|
||||
@@ -400,6 +415,8 @@ func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *tes
|
||||
}
|
||||
|
||||
func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This tests incoming webhooks from Stripe to update a subscription:
|
||||
// - All Stripe columns are updated in the user table
|
||||
// - When downgrading, excess reservations are deleted, including messages and attachments in
|
||||
@@ -423,7 +440,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_1",
|
||||
Code: "starter",
|
||||
StripePriceID: "price_1234", // !
|
||||
StripeMonthlyPriceID: "price_1234", // !
|
||||
ReservationLimit: 1, // !
|
||||
MessageLimit: 100,
|
||||
MessageExpiryDuration: time.Hour,
|
||||
@@ -435,7 +452,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_2",
|
||||
Code: "pro",
|
||||
StripePriceID: "price_1111", // !
|
||||
StripeMonthlyPriceID: "price_1111", // !
|
||||
ReservationLimit: 3, // !
|
||||
MessageLimit: 200,
|
||||
MessageExpiryDuration: time.Hour,
|
||||
@@ -457,6 +474,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
||||
StripeCustomerID: "acct_5555",
|
||||
StripeSubscriptionID: "sub_1234",
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
||||
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
||||
StripeSubscriptionCancelAt: time.Unix(456, 0),
|
||||
}
|
||||
@@ -499,9 +517,10 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
||||
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
|
||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
||||
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due"
|
||||
require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
|
||||
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
|
||||
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due"
|
||||
require.Equal(t, stripe.PriceRecurringIntervalYear, u.Billing.StripeSubscriptionInterval) // Not "month"
|
||||
require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
|
||||
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
|
||||
|
||||
// Verify that reservations were deleted
|
||||
r, err := s.userManager.Reservations("phil")
|
||||
@@ -546,10 +565,10 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
|
||||
|
||||
// Create a user with a Stripe subscription and 3 reservations
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_1",
|
||||
Code: "pro",
|
||||
StripePriceID: "price_1234",
|
||||
ReservationLimit: 1,
|
||||
ID: "ti_1",
|
||||
Code: "pro",
|
||||
StripeMonthlyPriceID: "price_1234",
|
||||
ReservationLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
@@ -562,6 +581,7 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
|
||||
StripeCustomerID: "acct_5555",
|
||||
StripeSubscriptionID: "sub_1234",
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
||||
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
||||
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
||||
}))
|
||||
@@ -615,11 +635,11 @@ func TestPayments_Subscription_Update_Different_Tier(t *testing.T) {
|
||||
stripeMock.
|
||||
On("UpdateSubscription", "sub_123", &stripe.SubscriptionParams{
|
||||
CancelAtPeriodEnd: stripe.Bool(false),
|
||||
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
|
||||
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)),
|
||||
Items: []*stripe.SubscriptionItemsParams{
|
||||
{
|
||||
ID: stripe.String("someid_123"),
|
||||
Price: stripe.String("price_456"),
|
||||
Price: stripe.String("price_457"),
|
||||
},
|
||||
},
|
||||
}).
|
||||
@@ -627,14 +647,16 @@ func TestPayments_Subscription_Update_Different_Tier(t *testing.T) {
|
||||
|
||||
// Create tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_123",
|
||||
Code: "pro",
|
||||
StripePriceID: "price_123",
|
||||
ID: "ti_123",
|
||||
Code: "pro",
|
||||
StripeMonthlyPriceID: "price_123",
|
||||
StripeYearlyPriceID: "price_124",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_456",
|
||||
Code: "business",
|
||||
StripePriceID: "price_456",
|
||||
ID: "ti_456",
|
||||
Code: "business",
|
||||
StripeMonthlyPriceID: "price_456",
|
||||
StripeYearlyPriceID: "price_457",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
@@ -644,7 +666,7 @@ func TestPayments_Subscription_Update_Different_Tier(t *testing.T) {
|
||||
}))
|
||||
|
||||
// Call endpoint to change subscription
|
||||
rr := request(t, s, "PUT", "/v1/account/billing/subscription", `{"tier":"business"}`, map[string]string{
|
||||
rr := request(t, s, "PUT", "/v1/account/billing/subscription", `{"tier":"business","interval":"year"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
@@ -795,7 +817,10 @@ const subscriptionUpdatedEventJSON = `
|
||||
"data": [
|
||||
{
|
||||
"price": {
|
||||
"id": "price_1234"
|
||||
"id": "price_1234",
|
||||
"recurring": {
|
||||
"interval": "year"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -818,7 +843,10 @@ const subscriptionDeletedEventJSON = `
|
||||
"data": [
|
||||
{
|
||||
"price": {
|
||||
"id": "price_1234"
|
||||
"id": "price_1234",
|
||||
"recurring": {
|
||||
"interval": "month"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -15,13 +15,12 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
@@ -83,7 +82,34 @@ func TestServer_PublishWithFirebase(t *testing.T) {
|
||||
require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.CustomData["message"])
|
||||
}
|
||||
|
||||
func TestServer_PublishWithFirebase_WithoutUsers_AndWithoutPanic(t *testing.T) {
|
||||
// This tests issue #641, which used to panic before the fix
|
||||
|
||||
firebaseKeyFile := filepath.Join(t.TempDir(), "firebase.json")
|
||||
contents := `{
|
||||
"type": "service_account",
|
||||
"project_id": "ntfy-test",
|
||||
"private_key_id": "fsfhskjdfhskdhfskdjfhsdf",
|
||||
"private_key": "lalala",
|
||||
"client_email": "firebase-adminsdk-muv04@ntfy-test.iam.gserviceaccount.com",
|
||||
"client_id": "123123213",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-muv04%40ntfy-test.iam.gserviceaccount.com"
|
||||
}
|
||||
`
|
||||
require.Nil(t, os.WriteFile(firebaseKeyFile, []byte(contents), 0600))
|
||||
c := newTestConfig(t)
|
||||
c.FirebaseKeyFile = firebaseKeyFile
|
||||
s := newTestServer(t, c)
|
||||
|
||||
response := request(t, s, "PUT", "/mytopic", "my first message", nil)
|
||||
require.Equal(t, "my first message", toMessage(t, response.Body.String()).Message)
|
||||
}
|
||||
|
||||
func TestServer_SubscribeOpenAndKeepalive(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t)
|
||||
c.KeepaliveInterval = time.Second
|
||||
s := newTestServer(t, c)
|
||||
@@ -122,6 +148,7 @@ func TestServer_SubscribeOpenAndKeepalive(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_PublishAndSubscribe(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
subscribeRR := httptest.NewRecorder()
|
||||
@@ -149,6 +176,8 @@ func TestServer_PublishAndSubscribe(t *testing.T) {
|
||||
require.Equal(t, "", messages[1].Title)
|
||||
require.Equal(t, 0, messages[1].Priority)
|
||||
require.Nil(t, messages[1].Tags)
|
||||
require.True(t, time.Now().Add(12*time.Hour-5*time.Second).Unix() < messages[1].Expires)
|
||||
require.True(t, time.Now().Add(12*time.Hour+5*time.Second).Unix() > messages[1].Expires)
|
||||
|
||||
require.Equal(t, messageEvent, messages[2].Event)
|
||||
require.Equal(t, "mytopic", messages[2].Topic)
|
||||
@@ -287,6 +316,7 @@ func TestServer_PublishNoCache(t *testing.T) {
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.NotEmpty(t, msg.ID)
|
||||
require.Equal(t, "this message is not cached", msg.Message)
|
||||
require.Equal(t, int64(0), msg.Expires)
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
messages := toMessages(t, response.Body.String())
|
||||
@@ -294,13 +324,11 @@ func TestServer_PublishNoCache(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_PublishAt(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.MinDelay = time.Second
|
||||
c.DelayedSenderInterval = 100 * time.Millisecond
|
||||
s := newTestServer(t, c)
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
|
||||
"In": "1s",
|
||||
"In": "1h",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
@@ -308,22 +336,62 @@ func TestServer_PublishAt(t *testing.T) {
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 0, len(messages))
|
||||
|
||||
time.Sleep(time.Second)
|
||||
require.Nil(t, s.sendDelayedMessages())
|
||||
// Update message time to the past
|
||||
fakeTime := time.Now().Add(-10 * time.Second).Unix()
|
||||
_, err := s.messageCache.db.Exec(`UPDATE messages SET time=?`, fakeTime)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Trigger delayed message sending
|
||||
require.Nil(t, s.sendDelayedMessages())
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
messages = toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "a message", messages[0].Message)
|
||||
require.Equal(t, netip.Addr{}, messages[0].Sender) // Never return the sender!
|
||||
|
||||
messages, err := s.messageCache.Messages("mytopic", sinceAllMessages, true)
|
||||
messages, err = s.messageCache.Messages("mytopic", sinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "a message", messages[0].Message)
|
||||
require.Equal(t, "9.9.9.9", messages[0].Sender.String()) // It's stored in the DB though!
|
||||
}
|
||||
|
||||
func TestServer_PublishAt_FromUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
|
||||
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
"In": "1h",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Message doesn't show up immediately
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 0, len(messages))
|
||||
|
||||
// Update message time to the past
|
||||
fakeTime := time.Now().Add(-10 * time.Second).Unix()
|
||||
_, err := s.messageCache.db.Exec(`UPDATE messages SET time=?`, fakeTime)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Trigger delayed message sending
|
||||
require.Nil(t, s.sendDelayedMessages())
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
messages = toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, fakeTime, messages[0].Time)
|
||||
require.Equal(t, "a message", messages[0].Message)
|
||||
|
||||
messages, err = s.messageCache.Messages("mytopic", sinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "a message", messages[0].Message)
|
||||
require.True(t, strings.HasPrefix(messages[0].User, "u_"))
|
||||
}
|
||||
|
||||
func TestServer_PublishAt_Expires(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
@@ -449,6 +517,7 @@ func TestServer_PublishWithNopCache(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_PublishAndPollSince(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
request(t, s, "PUT", "/mytopic", "test 1", nil)
|
||||
@@ -629,6 +698,7 @@ func TestServer_PollWithQueryFilters(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_SubscribeWithQueryFilters(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t)
|
||||
c.KeepaliveInterval = 800 * time.Millisecond
|
||||
s := newTestServer(t, c)
|
||||
@@ -761,6 +831,7 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
|
||||
|
||||
func TestServer_Auth_Fail_Rate_Limiting(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.VisitorAuthFailureLimitBurst = 10
|
||||
s := newTestServer(t, c)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
@@ -793,7 +864,27 @@ func TestServer_Auth_ViaQuery(t *testing.T) {
|
||||
require.Equal(t, 401, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_Auth_NonBasicHeader(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
|
||||
response := request(t, s, "PUT", "/mytopic", "test", map[string]string{
|
||||
"Authorization": "WebPush not-supported",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
response = request(t, s, "PUT", "/mytopic", "test", map[string]string{
|
||||
"Authorization": "Bearer supported",
|
||||
})
|
||||
require.Equal(t, 401, response.Code)
|
||||
|
||||
response = request(t, s, "PUT", "/mytopic", "test", map[string]string{
|
||||
"Authorization": "basic supported",
|
||||
})
|
||||
require.Equal(t, 401, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_StatsResetter(t *testing.T) {
|
||||
t.Parallel()
|
||||
// This tests the stats resetter for
|
||||
// - an anonymous user
|
||||
// - a user without a tier (treated like the same as the anonymous user)
|
||||
@@ -860,7 +951,15 @@ func TestServer_StatsResetter(t *testing.T) {
|
||||
require.Equal(t, int64(2), account.Stats.Messages)
|
||||
|
||||
// Wait for stats resetter to run
|
||||
time.Sleep(2200 * time.Millisecond)
|
||||
waitFor(t, func() bool {
|
||||
response = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
|
||||
require.Nil(t, err)
|
||||
return account.Stats.Messages == 0
|
||||
})
|
||||
|
||||
// User stats show 0 messages now!
|
||||
response = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||
@@ -934,6 +1033,8 @@ func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// This tests that the daily message quota is prefilled originally from the database,
|
||||
// if the visitor is unknown
|
||||
|
||||
@@ -1006,15 +1107,29 @@ func TestServer_PublishTooRequests_Defaults(t *testing.T) {
|
||||
|
||||
func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
|
||||
s := newTestServer(t, c)
|
||||
for i := 0; i < 65; i++ { // > 60
|
||||
for i := 0; i < 5; i++ { // > 3
|
||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_PublishTooRequests_Defaults_ExemptHosts_MessageDailyLimit(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.VisitorRequestLimitBurst = 10
|
||||
c.VisitorMessageDailyLimit = 4
|
||||
c.VisitorRequestExemptIPAddrs = []netip.Prefix{netip.MustParsePrefix("9.9.9.9/32")} // see request()
|
||||
s := newTestServer(t, c)
|
||||
for i := 0; i < 8; i++ { // 4
|
||||
response := request(t, s, "PUT", "/mytopic", "message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t)
|
||||
c.VisitorRequestLimitBurst = 60
|
||||
c.VisitorRequestLimitReplenish = time.Second
|
||||
@@ -1047,6 +1162,7 @@ func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t)
|
||||
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
|
||||
s := newTestServer(t, c)
|
||||
@@ -1092,6 +1208,63 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
|
||||
require.Equal(t, 400, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAndExpungeTopicAfter16Hours(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
subFn := func(v *visitor, msg *message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Publish and check last access
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"Cache": "no",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool {
|
||||
// .lastAccess set in t.Publish() -> t.Keepalive() in Goroutine
|
||||
s.topics["mytopic"].mu.RLock()
|
||||
defer s.topics["mytopic"].mu.RUnlock()
|
||||
return s.topics["mytopic"].lastAccess.Unix() >= time.Now().Unix()-2 &&
|
||||
s.topics["mytopic"].lastAccess.Unix() <= time.Now().Unix()+2
|
||||
})
|
||||
|
||||
// Topic won't get pruned
|
||||
s.execManager()
|
||||
require.NotNil(t, s.topics["mytopic"])
|
||||
|
||||
// Fudge with last access, but subscribe, and see that it won't get pruned (because of subscriber)
|
||||
subID := s.topics["mytopic"].Subscribe(subFn, "", func() {})
|
||||
s.topics["mytopic"].mu.Lock()
|
||||
s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour)
|
||||
s.topics["mytopic"].mu.Unlock()
|
||||
s.execManager()
|
||||
require.NotNil(t, s.topics["mytopic"])
|
||||
|
||||
// It'll finally get pruned now that there are no subscribers and last access is 17 hours ago
|
||||
s.topics["mytopic"].Unsubscribe(subID)
|
||||
s.execManager()
|
||||
require.Nil(t, s.topics["mytopic"])
|
||||
}
|
||||
|
||||
func TestServer_TopicKeepaliveOnPoll(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
// Create topic by polling once
|
||||
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Mess with last access time
|
||||
s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour)
|
||||
|
||||
// Poll again and check keepalive time
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.True(t, s.topics["mytopic"].lastAccess.Unix() >= time.Now().Unix()-2)
|
||||
require.True(t, s.topics["mytopic"].lastAccess.Unix() <= time.Now().Unix()+2)
|
||||
}
|
||||
|
||||
func TestServer_UnifiedPushDiscovery(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "GET", "/mytopic?up=1", "", nil)
|
||||
@@ -1105,7 +1278,15 @@ func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic?up=1", string(b), nil)
|
||||
|
||||
// Register a UnifiedPush subscriber
|
||||
response := request(t, s, "GET", "/up123456789012/json?poll=1", "", map[string]string{
|
||||
"Rate-Topics": "up123456789012",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Publish message to topic
|
||||
response = request(t, s, "PUT", "/up123456789012?up=1", string(b), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
m := toMessage(t, response.Body.String())
|
||||
@@ -1114,7 +1295,8 @@ func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, b, b2)
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", string(b), nil)
|
||||
// Retrieve and check published message
|
||||
response = request(t, s, "GET", "/up123456789012/json?poll=1", string(b), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
m = toMessage(t, response.Body.String())
|
||||
require.Equal(t, "base64", m.Encoding)
|
||||
@@ -1129,7 +1311,15 @@ func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic?up=1", string(b), nil)
|
||||
|
||||
// Register a UnifiedPush subscriber
|
||||
response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
|
||||
"Rate-Topics": "mytopic",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Publish message to topic
|
||||
response = request(t, s, "PUT", "/mytopic?up=1", string(b), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
m := toMessage(t, response.Body.String())
|
||||
@@ -1142,7 +1332,15 @@ func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) {
|
||||
|
||||
func TestServer_PublishUnifiedPushText(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic?up=1", "this is a unifiedpush text message", nil)
|
||||
|
||||
// Register a UnifiedPush subscriber
|
||||
response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
|
||||
"Rate-Topics": "mytopic",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Publish UnifiedPush text message
|
||||
response = request(t, s, "PUT", "/mytopic?up=1", "this is a unifiedpush text message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
m := toMessage(t, response.Body.String())
|
||||
@@ -1169,8 +1367,14 @@ func TestServer_MatrixGateway_Discovery_Failure_Unconfigured(t *testing.T) {
|
||||
|
||||
func TestServer_MatrixGateway_Push_Success(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
response := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
|
||||
"Rate-Topics": "mytopic", // Register first!
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String())
|
||||
|
||||
@@ -1180,6 +1384,42 @@ func TestServer_MatrixGateway_Push_Success(t *testing.T) {
|
||||
require.Equal(t, notification, m.Message)
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Push_Failure_NoSubscriber(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 507, response.Code)
|
||||
require.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Push_Failure_NoSubscriber_After13Hours(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
|
||||
|
||||
// No success if no rate visitor set (this also creates the topic in memory)
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 507, response.Code)
|
||||
require.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Nil(t, s.topics["mytopic"].rateVisitor)
|
||||
|
||||
// Fake: This topic has been around for 13 hours without a rate visitor
|
||||
s.topics["mytopic"].lastAccess = time.Now().Add(-13 * time.Hour)
|
||||
|
||||
// Same request should now return HTTP 200 with a rejected pushkey
|
||||
response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"rejected":["http://127.0.0.1:12345/mytopic?up=1"]}`, strings.TrimSpace(response.Body.String()))
|
||||
|
||||
// Slightly unrelated: Test that topic is pruned after 16 hours
|
||||
s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour)
|
||||
s.execManager()
|
||||
require.Nil(t, s.topics["mytopic"])
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Push_Failure_InvalidPushkey(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
notification := `{"notification":{"devices":[{"pushkey":"http://wrong-base-url.com/mytopic?up=1"}]}}`
|
||||
@@ -1197,9 +1437,12 @@ func TestServer_MatrixGateway_Push_Failure_EverythingIsWrong(t *testing.T) {
|
||||
notification := `{"message":"this is not really a Matrix message"}`
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 40019, err.Code)
|
||||
require.Equal(t, 400, err.HTTPCode)
|
||||
require.Equal(t, 40019, toHTTPError(t, response.Body.String()).Code)
|
||||
|
||||
notification = `this isn't even JSON'`
|
||||
response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40019, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Push_Failure_Unconfigured(t *testing.T) {
|
||||
@@ -1209,9 +1452,7 @@ func TestServer_MatrixGateway_Push_Failure_Unconfigured(t *testing.T) {
|
||||
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 500, response.Code)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 50003, err.Code)
|
||||
require.Equal(t, 500, err.HTTPCode)
|
||||
require.Equal(t, 50003, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishActions_AndPoll(t *testing.T) {
|
||||
@@ -1257,7 +1498,24 @@ func TestServer_PublishAsJSON(t *testing.T) {
|
||||
require.True(t, m.Time < time.Now().Unix()+31*60)
|
||||
}
|
||||
|
||||
func TestServer_PublishAsJSON_RateLimit_MessageDailyLimit(t *testing.T) {
|
||||
// Publishing as JSON follows a different path. This ensures that rate
|
||||
// limiting works for this endpoint as well
|
||||
c := newTestConfig(t)
|
||||
c.VisitorMessageDailyLimit = 3
|
||||
s := newTestServer(t, c)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
response := request(t, s, "PUT", "/", `{"topic":"mytopic","message":"A message"}`, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
}
|
||||
response := request(t, s, "PUT", "/", `{"topic":"mytopic","message":"A message"}`, nil)
|
||||
require.Equal(t, 429, response.Code)
|
||||
require.Equal(t, 42908, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
mailer := &testMailer{}
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
s.smtpSender = mailer
|
||||
@@ -1513,6 +1771,7 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentAndExpire(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := util.RandomString(5000) // > 4096
|
||||
|
||||
c := newTestConfig(t)
|
||||
@@ -1532,14 +1791,16 @@ func TestServer_PublishAttachmentAndExpire(t *testing.T) {
|
||||
require.Equal(t, content, response.Body.String())
|
||||
|
||||
// Prune and makes sure it's gone
|
||||
time.Sleep(time.Second) // Sigh ...
|
||||
s.execManager()
|
||||
require.NoFileExists(t, file)
|
||||
waitFor(t, func() bool {
|
||||
s.execManager() // May run many times
|
||||
return !util.FileExists(file)
|
||||
})
|
||||
response = request(t, s, "GET", path, "", nil)
|
||||
require.Equal(t, 404, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := util.RandomString(5000) // > 4096
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
@@ -1807,6 +2068,7 @@ func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
count := 50000
|
||||
c := newTestConfig(t)
|
||||
c.TotalTopicLimit = 50001
|
||||
@@ -1842,8 +2104,8 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
|
||||
start = time.Now()
|
||||
response := request(t, s, "PUT", "/mytopic", "some body", nil)
|
||||
m := toMessage(t, response.Body.String())
|
||||
assert.Equal(t, "some body", m.Message)
|
||||
assert.True(t, time.Since(start) < 100*time.Millisecond)
|
||||
require.Equal(t, "some body", m.Message)
|
||||
require.True(t, time.Since(start) < 100*time.Millisecond)
|
||||
log.Info("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond))
|
||||
|
||||
// Wait for all goroutines
|
||||
@@ -1886,6 +2148,340 @@ func TestServer_AnonymousUser_And_NonTierUser_Are_Same_Visitor(t *testing.T) {
|
||||
require.Equal(t, int64(2), account.Stats.Messages)
|
||||
}
|
||||
|
||||
func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// "Register" visitor 1.2.3.4 to topic "subscriber1topic" as a rate limit visitor
|
||||
subscriber1Fn := func(r *http.Request) {
|
||||
r.RemoteAddr = "1.2.3.4"
|
||||
}
|
||||
rr := request(t, s, "GET", "/subscriber1topic/json?poll=1", "", map[string]string{
|
||||
"Rate-Topics": "subscriber1topic",
|
||||
}, subscriber1Fn)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "", rr.Body.String())
|
||||
require.Equal(t, "1.2.3.4", s.topics["subscriber1topic"].rateVisitor.ip.String())
|
||||
|
||||
// "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name)
|
||||
subscriber2Fn := func(r *http.Request) {
|
||||
r.RemoteAddr = "8.7.7.1"
|
||||
}
|
||||
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, subscriber2Fn)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "", rr.Body.String())
|
||||
require.Equal(t, "8.7.7.1", s.topics["up012345678912"].rateVisitor.ip.String())
|
||||
|
||||
// Publish 2 messages to "subscriber1topic" as visitor 9.9.9.9. It'd be 3 normally, but the
|
||||
// GET request before is also counted towards the request limiter.
|
||||
for i := 0; i < 2; i++ {
|
||||
rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
rr = request(t, s, "PUT", "/subscriber1topic", "some message", nil)
|
||||
require.Equal(t, 429, rr.Code)
|
||||
|
||||
// Publish another 2 messages to "up012345678912" as visitor 9.9.9.9
|
||||
for i := 0; i < 2; i++ {
|
||||
rr := request(t, s, "PUT", "/up012345678912", "some message", nil)
|
||||
require.Equal(t, 200, rr.Code) // If we fail here, handlePublish is using the wrong visitor!
|
||||
}
|
||||
rr = request(t, s, "PUT", "/up012345678912", "some message", nil)
|
||||
require.Equal(t, 429, rr.Code)
|
||||
|
||||
// Hurray! At this point, visitor 9.9.9.9 has published 4 messages, even though
|
||||
// VisitorRequestLimitBurst is 3. That means it's working.
|
||||
|
||||
// Now let's confirm that so far we haven't used up any of visitor 9.9.9.9's request limiter
|
||||
// by publishing another 3 requests from it.
|
||||
for i := 0; i < 3; i++ {
|
||||
rr := request(t, s, "PUT", "/some-other-topic", "some message", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
rr = request(t, s, "PUT", "/some-other-topic", "some message", nil)
|
||||
require.Equal(t, 429, rr.Code)
|
||||
}
|
||||
|
||||
func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
c.VisitorSubscriberRateLimiting = false
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Subscriber rate limiting is disabled!
|
||||
|
||||
// Registering visitor 1.2.3.4 to topic has no effect
|
||||
rr := request(t, s, "GET", "/subscriber1topic/json?poll=1", "", map[string]string{
|
||||
"Rate-Topics": "subscriber1topic",
|
||||
}, func(r *http.Request) {
|
||||
r.RemoteAddr = "1.2.3.4"
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "", rr.Body.String())
|
||||
require.Nil(t, s.topics["subscriber1topic"].rateVisitor)
|
||||
|
||||
// Registering visitor 8.7.7.1 to topic has no effect
|
||||
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, func(r *http.Request) {
|
||||
r.RemoteAddr = "8.7.7.1"
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "", rr.Body.String())
|
||||
require.Nil(t, s.topics["up012345678912"].rateVisitor)
|
||||
|
||||
// Publish 3 messages to "subscriber1topic" as visitor 9.9.9.9
|
||||
for i := 0; i < 3; i++ {
|
||||
rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
rr = request(t, s, "PUT", "/subscriber1topic", "some message", nil)
|
||||
require.Equal(t, 429, rr.Code)
|
||||
rr = request(t, s, "PUT", "/up012345678912", "some message", nil)
|
||||
require.Equal(t, 429, rr.Code)
|
||||
}
|
||||
|
||||
func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// "Register" 5 different UnifiedPush visitors
|
||||
for i := 0; i < 5; i++ {
|
||||
subscriberFn := func(r *http.Request) {
|
||||
r.RemoteAddr = fmt.Sprintf("1.2.3.%d", i+1)
|
||||
}
|
||||
rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, subscriberFn)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
// Publish 2 messages per topic
|
||||
for i := 0; i < 5; i++ {
|
||||
for j := 0; j < 2; j++ {
|
||||
rr := request(t, s, "PUT", fmt.Sprintf("/up12345678901%d?up=1", i), "some message", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// "Register" 5 different UnifiedPush visitors
|
||||
for i := 0; i < 5; i++ {
|
||||
rr := request(t, s, "GET", fmt.Sprintf("/up12345678901%d/json?poll=1", i), "", nil, func(r *http.Request) {
|
||||
r.RemoteAddr = fmt.Sprintf("1.2.3.%d", i+1)
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
// Publish 2 messages per topic
|
||||
for i := 0; i < 5; i++ {
|
||||
notification := fmt.Sprintf(`{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/up12345678901%d?up=1"}]}}`, i)
|
||||
for j := 0; j < 2; j++ {
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String())
|
||||
}
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 429, response.Code, notification)
|
||||
require.Equal(t, 42901, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// "Register" rate visitor
|
||||
subscriberFn := func(r *http.Request) {
|
||||
r.RemoteAddr = "1.2.3.4"
|
||||
}
|
||||
rr := request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
|
||||
"rate-topics": "mytopic",
|
||||
}, subscriberFn)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "1.2.3.4", s.topics["mytopic"].rateVisitor.ip.String())
|
||||
require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["mytopic"].rateVisitor)
|
||||
|
||||
// Publish message, observe rate visitor tokens being decreased
|
||||
response := request(t, s, "POST", "/mytopic", "some message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, int64(0), s.visitors["ip:9.9.9.9"].messagesLimiter.Value())
|
||||
require.Equal(t, int64(1), s.topics["mytopic"].rateVisitor.messagesLimiter.Value())
|
||||
require.Equal(t, s.visitors["ip:1.2.3.4"], s.topics["mytopic"].rateVisitor)
|
||||
|
||||
// Expire visitor
|
||||
s.visitors["ip:1.2.3.4"].seen = time.Now().Add(-1 * 25 * time.Hour)
|
||||
s.pruneVisitors()
|
||||
|
||||
// Publish message again, observe that rateVisitor is not used anymore and is reset
|
||||
response = request(t, s, "POST", "/mytopic", "some message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, int64(1), s.visitors["ip:9.9.9.9"].messagesLimiter.Value())
|
||||
require.Nil(t, s.topics["mytopic"].rateVisitor)
|
||||
require.Nil(t, s.visitors["ip:1.2.3.4"])
|
||||
}
|
||||
|
||||
func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AuthDefault = user.PermissionDenyAll
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Create some ACLs
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "test",
|
||||
MessageLimit: 5,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("ben", "test"))
|
||||
require.Nil(t, s.userManager.AllowAccess("ben", "announcements", user.PermissionReadWrite))
|
||||
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead))
|
||||
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "public_topic", user.PermissionReadWrite))
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||
require.Nil(t, s.userManager.AddReservation("phil", "reserved-for-phil", user.PermissionReadWrite))
|
||||
|
||||
// Set rate visitor as user "phil" on topic
|
||||
// - "reserved-for-phil": Allowed, because I am the owner
|
||||
// - "public_topic": Allowed, because it has read-write permissions for everyone
|
||||
// - "announcements": NOT allowed, because it has read-only permissions for everyone
|
||||
rr := request(t, s, "GET", "/reserved-for-phil,public_topic,announcements/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
"Rate-Topics": "reserved-for-phil,public_topic,announcements",
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "phil", s.topics["reserved-for-phil"].rateVisitor.user.Name)
|
||||
require.Equal(t, "phil", s.topics["public_topic"].rateVisitor.user.Name)
|
||||
require.Nil(t, s.topics["announcements"].rateVisitor)
|
||||
|
||||
// Set rate visitor as user "ben" on topic
|
||||
// - "reserved-for-phil": NOT allowed, because I am not the owner
|
||||
// - "public_topic": Allowed, because it has read-write permissions for everyone
|
||||
// - "announcements": Allowed, because I have read-write permissions
|
||||
rr = request(t, s, "GET", "/reserved-for-phil,public_topic,announcements/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
"Rate-Topics": "reserved-for-phil,public_topic,announcements",
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "phil", s.topics["reserved-for-phil"].rateVisitor.user.Name)
|
||||
require.Equal(t, "ben", s.topics["public_topic"].rateVisitor.user.Name)
|
||||
require.Equal(t, "ben", s.topics["announcements"].rateVisitor.user.Name)
|
||||
}
|
||||
|
||||
func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AuthDefault = user.PermissionReadWrite
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Create some ACLs
|
||||
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead))
|
||||
|
||||
// Set rate visitor as ip:1.2.3.4 on topic
|
||||
// - "up123456789012": Allowed, because no ACLs and nobody owns the topic
|
||||
// - "announcements": NOT allowed, because it has read-only permissions for everyone
|
||||
rr := request(t, s, "GET", "/up123456789012,announcements/json?poll=1", "", nil, func(r *http.Request) {
|
||||
r.RemoteAddr = "1.2.3.4"
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "1.2.3.4", s.topics["up123456789012"].rateVisitor.ip.String())
|
||||
require.Nil(t, s.topics["announcements"].rateVisitor)
|
||||
}
|
||||
|
||||
func TestServer_MessageHistoryAndStatsEndpoint(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.ManagerInterval = 2 * time.Second
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Publish some messages, and get stats
|
||||
for i := 0; i < 5; i++ {
|
||||
response := request(t, s, "POST", "/mytopic", "some message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
}
|
||||
require.Equal(t, int64(5), s.messages)
|
||||
require.Equal(t, []int64{0}, s.messagesHistory)
|
||||
|
||||
response := request(t, s, "GET", "/v1/stats", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"messages":5,"messages_rate":0}`+"\n", response.Body.String())
|
||||
|
||||
// Run manager and see message history update
|
||||
s.execManager()
|
||||
require.Equal(t, []int64{0, 5}, s.messagesHistory)
|
||||
|
||||
response = request(t, s, "GET", "/v1/stats", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"messages":5,"messages_rate":2.5}`+"\n", response.Body.String()) // 5 messages in 2 seconds = 2.5 messages per second
|
||||
|
||||
// Publish some more messages
|
||||
for i := 0; i < 10; i++ {
|
||||
response := request(t, s, "POST", "/mytopic", "some message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
}
|
||||
require.Equal(t, int64(15), s.messages)
|
||||
require.Equal(t, []int64{0, 5}, s.messagesHistory)
|
||||
|
||||
response = request(t, s, "GET", "/v1/stats", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"messages":15,"messages_rate":2.5}`+"\n", response.Body.String()) // Rate did not update yet
|
||||
|
||||
// Run manager and see message history update
|
||||
s.execManager()
|
||||
require.Equal(t, []int64{0, 5, 15}, s.messagesHistory)
|
||||
|
||||
response = request(t, s, "GET", "/v1/stats", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"messages":15,"messages_rate":3.75}`+"\n", response.Body.String()) // 15 messages in 4 seconds = 3.75 messages per second
|
||||
}
|
||||
|
||||
func TestServer_MessageHistoryMaxSize(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
for i := 0; i < 20; i++ {
|
||||
s.messages = int64(i)
|
||||
s.execManager()
|
||||
}
|
||||
require.Equal(t, []int64{10, 11, 12, 13, 14, 15, 16, 17, 18, 19}, s.messagesHistory)
|
||||
}
|
||||
|
||||
func TestServer_MessageCountPersistence(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
s := newTestServer(t, c)
|
||||
s.messages = 1234
|
||||
s.execManager()
|
||||
waitFor(t, func() bool {
|
||||
messages, err := s.messageCache.Stats()
|
||||
require.Nil(t, err)
|
||||
return messages == 1234
|
||||
})
|
||||
|
||||
s = newTestServer(t, c)
|
||||
require.Equal(t, int64(1234), s.messages)
|
||||
}
|
||||
|
||||
func TestServer_PublishWithUTF8MimeHeader(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
response := request(t, s, "POST", "/mytopic", "some attachment", map[string]string{
|
||||
"X-Filename": "some attachment.txt",
|
||||
"X-Message": "=?UTF-8?B?8J+HqfCfh6o=?=",
|
||||
"X-Title": "=?UTF-8?B?bnRmeSDlvojmo5I=?=, no really I mean it! =?UTF-8?Q?This is q=C3=BC=C3=B6ted-print=C3=A4ble.?=",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "🇩🇪", m.Message)
|
||||
require.Equal(t, "ntfy 很棒, no really I mean it! This is qüöted-printäble.", m.Title)
|
||||
require.Equal(t, "some attachment.txt", m.Attachment.Name)
|
||||
}
|
||||
|
||||
func newTestConfig(t *testing.T) *Config {
|
||||
conf := NewConfig()
|
||||
conf.BaseURL = "http://127.0.0.1:12345"
|
||||
@@ -1911,17 +2507,20 @@ func newTestServer(t *testing.T, config *Config) *Server {
|
||||
return server
|
||||
}
|
||||
|
||||
func request(t *testing.T, s *Server, method, url, body string, headers map[string]string) *httptest.ResponseRecorder {
|
||||
func request(t *testing.T, s *Server, method, url, body string, headers map[string]string, fn ...func(r *http.Request)) *httptest.ResponseRecorder {
|
||||
rr := httptest.NewRecorder()
|
||||
req, err := http.NewRequest(method, url, strings.NewReader(body))
|
||||
r, err := http.NewRequest(method, url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.RemoteAddr = "9.9.9.9" // Used for tests
|
||||
r.RemoteAddr = "9.9.9.9" // Used for tests
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
s.handle(rr, req)
|
||||
for _, f := range fn {
|
||||
f(r)
|
||||
}
|
||||
s.handle(rr, r)
|
||||
return rr
|
||||
}
|
||||
|
||||
@@ -1973,3 +2572,18 @@ func readAll(t *testing.T, rc io.ReadCloser) string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, f func() bool) {
|
||||
waitForWithMaxWait(t, 5*time.Second, f)
|
||||
}
|
||||
|
||||
func waitForWithMaxWait(t *testing.T, maxWait time.Duration, f func() bool) {
|
||||
start := time.Now()
|
||||
for time.Since(start) < maxWait {
|
||||
if f() {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("Function f did not succeed after %v: %v", maxWait, string(debug.Stack()))
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@ func (s *smtpSender) Send(v *visitor, m *message, to string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
||||
var auth smtp.Auth
|
||||
if s.config.SMTPSenderUser != "" {
|
||||
auth = smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
||||
}
|
||||
ev := logvm(v, m).
|
||||
Tag(tagEmail).
|
||||
Fields(log.Context{
|
||||
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/emersion/go-smtp"
|
||||
@@ -21,9 +22,14 @@ var (
|
||||
errInvalidAddress = errors.New("invalid address")
|
||||
errInvalidTopic = errors.New("invalid topic")
|
||||
errTooManyRecipients = errors.New("too many recipients")
|
||||
errMultipartNestedTooDeep = errors.New("multipart message nested too deep")
|
||||
errUnsupportedContentType = errors.New("unsupported content type")
|
||||
)
|
||||
|
||||
const (
|
||||
maxMultipartDepth = 2
|
||||
)
|
||||
|
||||
// smtpBackend implements SMTP server methods.
|
||||
type smtpBackend struct {
|
||||
config *Config
|
||||
@@ -59,6 +65,7 @@ type smtpSession struct {
|
||||
backend *smtpBackend
|
||||
conn *smtp.Conn
|
||||
topic string
|
||||
token string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -75,6 +82,7 @@ func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
|
||||
func (s *smtpSession) Rcpt(to string) error {
|
||||
logem(s.conn).Field("smtp_rcpt_to", to).Debug("RCPT TO: %s", to)
|
||||
return s.withFailCount(func() error {
|
||||
token := ""
|
||||
conf := s.backend.config
|
||||
addressList, err := mail.ParseAddressList(to)
|
||||
if err != nil {
|
||||
@@ -86,18 +94,27 @@ func (s *smtpSession) Rcpt(to string) error {
|
||||
if !strings.HasSuffix(to, "@"+conf.SMTPServerDomain) {
|
||||
return errInvalidDomain
|
||||
}
|
||||
// Remove @ntfy.sh from end of email
|
||||
to = strings.TrimSuffix(to, "@"+conf.SMTPServerDomain)
|
||||
if conf.SMTPServerAddrPrefix != "" {
|
||||
if !strings.HasPrefix(to, conf.SMTPServerAddrPrefix) {
|
||||
return errInvalidAddress
|
||||
}
|
||||
// remove ntfy- from beginning of email
|
||||
to = strings.TrimPrefix(to, conf.SMTPServerAddrPrefix)
|
||||
}
|
||||
// If email contains token, split topic and token
|
||||
if strings.Contains(to, "+") {
|
||||
parts := strings.Split(to, "+")
|
||||
to = parts[0]
|
||||
token = parts[1]
|
||||
}
|
||||
if !topicRegex.MatchString(to) {
|
||||
return errInvalidTopic
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.topic = to
|
||||
s.token = token
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
})
|
||||
@@ -120,7 +137,7 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := readMailBody(msg)
|
||||
body, err := readMailBody(msg.Body, msg.Header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -148,6 +165,7 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
s.backend.mu.Lock()
|
||||
s.backend.success++
|
||||
s.backend.mu.Unlock()
|
||||
minc(metricEmailsReceivedSuccess)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -158,7 +176,6 @@ func (s *smtpSession) publishMessage(m *message) error {
|
||||
if err != nil {
|
||||
remoteAddr = s.conn.Conn().RemoteAddr().String()
|
||||
}
|
||||
|
||||
// Call HTTP handler with fake HTTP request
|
||||
url := fmt.Sprintf("%s/%s", s.backend.config.BaseURL, m.Topic)
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(m.Message))
|
||||
@@ -171,6 +188,9 @@ func (s *smtpSession) publishMessage(m *message) error {
|
||||
if m.Title != "" {
|
||||
req.Header.Set("Title", m.Title)
|
||||
}
|
||||
if s.token != "" {
|
||||
req.Header.Add("Authorization", "Bearer "+s.token)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
s.backend.handler(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
@@ -198,52 +218,57 @@ func (s *smtpSession) withFailCount(fn func() error) error {
|
||||
// We do not want to spam the log with WARN messages.
|
||||
logem(s.conn).Err(err).Debug("Incoming mail error")
|
||||
s.backend.failure++
|
||||
minc(metricEmailsReceivedFailure)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func readMailBody(msg *mail.Message) (string, error) {
|
||||
if msg.Header.Get("Content-Type") == "" {
|
||||
return readPlainTextMailBody(msg)
|
||||
func readMailBody(body io.Reader, header mail.Header) (string, error) {
|
||||
if header.Get("Content-Type") == "" {
|
||||
return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding"))
|
||||
}
|
||||
contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||
contentType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if contentType == "text/plain" {
|
||||
return readPlainTextMailBody(msg)
|
||||
} else if strings.HasPrefix(contentType, "multipart/") {
|
||||
return readMultipartMailBody(msg, params)
|
||||
if strings.ToLower(contentType) == "text/plain" {
|
||||
return readPlainTextMailBody(body, header.Get("Content-Transfer-Encoding"))
|
||||
} else if strings.HasPrefix(strings.ToLower(contentType), "multipart/") {
|
||||
return readMultipartMailBody(body, params, 0)
|
||||
}
|
||||
return "", errUnsupportedContentType
|
||||
}
|
||||
|
||||
func readPlainTextMailBody(msg *mail.Message) (string, error) {
|
||||
body, err := io.ReadAll(msg.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
func readMultipartMailBody(body io.Reader, params map[string]string, depth int) (string, error) {
|
||||
if depth >= maxMultipartDepth {
|
||||
return "", errMultipartNestedTooDeep
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func readMultipartMailBody(msg *mail.Message, params map[string]string) (string, error) {
|
||||
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||
mr := multipart.NewReader(body, params["boundary"])
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil { // may be io.EOF
|
||||
return "", err
|
||||
}
|
||||
partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
||||
partContentType, partParams, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if partContentType != "text/plain" {
|
||||
continue
|
||||
if strings.ToLower(partContentType) == "text/plain" {
|
||||
return readPlainTextMailBody(part, part.Header.Get("Content-Transfer-Encoding"))
|
||||
} else if strings.HasPrefix(strings.ToLower(partContentType), "multipart/") {
|
||||
return readMultipartMailBody(part, partParams, depth+1)
|
||||
}
|
||||
body, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
// Continue with next part
|
||||
}
|
||||
}
|
||||
|
||||
func readPlainTextMailBody(reader io.Reader, transferEncoding string) (string, error) {
|
||||
if strings.ToLower(transferEncoding) == "base64" {
|
||||
reader = base64.NewDecoder(base64.StdEncoding, reader)
|
||||
}
|
||||
body, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
@@ -348,6 +348,171 @@ what's up
|
||||
writeAndReadUntilLine(t, email, c, scanner, "451 4.0.0 invalid address")
|
||||
}
|
||||
|
||||
func TestSmtpBackend_Base64Body(t *testing.T) {
|
||||
email := `EHLO example.com
|
||||
MAIL FROM: test@mydomain.me
|
||||
RCPT TO: ntfy-mytopic@ntfy.sh
|
||||
DATA
|
||||
Content-Type: multipart/mixed; boundary="===============2138658284696597373=="
|
||||
MIME-Version: 1.0
|
||||
Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local
|
||||
From: =?utf-8?q?Robbie?= <test@mydomain.me>
|
||||
To: test@mydomain.me
|
||||
Date: Thu, 16 Feb 2023 01:04:00 -0000
|
||||
Message-ID: <truenas-20230216.010400.344514.b'8jfL'@truenas.local>
|
||||
|
||||
This is a multi-part message in MIME format.
|
||||
--===============2138658284696597373==
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
MIME-Version: 1.0
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4=
|
||||
|
||||
--===============2138658284696597373==
|
||||
Content-Type: text/html; charset="utf-8"
|
||||
MIME-Version: 1.0
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv
|
||||
L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg==
|
||||
|
||||
--===============2138658284696597373==--
|
||||
.
|
||||
`
|
||||
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local", r.Header.Get("Title"))
|
||||
require.Equal(t, "This is a test message from TrueNAS CORE.", readAll(t, r.Body))
|
||||
})
|
||||
defer s.Close()
|
||||
defer c.Close()
|
||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||
}
|
||||
|
||||
func TestSmtpBackend_NestedMultipartBase64(t *testing.T) {
|
||||
email := `EHLO example.com
|
||||
MAIL FROM: test@mydomain.me
|
||||
RCPT TO: ntfy-mytopic@ntfy.sh
|
||||
DATA
|
||||
Content-Type: multipart/mixed; boundary="===============2138658284696597373=="
|
||||
MIME-Version: 1.0
|
||||
Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local
|
||||
From: =?utf-8?q?Robbie?= <test@mydomain.me>
|
||||
To: test@mydomain.me
|
||||
Date: Thu, 16 Feb 2023 01:04:00 -0000
|
||||
Message-ID: <truenas-20230216.010400.344514.b'8jfL'@truenas.local>
|
||||
|
||||
This is a multi-part message in MIME format.
|
||||
--===============2138658284696597373==
|
||||
Content-Type: multipart/alternative; boundary="===============2233989480071754745=="
|
||||
MIME-Version: 1.0
|
||||
|
||||
--===============2233989480071754745==
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
MIME-Version: 1.0
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4=
|
||||
|
||||
--===============2233989480071754745==
|
||||
Content-Type: text/html; charset="utf-8"
|
||||
MIME-Version: 1.0
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv
|
||||
L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg==
|
||||
|
||||
--===============2233989480071754745==--
|
||||
|
||||
--===============2138658284696597373==--
|
||||
.
|
||||
`
|
||||
|
||||
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local", r.Header.Get("Title"))
|
||||
require.Equal(t, "This is a test message from TrueNAS CORE.", readAll(t, r.Body))
|
||||
})
|
||||
defer s.Close()
|
||||
defer c.Close()
|
||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||
}
|
||||
|
||||
func TestSmtpBackend_NestedMultipartTooDeep(t *testing.T) {
|
||||
email := `EHLO example.com
|
||||
MAIL FROM: test@mydomain.me
|
||||
RCPT TO: ntfy-mytopic@ntfy.sh
|
||||
DATA
|
||||
Content-Type: multipart/mixed; boundary="===============1=="
|
||||
MIME-Version: 1.0
|
||||
Subject: TrueNAS truenas.local: TrueNAS Test Message hostname: truenas.local
|
||||
From: =?utf-8?q?Robbie?= <test@mydomain.me>
|
||||
To: test@mydomain.me
|
||||
Date: Thu, 16 Feb 2023 01:04:00 -0000
|
||||
Message-ID: <truenas-20230216.010400.344514.b'8jfL'@truenas.local>
|
||||
|
||||
This is a multi-part message in MIME format.
|
||||
--===============1==
|
||||
Content-Type: multipart/alternative; boundary="===============2=="
|
||||
MIME-Version: 1.0
|
||||
|
||||
--===============2==
|
||||
Content-Type: multipart/alternative; boundary="===============3=="
|
||||
MIME-Version: 1.0
|
||||
|
||||
--===============3==
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
MIME-Version: 1.0
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
VGhpcyBpcyBhIHRlc3QgbWVzc2FnZSBmcm9tIFRydWVOQVMgQ09SRS4=
|
||||
|
||||
--===============3==
|
||||
Content-Type: text/html; charset="utf-8"
|
||||
MIME-Version: 1.0
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
PCFET0NUWVBFIEhUTUwgUFVCTElDICItLy9XM0MvL0RURCBIVE1MIDQuMCBUcmFuc2l0aW9uYWwv
|
||||
L0VOIj4KClRoaXMgaXMgYSB0ZXN0IG1lc3NhZ2UgZnJvbSBUcnVlTkFTIENPUkUuCg==
|
||||
|
||||
--===============3==--
|
||||
|
||||
--===============2==--
|
||||
|
||||
--===============1==--
|
||||
.
|
||||
`
|
||||
|
||||
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("This should not be called")
|
||||
})
|
||||
defer s.Close()
|
||||
defer c.Close()
|
||||
writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: multipart message nested too deep")
|
||||
}
|
||||
|
||||
func TestSmtpBackend_PlaintextWithToken(t *testing.T) {
|
||||
email := `EHLO example.com
|
||||
MAIL FROM: phil@example.com
|
||||
RCPT TO: ntfy-mytopic+tk_KLORUqSqvNRLpY11DfkHVbHu9NGG2@ntfy.sh
|
||||
DATA
|
||||
Subject: Very short mail
|
||||
|
||||
what's up
|
||||
.
|
||||
`
|
||||
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "Very short mail", r.Header.Get("Title"))
|
||||
require.Equal(t, "Bearer tk_KLORUqSqvNRLpY11DfkHVbHu9NGG2", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||
})
|
||||
defer s.Close()
|
||||
defer c.Close()
|
||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||
}
|
||||
|
||||
type smtpHandlerFunc func(http.ResponseWriter, *http.Request)
|
||||
|
||||
func newTestSMTPServer(t *testing.T, handler smtpHandlerFunc) (s *smtp.Server, c net.Conn, conf *Config, scanner *bufio.Scanner) {
|
||||
|
||||
@@ -2,8 +2,17 @@ package server
|
||||
|
||||
import (
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// topicExpungeAfter defines how long a topic is active before it is removed from memory.
|
||||
// This must be larger than matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter to give
|
||||
// time for more requests to come in, so that we can send a {"rejected":["<pushkey>"]} response back.
|
||||
topicExpungeAfter = 16 * time.Hour
|
||||
)
|
||||
|
||||
// topic represents a channel to which subscribers can subscribe, and publishers
|
||||
@@ -11,7 +20,9 @@ import (
|
||||
type topic struct {
|
||||
ID string
|
||||
subscribers map[int]*topicSubscriber
|
||||
mu sync.Mutex
|
||||
rateVisitor *visitor
|
||||
lastAccess time.Time
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type topicSubscriber struct {
|
||||
@@ -28,6 +39,7 @@ func newTopic(id string) *topic {
|
||||
return &topic{
|
||||
ID: id,
|
||||
subscribers: make(map[int]*topicSubscriber),
|
||||
lastAccess: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +53,41 @@ func (t *topic) Subscribe(s subscriber, userID string, cancel func()) int {
|
||||
subscriber: s,
|
||||
cancel: cancel,
|
||||
}
|
||||
t.lastAccess = time.Now()
|
||||
return subscriberID
|
||||
}
|
||||
|
||||
func (t *topic) Stale() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.rateVisitor != nil && !t.rateVisitor.Stale() {
|
||||
return false
|
||||
}
|
||||
return len(t.subscribers) == 0 && time.Since(t.lastAccess) > topicExpungeAfter
|
||||
}
|
||||
|
||||
func (t *topic) LastAccess() time.Time {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return t.lastAccess
|
||||
}
|
||||
|
||||
func (t *topic) SetRateVisitor(v *visitor) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.rateVisitor = v
|
||||
t.lastAccess = time.Now()
|
||||
}
|
||||
|
||||
func (t *topic) RateVisitor() *visitor {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.rateVisitor != nil && t.rateVisitor.Stale() {
|
||||
t.rateVisitor = nil
|
||||
}
|
||||
return t.rateVisitor
|
||||
}
|
||||
|
||||
// Unsubscribe removes the subscription from the list of subscribers
|
||||
func (t *topic) Unsubscribe(id int) {
|
||||
t.mu.Lock()
|
||||
@@ -71,15 +115,23 @@ func (t *topic) Publish(v *visitor, m *message) error {
|
||||
} else {
|
||||
logvm(v, m).Tag(tagPublish).Trace("No stream or WebSocket subscribers, not forwarding")
|
||||
}
|
||||
t.Keepalive()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubscribersCount returns the number of subscribers to this topic
|
||||
func (t *topic) SubscribersCount() int {
|
||||
// Stats returns the number of subscribers and last access to this topic
|
||||
func (t *topic) Stats() (int, time.Time) {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return len(t.subscribers), t.lastAccess
|
||||
}
|
||||
|
||||
// Keepalive sets the last access time and ensures that Stale does not return true
|
||||
func (t *topic) Keepalive() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return len(t.subscribers)
|
||||
t.lastAccess = time.Now()
|
||||
}
|
||||
|
||||
// CancelSubscribers calls the cancel function for all subscribers, forcing
|
||||
@@ -88,12 +140,34 @@ func (t *topic) CancelSubscribers(exceptUserID string) {
|
||||
defer t.mu.Unlock()
|
||||
for _, s := range t.subscribers {
|
||||
if s.userID != exceptUserID {
|
||||
log.Tag(tagSubscribe).Field("topic", t.ID).Debug("Canceling subscriber %s", s.userID)
|
||||
log.
|
||||
Tag(tagSubscribe).
|
||||
With(t).
|
||||
Fields(log.Context{
|
||||
"user_id": s.userID,
|
||||
}).
|
||||
Debug("Canceling subscriber %s", s.userID)
|
||||
s.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *topic) Context() log.Context {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
fields := map[string]any{
|
||||
"topic": t.ID,
|
||||
"topic_subscribers": len(t.subscribers),
|
||||
"topic_last_access": util.FormatTime(t.lastAccess),
|
||||
}
|
||||
if t.rateVisitor != nil {
|
||||
for k, v := range t.rateVisitor.Context() {
|
||||
fields["topic_rate_"+k] = v
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// subscribersCopy returns a shallow copy of the subscribers map
|
||||
func (t *topic) subscribersCopy() map[int]*topicSubscriber {
|
||||
t.mu.Lock()
|
||||
|
||||
41
server/topic_test.go
Normal file
41
server/topic_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTopic_CancelSubscribers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
subFn := func(v *visitor, msg *message) error {
|
||||
return nil
|
||||
}
|
||||
canceled1 := atomic.Bool{}
|
||||
cancelFn1 := func() {
|
||||
canceled1.Store(true)
|
||||
}
|
||||
canceled2 := atomic.Bool{}
|
||||
cancelFn2 := func() {
|
||||
canceled2.Store(true)
|
||||
}
|
||||
to := newTopic("mytopic")
|
||||
to.Subscribe(subFn, "", cancelFn1)
|
||||
to.Subscribe(subFn, "u_phil", cancelFn2)
|
||||
|
||||
to.CancelSubscribers("u_phil")
|
||||
require.True(t, canceled1.Load())
|
||||
require.False(t, canceled2.Load())
|
||||
}
|
||||
|
||||
func TestTopic_Keepalive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
to := newTopic("mytopic")
|
||||
to.lastAccess = time.Now().Add(-1 * time.Hour)
|
||||
to.Keepalive()
|
||||
require.True(t, to.LastAccess().Unix() >= time.Now().Unix()-2)
|
||||
require.True(t, to.LastAccess().Unix() <= time.Now().Unix()+2)
|
||||
}
|
||||
@@ -45,10 +45,10 @@ type message struct {
|
||||
|
||||
func (m *message) Context() log.Context {
|
||||
fields := map[string]any{
|
||||
"topic": m.Topic,
|
||||
"message_id": m.ID,
|
||||
"message_time": m.Time,
|
||||
"message_event": m.Event,
|
||||
"message_topic": m.Topic,
|
||||
"message_body_size": len(m.Message),
|
||||
}
|
||||
if m.Sender.IsValid() {
|
||||
@@ -239,6 +239,11 @@ type apiHealthResponse struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
}
|
||||
|
||||
type apiStatsResponse struct {
|
||||
Messages int64 `json:"messages"`
|
||||
MessagesRate float64 `json:"messages_rate"` // Average number of messages per second
|
||||
}
|
||||
|
||||
type apiAccountCreateRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -309,6 +314,7 @@ type apiAccountBilling struct {
|
||||
Customer bool `json:"customer"`
|
||||
Subscription bool `json:"subscription"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Interval string `json:"interval,omitempty"`
|
||||
PaidUntil int64 `json:"paid_until,omitempty"`
|
||||
CancelAt int64 `json:"cancel_at,omitempty"`
|
||||
}
|
||||
@@ -340,14 +346,20 @@ type apiConfigResponse struct {
|
||||
EnableSignup bool `json:"enable_signup"`
|
||||
EnablePayments bool `json:"enable_payments"`
|
||||
EnableReservations bool `json:"enable_reservations"`
|
||||
BillingContact string `json:"billing_contact"`
|
||||
DisallowedTopics []string `json:"disallowed_topics"`
|
||||
}
|
||||
|
||||
type apiAccountBillingPrices struct {
|
||||
Month int64 `json:"month"`
|
||||
Year int64 `json:"year"`
|
||||
}
|
||||
|
||||
type apiAccountBillingTier struct {
|
||||
Code string `json:"code,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Price string `json:"price,omitempty"`
|
||||
Limits *apiAccountLimits `json:"limits"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Prices *apiAccountBillingPrices `json:"prices,omitempty"`
|
||||
Limits *apiAccountLimits `json:"limits"`
|
||||
}
|
||||
|
||||
type apiAccountBillingSubscriptionCreateResponse struct {
|
||||
@@ -355,7 +367,8 @@ type apiAccountBillingSubscriptionCreateResponse struct {
|
||||
}
|
||||
|
||||
type apiAccountBillingSubscriptionChangeRequest struct {
|
||||
Tier string `json:"tier"`
|
||||
Tier string `json:"tier"`
|
||||
Interval string `json:"interval"`
|
||||
}
|
||||
|
||||
type apiAccountBillingPortalRedirectResponse struct {
|
||||
@@ -385,7 +398,10 @@ type apiStripeSubscriptionUpdatedEvent struct {
|
||||
Items *struct {
|
||||
Data []*struct {
|
||||
Price *struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
Recurring *struct {
|
||||
Interval string `json:"interval"`
|
||||
} `json:"recurring"`
|
||||
} `json:"price"`
|
||||
} `json:"data"`
|
||||
} `json:"items"`
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"net"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var mimeDecoder mime.WordDecoder
|
||||
|
||||
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||
value := strings.ToLower(readParam(r, names...))
|
||||
if value == "" {
|
||||
@@ -19,6 +21,17 @@ func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||
return value == "1" || value == "yes" || value == "true"
|
||||
}
|
||||
|
||||
func readCommaSeparatedParam(r *http.Request, names ...string) (params []string) {
|
||||
paramStr := readParam(r, names...)
|
||||
if paramStr != "" {
|
||||
params = make([]string, 0)
|
||||
for _, s := range util.SplitNoEmpty(paramStr, ",") {
|
||||
params = append(params, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func readParam(r *http.Request, names ...string) string {
|
||||
value := readHeaderParam(r, names...)
|
||||
if value != "" {
|
||||
@@ -89,56 +102,26 @@ func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T,
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
type httpResponseWriter struct {
|
||||
w http.ResponseWriter
|
||||
headerWritten bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type httpResponseWriterWithHijacker struct {
|
||||
httpResponseWriter
|
||||
}
|
||||
|
||||
var _ http.ResponseWriter = (*httpResponseWriter)(nil)
|
||||
var _ http.Flusher = (*httpResponseWriter)(nil)
|
||||
var _ http.Hijacker = (*httpResponseWriterWithHijacker)(nil)
|
||||
|
||||
func newHTTPResponseWriter(w http.ResponseWriter) http.ResponseWriter {
|
||||
if _, ok := w.(http.Hijacker); ok {
|
||||
return &httpResponseWriterWithHijacker{httpResponseWriter: httpResponseWriter{w: w}}
|
||||
func withContext(r *http.Request, ctx map[contextKey]any) *http.Request {
|
||||
c := r.Context()
|
||||
for k, v := range ctx {
|
||||
c = context.WithValue(c, k, v)
|
||||
}
|
||||
return &httpResponseWriter{w: w}
|
||||
return r.WithContext(c)
|
||||
}
|
||||
|
||||
func (w *httpResponseWriter) Header() http.Header {
|
||||
return w.w.Header()
|
||||
}
|
||||
|
||||
func (w *httpResponseWriter) Write(bytes []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
w.headerWritten = true
|
||||
w.mu.Unlock()
|
||||
return w.w.Write(bytes)
|
||||
}
|
||||
|
||||
func (w *httpResponseWriter) WriteHeader(statusCode int) {
|
||||
w.mu.Lock()
|
||||
if w.headerWritten {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
func fromContext[T any](r *http.Request, key contextKey) (T, error) {
|
||||
t, ok := r.Context().Value(key).(T)
|
||||
if !ok {
|
||||
return t, fmt.Errorf("cannot find key %v in request context", key)
|
||||
}
|
||||
w.headerWritten = true
|
||||
w.mu.Unlock()
|
||||
w.w.WriteHeader(statusCode)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (w *httpResponseWriter) Flush() {
|
||||
if f, ok := w.w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
func maybeDecodeHeader(header string) string {
|
||||
decoded, err := mimeDecoder.DecodeHeader(header)
|
||||
if err != nil {
|
||||
return header
|
||||
}
|
||||
}
|
||||
|
||||
func (w *httpResponseWriterWithHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
h, _ := w.w.(http.Hijacker)
|
||||
return h.Hijack()
|
||||
return decoded
|
||||
}
|
||||
|
||||
@@ -141,7 +141,9 @@ func (v *visitor) Context() log.Context {
|
||||
func (v *visitor) contextNoLock() log.Context {
|
||||
info := v.infoLightNoLock()
|
||||
fields := log.Context{
|
||||
"visitor_id": visitorID(v.ip, v.user),
|
||||
"visitor_ip": v.ip.String(),
|
||||
"visitor_seen": util.FormatTime(v.seen),
|
||||
"visitor_messages": info.Stats.Messages,
|
||||
"visitor_messages_limit": info.Limits.MessageLimit,
|
||||
"visitor_messages_remaining": info.Stats.MessagesRemaining,
|
||||
@@ -330,9 +332,13 @@ func (v *visitor) SetUser(u *user.User) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver
|
||||
v.user = u
|
||||
v.user = u // u may be nil!
|
||||
if shouldResetLimiters {
|
||||
v.resetLimitersNoLock(u.Stats.Messages, u.Stats.Emails, true)
|
||||
var messages, emails int64
|
||||
if u != nil {
|
||||
messages, emails = u.Stats.Messages, u.Stats.Emails
|
||||
}
|
||||
v.resetLimitersNoLock(messages, emails, true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
69
tools/loadgen/main.go
Normal file
69
tools/loadgen/main.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
baseURL := "https://staging.ntfy.sh"
|
||||
if len(os.Args) > 1 {
|
||||
baseURL = os.Args[1]
|
||||
}
|
||||
for i := 0; i < 2000; i++ {
|
||||
go subscribe(i, baseURL)
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
for i := 0; i < 2000; i++ {
|
||||
go func(worker int) {
|
||||
for {
|
||||
poll(worker, baseURL)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
time.Sleep(time.Hour)
|
||||
}
|
||||
|
||||
func subscribe(worker int, baseURL string) {
|
||||
fmt.Printf("[subscribe] worker=%d STARTING\n", worker)
|
||||
start := time.Now()
|
||||
topic, ip := fmt.Sprintf("subtopic%d", worker), fmt.Sprintf("1.2.%d.%d", (worker/255)%255, worker%255)
|
||||
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/%s/json", baseURL, topic), nil)
|
||||
req.Header.Set("X-Forwarded-For", ip)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("[subscribe] worker=%d time=%d error=%s\n", worker, time.Since(start).Milliseconds(), err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
// Do nothing
|
||||
}
|
||||
fmt.Printf("[subscribe] worker=%d status=%d time=%d EXITED\n", worker, resp.StatusCode, time.Since(start).Milliseconds())
|
||||
}
|
||||
|
||||
func poll(worker int, baseURL string) {
|
||||
fmt.Printf("[poll] worker=%d STARTING\n", worker)
|
||||
topic, ip := fmt.Sprintf("polltopic%d", worker), fmt.Sprintf("1.2.%d.%d", (worker/255)%255, worker%255)
|
||||
start := time.Now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
|
||||
defer cancel()
|
||||
|
||||
//req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://staging.ntfy.sh/%s/json?poll=1&since=all", topic), nil)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/%s/json?poll=1&since=all", baseURL, topic), nil)
|
||||
req.Header.Set("X-Forwarded-For", ip)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("[poll] worker=%d time=%d status=- error=%s\n", worker, time.Since(start).Milliseconds(), err.Error())
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
fmt.Printf("[poll] worker=%d time=%d status=%s\n", worker, time.Since(start).Milliseconds(), resp.Status)
|
||||
}
|
||||
535
user/manager.go
535
user/manager.go
@@ -46,7 +46,261 @@ var (
|
||||
|
||||
// Manager-related queries
|
||||
const (
|
||||
createTablesQueriesNoTx = `
|
||||
createTablesQueries = `
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS tier (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
messages_limit INT NOT NULL,
|
||||
messages_expiry_duration INT NOT NULL,
|
||||
emails_limit INT NOT NULL,
|
||||
reservations_limit INT NOT NULL,
|
||||
attachment_file_size_limit INT NOT NULL,
|
||||
attachment_total_size_limit INT NOT NULL,
|
||||
attachment_expiry_duration INT NOT NULL,
|
||||
attachment_bandwidth_limit INT NOT NULL,
|
||||
stripe_monthly_price_id TEXT,
|
||||
stripe_yearly_price_id TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_tier_code ON tier (code);
|
||||
CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);
|
||||
CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
id TEXT PRIMARY KEY,
|
||||
tier_id TEXT,
|
||||
user TEXT NOT NULL,
|
||||
pass TEXT NOT NULL,
|
||||
role TEXT CHECK (role IN ('anonymous', 'admin', 'user')) NOT NULL,
|
||||
prefs JSON NOT NULL DEFAULT '{}',
|
||||
sync_topic TEXT NOT NULL,
|
||||
stats_messages INT NOT NULL DEFAULT (0),
|
||||
stats_emails INT NOT NULL DEFAULT (0),
|
||||
stripe_customer_id TEXT,
|
||||
stripe_subscription_id TEXT,
|
||||
stripe_subscription_status TEXT,
|
||||
stripe_subscription_interval TEXT,
|
||||
stripe_subscription_paid_until INT,
|
||||
stripe_subscription_cancel_at INT,
|
||||
created INT NOT NULL,
|
||||
deleted INT,
|
||||
FOREIGN KEY (tier_id) REFERENCES tier (id)
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_user ON user (user);
|
||||
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
|
||||
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
|
||||
CREATE TABLE IF NOT EXISTS user_access (
|
||||
user_id TEXT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
read INT NOT NULL,
|
||||
write INT NOT NULL,
|
||||
owner_user_id INT,
|
||||
PRIMARY KEY (user_id, topic),
|
||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_token (
|
||||
user_id TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
last_access INT NOT NULL,
|
||||
last_origin TEXT NOT NULL,
|
||||
expires INT NOT NULL,
|
||||
PRIMARY KEY (user_id, token),
|
||||
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
INSERT INTO user (id, user, pass, role, sync_topic, created)
|
||||
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', UNIXEPOCH())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
COMMIT;
|
||||
`
|
||||
builtinStartupQueries = `
|
||||
PRAGMA foreign_keys = ON;
|
||||
`
|
||||
|
||||
selectUserByIDQuery = `
|
||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||
FROM user u
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE u.id = ?
|
||||
`
|
||||
selectUserByNameQuery = `
|
||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||
FROM user u
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE user = ?
|
||||
`
|
||||
selectUserByTokenQuery = `
|
||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||
FROM user u
|
||||
JOIN user_token tk on u.id = tk.user_id
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
|
||||
`
|
||||
selectUserByStripeCustomerIDQuery = `
|
||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
|
||||
FROM user u
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE u.stripe_customer_id = ?
|
||||
`
|
||||
selectTopicPermsQuery = `
|
||||
SELECT read, write
|
||||
FROM user_access a
|
||||
JOIN user u ON u.id = a.user_id
|
||||
WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic
|
||||
ORDER BY u.user DESC
|
||||
`
|
||||
|
||||
insertUserQuery = `
|
||||
INSERT INTO user (id, user, pass, role, sync_topic, created)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
selectUsernamesQuery = `
|
||||
SELECT user
|
||||
FROM user
|
||||
ORDER BY
|
||||
CASE role
|
||||
WHEN 'admin' THEN 1
|
||||
WHEN 'anonymous' THEN 3
|
||||
ELSE 2
|
||||
END, user
|
||||
`
|
||||
selectUserCountQuery = `SELECT COUNT(*) FROM user`
|
||||
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
||||
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
||||
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
|
||||
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ? WHERE id = ?`
|
||||
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0`
|
||||
updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?`
|
||||
deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?`
|
||||
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
||||
|
||||
upsertUserAccessQuery = `
|
||||
INSERT INTO user_access (user_id, topic, read, write, owner_user_id)
|
||||
VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))))
|
||||
ON CONFLICT (user_id, topic)
|
||||
DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id
|
||||
`
|
||||
selectUserAccessQuery = `
|
||||
SELECT topic, read, write
|
||||
FROM user_access
|
||||
WHERE user_id = (SELECT id FROM user WHERE user = ?)
|
||||
ORDER BY write DESC, read DESC, topic
|
||||
`
|
||||
selectUserReservationsQuery = `
|
||||
SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write
|
||||
FROM user_access a_user
|
||||
LEFT JOIN user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM user WHERE user = ?)
|
||||
WHERE a_user.user_id = a_user.owner_user_id
|
||||
AND a_user.owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
ORDER BY a_user.topic
|
||||
`
|
||||
selectUserReservationsCountQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM user_access
|
||||
WHERE user_id = owner_user_id
|
||||
AND owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
`
|
||||
selectUserReservationsOwnerQuery = `
|
||||
SELECT owner_user_id
|
||||
FROM user_access
|
||||
WHERE topic = ?
|
||||
AND user_id = owner_user_id
|
||||
`
|
||||
selectUserHasReservationQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM user_access
|
||||
WHERE user_id = owner_user_id
|
||||
AND owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
AND topic = ?
|
||||
`
|
||||
selectOtherAccessCountQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM user_access
|
||||
WHERE (topic = ? OR ? LIKE topic)
|
||||
AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?))
|
||||
`
|
||||
deleteAllAccessQuery = `DELETE FROM user_access`
|
||||
deleteUserAccessQuery = `
|
||||
DELETE FROM user_access
|
||||
WHERE user_id = (SELECT id FROM user WHERE user = ?)
|
||||
OR owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
`
|
||||
deleteTopicAccessQuery = `
|
||||
DELETE FROM user_access
|
||||
WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?))
|
||||
AND topic = ?
|
||||
`
|
||||
|
||||
selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?`
|
||||
selectTokensQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ?`
|
||||
selectTokenQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ? AND token = ?`
|
||||
insertTokenQuery = `INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?`
|
||||
updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?`
|
||||
updateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?`
|
||||
deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?`
|
||||
deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?`
|
||||
deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?`
|
||||
deleteExcessTokensQuery = `
|
||||
DELETE FROM user_token
|
||||
WHERE (user_id, token) NOT IN (
|
||||
SELECT user_id, token
|
||||
FROM user_token
|
||||
WHERE user_id = ?
|
||||
ORDER BY expires DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`
|
||||
|
||||
insertTierQuery = `
|
||||
INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
updateTierQuery = `
|
||||
UPDATE tier
|
||||
SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ?
|
||||
WHERE code = ?
|
||||
`
|
||||
selectTiersQuery = `
|
||||
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
|
||||
FROM tier
|
||||
`
|
||||
selectTierByCodeQuery = `
|
||||
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
|
||||
FROM tier
|
||||
WHERE code = ?
|
||||
`
|
||||
selectTierByPriceIDQuery = `
|
||||
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id
|
||||
FROM tier
|
||||
WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?)
|
||||
`
|
||||
updateUserTierQuery = `UPDATE user SET tier_id = (SELECT id FROM tier WHERE code = ?) WHERE user = ?`
|
||||
deleteUserTierQuery = `UPDATE user SET tier_id = null WHERE user = ?`
|
||||
deleteTierQuery = `DELETE FROM tier WHERE code = ?`
|
||||
|
||||
updateBillingQuery = `
|
||||
UPDATE user
|
||||
SET stripe_customer_id = ?, stripe_subscription_id = ?, stripe_subscription_status = ?, stripe_subscription_interval = ?, stripe_subscription_paid_until = ?, stripe_subscription_cancel_at = ?
|
||||
WHERE user = ?
|
||||
`
|
||||
)
|
||||
|
||||
// Schema management queries
|
||||
const (
|
||||
currentSchemaVersion = 3
|
||||
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
||||
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||
|
||||
// 1 -> 2 (complex migration!)
|
||||
migrate1To2CreateTablesQueries = `
|
||||
ALTER TABLE user RENAME TO user_old;
|
||||
CREATE TABLE IF NOT EXISTS tier (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
@@ -110,186 +364,9 @@ const (
|
||||
version INT NOT NULL
|
||||
);
|
||||
INSERT INTO user (id, user, pass, role, sync_topic, created)
|
||||
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', UNIXEPOCH())
|
||||
VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH())
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
`
|
||||
createTablesQueries = `BEGIN; ` + createTablesQueriesNoTx + ` COMMIT;`
|
||||
builtinStartupQueries = `
|
||||
PRAGMA foreign_keys = ON;
|
||||
`
|
||||
|
||||
selectUserByIDQuery = `
|
||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_price_id
|
||||
FROM user u
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE u.id = ?
|
||||
`
|
||||
selectUserByNameQuery = `
|
||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_price_id
|
||||
FROM user u
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE user = ?
|
||||
`
|
||||
selectUserByTokenQuery = `
|
||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_price_id
|
||||
FROM user u
|
||||
JOIN user_token tk on u.id = tk.user_id
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?)
|
||||
`
|
||||
selectUserByStripeCustomerIDQuery = `
|
||||
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_price_id
|
||||
FROM user u
|
||||
LEFT JOIN tier t on t.id = u.tier_id
|
||||
WHERE u.stripe_customer_id = ?
|
||||
`
|
||||
selectTopicPermsQuery = `
|
||||
SELECT read, write
|
||||
FROM user_access a
|
||||
JOIN user u ON u.id = a.user_id
|
||||
WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic
|
||||
ORDER BY u.user DESC
|
||||
`
|
||||
|
||||
insertUserQuery = `
|
||||
INSERT INTO user (id, user, pass, role, sync_topic, created)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
selectUsernamesQuery = `
|
||||
SELECT user
|
||||
FROM user
|
||||
ORDER BY
|
||||
CASE role
|
||||
WHEN 'admin' THEN 1
|
||||
WHEN 'anonymous' THEN 3
|
||||
ELSE 2
|
||||
END, user
|
||||
`
|
||||
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
||||
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
||||
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
|
||||
updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ? WHERE id = ?`
|
||||
updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0`
|
||||
updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?`
|
||||
deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?`
|
||||
deleteUserQuery = `DELETE FROM user WHERE user = ?`
|
||||
|
||||
upsertUserAccessQuery = `
|
||||
INSERT INTO user_access (user_id, topic, read, write, owner_user_id)
|
||||
VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?))))
|
||||
ON CONFLICT (user_id, topic)
|
||||
DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id
|
||||
`
|
||||
selectUserAccessQuery = `
|
||||
SELECT topic, read, write
|
||||
FROM user_access
|
||||
WHERE user_id = (SELECT id FROM user WHERE user = ?)
|
||||
ORDER BY write DESC, read DESC, topic
|
||||
`
|
||||
selectUserReservationsQuery = `
|
||||
SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write
|
||||
FROM user_access a_user
|
||||
LEFT JOIN user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM user WHERE user = ?)
|
||||
WHERE a_user.user_id = a_user.owner_user_id
|
||||
AND a_user.owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
ORDER BY a_user.topic
|
||||
`
|
||||
selectUserReservationsCountQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM user_access
|
||||
WHERE user_id = owner_user_id AND owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
`
|
||||
selectUserHasReservationQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM user_access
|
||||
WHERE user_id = owner_user_id
|
||||
AND owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
AND topic = ?
|
||||
`
|
||||
selectOtherAccessCountQuery = `
|
||||
SELECT COUNT(*)
|
||||
FROM user_access
|
||||
WHERE (topic = ? OR ? LIKE topic)
|
||||
AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?))
|
||||
`
|
||||
deleteAllAccessQuery = `DELETE FROM user_access`
|
||||
deleteUserAccessQuery = `
|
||||
DELETE FROM user_access
|
||||
WHERE user_id = (SELECT id FROM user WHERE user = ?)
|
||||
OR owner_user_id = (SELECT id FROM user WHERE user = ?)
|
||||
`
|
||||
deleteTopicAccessQuery = `
|
||||
DELETE FROM user_access
|
||||
WHERE (user_id = (SELECT id FROM user WHERE user = ?) OR owner_user_id = (SELECT id FROM user WHERE user = ?))
|
||||
AND topic = ?
|
||||
`
|
||||
|
||||
selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?`
|
||||
selectTokensQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ?`
|
||||
selectTokenQuery = `SELECT token, label, last_access, last_origin, expires FROM user_token WHERE user_id = ? AND token = ?`
|
||||
insertTokenQuery = `INSERT INTO user_token (user_id, token, label, last_access, last_origin, expires) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?`
|
||||
updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?`
|
||||
updateTokenLastAccessQuery = `UPDATE user_token SET last_access = ?, last_origin = ? WHERE token = ?`
|
||||
deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?`
|
||||
deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?`
|
||||
deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?`
|
||||
deleteExcessTokensQuery = `
|
||||
DELETE FROM user_token
|
||||
WHERE (user_id, token) NOT IN (
|
||||
SELECT user_id, token
|
||||
FROM user_token
|
||||
WHERE user_id = ?
|
||||
ORDER BY expires DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`
|
||||
|
||||
insertTierQuery = `
|
||||
INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_price_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
updateTierQuery = `
|
||||
UPDATE tier
|
||||
SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_price_id = ?
|
||||
WHERE code = ?
|
||||
`
|
||||
selectTiersQuery = `
|
||||
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_price_id
|
||||
FROM tier
|
||||
`
|
||||
selectTierByCodeQuery = `
|
||||
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_price_id
|
||||
FROM tier
|
||||
WHERE code = ?
|
||||
`
|
||||
selectTierByPriceIDQuery = `
|
||||
SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_price_id
|
||||
FROM tier
|
||||
WHERE stripe_price_id = ?
|
||||
`
|
||||
updateUserTierQuery = `UPDATE user SET tier_id = (SELECT id FROM tier WHERE code = ?) WHERE user = ?`
|
||||
deleteUserTierQuery = `UPDATE user SET tier_id = null WHERE user = ?`
|
||||
deleteTierQuery = `DELETE FROM tier WHERE code = ?`
|
||||
|
||||
updateBillingQuery = `
|
||||
UPDATE user
|
||||
SET stripe_customer_id = ?, stripe_subscription_id = ?, stripe_subscription_status = ?, stripe_subscription_paid_until = ?, stripe_subscription_cancel_at = ?
|
||||
WHERE user = ?
|
||||
`
|
||||
)
|
||||
|
||||
// Schema management queries
|
||||
const (
|
||||
currentSchemaVersion = 2
|
||||
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||
updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1`
|
||||
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||
|
||||
// 1 -> 2 (complex migration!)
|
||||
migrate1To2RenameUserTableQueryNoTx = `
|
||||
ALTER TABLE user RENAME TO user_old;
|
||||
`
|
||||
migrate1To2SelectAllOldUsernamesNoTx = `SELECT user FROM user_old`
|
||||
migrate1To2InsertUserNoTx = `
|
||||
INSERT INTO user (id, user, pass, role, sync_topic, created)
|
||||
@@ -304,11 +381,22 @@ const (
|
||||
DROP TABLE access;
|
||||
DROP TABLE user_old;
|
||||
`
|
||||
|
||||
// 2 -> 3
|
||||
migrate2To3UpdateQueries = `
|
||||
ALTER TABLE user ADD COLUMN stripe_subscription_interval TEXT;
|
||||
ALTER TABLE tier RENAME COLUMN stripe_price_id TO stripe_monthly_price_id;
|
||||
ALTER TABLE tier ADD COLUMN stripe_yearly_price_id TEXT;
|
||||
DROP INDEX IF EXISTS idx_tier_price_id;
|
||||
CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id);
|
||||
CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id);
|
||||
`
|
||||
)
|
||||
|
||||
var (
|
||||
migrations = map[int]func(db *sql.DB) error{
|
||||
1: migrateFrom1,
|
||||
2: migrateFrom2,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -766,6 +854,23 @@ func (a *Manager) Users() ([]*User, error) {
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// UsersCount returns the number of users in the databsae
|
||||
func (a *Manager) UsersCount() (int64, error) {
|
||||
rows, err := a.db.Query(selectUserCountQuery)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return 0, errNoRows
|
||||
}
|
||||
var count int64
|
||||
if err := rows.Scan(&count); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// User returns the user with the given username if it exists, or ErrUserNotFound otherwise.
|
||||
// You may also pass Everyone to retrieve the anonymous user and its Grant list.
|
||||
func (a *Manager) User(username string) (*User, error) {
|
||||
@@ -805,13 +910,13 @@ func (a *Manager) userByToken(token string) (*User, error) {
|
||||
func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
||||
defer rows.Close()
|
||||
var id, username, hash, role, prefs, syncTopic string
|
||||
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripePriceID, tierID, tierCode, tierName sql.NullString
|
||||
var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString
|
||||
var messages, emails int64
|
||||
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64
|
||||
if !rows.Next() {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripePriceID); err != nil {
|
||||
if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
|
||||
return nil, err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
@@ -828,11 +933,12 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
||||
Emails: emails,
|
||||
},
|
||||
Billing: &Billing{
|
||||
StripeCustomerID: stripeCustomerID.String, // May be empty
|
||||
StripeSubscriptionID: stripeSubscriptionID.String, // May be empty
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty
|
||||
StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero
|
||||
StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero
|
||||
StripeCustomerID: stripeCustomerID.String, // May be empty
|
||||
StripeSubscriptionID: stripeSubscriptionID.String, // May be empty
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatus(stripeSubscriptionStatus.String), // May be empty
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringInterval(stripeSubscriptionInterval.String), // May be empty
|
||||
StripeSubscriptionPaidUntil: time.Unix(stripeSubscriptionPaidUntil.Int64, 0), // May be zero
|
||||
StripeSubscriptionCancelAt: time.Unix(stripeSubscriptionCancelAt.Int64, 0), // May be zero
|
||||
},
|
||||
Deleted: deleted.Valid,
|
||||
}
|
||||
@@ -853,7 +959,8 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
|
||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
|
||||
AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second,
|
||||
AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64,
|
||||
StripePriceID: stripePriceID.String, // May be empty
|
||||
StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty
|
||||
StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty
|
||||
}
|
||||
}
|
||||
return user, nil
|
||||
@@ -943,6 +1050,24 @@ func (a *Manager) ReservationsCount(username string) (int64, error) {
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ReservationOwner returns user ID of the user that owns this topic, or an
|
||||
// empty string if it's not owned by anyone
|
||||
func (a *Manager) ReservationOwner(topic string) (string, error) {
|
||||
rows, err := a.db.Query(selectUserReservationsOwnerQuery, topic)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return "", nil
|
||||
}
|
||||
var ownerUserID string
|
||||
if err := rows.Scan(&ownerUserID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ownerUserID, nil
|
||||
}
|
||||
|
||||
// ChangePassword changes a user's password
|
||||
func (a *Manager) ChangePassword(username, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), a.bcryptCost)
|
||||
@@ -1134,7 +1259,7 @@ func (a *Manager) AddTier(tier *Tier) error {
|
||||
if tier.ID == "" {
|
||||
tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength)
|
||||
}
|
||||
if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripePriceID)); err != nil {
|
||||
if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -1142,7 +1267,7 @@ func (a *Manager) AddTier(tier *Tier) error {
|
||||
|
||||
// UpdateTier updates a tier's properties in the database
|
||||
func (a *Manager) UpdateTier(tier *Tier) error {
|
||||
if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripePriceID), tier.Code); err != nil {
|
||||
if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -1162,7 +1287,7 @@ func (a *Manager) RemoveTier(code string) error {
|
||||
|
||||
// ChangeBilling updates a user's billing fields, namely the Stripe customer ID, and subscription information
|
||||
func (a *Manager) ChangeBilling(username string, billing *Billing) error {
|
||||
if _, err := a.db.Exec(updateBillingQuery, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil {
|
||||
if _, err := a.db.Exec(updateBillingQuery, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullString(string(billing.StripeSubscriptionInterval)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -1200,7 +1325,7 @@ func (a *Manager) Tier(code string) (*Tier, error) {
|
||||
|
||||
// TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist
|
||||
func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) {
|
||||
rows, err := a.db.Query(selectTierByPriceIDQuery, priceID)
|
||||
rows, err := a.db.Query(selectTierByPriceIDQuery, priceID, priceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1210,12 +1335,12 @@ func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) {
|
||||
|
||||
func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
|
||||
var id, code, name string
|
||||
var stripePriceID sql.NullString
|
||||
var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString
|
||||
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64
|
||||
if !rows.Next() {
|
||||
return nil, ErrTierNotFound
|
||||
}
|
||||
if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripePriceID); err != nil {
|
||||
if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil {
|
||||
return nil, err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
@@ -1233,7 +1358,8 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
|
||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
|
||||
AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second,
|
||||
AttachmentBandwidthLimit: attachmentBandwidthLimit.Int64,
|
||||
StripePriceID: stripePriceID.String, // May be empty
|
||||
StripeMonthlyPriceID: stripeMonthlyPriceID.String, // May be empty
|
||||
StripeYearlyPriceID: stripeYearlyPriceID.String, // May be empty
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1313,10 +1439,7 @@ func migrateFrom1(db *sql.DB) error {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// Rename user -> user_old, and create new tables
|
||||
if _, err := tx.Exec(migrate1To2RenameUserTableQueryNoTx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(createTablesQueriesNoTx); err != nil {
|
||||
if _, err := tx.Exec(migrate1To2CreateTablesQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
// Insert users from user_old into new user table, with ID and sync_topic
|
||||
@@ -1356,6 +1479,22 @@ func migrateFrom1(db *sql.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateFrom2(db *sql.DB) error {
|
||||
log.Tag(tag).Info("Migrating user database schema: from 2 to 3")
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(migrate2To3UpdateQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(updateSchemaVersion, 3); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func nullString(s string) sql.NullString {
|
||||
if s == "" {
|
||||
return sql.NullString{}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"heckel.io/ntfy/util"
|
||||
"net/netip"
|
||||
@@ -113,7 +114,8 @@ func TestManager_AddUser_And_Query(t *testing.T) {
|
||||
require.Nil(t, a.ChangeBilling("user", &Billing{
|
||||
StripeCustomerID: "acct_123",
|
||||
StripeSubscriptionID: "sub_123",
|
||||
StripeSubscriptionStatus: "active",
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatusActive,
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
||||
StripeSubscriptionPaidUntil: time.Now().Add(time.Hour),
|
||||
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
||||
}))
|
||||
@@ -131,29 +133,6 @@ func TestManager_AddUser_And_Query(t *testing.T) {
|
||||
require.Equal(t, u.ID, u3.ID)
|
||||
}
|
||||
|
||||
func TestManager_Authenticate_Timing(t *testing.T) {
|
||||
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
|
||||
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
|
||||
|
||||
// Timing a correct attempt
|
||||
start := time.Now().UnixMilli()
|
||||
_, err := a.Authenticate("user", "pass")
|
||||
require.Nil(t, err)
|
||||
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||
|
||||
// Timing an incorrect attempt
|
||||
start = time.Now().UnixMilli()
|
||||
_, err = a.Authenticate("user", "INCORRECT")
|
||||
require.Equal(t, ErrUnauthenticated, err)
|
||||
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||
|
||||
// Timing a non-existing user attempt
|
||||
start = time.Now().UnixMilli()
|
||||
_, err = a.Authenticate("DOES-NOT-EXIST", "hithere")
|
||||
require.Equal(t, ErrUnauthenticated, err)
|
||||
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||
}
|
||||
|
||||
func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
|
||||
@@ -395,7 +374,7 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
|
||||
require.Nil(t, a.AddTier(&Tier{
|
||||
Code: "pro",
|
||||
Name: "ntfy Pro",
|
||||
StripePriceID: "price123",
|
||||
StripeMonthlyPriceID: "price123",
|
||||
MessageLimit: 5_000,
|
||||
MessageExpiryDuration: 3 * 24 * time.Hour,
|
||||
EmailLimit: 50,
|
||||
@@ -761,7 +740,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
|
||||
AttachmentTotalSizeLimit: 1,
|
||||
AttachmentExpiryDuration: time.Second,
|
||||
AttachmentBandwidthLimit: 1,
|
||||
StripePriceID: "price_1",
|
||||
StripeMonthlyPriceID: "price_1",
|
||||
}))
|
||||
require.Nil(t, a.AddTier(&Tier{
|
||||
Code: "pro",
|
||||
@@ -774,7 +753,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
|
||||
AttachmentTotalSizeLimit: 123123,
|
||||
AttachmentExpiryDuration: 10800 * time.Second,
|
||||
AttachmentBandwidthLimit: 21474836480,
|
||||
StripePriceID: "price_2",
|
||||
StripeMonthlyPriceID: "price_2",
|
||||
}))
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
|
||||
require.Nil(t, a.ChangeTier("phil", "pro"))
|
||||
@@ -800,7 +779,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
|
||||
require.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit)
|
||||
require.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration)
|
||||
require.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit)
|
||||
require.Equal(t, "price_2", ti.StripePriceID)
|
||||
require.Equal(t, "price_2", ti.StripeMonthlyPriceID)
|
||||
|
||||
// Update tier
|
||||
ti.EmailLimit = 999999
|
||||
@@ -822,7 +801,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
|
||||
require.Equal(t, int64(1), ti.AttachmentTotalSizeLimit)
|
||||
require.Equal(t, time.Second, ti.AttachmentExpiryDuration)
|
||||
require.Equal(t, int64(1), ti.AttachmentBandwidthLimit)
|
||||
require.Equal(t, "price_1", ti.StripePriceID)
|
||||
require.Equal(t, "price_1", ti.StripeMonthlyPriceID)
|
||||
|
||||
ti = tiers[1]
|
||||
require.Equal(t, "pro", ti.Code)
|
||||
@@ -835,7 +814,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
|
||||
require.Equal(t, int64(123123), ti.AttachmentTotalSizeLimit)
|
||||
require.Equal(t, 10800*time.Second, ti.AttachmentExpiryDuration)
|
||||
require.Equal(t, int64(21474836480), ti.AttachmentBandwidthLimit)
|
||||
require.Equal(t, "price_2", ti.StripePriceID)
|
||||
require.Equal(t, "price_2", ti.StripeMonthlyPriceID)
|
||||
|
||||
ti, err = a.TierByStripePrice("price_1")
|
||||
require.Nil(t, err)
|
||||
@@ -849,7 +828,7 @@ func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
|
||||
require.Equal(t, int64(1), ti.AttachmentTotalSizeLimit)
|
||||
require.Equal(t, time.Second, ti.AttachmentExpiryDuration)
|
||||
require.Equal(t, int64(1), ti.AttachmentBandwidthLimit)
|
||||
require.Equal(t, "price_1", ti.StripePriceID)
|
||||
require.Equal(t, "price_1", ti.StripeMonthlyPriceID)
|
||||
|
||||
// Cannot remove tier, since user has this tier
|
||||
require.Error(t, a.RemoveTier("pro"))
|
||||
|
||||
@@ -91,15 +91,17 @@ type Tier struct {
|
||||
AttachmentTotalSizeLimit int64 // Total file size for all files of this user (bytes)
|
||||
AttachmentExpiryDuration time.Duration // Duration after which attachments will be deleted
|
||||
AttachmentBandwidthLimit int64 // Daily bandwidth limit for the user
|
||||
StripePriceID string // Price ID for paid tiers (price_...)
|
||||
StripeMonthlyPriceID string // Monthly price ID for paid tiers (price_...)
|
||||
StripeYearlyPriceID string // Yearly price ID for paid tiers (price_...)
|
||||
}
|
||||
|
||||
// Context returns fields for the log
|
||||
func (t *Tier) Context() log.Context {
|
||||
return log.Context{
|
||||
"tier_id": t.ID,
|
||||
"tier_code": t.Code,
|
||||
"stripe_price_id": t.StripePriceID,
|
||||
"tier_id": t.ID,
|
||||
"tier_code": t.Code,
|
||||
"stripe_monthly_price_id": t.StripeMonthlyPriceID,
|
||||
"stripe_yearly_price_id": t.StripeYearlyPriceID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +138,7 @@ type Billing struct {
|
||||
StripeCustomerID string
|
||||
StripeSubscriptionID string
|
||||
StripeSubscriptionStatus stripe.SubscriptionStatus
|
||||
StripeSubscriptionInterval stripe.PriceRecurringInterval
|
||||
StripeSubscriptionPaidUntil time.Time
|
||||
StripeSubscriptionCancelAt time.Time
|
||||
}
|
||||
|
||||
@@ -49,12 +49,15 @@ func TestAllowedTier(t *testing.T) {
|
||||
|
||||
func TestTierContext(t *testing.T) {
|
||||
tier := &Tier{
|
||||
ID: "ti_abc",
|
||||
Code: "pro",
|
||||
StripePriceID: "price_123",
|
||||
ID: "ti_abc",
|
||||
Code: "pro",
|
||||
StripeMonthlyPriceID: "price_123",
|
||||
StripeYearlyPriceID: "price_456",
|
||||
}
|
||||
context := tier.Context()
|
||||
require.Equal(t, "ti_abc", context["tier_id"])
|
||||
require.Equal(t, "pro", context["tier_code"])
|
||||
require.Equal(t, "price_123", context["stripe_price_id"])
|
||||
require.Equal(t, "price_123", context["stripe_monthly_price_id"])
|
||||
require.Equal(t, "price_456", context["stripe_yearly_price_id"])
|
||||
|
||||
}
|
||||
|
||||
29
util/time.go
29
util/time.go
@@ -14,6 +14,15 @@ var (
|
||||
durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
|
||||
)
|
||||
|
||||
const (
|
||||
timestampFormat = "2006-01-02T15:04:05.999Z07:00" // Like RFC3339, but with milliseconds
|
||||
)
|
||||
|
||||
// FormatTime formats a time.Time in a RFC339-like format that includes milliseconds
|
||||
func FormatTime(t time.Time) string {
|
||||
return t.Format(timestampFormat)
|
||||
}
|
||||
|
||||
// NextOccurrenceUTC takes a time of day (e.g. 9:00am), and returns the next occurrence
|
||||
// of that time from the current time (in UTC).
|
||||
func NextOccurrenceUTC(timeOfDay, base time.Time) time.Time {
|
||||
@@ -45,15 +54,9 @@ func ParseFutureTime(s string, now time.Time) (time.Time, error) {
|
||||
return time.Time{}, errUnparsableTime
|
||||
}
|
||||
|
||||
func parseFromDuration(s string, now time.Time) (time.Time, error) {
|
||||
d, err := parseDuration(s)
|
||||
if err == nil {
|
||||
return now.Add(d), nil
|
||||
}
|
||||
return time.Time{}, errUnparsableTime
|
||||
}
|
||||
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
// ParseDuration is like time.ParseDuration, except that it also understands days (d), which
|
||||
// translates to 24 hours, e.g. "2d" or "20h".
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
d, err := time.ParseDuration(s)
|
||||
if err == nil {
|
||||
return d, nil
|
||||
@@ -80,6 +83,14 @@ func parseDuration(s string) (time.Duration, error) {
|
||||
return 0, errUnparsableTime
|
||||
}
|
||||
|
||||
func parseFromDuration(s string, now time.Time) (time.Time, error) {
|
||||
d, err := ParseDuration(s)
|
||||
if err == nil {
|
||||
return now.Add(d), nil
|
||||
}
|
||||
return time.Time{}, errUnparsableTime
|
||||
}
|
||||
|
||||
func parseUnixTime(s string, now time.Time) (time.Time, error) {
|
||||
t, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
|
||||
@@ -78,3 +78,17 @@ func TestParseFutureTime_UnixTime(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, time.Date(2021, 12, 11, 0, 51, 51, 0, time.UTC), d)
|
||||
}
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
d, err := ParseDuration("2d")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 48*time.Hour, d)
|
||||
|
||||
d, err = ParseDuration("2h")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2*time.Hour, d)
|
||||
|
||||
d, err = ParseDuration("0")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, time.Duration(0), d)
|
||||
}
|
||||
|
||||
2044
web/package-lock.json
generated
2044
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,12 @@
|
||||
// During web development, you may change values here for rapid testing.
|
||||
|
||||
var config = {
|
||||
base_url: window.location.origin, // Set this to "https://127.0.0.1" to test against a different server
|
||||
base_url: window.location.origin, // Change to test against a different server
|
||||
app_root: "/app",
|
||||
enable_login: true,
|
||||
enable_signup: true,
|
||||
enable_payments: true,
|
||||
enable_reservations: true,
|
||||
billing_contact: "",
|
||||
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]
|
||||
};
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: local(''),
|
||||
url('../fonts/roboto-v29-latin-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../fonts/roboto-v29-latin-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
url('../fonts/roboto-v29-latin-300.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-regular - latin */
|
||||
@@ -16,8 +15,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local(''),
|
||||
url('../fonts/roboto-v29-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../fonts/roboto-v29-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
url('../fonts/roboto-v29-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-500 - latin */
|
||||
@@ -26,8 +24,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: local(''),
|
||||
url('../fonts/roboto-v29-latin-500.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../fonts/roboto-v29-latin-500.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
url('../fonts/roboto-v29-latin-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-700 - latin */
|
||||
@@ -36,6 +33,5 @@
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: local(''),
|
||||
url('../fonts/roboto-v29-latin-700.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../fonts/roboto-v29-latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
url('../fonts/roboto-v29-latin-700.woff2') format('woff2');
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -39,7 +39,295 @@
|
||||
"message_bar_type_message": "اكتب رسالة هنا",
|
||||
"alert_not_supported_title": "الإشعارات غير مدعومة",
|
||||
"alert_not_supported_description": "الإشعارات غير مدعومة في متصفحك.",
|
||||
"message_bar_error_publishing": "خطأ أثناء نشر الإشعار",
|
||||
"message_bar_error_publishing": "خطأ خلال نشر الإشعار",
|
||||
"notifications_delete": "حذف",
|
||||
"notifications_copied_to_clipboard": "تم نسخه إلى الحافظة"
|
||||
"notifications_copied_to_clipboard": "تم نسخه إلى الحافظة",
|
||||
"action_bar_toggle_mute": "كتم / إلغاء كتم الإشعارات",
|
||||
"action_bar_toggle_action_menu": "فتح/إغلاق قائمة الإجراءات",
|
||||
"alert_grant_button": "امنح الآن",
|
||||
"notifications_attachment_open_button": "فتح المرفق",
|
||||
"notifications_attachment_copy_url_title": "نسخ عنوان URL للمرفق إلى الحافظة",
|
||||
"notifications_click_copy_url_title": "انسخ رابط URL إلى الحافظة",
|
||||
"notifications_none_for_topic_title": "لم تتلق بعد أية إشعارات حول هذا الموضوع.",
|
||||
"notifications_none_for_any_title": "لم تتلق أية إشعارات.",
|
||||
"notifications_no_subscriptions_title": "يبدو أنك لا تملك أي اشتراكات بعد.",
|
||||
"notifications_example": "مثال",
|
||||
"notifications_loading": "تحميل الإشعارات…",
|
||||
"publish_dialog_title_topic": "أنشُر إلى {{topic}}",
|
||||
"publish_dialog_title_no_topic": "انشُر الإشعار",
|
||||
"publish_dialog_emoji_picker_show": "اختر رمزًا تعبيريًا",
|
||||
"publish_dialog_priority_min": "أولوية دنيا",
|
||||
"publish_dialog_priority_low": "أولوية منخفضة",
|
||||
"publish_dialog_priority_default": "الأولوية الافتراضية",
|
||||
"publish_dialog_priority_high": "أولوية عالية",
|
||||
"publish_dialog_base_url_label": "الرابط التشعبي للخدمة",
|
||||
"publish_dialog_priority_max": "أولوية قصوى",
|
||||
"publish_dialog_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts",
|
||||
"publish_dialog_title_label": "العنوان",
|
||||
"publish_dialog_title_placeholder": "عنوان الإشعار، على سبيل المثال تنبيه مساحة القرص",
|
||||
"publish_dialog_message_label": "الرسالة",
|
||||
"publish_dialog_message_placeholder": "اكتب رسالة هنا",
|
||||
"publish_dialog_tags_label": "الوسوم",
|
||||
"publish_dialog_priority_label": "الأولوية",
|
||||
"publish_dialog_click_placeholder": "العنوان التشعبي URL الذي يتم فتحه عند النقر فوق الإشعار",
|
||||
"publish_dialog_email_label": "البريد الإلكتروني",
|
||||
"publish_dialog_filename_label": "اسم الملف",
|
||||
"publish_dialog_attach_label": "الرابط التشعبي URL للمرفق",
|
||||
"publish_dialog_filename_placeholder": "اسم ملف المرفق",
|
||||
"publish_dialog_delay_label": "تأخير",
|
||||
"publish_dialog_delay_reset": "إزالة تأخر التسليم",
|
||||
"publish_dialog_chip_click_label": "انقر على عنوان URL",
|
||||
"publish_dialog_chip_email_label": "إعادة التوجيه إلى البريد الإلكتروني",
|
||||
"publish_dialog_chip_attach_file_label": "إرفاق ملف محلي",
|
||||
"publish_dialog_chip_topic_label": "تغيير الموضوع",
|
||||
"publish_dialog_button_cancel_sending": "إلغاء الإرسال",
|
||||
"publish_dialog_button_send": "أرسل",
|
||||
"publish_dialog_checkbox_publish_another": "نشر آخر",
|
||||
"publish_dialog_attached_file_title": "الملف المرفق:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "اسم الملف المرفق",
|
||||
"publish_dialog_attached_file_remove": "إزالة الملف المرفق",
|
||||
"publish_dialog_drop_file_here": "قم بإسقاط ملف هنا",
|
||||
"emoji_picker_search_placeholder": "البحث عن رمز تعبيري",
|
||||
"emoji_picker_search_clear": "مسح البحث",
|
||||
"subscribe_dialog_subscribe_title": "الإشتراك في الموضوع",
|
||||
"subscribe_dialog_subscribe_use_another_label": "استخدام خادم آخر",
|
||||
"subscribe_dialog_subscribe_base_url_label": "الرابط التشعبي URL للخدمة",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "اشترِك",
|
||||
"subscribe_dialog_login_title": "تسجيل الدخول مطلوب",
|
||||
"subscribe_dialog_login_username_label": "اسم المستخدم، على سبيل المثال phil",
|
||||
"subscribe_dialog_login_password_label": "كلمة المرور",
|
||||
"subscribe_dialog_login_button_login": "الولوج",
|
||||
"subscribe_dialog_error_user_anonymous": "مجهول",
|
||||
"prefs_notifications_title": "الإشعارات",
|
||||
"prefs_notifications_sound_title": "صوت الإشعار",
|
||||
"prefs_notifications_sound_no_sound": "لا صوت",
|
||||
"prefs_notifications_min_priority_description_any": "عرض جميع الإشعارات، بغض النظر عن الأولوية",
|
||||
"prefs_notifications_delete_after_title": "حذف الإشعارات",
|
||||
"prefs_notifications_delete_after_never": "أبداً",
|
||||
"prefs_notifications_delete_after_three_hours": "بعد ثلاث ساعات",
|
||||
"prefs_notifications_delete_after_one_day": "بعد يوم واحد",
|
||||
"prefs_notifications_delete_after_one_month": "بعد شهر واحد",
|
||||
"prefs_notifications_delete_after_never_description": "لا يتم حذف الإشعارات تلقائيا مطلقا",
|
||||
"prefs_notifications_delete_after_one_week_description": "يتم حذف الإشعارات تلقائيا بعد يوم واحد",
|
||||
"prefs_notifications_delete_after_one_month_description": "يتم حذف الإشعارات تلقائيا بعد شهر واحد",
|
||||
"prefs_users_table": "قائمة المستخدمين",
|
||||
"prefs_users_edit_button": "تعديل المستخدم",
|
||||
"prefs_users_table_user_header": "المستخدم",
|
||||
"prefs_users_table_base_url_header": "الرابط التشعبي للخدمة",
|
||||
"priority_default": "افتراضية",
|
||||
"prefs_users_dialog_username_label": "اسم المستخدم، على سبيل المثال phil",
|
||||
"prefs_users_dialog_button_cancel": "إلغاء",
|
||||
"prefs_users_dialog_button_add": "اضافة",
|
||||
"prefs_users_dialog_button_save": "حفظ",
|
||||
"prefs_appearance_title": "المظهر",
|
||||
"prefs_appearance_language_title": "اللغة",
|
||||
"error_boundary_gathering_info": "جمع مزيد من المعلومات …",
|
||||
"error_boundary_unsupported_indexeddb_title": "التصفح الخاص غير مدعوم",
|
||||
"priority_high": "عالية",
|
||||
"priority_max": "قصوى",
|
||||
"error_boundary_title": "أوه لا ، لقد تحطم ntfy",
|
||||
"prefs_users_delete_button": "حذف المستخدم",
|
||||
"prefs_users_add_button": "إضافة مستخدم",
|
||||
"prefs_notifications_min_priority_any": "مهما كانت الأولوية",
|
||||
"prefs_notifications_delete_after_one_week": "بعد أسبوع واحد",
|
||||
"prefs_notifications_delete_after_three_hours_description": "يتم حذف الإشعارات تلقائيا بعد ثلاث ساعات",
|
||||
"prefs_notifications_delete_after_one_day_description": "يتم حذف الإشعارات تلقائيا بعد يوم واحد",
|
||||
"prefs_users_title": "إدارة المستخدمين",
|
||||
"prefs_users_dialog_title_add": "إضافة مستخدم",
|
||||
"prefs_users_dialog_title_edit": "تعديل المستخدم",
|
||||
"prefs_users_dialog_base_url_label": "عنوان URL للخدمة، على سبيل المثال، https://ntfy.sh",
|
||||
"publish_dialog_button_cancel": "إلغاء",
|
||||
"publish_dialog_message_published": "تم نشر الإشعار",
|
||||
"prefs_users_dialog_password_label": "كلمة المرور",
|
||||
"publish_dialog_base_url_placeholder": "عنوان URL للخدمة، على سبيل المثال، https://example.com",
|
||||
"publish_dialog_progress_uploading": "جارٍ التحميل…",
|
||||
"publish_dialog_topic_label": "اسم الموضوع",
|
||||
"publish_dialog_topic_reset": "إعادة تعيين الموضوع",
|
||||
"publish_dialog_email_reset": "إزالة إعادة توجيه البريد الإلكتروني",
|
||||
"publish_dialog_email_placeholder": "عنوان لإعادة توجيه الإشعار إليه، على سبيل المثال phil@example.com",
|
||||
"publish_dialog_other_features": "ميزات أخرى:",
|
||||
"publish_dialog_chip_attach_url_label": "إرفاق ملف عن طريق عنوان URL",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts",
|
||||
"prefs_notifications_sound_description_none": "لا تصدر الإشعارات أي صوت عند وصولها",
|
||||
"publish_dialog_chip_delay_label": "تأخير التسليم",
|
||||
"subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.",
|
||||
"subscribe_dialog_subscribe_button_cancel": "إلغاء",
|
||||
"subscribe_dialog_login_button_back": "العودة",
|
||||
"prefs_notifications_sound_play": "تشغيل الصوت المحدد",
|
||||
"prefs_notifications_min_priority_title": "أولوية دنيا",
|
||||
"prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط",
|
||||
"notifications_no_subscriptions_description": "انقر فوق الرابط \"{{linktext}}\" لإنشاء موضوع أو الاشتراك فيه. بعد ذلك، يمكنك إرسال رسائل عبر PUT أو POST وستتلقى إشعارات هنا.",
|
||||
"publish_dialog_click_label": "الرابط التشعبي URL للنقر",
|
||||
"publish_dialog_tags_placeholder": "قائمة علامات مفصولة بفواصل، على سبيل المثال تحذير, srv1-backup",
|
||||
"publish_dialog_attach_placeholder": "إرفاق ملف بعنوان URL ، على سبيل المثال https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_attach_reset": "إزالة عنوان URL للمرفق",
|
||||
"subscribe_dialog_error_user_not_authorized": "المستخدم {{username}} غير مصرح به",
|
||||
"common_save": "حفظ",
|
||||
"common_add": "إضافة",
|
||||
"signup_form_username": "إسم المستخدم",
|
||||
"signup_form_confirm_password": "تأكيد كلمة المرور",
|
||||
"login_title": "تسجيل الدخول إلى حسابك ntfy",
|
||||
"login_form_button_submit": "الولوج",
|
||||
"login_link_signup": "إنشاء حساب",
|
||||
"login_disabled": "تم تعطيل تسجيل الدخول",
|
||||
"action_bar_account": "الحساب",
|
||||
"action_bar_change_display_name": "تغيير الإسم المعروض",
|
||||
"signup_error_creation_limit_reached": "تم بلوغ حد إنشاء الحسابات",
|
||||
"action_bar_reservation_add": "حجز الموضوع",
|
||||
"action_bar_reservation_edit": "تغيير الحجز",
|
||||
"action_bar_profile_title": "الملف التعريفي",
|
||||
"action_bar_profile_settings": "اﻹعدادات",
|
||||
"action_bar_profile_logout": "الخروج",
|
||||
"action_bar_sign_in": "الولوج",
|
||||
"action_bar_sign_up": "إنشاء حساب",
|
||||
"nav_button_account": "الحساب",
|
||||
"nav_upgrade_banner_label": "قم بالترقية إلى NTFY Pro",
|
||||
"reserve_dialog_checkbox_label": "حجز الموضوع وإعداد الوصول",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "توليد إسم",
|
||||
"subscribe_dialog_error_topic_already_reserved": "الموضوع محجوز بالفعل",
|
||||
"account_basics_title": "الحساب",
|
||||
"account_basics_username_title": "إسم المستخدم",
|
||||
"account_basics_username_description": "مرحبًا، هذا أنت ❤",
|
||||
"account_basics_username_admin_tooltip": "أنت مدير",
|
||||
"account_basics_password_title": "كلمة المرور",
|
||||
"account_basics_password_description": "غيّر كلمة مرور حسابك",
|
||||
"account_basics_password_dialog_title": "تغيير كلمة المرور",
|
||||
"account_basics_password_dialog_current_password_label": "كلمة المرور الحالية",
|
||||
"account_basics_password_dialog_new_password_label": "كلمة المرور الجديدة",
|
||||
"account_basics_password_dialog_confirm_password_label": "تأكيد كلمة المرور",
|
||||
"account_basics_password_dialog_button_submit": "تغيير كلمة المرور",
|
||||
"account_basics_password_dialog_current_password_incorrect": "الكلمة السرية خاطئة",
|
||||
"account_usage_title": "الإستخدام",
|
||||
"account_usage_of_limit": "من {{limit}}",
|
||||
"account_usage_unlimited": "غير محدود",
|
||||
"account_basics_tier_title": "نوع الحساب",
|
||||
"account_basics_tier_description": "مستوى قوة حسابك",
|
||||
"account_basics_tier_admin": "مدير",
|
||||
"account_basics_tier_free": "مجاني",
|
||||
"account_basics_tier_upgrade_button": "الترقية إلى Pro",
|
||||
"account_basics_tier_change_button": "تغيير",
|
||||
"account_basics_tier_manage_billing_button": "إدارة الفوترة",
|
||||
"account_usage_messages_title": "الرسائل المنشورة",
|
||||
"account_usage_reservations_title": "المواضيع المحجوزة",
|
||||
"account_usage_attachment_storage_title": "تخزين المرفقات",
|
||||
"account_delete_title": "حذف الحساب",
|
||||
"account_delete_description": "احذف حسابك نهائيا",
|
||||
"account_delete_dialog_label": "كلمة المرور",
|
||||
"account_upgrade_dialog_title": "تغيير فئة الحساب",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} رسائل يومية",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} من رسائل البريد الإلكتروني اليومية",
|
||||
"account_upgrade_dialog_button_cancel": "إلغاء",
|
||||
"account_upgrade_dialog_button_pay_now": "ادفع الآن واشترك",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "إلغاء الاشتراك",
|
||||
"account_tokens_title": "رموز الوصول",
|
||||
"account_tokens_table_token_header": "الرمز المميز",
|
||||
"account_tokens_table_last_access_header": "آخر وصول",
|
||||
"account_tokens_table_expires_header": "تنتهي مدة صلاحيته في",
|
||||
"account_tokens_table_never_expires": "لا تنتهي صلاحيتها أبدا",
|
||||
"account_tokens_table_current_session": "جلسة المتصفح الحالية",
|
||||
"account_tokens_table_copy_to_clipboard": "انسخ إلى الحافظة",
|
||||
"account_tokens_table_cannot_delete_or_edit": "لا يمكن تحرير أو حذف الرمز المميز للجلسة الحالية",
|
||||
"account_tokens_table_create_token_button": "إنشاء رمز مميز للوصول",
|
||||
"account_tokens_table_last_origin_tooltip": "من عنوان IP {{ip}}، انقر للبحث",
|
||||
"account_tokens_dialog_title_create": "إنشاء رمز مميز للوصول",
|
||||
"account_tokens_dialog_title_edit": "تعديل الرمز المميز للوصول",
|
||||
"account_tokens_dialog_title_delete": "حذف الرمز المميز للوصول",
|
||||
"account_tokens_dialog_label": "التسمية، على سبيل المثال إشعارات الرادار",
|
||||
"account_tokens_dialog_button_create": "إنشاء رمز مميز",
|
||||
"account_tokens_dialog_button_update": "تحديث الرمز المميز",
|
||||
"account_tokens_dialog_button_cancel": "إلغاء",
|
||||
"account_tokens_dialog_expires_label": "تنتهي صلاحية الرمز المميز للوصول في",
|
||||
"account_tokens_dialog_expires_unchanged": "اترك تاريخ انتهاء الصلاحية دون تغيير",
|
||||
"account_tokens_dialog_expires_x_hours": "تنتهي صلاحية الرمز المميز في {{hours}} ساعات",
|
||||
"account_tokens_dialog_expires_never": "لا تنتهي صلاحية الرمز المميز أبدًا",
|
||||
"account_tokens_delete_dialog_title": "حذف الرمز المميز للوصول",
|
||||
"account_tokens_delete_dialog_submit_button": "حذف الرمز المميز نهائيا",
|
||||
"prefs_users_table_cannot_delete_or_edit": "لا يمكن حذف أو تحرير المستخدم الذي قام بتسجيل الدخول",
|
||||
"prefs_reservations_add_button": "إضافة موضوع محجوز",
|
||||
"prefs_reservations_table": "جدول المواضيع المحجوزة",
|
||||
"prefs_reservations_table_topic_header": "الموضوع",
|
||||
"prefs_reservations_table_access_header": "الوصول",
|
||||
"prefs_reservations_table_everyone_deny_all": "أنا فقط من يستطيع النشر والاشتراك",
|
||||
"prefs_reservations_table_everyone_write_only": "يمكنني النشر والاشتراك ، ويمكن للجميع النشر",
|
||||
"prefs_reservations_table_everyone_read_write": "يمكن للجميع النشر والاشتراك",
|
||||
"prefs_reservations_table_not_subscribed": "غير مشترك",
|
||||
"prefs_reservations_dialog_title_edit": "تحرير الموضوع المحجوز",
|
||||
"prefs_reservations_dialog_topic_label": "الموضوع",
|
||||
"prefs_reservations_dialog_access_label": "الوصول",
|
||||
"reservation_delete_dialog_action_delete_title": "حذف الرسائل والمرفقات المخزنة مؤقتا",
|
||||
"reservation_delete_dialog_submit_button": "حذف الحجز",
|
||||
"signup_title": "إنشاء حساب ntfy",
|
||||
"common_cancel": "إلغاء",
|
||||
"signup_form_password": "كلمة المرور",
|
||||
"signup_already_have_account": "هل لديك حساب؟ قم بتسجيل الدخول!",
|
||||
"signup_form_button_submit": "إنشاء حساب",
|
||||
"signup_disabled": "تم تعطيل التسجيل",
|
||||
"display_name_dialog_placeholder": "الإسم المعروض",
|
||||
"display_name_dialog_title": "تغيير الإسم المعروض",
|
||||
"account_basics_tier_basic": "أساسي",
|
||||
"account_usage_emails_title": "رسائل البريد الإلكتروني المرسلة",
|
||||
"account_usage_reservations_none": "لا توجد مواضيع محجوزة لهذا الحساب",
|
||||
"account_usage_cannot_create_portal_session": "تعذر فتح بوابة الفوترة",
|
||||
"account_delete_dialog_button_cancel": "إلغاء",
|
||||
"account_delete_dialog_button_submit": "حذف الحساب نهائيا",
|
||||
"account_upgrade_dialog_button_update_subscription": "تحديث الاشتراك",
|
||||
"account_tokens_table_copied_to_clipboard": "تم نسخ الرمز المميز للوصول",
|
||||
"prefs_reservations_title": "المواضيع المحجوزة",
|
||||
"prefs_reservations_table_everyone_read_only": "يمكنني النشر والاشتراك ، ويمكن للجميع الاشتراك",
|
||||
"prefs_reservations_table_click_to_subscribe": "انقر للاشتراك",
|
||||
"reservation_delete_dialog_action_keep_title": "الاحتفاظ بالرسائل والمرفقات المخزنة مؤقتًا",
|
||||
"action_bar_reservation_delete": "إزالة الحجز",
|
||||
"display_name_dialog_description": "قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر.",
|
||||
"prefs_users_description": "إضافة / إزالة المستخدمين لمواضيعك المحمية هنا. يرجى الأخذ بعين الاعتبار أنه يتم تخزين اسم المستخدم وكلمة المرور في التخزين المحلي للمتصفح.",
|
||||
"notifications_more_details": "لمزيد من المعلومات، الرجاء الاطّلاع على <websiteLink>موقع الويب</websiteLink> أو على <docsLink>الدليل</docsLink>.",
|
||||
"publish_dialog_details_examples_description": "للحصول على أمثلة ووصف مُفصّل لجميع ميزات الإرسال، يرجى الاستناد إلى <docsLink>الدليل</docsLink>.",
|
||||
"subscribe_dialog_subscribe_description": "قد لا تكون الموضوعات محمية بكلمة سر لذا اختر اسمًا ليس من السهل تخمينه وبمجرد اشتراكك، يمكنك الحصول على إشعارات عبر \"PUT/POST\".",
|
||||
"prefs_notifications_sound_description_some": "تقوم الإشعارات بتشغيل صوت {{sound}} عند وصولها",
|
||||
"notifications_none_for_topic_description": "لإرسال إشعارات إلى هذا الموضوع، ما عليك سوى PUT أو POST إلى عنوان URL الخاص بالموضوع.",
|
||||
"priority_low": "منخفضة",
|
||||
"signup_form_toggle_password_visibility": "تبديل رؤية كلمة المرور",
|
||||
"account_usage_limits_reset_daily": "يعاد تحديد حدود الاستخدام يوميا في منتصف الليل (UTC)",
|
||||
"account_tokens_table_label_header": "المُلصَقة",
|
||||
"account_upgrade_dialog_button_redirect_signup": "تسجيل فوري",
|
||||
"account_upgrade_dialog_tier_current_label": "الحالي",
|
||||
"account_tokens_dialog_expires_x_days": "تنتهي صلاحية الرمز المميز في غضون {{days}} أيام",
|
||||
"prefs_reservations_dialog_title_add": "حجز موضوع",
|
||||
"prefs_reservations_description": "يمكنك حجز أسماء الموضوعات للاستخدام الشخصي هنا. يمنحك حجز موضوع ما ملكية الموضوع، ويسمح لك بتحديد تصريحات الوصول للمستخدمين الآخرين إلى الموضوع.",
|
||||
"prefs_users_description_no_sync": "لا تتم مزامنة المستخدمين وكلمات المرور مع حسابك.",
|
||||
"reservation_delete_dialog_action_delete_description": "سيتم حذف الرسائل والمرفقات المخزنة مؤقتا نهائيا. لا يمكن التراجع عن هذا الإجراء.",
|
||||
"notifications_actions_http_request_title": "إرسال طلب HTTP {{method}} إلى {{url}}",
|
||||
"notifications_none_for_any_description": "لإرسال إشعارات إلى موضوع ما، ما عليك سوى إرسال طلب PUT أو POST إلى الرابط التشعبي URL للموضوع. إليك مثال باستخدام أحد مواضيعك.",
|
||||
"error_boundary_description": "من الواضح أن هذا لا ينبغي أن يحدث. آسف جدًا بشأن هذا. <br/> إن كان لديك دقيقة، يرجى <githubLink> الإبلاغ عن ذلك على GitHub </githubLink> ، أو إعلامنا عبر <discordLink> Discord </discordLink> أو <matrixLink> Matrix </matrixLink>.",
|
||||
"nav_button_muted": "الإشعارات المكتومة",
|
||||
"priority_min": "دنيا",
|
||||
"signup_error_username_taken": "تم حجز اسم المستخدم {{username}} مِن قَبلُ",
|
||||
"action_bar_reservation_limit_reached": "بلغت الحد الأقصى",
|
||||
"prefs_reservations_delete_button": "إعادة تعيين الوصول إلى الموضوع",
|
||||
"prefs_reservations_edit_button": "تعديل الوصول إلى موضوع",
|
||||
"prefs_reservations_limit_reached": "لقد بلغت الحد الأقصى من المواضيع المحجوزة.",
|
||||
"reservation_delete_dialog_action_keep_description": "ستصبح الرسائل والمرفقات المخزنة مؤقتًا على الخادم مرئية للعموم وللأشخاص الذين لديهم معرفة باسم الموضوع.",
|
||||
"reservation_delete_dialog_description": "تؤدي إزالة الحجز إلى التخلي عن ملكية الموضوع، مما يسمح للآخرين بحجزه. يمكنك الاحتفاظ بالرسائل والمرفقات الموجودة أو حذفها.",
|
||||
"prefs_reservations_dialog_description": "يمنحك حجز موضوع ما ملكية الموضوع، ويسمح لك بتحديد تصريحات وصول المستخدمين الآخرين إليه.",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "توفير ما يصل إلى {{discount}}٪",
|
||||
"account_upgrade_dialog_interval_monthly": "شهريا",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "إجمالي مساحة التخزين {{totalsize}}",
|
||||
"publish_dialog_progress_uploading_detail": "تحميل {{loaded}}/{{total}} ({{percent}}٪) …",
|
||||
"account_basics_tier_interval_monthly": "شهريا",
|
||||
"account_basics_tier_interval_yearly": "سنويا",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} مواضيع محجوزة",
|
||||
"account_upgrade_dialog_billing_contact_website": "للأسئلة المتعلقة بالفوترة، يرجى الرجوع إلى <Link>موقعنا على الويب</Link>.",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "إظهار الإشعارات إذا كانت الأولوية {{number}} ({{name}}) أو أعلى",
|
||||
"account_upgrade_dialog_billing_contact_email": "للأسئلة المتعلقة بالفوترة، الرجاء <Link>الاتصال بنا</Link> مباشرة.",
|
||||
"account_upgrade_dialog_tier_selected_label": "المحدد",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} لكل ملف",
|
||||
"account_upgrade_dialog_interval_yearly": "سنويا",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "لا توجد مواضيع محجوزة",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "وفر {{discount}}٪",
|
||||
"publish_dialog_click_reset": "إزالة الرابط التشعبي URL للنقر",
|
||||
"prefs_notifications_min_priority_description_max": "إظهار الإشعارات إذا كانت الأولوية 5 (كحد أقصى)",
|
||||
"publish_dialog_attachment_limits_file_reached": "يتجاوز الحد الأقصى للملف {{fileSizeLimit}}",
|
||||
"publish_dialog_attachment_limits_quota_reached": "يتجاوز الحصة، {{remainingBytes}} متبقية",
|
||||
"account_basics_tier_paid_until": "تم دفع مبلغ الاشتراك إلى غاية {{date}}، وسيتم تجديده تِلْقائيًا",
|
||||
"account_basics_tier_canceled_subscription": "تم إلغاء اشتراكك وسيتم إعادته إلى مستوى حساب مجاني بداية مِن {{date}}.",
|
||||
"account_delete_dialog_billing_warning": "إلغاء حسابك أيضاً يلغي اشتراكك في الفوترة فوراً ولن تتمكن من الوصول إلى لوح الفوترة بعد الآن."
|
||||
}
|
||||
|
||||
@@ -187,5 +187,105 @@
|
||||
"prefs_users_table": "Таблица с потребители",
|
||||
"prefs_users_edit_button": "Промяна на потребител",
|
||||
"error_boundary_unsupported_indexeddb_title": "Поверително разглеждане не се поддържа",
|
||||
"error_boundary_unsupported_indexeddb_description": "За да работи интернет-приложението ntfy се нуждае от IndexedDB, а мрежовият четец не поддържа IndexedDB в режим на поверително разглеждане.<br/><br/>Въпреки това, няма смисъл да използвате интернет-приложението ntfy в режим на поверително разглеждане, тъй като всичко се пази в хранилището на четеца. Можете да прочетете повече по <githubLink>проблема в GitHub</githubLink> или да се свържете с нас в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>."
|
||||
"error_boundary_unsupported_indexeddb_description": "За да работи интернет-приложението ntfy се нуждае от IndexedDB, а мрежовият четец не поддържа IndexedDB в режим на поверително разглеждане.<br/><br/>Въпреки това, няма смисъл да използвате интернет-приложението ntfy в режим на поверително разглеждане, тъй като всичко се пази в хранилището на четеца. Можете да прочетете повече по <githubLink>проблема в GitHub</githubLink> или да се свържете с нас в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
|
||||
"signup_title": "Създаване на профил в ntfy",
|
||||
"signup_form_username": "Потребител",
|
||||
"signup_form_password": "Парола",
|
||||
"signup_form_button_submit": "Регистриране",
|
||||
"signup_form_toggle_password_visibility": "Превключване видимостта на паролата",
|
||||
"signup_already_have_account": "Имате профил? Впишете се!",
|
||||
"signup_error_username_taken": "Потребителското име {{username}} е заето",
|
||||
"login_title": "Впишете се в профила си в ntfy",
|
||||
"login_form_button_submit": "Вписване",
|
||||
"login_link_signup": "Регистриране",
|
||||
"login_disabled": "Вписването е изключено",
|
||||
"action_bar_account": "Профил",
|
||||
"action_bar_change_display_name": "Промяна на показваното име",
|
||||
"action_bar_reservation_add": "Резервиране на тема",
|
||||
"action_bar_reservation_delete": "Премахване на резервацията",
|
||||
"action_bar_reservation_limit_reached": "Ограничението е достигнато",
|
||||
"action_bar_profile_title": "Профил",
|
||||
"action_bar_profile_settings": "Настройки",
|
||||
"action_bar_profile_logout": "Изход",
|
||||
"action_bar_sign_in": "Вписване",
|
||||
"nav_button_account": "Профил",
|
||||
"nav_upgrade_banner_label": "Надграждане до ntfy Pro",
|
||||
"signup_form_confirm_password": "Парола отново",
|
||||
"signup_disabled": "Регистрациите са затворени",
|
||||
"signup_error_creation_limit_reached": "Достигнатео е ограничението за създаване на профили",
|
||||
"display_name_dialog_title": "Промяна на показваното име",
|
||||
"action_bar_reservation_edit": "Промяна на резервацията",
|
||||
"action_bar_sign_up": "Регистриране",
|
||||
"account_basics_title": "Профил",
|
||||
"alert_not_supported_context_description": "Известията се поддържат само през HTTPS. Това е ограничение на <mdnLink>Notifications API</mdnLink>.",
|
||||
"display_name_dialog_description": "Изберете друго име за темата, което да се показва в списъка с абонаменти. Помага за по-лесното разпознаване на теми със сложни имена.",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Темата вече е резервирана",
|
||||
"nav_upgrade_banner_description": "Резервиране на теми, повече съобщения и имейли и по-големи прикачени файлове",
|
||||
"display_name_dialog_placeholder": "Наименование",
|
||||
"reserve_dialog_checkbox_label": "Резервиране на тема и настройки за достъп",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Произволно име",
|
||||
"account_basics_username_title": "Потребител",
|
||||
"account_basics_username_description": "Хей, това сте вие ❤",
|
||||
"account_basics_username_admin_tooltip": "Вие сте администратор",
|
||||
"account_basics_password_title": "Парола",
|
||||
"account_delete_dialog_label": "Парола",
|
||||
"account_basics_password_dialog_title": "Смяна на парола",
|
||||
"account_basics_password_dialog_current_password_label": "Текуща парола",
|
||||
"account_basics_password_dialog_new_password_label": "Нова парола",
|
||||
"account_basics_password_dialog_confirm_password_label": "Парола отново",
|
||||
"account_basics_password_dialog_button_submit": "Смяна на парола",
|
||||
"account_usage_title": "Употреба",
|
||||
"account_usage_of_limit": "от {{limit}}",
|
||||
"account_usage_unlimited": "Неограничено",
|
||||
"account_usage_limits_reset_daily": "Ограниченията се нулират всеки ден в полунощ (UTC)",
|
||||
"account_basics_tier_interval_monthly": "месечно",
|
||||
"account_basics_tier_interval_yearly": "годишно",
|
||||
"account_basics_password_description": "Промяна на паролата на профила",
|
||||
"account_basics_tier_title": "Вид на профила",
|
||||
"account_basics_tier_admin": "Администратор",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(с {{tier}} ниво)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(без ниво)",
|
||||
"account_basics_tier_free": "безплатен",
|
||||
"account_basics_tier_basic": "базов",
|
||||
"account_basics_tier_change_button": "Променяне",
|
||||
"account_basics_tier_paid_until": "Абонаментът е платен до {{date}} и автоматично ще се поднови",
|
||||
"account_usage_attachment_storage_title": "Хранилище за прикачени файлове",
|
||||
"account_delete_dialog_button_cancel": "Отказ",
|
||||
"account_upgrade_dialog_interval_monthly": "Месечно",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} резервирани теми",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "Няма резервирани теми",
|
||||
"account_tokens_dialog_button_cancel": "Отказ",
|
||||
"account_delete_title": "Премахване на профила",
|
||||
"account_upgrade_dialog_title": "Промяна нивото на профила",
|
||||
"account_usage_emails_title": "Изпратени съобщения",
|
||||
"account_usage_reservations_title": "Резервирани теми",
|
||||
"account_usage_reservations_none": "Няма резервирани теми",
|
||||
"account_usage_cannot_create_portal_session": "Порталът за разплащане не може да бъде отворен",
|
||||
"account_upgrade_dialog_interval_yearly": "Годишно",
|
||||
"account_delete_description": "Безвъзвратно премахване на профила",
|
||||
"account_delete_dialog_button_submit": "Безвъзвратно премахване на профила",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "отстъпка {{discount}}%",
|
||||
"account_upgrade_dialog_button_cancel": "Отказ",
|
||||
"account_upgrade_dialog_button_redirect_signup": "Регистриране",
|
||||
"account_tokens_table_label_header": "Етикет",
|
||||
"prefs_reservations_edit_button": "Настройки на достъпа",
|
||||
"prefs_reservations_table_topic_header": "Тема",
|
||||
"prefs_reservations_table_access_header": "Достъп",
|
||||
"prefs_reservations_dialog_topic_label": "Тема",
|
||||
"prefs_reservations_dialog_access_label": "Достъп",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Грешна парола",
|
||||
"account_basics_tier_description": "Ниво на профила",
|
||||
"account_basics_tier_upgrade_button": "Надграждане до Pro",
|
||||
"account_usage_messages_title": "Публикувани съобщения",
|
||||
"account_tokens_table_last_access_header": "Последен достъп",
|
||||
"account_basics_tier_payment_overdue": "Имате просрочено задължение. Обновете начина на плащане, защото в противен случай скоро профилът ви ще загуби предимствата на абонамента.",
|
||||
"account_usage_basis_ip_description": "Статистиката и ограниченията на използване се отчитат по IP адрес, така че може да бъдат споделени с други потребители. Показаните по-горе ограничения са приблизителни и се основават на съществуващите ограничения на използване.",
|
||||
"account_delete_dialog_description": "Това действие ще доведе до безвъзвратното изтриване на профила ви, включително на всички данни, които се съхраняват на сървъра. След изтриването потребителското ви име няма да бъде достъпно в продължение на 7 дни. Ако наистина искате да продължите, потвърдете с паролата си в полето по-долу.",
|
||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} резервирана тема",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "спестете до {{discount}}%",
|
||||
"account_delete_dialog_billing_warning": "Изтриването на профила незабавно отменя и платения абонамент. Няма да имате достъп до таблото за плащания.",
|
||||
"account_upgrade_dialog_cancel_warning": "Това действие ще <strong>прекрати абонамента</strong> и ще промени профила ви на неплатен на {{date}}. На тази дата резервираните теми, както и пазените на сървъра съобщения, <strong> ще бъдат премахнати</strong>.",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Преизчисляване на плащания</strong>: При надграждане между платени планове разликата в цената ще бъде <strong>начислена незабавно</strong>. При преминаване към по-евтин план надплатената сума ще бъде използвана за плащане за бъдещи периоди.",
|
||||
"account_basics_tier_manage_billing_button": "Управление на плащанията",
|
||||
"account_basics_tier_canceled_subscription": "Абонаментът е прекратен и профилът ще бъде променен на неплатен на {{date}}."
|
||||
}
|
||||
|
||||
@@ -187,5 +187,173 @@
|
||||
"prefs_notifications_sound_play": "Přehrát vybraný zvuk",
|
||||
"prefs_users_table": "Tabulka uživatelů",
|
||||
"notifications_attachment_file_document": "jiný dokument",
|
||||
"publish_dialog_delay_reset": "Odebrat odložené doručení"
|
||||
"publish_dialog_delay_reset": "Odebrat odložené doručení",
|
||||
"signup_form_confirm_password": "Potvrdit heslo",
|
||||
"signup_form_button_submit": "Zaregistrovat se",
|
||||
"signup_form_username": "Uživatelské jméno",
|
||||
"signup_form_toggle_password_visibility": "Přepnout viditelnost hesla",
|
||||
"signup_already_have_account": "Už máte účet? Přihlašte se!",
|
||||
"signup_error_username_taken": "Uživatelské jméno {{username}} je již obsazeno",
|
||||
"signup_error_creation_limit_reached": "Dosažen limit pro vytvoření účtu",
|
||||
"login_title": "Přihlaste se do svého ntfy účtu",
|
||||
"login_form_button_submit": "Přihlásit se",
|
||||
"login_link_signup": "Zaregistrovat se",
|
||||
"login_disabled": "Přihlašování je zakázáno",
|
||||
"action_bar_account": "Účet",
|
||||
"action_bar_reservation_add": "Rezervovat téma",
|
||||
"action_bar_reservation_edit": "Změnit rezervaci",
|
||||
"action_bar_reservation_delete": "Odstranit rezervaci",
|
||||
"action_bar_reservation_limit_reached": "Limit dosažen",
|
||||
"action_bar_profile_title": "Profil",
|
||||
"action_bar_profile_settings": "Nastavení",
|
||||
"action_bar_profile_logout": "Odhlásit se",
|
||||
"action_bar_sign_up": "Zaregistrovat se",
|
||||
"nav_button_account": "Účet",
|
||||
"nav_upgrade_banner_label": "Upgradovat na nfty Pro",
|
||||
"nav_upgrade_banner_description": "Rezervace témat, více zpráv a emailů a větší přílohy",
|
||||
"signup_title": "Vytvořit nfty účet",
|
||||
"signup_form_password": "Heslo",
|
||||
"display_name_dialog_description": "Nastaví alternativní název pro téma, které se zobrazí v seznamu odběrů. Toto pomáhá jednodušeji identifikovat témata s komplikovanými jmény.",
|
||||
"action_bar_change_display_name": "Změnit zobrazovaný název",
|
||||
"action_bar_sign_in": "Přihlásit se",
|
||||
"alert_not_supported_context_description": "Oznámení jsou podporována pouze přes HTTPS. Toto je limitace <mdnLink>Notifications API</mdnLink>.",
|
||||
"display_name_dialog_title": "Změnit zobrazovaný název",
|
||||
"account_basics_password_title": "Heslo",
|
||||
"account_basics_password_dialog_title": "Změna hesla",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Téma již rezervováno",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Generovat název",
|
||||
"account_delete_dialog_description": "Dojde k trvalému odstranění vašeho účtu včetně všech dat uložených na serveru. Po smazání bude vaše uživatelské jméno po dobu 7 dnů nedostupné. Pokud opravdu chcete pokračovat, potvrďte prosím své heslo.",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(s úrovní {{tier}})",
|
||||
"account_basics_tier_admin": "Administrátor",
|
||||
"account_basics_tier_basic": "Základní",
|
||||
"account_basics_tier_free": "Zdarma",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(žádná úroveň)",
|
||||
"account_basics_tier_upgrade_button": "Přejít na verzi Pro",
|
||||
"account_upgrade_dialog_cancel_warning": "Vaše <strong>předplatné se tímto zruší</strong> a váš účet se k datu {{date}} degraduje na nižší úroveň. K tomuto datu budou <strong>smazány</strong> rezervace témat i zprávy uložené v mezipaměti serveru.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Vybraná úroveň umožňuje méně rezervovaných témat než vaše aktuální úroveň. Před změnou úrovně <strong>odstraňte alespoň {{počet}} rezervací</strong>. Rezervace můžete odstranit v <Link>Nastavení</Link>.",
|
||||
"reservation_delete_dialog_description": "Odstraněním rezervace se vzdáte vlastnictví tématu a umožníte ostatním, aby si ho rezervovali. Stávající zprávy a přílohy si můžete ponechat nebo je odstranit.",
|
||||
"account_tokens_description": "Při publikování a odběru prostřednictvím rozhraní ntfy API používejte přístupové tokeny, abyste nemuseli odesílat přihlašovací údaje k účtu. Více informací najdete v <Link>dokumentaci</Link>.",
|
||||
"account_tokens_table_copied_to_clipboard": "Přístupový token zkopírován",
|
||||
"account_tokens_table_last_origin_tooltip": "Z IP adresy {{ip}}, klikněte pro vyhledání",
|
||||
"account_tokens_dialog_button_cancel": "Zrušit",
|
||||
"account_tokens_dialog_expires_never": "Token nikdy nevyprší",
|
||||
"account_tokens_delete_dialog_description": "Před odstraněním přístupového tokenu se ujistěte, že jej aktivně nepoužívají žádné aplikace ani skripty. <strong>Tuto akci nelze vrátit zpět</strong>.",
|
||||
"prefs_users_description_no_sync": "Uživatelé a hesla nejsou synchronizováni s vaším účtem.",
|
||||
"prefs_users_table_cannot_delete_or_edit": "Nelze odstranit ani upravit přihlášeného uživatele",
|
||||
"prefs_reservations_title": "Rezervovaná témata",
|
||||
"prefs_reservations_description": "Zde si můžete rezervovat názvy témat pro osobní použití. Rezervací tématu získáte vlastnické právo k tématu a můžete definovat přístupová práva pro ostatní uživatele k tématu.",
|
||||
"prefs_reservations_table_click_to_subscribe": "Kliknutím se přihlásíte k odběru",
|
||||
"prefs_reservations_dialog_description": "Rezervací tématu získáte vlastnictví tématu a můžete definovat přístupová oprávnění pro ostatní uživatele.",
|
||||
"prefs_reservations_dialog_access_label": "Přístup",
|
||||
"reservation_delete_dialog_action_keep_title": "Zachovat zprávy a přílohy v mezipaměti",
|
||||
"signup_disabled": "Přihlášení je zakázáno",
|
||||
"display_name_dialog_placeholder": "Zobrazovaný název",
|
||||
"reserve_dialog_checkbox_label": "Rezervace tématu a nastavení přístupu",
|
||||
"account_basics_title": "Účet",
|
||||
"account_basics_username_title": "Uživatelské jméno",
|
||||
"account_basics_username_description": "Hej, to jsi ty ❤",
|
||||
"account_basics_username_admin_tooltip": "Jste správce",
|
||||
"account_basics_password_description": "Změna hesla k účtu",
|
||||
"account_basics_password_dialog_current_password_label": "Současné heslo",
|
||||
"account_basics_password_dialog_new_password_label": "Nové heslo",
|
||||
"account_basics_password_dialog_confirm_password_label": "Potvrzení hesla",
|
||||
"account_basics_password_dialog_button_submit": "Změnit heslo",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Nesprávné heslo",
|
||||
"account_usage_title": "Použití",
|
||||
"account_usage_of_limit": "z {{limit}}",
|
||||
"account_usage_unlimited": "Neomezeně",
|
||||
"account_usage_limits_reset_daily": "Limity používání se resetují denně o půlnoci (UTC)",
|
||||
"account_basics_tier_title": "Typ účtu",
|
||||
"account_basics_tier_description": "Úroveň oprávnění vašeho účtu",
|
||||
"account_basics_tier_change_button": "Změnit",
|
||||
"account_basics_tier_paid_until": "Předplatné zaplaceno do {{date}} a bude automaticky obnoveno",
|
||||
"account_basics_tier_payment_overdue": "Vaše platba je po splatnosti. Aktualizujte prosím svůj způsob platby, jinak bude váš účet brzy degradován.",
|
||||
"account_basics_tier_canceled_subscription": "Vaše předplatné bylo zrušeno a ke dni {{date}} bude převedeno na bezplatný účet.",
|
||||
"account_basics_tier_manage_billing_button": "Správa vyúčtování",
|
||||
"account_usage_messages_title": "Zveřejněné zprávy",
|
||||
"account_usage_emails_title": "Odeslané e-maily",
|
||||
"account_usage_reservations_title": "Rezervovaná témata",
|
||||
"account_usage_reservations_none": "Žádná rezervovaná témata pro tento účet",
|
||||
"account_usage_attachment_storage_title": "Úložiště příloh",
|
||||
"account_usage_attachment_storage_description": "{{filesize}} na soubor, maže se po {{expiry}}",
|
||||
"account_usage_basis_ip_description": "Statistiky a limity používání tohoto účtu jsou založeny na vaší IP adrese, takže mohou být sdíleny s ostatními uživateli. Výše uvedené limity jsou přibližné a vycházejí ze stávajících limitů.",
|
||||
"account_usage_cannot_create_portal_session": "Nelze otevřít portál pro fakturaci",
|
||||
"account_delete_title": "Odstranit účet",
|
||||
"account_delete_description": "Trvale odstranit účet",
|
||||
"account_delete_dialog_label": "Heslo",
|
||||
"account_delete_dialog_button_cancel": "Zrušit",
|
||||
"account_delete_dialog_button_submit": "Trvale odstranit účet",
|
||||
"account_delete_dialog_billing_warning": "Odstraněním účtu se také okamžitě zruší vaše předplatné. Nebudete již mít přístup k fakturačnímu panelu.",
|
||||
"account_upgrade_dialog_title": "Změna úrovně účtu",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Prohlášení</strong>: Při přechodu mezi placenými úrovněmi bude rozdíl v ceně <strong>zaúčtován okamžitě</strong>. Při přechodu na nižší úroveň se zůstatek použije na platbu za budoucí zúčtovací období.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Vybraná úroveň umožňuje méně rezervovaných témat než vaše aktuální úroveň. Než změníte svou úroveň, <strong>odstraňte alespoň jednu rezervaci</strong>. Rezervace můžete odstranit v <Link>Nastavení</Link>.",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} rezervovaných témat",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} denních zpráv",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} denních e-mailů",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} na soubor",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} celkový úložný prostor",
|
||||
"account_upgrade_dialog_tier_selected_label": "Vybráno",
|
||||
"account_upgrade_dialog_tier_current_label": "Současné",
|
||||
"account_upgrade_dialog_button_cancel": "Zrušit",
|
||||
"account_upgrade_dialog_button_redirect_signup": "Zaregistrovat se nyní",
|
||||
"account_upgrade_dialog_button_pay_now": "Zaplatit a předplatit si",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "Zrušit předplatné",
|
||||
"account_upgrade_dialog_button_update_subscription": "Aktualizovat předplatné",
|
||||
"account_tokens_title": "Přístupové tokeny",
|
||||
"account_tokens_table_token_header": "Token",
|
||||
"account_tokens_table_last_access_header": "Poslední přístup",
|
||||
"account_tokens_table_expires_header": "Vyprší",
|
||||
"account_tokens_table_never_expires": "Nikdy nevyprší",
|
||||
"account_tokens_table_current_session": "Současná relace prohlížeče",
|
||||
"account_tokens_table_copy_to_clipboard": "Kopírování do schránky",
|
||||
"account_tokens_table_label_header": "Popisek",
|
||||
"account_tokens_table_cannot_delete_or_edit": "Nelze upravit nebo odstranit aktuální token relace",
|
||||
"account_tokens_table_create_token_button": "Vytvořit přístupový token",
|
||||
"account_tokens_dialog_title_create": "Vytvoření přístupového tokenu",
|
||||
"account_tokens_dialog_title_edit": "Úprava přístupového tokenu",
|
||||
"account_tokens_dialog_title_delete": "Odstranění přístupového tokenu",
|
||||
"account_tokens_dialog_label": "Popisek, např. Radarr notifications",
|
||||
"account_tokens_dialog_button_create": "Vytvořit token",
|
||||
"account_tokens_dialog_button_update": "Aktualizovat token",
|
||||
"account_tokens_dialog_expires_label": "Platnost přístupového tokenu vyprší za",
|
||||
"account_tokens_dialog_expires_unchanged": "Ponechat datum vypršení platnosti beze změny",
|
||||
"account_tokens_dialog_expires_x_hours": "Token vyprší za {{hours}} hodin",
|
||||
"account_tokens_dialog_expires_x_days": "Token vyprší za {{days}} dní",
|
||||
"account_tokens_delete_dialog_title": "Odstranění přístupového tokenu",
|
||||
"account_tokens_delete_dialog_submit_button": "Trvale odstranit token",
|
||||
"prefs_reservations_limit_reached": "Dosáhli jste limitu rezervovaných témat.",
|
||||
"prefs_reservations_add_button": "Přidat rezervované téma",
|
||||
"prefs_reservations_edit_button": "Upravit přístup k tématu",
|
||||
"prefs_reservations_delete_button": "Resetovat přístup k tématu",
|
||||
"prefs_reservations_table": "Tabulka rezervovaných témat",
|
||||
"prefs_reservations_table_topic_header": "Téma",
|
||||
"prefs_reservations_table_access_header": "Přístup",
|
||||
"prefs_reservations_table_everyone_deny_all": "Pouze já mohu publikovat a přihlásit se k odběru",
|
||||
"prefs_reservations_table_everyone_read_only": "Mohu publikovat a přihlásit se k odběru, kdokoli se může přihlásit k odběru",
|
||||
"prefs_reservations_table_everyone_write_only": "Mohu publikovat a přihlásit se k odběru, kdokoli může publikovat",
|
||||
"prefs_reservations_table_everyone_read_write": "Kdokoli může publikovat a přihlásit se k odběru",
|
||||
"prefs_reservations_table_not_subscribed": "Odběr není přihlášen",
|
||||
"prefs_reservations_dialog_title_add": "Rezervovat téma",
|
||||
"prefs_reservations_dialog_title_edit": "Úprava rezervovaného tématu",
|
||||
"prefs_reservations_dialog_title_delete": "Odstranění rezervovaného tématu",
|
||||
"prefs_reservations_dialog_topic_label": "Téma",
|
||||
"reservation_delete_dialog_action_keep_description": "Zprávy a přílohy, které jsou uloženy v mezipaměti serveru, se stanou veřejně viditelnými pro osoby, které znají název tématu.",
|
||||
"reservation_delete_dialog_action_delete_title": "Odstranění zpráv a příloh uložených v mezipaměti",
|
||||
"reservation_delete_dialog_action_delete_description": "Zprávy a přílohy uložené v mezipaměti budou trvale odstraněny. Tuto akci nelze vrátit zpět.",
|
||||
"reservation_delete_dialog_submit_button": "Odstranit rezervaci",
|
||||
"account_basics_tier_interval_yearly": "roční",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "ušetříte {{discount}}%",
|
||||
"account_upgrade_dialog_tier_price_per_month": "měsíc",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "Žádná rezervovaná témata",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "ušetříte až {{discount}}%",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} účtováno ročně. Ušetříte {{save}}.",
|
||||
"account_basics_tier_interval_monthly": "měsíční",
|
||||
"account_upgrade_dialog_interval_monthly": "Měsíční",
|
||||
"account_upgrade_dialog_interval_yearly": "Roční",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} za rok. Účtuje se měsíčně.",
|
||||
"account_upgrade_dialog_billing_contact_email": "V případě dotazů týkajících se fakturace nás prosím <Link>kontaktujte</Link> přímo.",
|
||||
"account_upgrade_dialog_billing_contact_website": "Otázky týkající se fakturace naleznete na našich <Link>webových stránkách</Link>.",
|
||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} rezervované téma",
|
||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} denní zpráva",
|
||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} denní e-mail"
|
||||
}
|
||||
|
||||
43
web/public/static/langs/cy.json
Normal file
43
web/public/static/langs/cy.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"notifications_delete": "Dileu",
|
||||
"action_bar_sign_in": "Mewngofnodi",
|
||||
"notifications_copied_to_clipboard": "Wedi'i gopio i'r clipfwrdd",
|
||||
"common_cancel": "Canslo",
|
||||
"nav_button_account": "Cyfrif",
|
||||
"common_save": "Arbed",
|
||||
"common_add": "Ychwanegu",
|
||||
"signup_title": "Creu cyfrif ntfy",
|
||||
"signup_form_username": "Enw defnyddiwr",
|
||||
"signup_form_password": "Cyfrinair",
|
||||
"action_bar_logo_alt": "logo ntfy",
|
||||
"action_bar_settings": "Gosodiadau",
|
||||
"action_bar_profile_title": "Proffil",
|
||||
"action_bar_profile_logout": "Allgofnodi",
|
||||
"message_bar_publish": "Cyhoeddi neges",
|
||||
"notifications_attachment_copy_url_button": "Copio URL",
|
||||
"notifications_attachment_open_title": "Ewch i {{url}}",
|
||||
"publish_dialog_base_url_label": "URL y Gwasanaeth",
|
||||
"publish_dialog_priority_high": "Blaenoriaeth uchel",
|
||||
"publish_dialog_title_label": "Teitl",
|
||||
"publish_dialog_message_label": "Neges",
|
||||
"publish_dialog_attach_label": "URL Atodiad",
|
||||
"publish_dialog_filename_label": "Enw ffeil",
|
||||
"publish_dialog_filename_placeholder": "Enw ffeil yr atodiad",
|
||||
"action_bar_account": "Cyfrif",
|
||||
"action_bar_unsubscribe": "Dad-danysgrifio",
|
||||
"login_title": "Mewngofnodi i'ch cyfrif ntfy",
|
||||
"login_form_button_submit": "Mewngofnodi",
|
||||
"action_bar_change_display_name": "Newid enw arddangos",
|
||||
"action_bar_profile_settings": "Gosodiadau",
|
||||
"nav_button_settings": "Gosodiadau",
|
||||
"nav_button_documentation": "Dogfennaeth",
|
||||
"alert_not_supported_context_description": "Dim ond dros HTTPS y gellir derbyn cyhoeddiadau. Mae hyn yn gyfyngiad ar yr API <mdnLink>Notifications</mdnLink>.",
|
||||
"notifications_attachment_open_button": "Agor atodiad",
|
||||
"notifications_attachment_file_document": "dogfen arall",
|
||||
"notifications_click_open_button": "Agor linc",
|
||||
"publish_dialog_base_url_placeholder": "URL y Gwasanaeth, e.e. https://example.com",
|
||||
"publish_dialog_attach_placeholder": "Atodi ffeil drwy URL, e.e. https://f-droid.org/F-Droid.apk",
|
||||
"notifications_click_copy_url_button": "Copio linc",
|
||||
"notifications_actions_open_url_title": "Ewch i {{url}}",
|
||||
"publish_dialog_email_label": "Ebost"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user