Compare commits
297 Commits
new-homepa
...
v2.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64b3c3c2fa | ||
|
|
983afb2b45 | ||
|
|
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 | ||
|
|
7fb6f794e5 | ||
|
|
df68b0cb43 | ||
|
|
ca49fd1161 | ||
|
|
bb3f17ada2 | ||
|
|
d18c61f0da | ||
|
|
92cfc04024 | ||
|
|
2d0ce79011 | ||
|
|
c6e091a754 | ||
|
|
c8c16eb8e6 | ||
|
|
0e1082b09c | ||
|
|
c815b183d4 | ||
|
|
a95d1f9200 | ||
|
|
b8e976f4f6 | ||
|
|
6c51b7558a | ||
|
|
c4e4cc5aa7 | ||
|
|
5e90ff7db0 | ||
|
|
6451762508 | ||
|
|
fda90c217f | ||
|
|
94066c24dc | ||
|
|
76d46ec646 | ||
|
|
b9badee6db | ||
|
|
c6b64df662 | ||
|
|
e90f52f375 | ||
|
|
ca68494203 | ||
|
|
396e61cdb3 | ||
|
|
dfaab8c386 | ||
|
|
0df3e3e4f5 | ||
|
|
f2f5a06be1 | ||
|
|
8d7ff4d7db | ||
|
|
7c5b9c0e62 | ||
|
|
6bfe4a9779 | ||
|
|
fb2fa4c478 | ||
|
|
28b654ae27 | ||
|
|
9f052bdf8b | ||
|
|
5472c8513f | ||
|
|
c028ec9083 | ||
|
|
31a87935a5 | ||
|
|
80292f1f4d | ||
|
|
d686e1ee77 | ||
|
|
66cf54e458 | ||
|
|
610adb062b | ||
|
|
70aa384bc3 | ||
|
|
355424c0da | ||
|
|
9b118e8085 | ||
|
|
9e20ee35e1 | ||
|
|
0d4ef18358 | ||
|
|
8bde80a3d2 | ||
|
|
bed60b71ff | ||
|
|
cc309e87e9 | ||
|
|
9131d3d521 | ||
|
|
6b4971786f | ||
|
|
1f010acb30 | ||
|
|
8bf64d8723 | ||
|
|
73b0161ff7 | ||
|
|
4cbf1f5371 | ||
|
|
e5a33523d9 | ||
|
|
224c54b1a2 | ||
|
|
020f561ad4 | ||
|
|
669d269fd9 | ||
|
|
b026e45189 | ||
|
|
7e38419cdb | ||
|
|
cfcc3793c5 | ||
|
|
5724bdf436 | ||
|
|
432cc2003e | ||
|
|
79f9e78c37 | ||
|
|
d8dd4c92bf | ||
|
|
057c4a3239 | ||
|
|
dc77efc31a | ||
|
|
e6bb5f484c | ||
|
|
bcb22d8d4c | ||
|
|
b37cf02a6e | ||
|
|
7706bd9845 | ||
|
|
b17a7cfa95 | ||
|
|
e1a4a74905 | ||
|
|
3ac315a9e7 | ||
|
|
fb3e47386c | ||
|
|
aea8a6d04b | ||
|
|
e449f0bda4 | ||
|
|
ff3cb6c5cc | ||
|
|
2b4f7ab56f | ||
|
|
f5a8216be6 | ||
|
|
19324ab232 | ||
|
|
bf96d21d67 | ||
|
|
2f0fdf1252 | ||
|
|
d44a11325d | ||
|
|
a32e8abc12 | ||
|
|
3779b4a923 | ||
|
|
9738e4a225 | ||
|
|
0905016b1f | ||
|
|
e3b39f670f | ||
|
|
9b54f63eb1 | ||
|
|
b5158adb51 | ||
|
|
7cc8c81bd8 | ||
|
|
27bd79febf | ||
|
|
5d6051c490 | ||
|
|
a6641980c2 | ||
|
|
5f8ecfaf81 | ||
|
|
af4175a5bc | ||
|
|
8f5ca5220e | ||
|
|
8da46afab4 | ||
|
|
0885951a67 | ||
|
|
180a7df1e7 | ||
|
|
07cdf2bc7a | ||
|
|
259293f9b3 | ||
|
|
ef8f7c9884 | ||
|
|
b516f99394 | ||
|
|
b10b0f8a6a | ||
|
|
4ad1099e9f | ||
|
|
4f5e40e161 | ||
|
|
d717bf39ac | ||
|
|
c12ecb9f21 | ||
|
|
00af52411c | ||
|
|
f4c54a1643 | ||
|
|
40ba143a63 | ||
|
|
0e36ac84d8 | ||
|
|
92d563371c | ||
|
|
e596834096 | ||
|
|
000bf27c87 | ||
|
|
b77920bb4b | ||
|
|
16c14bf709 | ||
|
|
62140ec001 | ||
|
|
ccc2dd1128 | ||
|
|
9e9caee639 | ||
|
|
22c66203a0 | ||
|
|
facf4684ae | ||
|
|
810a29ea72 | ||
|
|
c874a641df | ||
|
|
a036814d98 | ||
|
|
2624897efe | ||
|
|
df6f53a161 | ||
|
|
03312559a7 | ||
|
|
3ab352e253 | ||
|
|
b941551fff | ||
|
|
593e0748a8 | ||
|
|
236254d907 | ||
|
|
1771cb3fdb | ||
|
|
eecd689ad5 | ||
|
|
3e48c86ee9 | ||
|
|
471775ae49 | ||
|
|
a278297f28 | ||
|
|
38a1193523 | ||
|
|
3d84bdf77b | ||
|
|
8668143127 | ||
|
|
0d537c8a24 | ||
|
|
bce71cb196 | ||
|
|
e82a2e518c | ||
|
|
954d919361 | ||
|
|
295bad59bb | ||
|
|
804ee3b298 | ||
|
|
9c082a8331 | ||
|
|
88abd8872d | ||
|
|
c66a9851cc | ||
|
|
75c07221ef | ||
|
|
f443e643ee | ||
|
|
b82794df05 | ||
|
|
14f3571e67 | ||
|
|
5a7cedce95 | ||
|
|
5310b1d48e | ||
|
|
167656b38e | ||
|
|
5d81f875cb | ||
|
|
6ae200e338 | ||
|
|
ab6b902fb5 | ||
|
|
9f423b01ef | ||
|
|
c863c86f4c | ||
|
|
5b14c76e54 | ||
|
|
2bd27a5d0b | ||
|
|
cff8f88920 | ||
|
|
87f5479662 | ||
|
|
2ec13c64f3 | ||
|
|
c916eeb9d7 | ||
|
|
8ee85a4007 | ||
|
|
36c0be1097 |
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1 +1,2 @@
|
||||
github: [binwiederhier]
|
||||
liberapay: ntfy
|
||||
|
||||
2
.github/workflows/build.yaml
vendored
2
.github/workflows/build.yaml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
name: Install Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.18.x'
|
||||
go-version: '1.19.x'
|
||||
-
|
||||
name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
|
||||
2
.github/workflows/release.yaml
vendored
2
.github/workflows/release.yaml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
name: Install Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.18.x'
|
||||
go-version: '1.19.x'
|
||||
-
|
||||
name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
|
||||
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
name: Install Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.18.x'
|
||||
go-version: '1.19.x'
|
||||
-
|
||||
name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
|
||||
@@ -3,7 +3,5 @@ MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
|
||||
|
||||
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"]
|
||||
|
||||
9
Makefile
9
Makefile
@@ -88,7 +88,6 @@ build-deps-ubuntu:
|
||||
curl \
|
||||
gcc-aarch64-linux-gnu \
|
||||
gcc-arm-linux-gnueabi \
|
||||
upx \
|
||||
jq
|
||||
which pip3 || sudo apt install -y python3-pip
|
||||
|
||||
@@ -201,7 +200,6 @@ cli-deps-static-sites:
|
||||
touch server/docs/index.html server/site/app.html
|
||||
|
||||
cli-deps-all:
|
||||
which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; }
|
||||
go install github.com/goreleaser/goreleaser@latest
|
||||
|
||||
cli-deps-gcc-armv6-armv7:
|
||||
@@ -231,14 +229,17 @@ cli-build-results:
|
||||
check: test fmt-check vet lint staticcheck
|
||||
|
||||
test: .PHONY
|
||||
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
|
||||
testv: .PHONY
|
||||
go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
|
||||
race: .PHONY
|
||||
go test -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
go test -v -race $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
|
||||
coverage:
|
||||
mkdir -p build/coverage
|
||||
go test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
go test -v -race -coverprofile=build/coverage/coverage.txt -covermode=atomic $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||
go tool cover -func build/coverage/coverage.txt
|
||||
|
||||
coverage-html:
|
||||
|
||||
29
README.md
29
README.md
@@ -13,11 +13,9 @@
|
||||
[](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">
|
||||
@@ -61,9 +59,9 @@ for the server and the Android app. Or, if you'd like to help translate 🇩🇪
|
||||
</a>
|
||||
|
||||
## Sponsors
|
||||
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier).
|
||||
I would be humbled if you helped me carry the server and developer account costs. Even small donations are very much
|
||||
appreciated. A big fat **Thank You** to the folks already sponsoring ntfy:
|
||||
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier),
|
||||
and [Liberapay](https://liberapay.com/ntfy). I would be humbled if you helped me carry the server and developer
|
||||
account costs. Even small donations are very much appreciated. A big fat **Thank You** to the folks already sponsoring ntfy:
|
||||
|
||||
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
|
||||
<a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a>
|
||||
@@ -110,11 +108,24 @@ appreciated. A big fat **Thank You** to the folks already sponsoring ntfy:
|
||||
<a href="https://github.com/biopsin"><img src="https://github.com/biopsin.png" width="40px" /></a>
|
||||
<a href="https://github.com/thebino"><img src="https://github.com/thebino.png" width="40px" /></a>
|
||||
<a href="https://github.com/sky4055"><img src="https://github.com/sky4055.png" width="40px" /></a>
|
||||
<a href="https://github.com/julianlam"><img src="https://github.com/julianlam.png" width="40px" /></a>
|
||||
<a href="https://github.com/andreapx"><img src="https://github.com/andreapx.png" width="40px" /></a>
|
||||
<a href="https://github.com/billycao"><img src="https://github.com/billycao.png" width="40px" /></a>
|
||||
<a href="https://github.com/zoic21"><img src="https://github.com/zoic21.png" width="40px" /></a>
|
||||
<a href="https://github.com/IanKulin"><img src="https://github.com/IanKulin.png" width="40px" /></a>
|
||||
<a href="https://github.com/Joachim256"><img src="https://github.com/Joachim256.png" width="40px" /></a>
|
||||
<a href="https://github.com/overtone1000"><img src="https://github.com/overtone1000.png" width="40px" /></a>
|
||||
<a href="https://github.com/oakd"><img src="https://github.com/oakd.png" width="40px" /></a>
|
||||
<a href="https://github.com/KucharczykL"><img src="https://github.com/KucharczykL.png" width="40px" /></a>
|
||||
<a href="https://github.com/hansbickhofe"><img src="https://github.com/hansbickhofe.png" width="40px" /></a>
|
||||
<a href="https://github.com/caseodilla"><img src="https://github.com/caseodilla.png" width="40px" /></a>
|
||||
<a href="https://github.com/0xAF"><img src="https://github.com/0xAF.png" width="40px" /></a>
|
||||
<a href="https://github.com/soonoo"><img src="https://github.com/soonoo.png" width="40px" /></a>
|
||||
|
||||
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://www.digitalocean.com/) for supporting the project:
|
||||
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
|
||||
|
||||
<a href="https://www.digitalocean.com/"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
|
||||
<a href="https://m.do.co/c/442b929528db"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
|
||||
|
||||
## Code of Conduct
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
||||
|
||||
@@ -4,11 +4,18 @@ import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/client"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/test"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
log.SetLevel(log.ErrorLevel)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestClient_Publish_Subscribe(t *testing.T) {
|
||||
s, port := test.StartServer(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
@@ -87,6 +87,11 @@ func WithBasicAuth(user, pass string) PublishOption {
|
||||
return WithHeader("Authorization", util.BasicAuth(user, pass))
|
||||
}
|
||||
|
||||
// WithBearerAuth adds the Authorization header for Bearer auth to the request
|
||||
func WithBearerAuth(token string) PublishOption {
|
||||
return WithHeader("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
}
|
||||
|
||||
// WithNoCache instructs the server not to cache the message server-side
|
||||
func WithNoCache() PublishOption {
|
||||
return WithHeader("X-Cache", "no")
|
||||
|
||||
@@ -19,7 +19,7 @@ const (
|
||||
)
|
||||
|
||||
var flagsAccess = append(
|
||||
flagsUser,
|
||||
append([]cli.Flag{}, flagsUser...),
|
||||
&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
|
||||
)
|
||||
|
||||
@@ -189,7 +189,11 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s (%s)\n", u.Name, u.Role)
|
||||
tier := "none"
|
||||
if u.Tier != nil {
|
||||
tier = u.Tier.Name
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s (role: %s, tier: %s)\n", u.Name, u.Role, tier)
|
||||
if u.Role == user.RoleAdmin {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
|
||||
} else if len(grants) > 0 {
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestCLI_Access_Show(t *testing.T) {
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
require.Nil(t, runAccessCommand(app, conf))
|
||||
require.Contains(t, stderr.String(), "user * (anonymous)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
|
||||
require.Contains(t, stderr.String(), "user * (role: anonymous, tier: none)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
|
||||
}
|
||||
|
||||
func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
||||
@@ -32,12 +32,12 @@ func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
require.Nil(t, runAccessCommand(app, conf))
|
||||
expected := `user phil (admin)
|
||||
expected := `user phil (role: admin, tier: none)
|
||||
- read-write access to all topics (admin role)
|
||||
user ben (user)
|
||||
user ben (role: user, tier: none)
|
||||
- read-write access to topic announcements
|
||||
- read-only access to topic sometopic
|
||||
user * (anonymous)
|
||||
user * (role: anonymous, tier: none)
|
||||
- read-only access to topic announcements
|
||||
- no access to any (other) topics (server config)
|
||||
`
|
||||
@@ -79,7 +79,9 @@ user * (anonymous)
|
||||
func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
userArgs := []string{
|
||||
"ntfy",
|
||||
"--log-level=ERROR",
|
||||
"access",
|
||||
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
|
||||
"--auth-file=" + conf.AuthFile,
|
||||
"--auth-default-access=" + conf.AuthDefault.String(),
|
||||
}
|
||||
|
||||
40
cmd/app.go
40
cmd/app.go
@@ -2,10 +2,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/log"
|
||||
"os"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,8 +22,15 @@ var flagsDefault = []cli.Flag{
|
||||
&cli.BoolFlag{Name: "trace", EnvVars: []string{"NTFY_TRACE"}, Usage: "enable tracing (very verbose, be careful)"},
|
||||
&cli.BoolFlag{Name: "no-log-dates", Aliases: []string{"no_log_dates"}, EnvVars: []string{"NTFY_NO_LOG_DATES"}, Usage: "disable the date/time prefix"},
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "log-level-overrides", Aliases: []string{"log_level_overrides"}, EnvVars: []string{"NTFY_LOG_LEVEL_OVERRIDES"}, Usage: "set log level overrides"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-format", Aliases: []string{"log_format"}, Value: log.TextFormat.String(), EnvVars: []string{"NTFY_LOG_FORMAT"}, Usage: "set log format"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-file", Aliases: []string{"log_file"}, EnvVars: []string{"NTFY_LOG_FILE"}, Usage: "set log file, default is STDOUT"}),
|
||||
}
|
||||
|
||||
var (
|
||||
logLevelOverrideRegex = regexp.MustCompile(`(?i)^([^=\s]+)(?:\s*=\s*(\S+))?\s*->\s*(TRACE|DEBUG|INFO|WARN|ERROR)$`)
|
||||
)
|
||||
|
||||
// New creates a new CLI application
|
||||
func New() *cli.App {
|
||||
return &cli.App{
|
||||
@@ -40,15 +49,42 @@ func New() *cli.App {
|
||||
}
|
||||
|
||||
func initLogFunc(c *cli.Context) error {
|
||||
log.SetLevel(log.ToLevel(c.String("log-level")))
|
||||
log.SetFormat(log.ToFormat(c.String("log-format")))
|
||||
if c.Bool("trace") {
|
||||
log.SetLevel(log.TraceLevel)
|
||||
} else if c.Bool("debug") {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
} else {
|
||||
log.SetLevel(log.ToLevel(c.String("log-level")))
|
||||
}
|
||||
if c.Bool("no-log-dates") {
|
||||
log.DisableDates()
|
||||
}
|
||||
if err := applyLogLevelOverrides(c.StringSlice("log-level-overrides")); err != nil {
|
||||
return err
|
||||
}
|
||||
logFile := c.String("log-file")
|
||||
if logFile != "" {
|
||||
w, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.SetOutput(w)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyLogLevelOverrides(rawOverrides []string) error {
|
||||
for _, override := range rawOverrides {
|
||||
m := logLevelOverrideRegex.FindStringSubmatch(override)
|
||||
if len(m) == 4 {
|
||||
field, value, level := m[1], m[2], m[3]
|
||||
log.SetLevelOverride(field, value, log.ToLevel(level))
|
||||
} else if len(m) == 3 {
|
||||
field, level := m[1], m[2]
|
||||
log.SetLevelOverride(field, "", log.ToLevel(level)) // Matches any value
|
||||
} else {
|
||||
return fmt.Errorf(`invalid log level override "%s", must be "field=value -> loglevel", e.g. "user_id=u_123 -> DEBUG"`, override)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/client"
|
||||
"heckel.io/ntfy/log"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -13,7 +14,7 @@ import (
|
||||
// This only contains helpers so far
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// log.SetOutput(io.Discard)
|
||||
log.SetLevel(log.ErrorLevel)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ func init() {
|
||||
}
|
||||
|
||||
var flagsPublish = append(
|
||||
flagsDefault,
|
||||
append([]cli.Flag{}, flagsDefault...),
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
|
||||
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
|
||||
&cli.StringFlag{Name: "message", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MESSAGE"}, Usage: "message body"},
|
||||
@@ -35,11 +35,11 @@ var flagsPublish = append(
|
||||
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
||||
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
||||
&cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"},
|
||||
&cli.IntFlag{Name: "wait-pid", Aliases: []string{"wait_pid", "pid"}, EnvVars: []string{"NTFY_WAIT_PID"}, Usage: "wait until PID exits before publishing"},
|
||||
&cli.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"},
|
||||
)
|
||||
|
||||
@@ -99,10 +99,18 @@ func execPublish(c *cli.Context) error {
|
||||
file := c.String("file")
|
||||
email := c.String("email")
|
||||
user := c.String("user")
|
||||
token := c.String("token")
|
||||
noCache := c.Bool("no-cache")
|
||||
noFirebase := c.Bool("no-firebase")
|
||||
quiet := c.Bool("quiet")
|
||||
pid := c.Int("wait-pid")
|
||||
|
||||
// Checks
|
||||
if user != "" && token != "" {
|
||||
return errors.New("cannot set both --user and --token")
|
||||
}
|
||||
|
||||
// Do the things
|
||||
topic, message, command, err := parseTopicMessageCommand(c)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -144,6 +152,9 @@ func execPublish(c *cli.Context) error {
|
||||
if noFirebase {
|
||||
options = append(options, client.WithNoFirebase())
|
||||
}
|
||||
if token != "" {
|
||||
options = append(options, client.WithBearerAuth(token))
|
||||
}
|
||||
if user != "" {
|
||||
var pass string
|
||||
parts := strings.SplitN(user, ":", 2)
|
||||
|
||||
@@ -8,20 +8,27 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
||||
testMessage := util.RandomString(10)
|
||||
|
||||
app, _, _, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
|
||||
time.Sleep(3 * time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s
|
||||
|
||||
app2, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}))
|
||||
require.Contains(t, stdout.String(), testMessage)
|
||||
_, err := util.Retry(func() (*int, error) {
|
||||
app2, _, stdout, _ := newTestApp()
|
||||
if err := app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.Contains(stdout.String(), testMessage) {
|
||||
return nil, fmt.Errorf("test message %s not found in topic", testMessage)
|
||||
}
|
||||
return util.Int(1), nil
|
||||
}, time.Second, 2*time.Second, 5*time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Subscribe_Poll(t *testing.T) {
|
||||
@@ -127,7 +134,7 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -136,7 +143,7 @@ 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)
|
||||
}
|
||||
|
||||
49
cmd/serve.go
49
cmd/serve.go
@@ -34,7 +34,7 @@ const (
|
||||
)
|
||||
|
||||
var flagsServe = append(
|
||||
flagsDefault,
|
||||
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"}),
|
||||
@@ -58,6 +58,7 @@ var flagsServe = append(
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
|
||||
@@ -77,11 +78,13 @@ var flagsServe = append(
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
|
||||
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: "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)"}),
|
||||
)
|
||||
|
||||
var cmdServe = &cli.Command{
|
||||
@@ -131,6 +134,7 @@ func execServe(c *cli.Context) error {
|
||||
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
|
||||
keepaliveInterval := c.Duration("keepalive-interval")
|
||||
managerInterval := c.Duration("manager-interval")
|
||||
disallowedTopics := c.StringSlice("disallowed-topics")
|
||||
webRoot := c.String("web-root")
|
||||
enableSignup := c.Bool("enable-signup")
|
||||
enableLogin := c.Bool("enable-login")
|
||||
@@ -150,11 +154,13 @@ func execServe(c *cli.Context) error {
|
||||
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
||||
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
|
||||
visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",")
|
||||
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
|
||||
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
||||
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
|
||||
behindProxy := c.Bool("behind-proxy")
|
||||
stripeSecretKey := c.String("stripe-secret-key")
|
||||
stripeWebhookKey := c.String("stripe-webhook-key")
|
||||
billingContact := c.String("billing-contact")
|
||||
|
||||
// Check values
|
||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||
@@ -246,11 +252,16 @@ func execServe(c *cli.Context) error {
|
||||
|
||||
// Stripe things
|
||||
if stripeSecretKey != "" {
|
||||
stripe.EnableTelemetry = false // Whoa!
|
||||
stripe.Key = stripeSecretKey
|
||||
}
|
||||
|
||||
// Add default forbidden topics
|
||||
disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...)
|
||||
|
||||
// Run server
|
||||
conf := server.NewConfig()
|
||||
conf.File = config
|
||||
conf.BaseURL = baseURL
|
||||
conf.ListenHTTP = listenHTTP
|
||||
conf.ListenHTTPS = listenHTTPS
|
||||
@@ -273,6 +284,7 @@ func execServe(c *cli.Context) error {
|
||||
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
||||
conf.KeepaliveInterval = keepaliveInterval
|
||||
conf.ManagerInterval = managerInterval
|
||||
conf.DisallowedTopics = disallowedTopics
|
||||
conf.WebRootIsApp = webRootIsApp
|
||||
conf.UpstreamBaseURL = upstreamBaseURL
|
||||
conf.SMTPSenderAddr = smtpSenderAddr
|
||||
@@ -285,15 +297,17 @@ func execServe(c *cli.Context) error {
|
||||
conf.TotalTopicLimit = totalTopicLimit
|
||||
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
||||
conf.VisitorAttachmentDailyBandwidthLimit = int(visitorAttachmentDailyBandwidthLimit)
|
||||
conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit
|
||||
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
|
||||
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
|
||||
conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs
|
||||
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
|
||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||
conf.BehindProxy = behindProxy
|
||||
conf.StripeSecretKey = stripeSecretKey
|
||||
conf.StripeWebhookKey = stripeWebhookKey
|
||||
conf.BillingContact = billingContact
|
||||
conf.EnableWeb = enableWeb
|
||||
conf.EnableSignup = enableSignup
|
||||
conf.EnableLogin = enableLogin
|
||||
@@ -306,9 +320,9 @@ func execServe(c *cli.Context) error {
|
||||
// Run server
|
||||
s, err := server.New(conf)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
log.Fatal(err.Error())
|
||||
} else if err := s.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
log.Fatal(err.Error())
|
||||
}
|
||||
log.Info("Exiting.")
|
||||
return nil
|
||||
@@ -335,7 +349,9 @@ func sigHandlerConfigReload(config string) {
|
||||
log.Warn("Hot reload failed: %s", err.Error())
|
||||
continue
|
||||
}
|
||||
reloadLogLevel(inputSource)
|
||||
if err := reloadLogLevel(inputSource); err != nil {
|
||||
log.Warn("Reloading log level failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,13 +380,24 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func reloadLogLevel(inputSource altsrc.InputSourceContext) {
|
||||
func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
|
||||
newLevelStr, err := inputSource.String("log-level")
|
||||
if err != nil {
|
||||
log.Warn("Cannot load log level: %s", err.Error())
|
||||
return
|
||||
return fmt.Errorf("cannot load log level: %s", err.Error())
|
||||
}
|
||||
newLevel := log.ToLevel(newLevelStr)
|
||||
log.SetLevel(newLevel)
|
||||
log.Info("Log level is %s", newLevel.String())
|
||||
overrides, err := inputSource.StringSlice("log-level-overrides")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load log level overrides (1): %s", err.Error())
|
||||
}
|
||||
log.ResetLevelOverrides()
|
||||
if err := applyLogLevelOverrides(overrides); err != nil {
|
||||
return fmt.Errorf("cannot load log level overrides (2): %s", err.Error())
|
||||
}
|
||||
log.SetLevel(log.ToLevel(newLevelStr))
|
||||
if len(overrides) > 0 {
|
||||
log.Info("Log level is %v, %d override(s) in place", strings.ToUpper(newLevelStr), len(overrides))
|
||||
} else {
|
||||
log.Info("Log level is %v", strings.ToUpper(newLevelStr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const (
|
||||
)
|
||||
|
||||
var flagsSubscribe = append(
|
||||
flagsDefault,
|
||||
append([]cli.Flag{}, flagsDefault...),
|
||||
&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"},
|
||||
|
||||
366
cmd/tier.go
Normal file
366
cmd/tier.go
Normal file
@@ -0,0 +1,366 @@
|
||||
//go:build !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/user"
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdTier)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultMessageLimit = 5000
|
||||
defaultMessageExpiryDuration = "12h"
|
||||
defaultEmailLimit = 20
|
||||
defaultReservationLimit = 3
|
||||
defaultAttachmentFileSizeLimit = "15M"
|
||||
defaultAttachmentTotalSizeLimit = "100M"
|
||||
defaultAttachmentExpiryDuration = "6h"
|
||||
defaultAttachmentBandwidthLimit = "1G"
|
||||
)
|
||||
|
||||
var (
|
||||
flagsTier = append([]cli.Flag{}, flagsUser...)
|
||||
)
|
||||
|
||||
var cmdTier = &cli.Command{
|
||||
Name: "tier",
|
||||
Usage: "Manage/show tiers",
|
||||
UsageText: "ntfy tier [list|add|change|remove] ...",
|
||||
Flags: flagsTier,
|
||||
Before: initConfigFileInputSourceFunc("config", flagsUser, initLogFunc),
|
||||
Category: categoryServer,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "add",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Adds a new tier",
|
||||
UsageText: "ntfy tier add [OPTIONS] CODE",
|
||||
Action: execTierAdd,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "name", Usage: "tier name"},
|
||||
&cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"},
|
||||
&cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
|
||||
&cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"},
|
||||
&cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"},
|
||||
&cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"},
|
||||
&cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"},
|
||||
&cli.StringFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"},
|
||||
&cli.StringFlag{Name: "attachment-bandwidth-limit", Value: defaultAttachmentBandwidthLimit, Usage: "daily bandwidth limit for attachment uploads/downloads"},
|
||||
&cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
&cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
&cli.BoolFlag{Name: "ignore-exists", Usage: "if the tier already exists, perform no action and exit"},
|
||||
},
|
||||
Description: `Add a new tier to the ntfy user database.
|
||||
|
||||
Tiers can be used to grant users higher limits, such as daily message limits, attachment size, or
|
||||
make it possible for users to reserve topics.
|
||||
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
|
||||
Examples:
|
||||
ntfy tier add pro # Add tier with code "pro", using the defaults
|
||||
ntfy tier add \ # Add a tier with custom limits
|
||||
--name="Pro" \
|
||||
--message-limit=10000 \
|
||||
--message-expiry-duration=24h \
|
||||
--email-limit=50 \
|
||||
--reservation-limit=10 \
|
||||
--attachment-file-size-limit=100M \
|
||||
--attachment-total-size-limit=1G \
|
||||
--attachment-expiry-duration=12h \
|
||||
--attachment-bandwidth-limit=5G \
|
||||
pro
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "change",
|
||||
Aliases: []string{"ch"},
|
||||
Usage: "Change a tier",
|
||||
UsageText: "ntfy tier change [OPTIONS] CODE",
|
||||
Action: execTierChange,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "name", Usage: "tier name"},
|
||||
&cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"},
|
||||
&cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
|
||||
&cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"},
|
||||
&cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"},
|
||||
&cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"},
|
||||
&cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"},
|
||||
&cli.StringFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"},
|
||||
&cli.StringFlag{Name: "attachment-bandwidth-limit", Usage: "daily bandwidth limit for attachment uploads/downloads"},
|
||||
&cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
&cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
},
|
||||
Description: `Updates a tier to change the limits.
|
||||
|
||||
After updating a tier, you may have to restart the ntfy server to apply them
|
||||
to all visitors.
|
||||
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
|
||||
Examples:
|
||||
ntfy tier change --name="Pro" pro # Update the name of an existing tier
|
||||
ntfy tier change \ # Update multiple limits and fields
|
||||
--message-expiry-duration=24h \
|
||||
--stripe-monthly-price-id=price_1234 \
|
||||
--stripe-monthly-price-id=price_5678 \
|
||||
pro
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
Aliases: []string{"del", "rm"},
|
||||
Usage: "Removes a tier",
|
||||
UsageText: "ntfy tier remove CODE",
|
||||
Action: execTierDel,
|
||||
Description: `Remove a tier from the ntfy user database.
|
||||
|
||||
You cannot remove a tier if there are users associated with a tier. Use "ntfy user change-tier"
|
||||
to remove or switch their tier first.
|
||||
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
|
||||
Example:
|
||||
ntfy tier del pro
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Shows a list of tiers",
|
||||
Action: execTierList,
|
||||
Description: `Shows a list of all configured tiers.
|
||||
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
`,
|
||||
},
|
||||
},
|
||||
Description: `Manage tiers of the ntfy server.
|
||||
|
||||
The command allows you to add/remove/change tiers in the ntfy user database. Tiers are used
|
||||
to grant users higher limits, such as daily message limits, attachment size, or make it
|
||||
possible for users to reserve topics.
|
||||
|
||||
This is a server-only command. It directly manages the user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
|
||||
Examples:
|
||||
ntfy tier add pro # Add tier with code "pro", using the defaults
|
||||
ntfy tier change --name="Pro" pro # Update the name of an existing tier
|
||||
ntfy tier del pro # Delete an existing tier
|
||||
`,
|
||||
}
|
||||
|
||||
func execTierAdd(c *cli.Context) error {
|
||||
code := c.Args().Get(0)
|
||||
if code == "" {
|
||||
return errors.New("tier code expected, type 'ntfy tier add --help' for help")
|
||||
} else if !user.AllowedTier(code) {
|
||||
return errors.New("tier code must consist only of numbers and letters")
|
||||
} else if c.String("stripe-monthly-price-id") != "" && c.String("stripe-yearly-price-id") == "" {
|
||||
return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set")
|
||||
} else if c.String("stripe-monthly-price-id") == "" && c.String("stripe-yearly-price-id") != "" {
|
||||
return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tier, _ := manager.Tier(code); tier != nil {
|
||||
if c.Bool("ignore-exists") {
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier %s already exists (exited successfully)\n", code)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("tier %s already exists", code)
|
||||
}
|
||||
name := c.String("name")
|
||||
if name == "" {
|
||||
name = code
|
||||
}
|
||||
messageExpiryDuration, err := util.ParseDuration(c.String("message-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentFileSizeLimit, err := util.ParseSize(c.String("attachment-file-size-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentTotalSizeLimit, err := util.ParseSize(c.String("attachment-total-size-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentBandwidthLimit, err := util.ParseSize(c.String("attachment-bandwidth-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentExpiryDuration, err := util.ParseDuration(c.String("attachment-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tier := &user.Tier{
|
||||
ID: "", // Generated
|
||||
Code: code,
|
||||
Name: name,
|
||||
MessageLimit: c.Int64("message-limit"),
|
||||
MessageExpiryDuration: messageExpiryDuration,
|
||||
EmailLimit: c.Int64("email-limit"),
|
||||
ReservationLimit: c.Int64("reservation-limit"),
|
||||
AttachmentFileSizeLimit: attachmentFileSizeLimit,
|
||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit,
|
||||
AttachmentExpiryDuration: attachmentExpiryDuration,
|
||||
AttachmentBandwidthLimit: attachmentBandwidthLimit,
|
||||
StripeMonthlyPriceID: c.String("stripe-monthly-price-id"),
|
||||
StripeYearlyPriceID: c.String("stripe-yearly-price-id"),
|
||||
}
|
||||
if err := manager.AddTier(tier); err != nil {
|
||||
return err
|
||||
}
|
||||
tier, err = manager.Tier(code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier added\n\n")
|
||||
printTier(c, tier)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execTierChange(c *cli.Context) error {
|
||||
code := c.Args().Get(0)
|
||||
if code == "" {
|
||||
return errors.New("tier code expected, type 'ntfy tier change --help' for help")
|
||||
} else if !user.AllowedTier(code) {
|
||||
return errors.New("tier code must consist only of numbers and letters")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tier, err := manager.Tier(code)
|
||||
if err == user.ErrTierNotFound {
|
||||
return fmt.Errorf("tier %s does not exist", code)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.IsSet("name") {
|
||||
tier.Name = c.String("name")
|
||||
}
|
||||
if c.IsSet("message-limit") {
|
||||
tier.MessageLimit = c.Int64("message-limit")
|
||||
}
|
||||
if c.IsSet("message-expiry-duration") {
|
||||
tier.MessageExpiryDuration, err = util.ParseDuration(c.String("message-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IsSet("email-limit") {
|
||||
tier.EmailLimit = c.Int64("email-limit")
|
||||
}
|
||||
if c.IsSet("reservation-limit") {
|
||||
tier.ReservationLimit = c.Int64("reservation-limit")
|
||||
}
|
||||
if c.IsSet("attachment-file-size-limit") {
|
||||
tier.AttachmentFileSizeLimit, err = util.ParseSize(c.String("attachment-file-size-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IsSet("attachment-total-size-limit") {
|
||||
tier.AttachmentTotalSizeLimit, err = util.ParseSize(c.String("attachment-total-size-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IsSet("attachment-expiry-duration") {
|
||||
tier.AttachmentExpiryDuration, err = util.ParseDuration(c.String("attachment-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IsSet("attachment-bandwidth-limit") {
|
||||
tier.AttachmentBandwidthLimit, err = util.ParseSize(c.String("attachment-bandwidth-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IsSet("stripe-monthly-price-id") {
|
||||
tier.StripeMonthlyPriceID = c.String("stripe-monthly-price-id")
|
||||
}
|
||||
if c.IsSet("stripe-yearly-price-id") {
|
||||
tier.StripeYearlyPriceID = c.String("stripe-yearly-price-id")
|
||||
}
|
||||
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID == "" {
|
||||
return errors.New("if stripe-monthly-price-id is set, stripe-yearly-price-id must also be set")
|
||||
} else if tier.StripeMonthlyPriceID == "" && tier.StripeYearlyPriceID != "" {
|
||||
return errors.New("if stripe-yearly-price-id is set, stripe-monthly-price-id must also be set")
|
||||
}
|
||||
if err := manager.UpdateTier(tier); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier updated\n\n")
|
||||
printTier(c, tier)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execTierDel(c *cli.Context) error {
|
||||
code := c.Args().Get(0)
|
||||
if code == "" {
|
||||
return errors.New("tier code expected, type 'ntfy tier del --help' for help")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := manager.Tier(code); err == user.ErrTierNotFound {
|
||||
return fmt.Errorf("tier %s does not exist", code)
|
||||
}
|
||||
if err := manager.RemoveTier(code); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier %s removed\n", code)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execTierList(c *cli.Context) error {
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tiers, err := manager.Tiers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, tier := range tiers {
|
||||
printTier(c, tier)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printTier(c *cli.Context, tier *user.Tier) {
|
||||
prices := "(none)"
|
||||
if tier.StripeMonthlyPriceID != "" && tier.StripeYearlyPriceID != "" {
|
||||
prices = fmt.Sprintf("%s / %s", tier.StripeMonthlyPriceID, tier.StripeYearlyPriceID)
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "tier %s (id: %s)\n", tier.Code, tier.ID)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Name: %s\n", tier.Name)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds()))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSize(tier.AttachmentBandwidthLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices)
|
||||
}
|
||||
67
cmd/tier_test.go
Normal file
67
cmd/tier_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/server"
|
||||
"heckel.io/ntfy/test"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, _, _, stderr := newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro"))
|
||||
require.Contains(t, stderr.String(), "tier added\n\ntier pro (id: ti_")
|
||||
|
||||
err := runTierCommand(app, conf, "add", "pro")
|
||||
require.NotNil(t, err)
|
||||
require.Equal(t, "tier pro already exists", err.Error())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "list"))
|
||||
require.Contains(t, stderr.String(), "tier pro (id: ti_")
|
||||
require.Contains(t, stderr.String(), "- Name: Pro")
|
||||
require.Contains(t, stderr.String(), "- Message limit: 1234")
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "change",
|
||||
"--message-limit=999",
|
||||
"--message-expiry-duration=2d",
|
||||
"--email-limit=91",
|
||||
"--reservation-limit=98",
|
||||
"--attachment-file-size-limit=100m",
|
||||
"--attachment-expiry-duration=1d",
|
||||
"--attachment-total-size-limit=10G",
|
||||
"--attachment-bandwidth-limit=100G",
|
||||
"--stripe-monthly-price-id=price_991",
|
||||
"--stripe-yearly-price-id=price_992",
|
||||
"pro",
|
||||
))
|
||||
require.Contains(t, stderr.String(), "- Message limit: 999")
|
||||
require.Contains(t, stderr.String(), "- Message expiry duration: 48h")
|
||||
require.Contains(t, stderr.String(), "- Email limit: 91")
|
||||
require.Contains(t, stderr.String(), "- Reservation limit: 98")
|
||||
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB")
|
||||
require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h")
|
||||
require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB")
|
||||
require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "remove", "pro"))
|
||||
require.Contains(t, stderr.String(), "tier pro removed")
|
||||
}
|
||||
|
||||
func runTierCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
userArgs := []string{
|
||||
"ntfy",
|
||||
"--log-level=ERROR",
|
||||
"tier",
|
||||
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
|
||||
"--auth-file=" + conf.AuthFile,
|
||||
"--auth-default-access=" + conf.AuthDefault.String(),
|
||||
}
|
||||
return app.Run(append(userArgs, args...))
|
||||
}
|
||||
210
cmd/token.go
Normal file
210
cmd/token.go
Normal file
@@ -0,0 +1,210 @@
|
||||
//go:build !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/user"
|
||||
"heckel.io/ntfy/util"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdToken)
|
||||
}
|
||||
|
||||
var flagsToken = append([]cli.Flag{}, flagsUser...)
|
||||
|
||||
var cmdToken = &cli.Command{
|
||||
Name: "token",
|
||||
Usage: "Create, list or delete user tokens",
|
||||
UsageText: "ntfy token [list|add|remove] ...",
|
||||
Flags: flagsToken,
|
||||
Before: initConfigFileInputSourceFunc("config", flagsToken, initLogFunc),
|
||||
Category: categoryServer,
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "add",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Create a new token",
|
||||
UsageText: "ntfy token add [--expires=<duration>] [--label=..] USERNAME",
|
||||
Action: execTokenAdd,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "expires", Aliases: []string{"e"}, Value: "", Usage: "token expires after"},
|
||||
&cli.StringFlag{Name: "label", Aliases: []string{"l"}, Value: "", Usage: "token label"},
|
||||
},
|
||||
Description: `Create a new user access token.
|
||||
|
||||
User access tokens can be used to publish, subscribe, or perform any other user-specific tasks.
|
||||
Tokens have full access, and can perform any task a user can do. They are meant to be used to
|
||||
avoid spreading the password to various places.
|
||||
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
|
||||
Examples:
|
||||
ntfy token add phil # Create token for user phil which never expires
|
||||
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
|
||||
ntfy token add -e "tuesday, 8pm" phil # Create token for user phil which expires next Tuesday
|
||||
ntfy token add -l backups phil # Create token for user phil with label "backups"`,
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
Aliases: []string{"del", "rm"},
|
||||
Usage: "Removes a token",
|
||||
UsageText: "ntfy token remove USERNAME TOKEN",
|
||||
Action: execTokenDel,
|
||||
Description: `Remove a token from the ntfy user database.
|
||||
|
||||
Example:
|
||||
ntfy token del phil tk_th2srHVlxrANQHAso5t0HuQ1J1TjN`,
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Aliases: []string{"l"},
|
||||
Usage: "Shows a list of tokens",
|
||||
Action: execTokenList,
|
||||
Description: `Shows a list of all tokens.
|
||||
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.`,
|
||||
},
|
||||
},
|
||||
Description: `Manage access tokens for individual users.
|
||||
|
||||
User access tokens can be used to publish, subscribe, or perform any other user-specific tasks.
|
||||
Tokens have full access, and can perform any task a user can do. They are meant to be used to
|
||||
avoid spreading the password to various places.
|
||||
|
||||
This is a server-only command. It directly manages the user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
|
||||
Examples:
|
||||
ntfy token list # Shows list of tokens for all users
|
||||
ntfy token list phil # Shows list of tokens for user phil
|
||||
ntfy token add phil # Create token for user phil which never expires
|
||||
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
|
||||
ntfy token remove phil tk_th2srHVlxr... # Delete token`,
|
||||
}
|
||||
|
||||
func execTokenAdd(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
expiresStr := c.String("expires")
|
||||
label := c.String("label")
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy token add --help' for help")
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
expires := time.Unix(0, 0)
|
||||
if expiresStr != "" {
|
||||
var err error
|
||||
expires, err = util.ParseFutureTime(expiresStr, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := manager.CreateToken(u.ID, label, expires, netip.IPv4Unspecified())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if expires.Unix() == 0 {
|
||||
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, never expires\n", token.Value, u.Name)
|
||||
} else {
|
||||
fmt.Fprintf(c.App.ErrWriter, "token %s created for user %s, expires %v\n", token.Value, u.Name, expires.Format(time.UnixDate))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execTokenDel(c *cli.Context) error {
|
||||
username, token := c.Args().Get(0), c.Args().Get(1)
|
||||
if username == "" || token == "" {
|
||||
return errors.New("username and token expected, type 'ntfy token remove --help' for help")
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := manager.RemoveToken(u.ID, token); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "token %s for user %s removed\n", token, username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func execTokenList(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var users []*user.User
|
||||
if username != "" {
|
||||
u, err := manager.User(username)
|
||||
if err == user.ErrUserNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
users = append(users, u)
|
||||
} else {
|
||||
users, err = manager.Users()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
usersWithTokens := 0
|
||||
for _, u := range users {
|
||||
tokens, err := manager.Tokens(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if len(tokens) == 0 && username != "" {
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s has no access tokens\n", username)
|
||||
return nil
|
||||
} else if len(tokens) == 0 {
|
||||
continue
|
||||
}
|
||||
usersWithTokens++
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s\n", u.Name)
|
||||
for _, t := range tokens {
|
||||
var label, expires string
|
||||
if t.Label != "" {
|
||||
label = fmt.Sprintf(" (%s)", t.Label)
|
||||
}
|
||||
if t.Expires.Unix() == 0 {
|
||||
expires = "never expires"
|
||||
} else {
|
||||
expires = fmt.Sprintf("expires %s", t.Expires.Format(time.RFC822))
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "- %s%s, %s, accessed from %s at %s\n", t.Value, label, expires, t.LastOrigin.String(), t.LastAccess.Format(time.RFC822))
|
||||
}
|
||||
}
|
||||
if usersWithTokens == 0 {
|
||||
fmt.Fprintf(c.App.ErrWriter, "no users with tokens\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
50
cmd/token_test.go
Normal file
50
cmd/token_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/server"
|
||||
"heckel.io/ntfy/test"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLI_Token_AddListRemove(t *testing.T) {
|
||||
s, conf, port := newTestServerWithAuth(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
|
||||
app, stdin, _, stderr := newTestApp()
|
||||
stdin.WriteString("mypass\nmypass")
|
||||
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
|
||||
require.Contains(t, stderr.String(), "user phil added with role user")
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "add", "phil"))
|
||||
require.Regexp(t, `token tk_.+ created for user phil, never expires`, stderr.String())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "list", "phil"))
|
||||
require.Regexp(t, `user phil\n- tk_.+, never expires, accessed from 0.0.0.0 at .+`, stderr.String())
|
||||
re := regexp.MustCompile(`tk_\w+`)
|
||||
token := re.FindString(stderr.String())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "remove", "phil", token))
|
||||
require.Regexp(t, fmt.Sprintf("token %s for user phil removed", token), stderr.String())
|
||||
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTokenCommand(app, conf, "list"))
|
||||
require.Equal(t, "no users with tokens\n", stderr.String())
|
||||
}
|
||||
|
||||
func runTokenCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
userArgs := []string{
|
||||
"ntfy",
|
||||
"--log-level=ERROR",
|
||||
"token",
|
||||
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
|
||||
"--auth-file=" + conf.AuthFile,
|
||||
}
|
||||
return app.Run(append(userArgs, args...))
|
||||
}
|
||||
36
cmd/user.go
36
cmd/user.go
@@ -16,8 +16,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
tierReset = "-"
|
||||
createdByCLI = "cli"
|
||||
tierReset = "-"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -25,7 +24,7 @@ func init() {
|
||||
}
|
||||
|
||||
var flagsUser = append(
|
||||
flagsDefault,
|
||||
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: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||
@@ -47,6 +46,7 @@ var cmdUser = &cli.Command{
|
||||
Action: execUserAdd,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"},
|
||||
&cli.BoolFlag{Name: "ignore-exists", Usage: "if the user already exists, perform no action and exit"},
|
||||
},
|
||||
Description: `Add a new user to the ntfy user database.
|
||||
|
||||
@@ -140,22 +140,22 @@ Example:
|
||||
Action: execUserList,
|
||||
Description: `Shows a list of all configured users, including the everyone ('*') user.
|
||||
|
||||
This is a server-only command. It directly reads from the user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
|
||||
This command is an alias to calling 'ntfy access' (display access control list).
|
||||
|
||||
This is a server-only command. It directly reads from user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined.
|
||||
`,
|
||||
},
|
||||
},
|
||||
Description: `Manage users of the ntfy server.
|
||||
|
||||
The command allows you to add/remove/change users in the ntfy user database, as well as change
|
||||
passwords or roles.
|
||||
|
||||
This is a server-only command. It directly manages the user.db as defined in the server config
|
||||
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
|
||||
to the related command 'ntfy access'.
|
||||
|
||||
The command allows you to add/remove/change users in the ntfy user database, as well as change
|
||||
passwords or roles.
|
||||
|
||||
Examples:
|
||||
ntfy user list # Shows list of users (alias: 'ntfy access')
|
||||
ntfy user add phil # Add regular user phil
|
||||
@@ -177,7 +177,7 @@ func execUserAdd(c *cli.Context) error {
|
||||
password := os.Getenv("NTFY_PASSWORD")
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy user add --help' for help")
|
||||
} else if username == userEveryone {
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
} else if !user.AllowedRole(role) {
|
||||
return errors.New("role must be either 'user' or 'admin'")
|
||||
@@ -187,6 +187,10 @@ func execUserAdd(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
if user, _ := manager.User(username); user != nil {
|
||||
if c.Bool("ignore-exists") {
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s already exists (exited successfully)\n", username)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("user %s already exists", username)
|
||||
}
|
||||
if password == "" {
|
||||
@@ -197,7 +201,7 @@ func execUserAdd(c *cli.Context) error {
|
||||
|
||||
password = p
|
||||
}
|
||||
if err := manager.AddUser(username, password, role, createdByCLI); err != nil {
|
||||
if err := manager.AddUser(username, password, role); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role)
|
||||
@@ -208,7 +212,7 @@ func execUserDel(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy user del --help' for help")
|
||||
} else if username == userEveryone {
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
@@ -230,7 +234,7 @@ func execUserChangePass(c *cli.Context) error {
|
||||
password := os.Getenv("NTFY_PASSWORD")
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy user change-pass --help' for help")
|
||||
} else if username == userEveryone {
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
@@ -258,7 +262,7 @@ func execUserChangeRole(c *cli.Context) error {
|
||||
role := user.Role(c.Args().Get(1))
|
||||
if username == "" || !user.AllowedRole(role) {
|
||||
return errors.New("username and new role expected, type 'ntfy user change-role --help' for help")
|
||||
} else if username == userEveryone {
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
@@ -282,7 +286,7 @@ func execUserChangeTier(c *cli.Context) error {
|
||||
return errors.New("username and new tier expected, type 'ntfy user change-tier --help' for help")
|
||||
} else if !user.AllowedTier(tier) && tier != tierReset {
|
||||
return errors.New("invalid tier, must be tier code, or - to reset")
|
||||
} else if username == userEveryone {
|
||||
} else if username == userEveryone || username == user.Everyone {
|
||||
return errors.New("username not allowed")
|
||||
}
|
||||
manager, err := createUserManager(c)
|
||||
@@ -331,7 +335,7 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
|
||||
if err != nil {
|
||||
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
||||
}
|
||||
return user.NewManager(authFile, authStartupQueries, authDefault)
|
||||
return user.NewManager(authFile, authStartupQueries, authDefault, user.DefaultUserPasswordBcryptCost, user.DefaultUserStatsQueueWriterInterval)
|
||||
}
|
||||
|
||||
func readPasswordAndConfirm(c *cli.Context) (string, error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"heckel.io/ntfy/server"
|
||||
"heckel.io/ntfy/test"
|
||||
"heckel.io/ntfy/user"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
@@ -113,7 +114,10 @@ func TestCLI_User_Delete(t *testing.T) {
|
||||
}
|
||||
|
||||
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
|
||||
configFile := filepath.Join(t.TempDir(), "server-dummy.yml")
|
||||
require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml
|
||||
conf = server.NewConfig()
|
||||
conf.File = configFile
|
||||
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||
conf.AuthDefault = user.PermissionDenyAll
|
||||
s, port = test.StartServerWithConfig(t, conf)
|
||||
@@ -123,7 +127,9 @@ func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config,
|
||||
func runUserCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||
userArgs := []string{
|
||||
"ntfy",
|
||||
"--log-level=ERROR",
|
||||
"user",
|
||||
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
|
||||
"--auth-file=" + conf.AuthFile,
|
||||
"--auth-default-access=" + conf.AuthDefault.String(),
|
||||
}
|
||||
|
||||
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 %}
|
||||
297
docs/config.md
297
docs/config.md
@@ -161,6 +161,7 @@ ntfy user add --role=admin phil # Add admin user phil
|
||||
ntfy user del phil # Delete user phil
|
||||
ntfy user change-pass phil # Change password for user phil
|
||||
ntfy user change-role phil admin # Make user phil an admin
|
||||
ntfy user change-tier phil pro # Change phil's tier to "pro"
|
||||
```
|
||||
|
||||
### Access control list (ACL)
|
||||
@@ -222,6 +223,39 @@ User `ben` has three topic-specific entries. He can read, but not write to topic
|
||||
to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated
|
||||
(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics.
|
||||
|
||||
### Access tokens
|
||||
In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful
|
||||
to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may
|
||||
want to use a dedicated token to publish from your backup host, and one from your home automation system.
|
||||
|
||||
!!! info
|
||||
As of today, access tokens grant users **full access to the user account**. Aside from changing the password,
|
||||
and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap,
|
||||
but not yet implemented.
|
||||
|
||||
The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire
|
||||
automatically (or never expire). Each user can have up to 20 tokens (hardcoded).
|
||||
|
||||
**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details):
|
||||
```
|
||||
ntfy token list # Shows list of tokens for all users
|
||||
ntfy token list phil # Shows list of tokens for user phil
|
||||
ntfy token add phil # Create token for user phil which never expires
|
||||
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
|
||||
ntfy token remove phil tk_th2sxr... # Delete token
|
||||
```
|
||||
|
||||
**Creating an access token:**
|
||||
```
|
||||
$ ntfy token add --expires=30d --label="backups" phil
|
||||
$ ntfy token list
|
||||
user phil
|
||||
- tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST
|
||||
```
|
||||
|
||||
Once an access token is created, you can **use it to authenticate against the ntfy server, e.g. when you publish or
|
||||
subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens).
|
||||
|
||||
### Example: Private instance
|
||||
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`:
|
||||
|
||||
@@ -504,7 +538,7 @@ or the root domain:
|
||||
proxy_send_timeout 3m;
|
||||
proxy_read_timeout 3m;
|
||||
|
||||
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml
|
||||
client_max_body_size 0; # Stream request body to backend
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,7 +574,7 @@ or the root domain:
|
||||
proxy_send_timeout 3m;
|
||||
proxy_read_timeout 3m;
|
||||
|
||||
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml
|
||||
client_max_body_size 0; # Stream request body to backend
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -571,7 +605,7 @@ or the root domain:
|
||||
proxy_send_timeout 3m;
|
||||
proxy_read_timeout 3m;
|
||||
|
||||
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml
|
||||
client_max_body_size 0; # Stream request body to backend
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,7 +637,7 @@ or the root domain:
|
||||
proxy_send_timeout 3m;
|
||||
proxy_read_timeout 3m;
|
||||
|
||||
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml
|
||||
client_max_body_size 0; # Stream request body to backend
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -754,6 +788,72 @@ Note that the self-hosted server literally sends the message `New message` for e
|
||||
may be `Some other message`. This is so that if iOS cannot talk to the self-hosted server (in time, or at all),
|
||||
it'll show `New message` as a popup.
|
||||
|
||||
## Tiers
|
||||
ntfy supports associating users to pre-defined tiers. Tiers can be used to grant users higher limits, such as
|
||||
daily message limits, attachment size, or make it possible for users to reserve topics. If [payments are enabled](#payments),
|
||||
tiers can be paid or unpaid, and users can upgrade/downgrade between them. If payments are disabled, then the only way
|
||||
to switch between tiers is with the `ntfy user change-tier` command (see [users and roles](#users-and-roles)).
|
||||
|
||||
By default, **newly created users have no tier**, and all usage limits are read from the `server.yml` config file.
|
||||
Once a user is associated with a tier, some limits are overridden based on the tier.
|
||||
|
||||
The `ntfy tier` command can be used to manage all available tiers. By default, there are no pre-defined tiers.
|
||||
|
||||
**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details):
|
||||
```
|
||||
ntfy tier add pro # Add tier with code "pro", using the defaults
|
||||
ntfy tier change --name="Pro" pro # Update the name of an existing tier
|
||||
ntfy tier del starter # Delete an existing tier
|
||||
ntfy user change-tier phil pro # Switch user "phil" to tier "pro"
|
||||
```
|
||||
|
||||
**Creating a tier (full example):**
|
||||
```
|
||||
ntfy tier add \
|
||||
--name="Pro" \
|
||||
--message-limit=10000 \
|
||||
--message-expiry-duration=24h \
|
||||
--email-limit=50 \
|
||||
--reservation-limit=10 \
|
||||
--attachment-file-size-limit=100M \
|
||||
--attachment-total-size-limit=1G \
|
||||
--attachment-expiry-duration=12h \
|
||||
--attachment-bandwidth-limit=5G \
|
||||
--stripe-price-id=price_123456 \
|
||||
pro
|
||||
```
|
||||
|
||||
## Payments
|
||||
ntfy supports paid [tiers](#tiers) via [Stripe](https://stripe.com/) as a payment provider. If payments are enabled,
|
||||
users can register, login and switch plans in the web app. The web app will behave slightly differently if payments
|
||||
are enabled (e.g. showing an upgrade banner, or "ntfy Pro" tags).
|
||||
|
||||
!!! info
|
||||
The ntfy payments integration is very tailored to ntfy.sh and Stripe. I do not intend to support arbitrary use
|
||||
cases.
|
||||
|
||||
To enable payments, sign up with [Stripe](https://stripe.com/), set the `stripe-secret-key` and `stripe-webhook-key`
|
||||
config options:
|
||||
|
||||
* `stripe-secret-key` is the key used for the Stripe API communication. Setting this values
|
||||
enables payments in the ntfy web app (e.g. Upgrade dialog). See [API keys](https://dashboard.stripe.com/apikeys).
|
||||
* `stripe-webhook-key` is the key required to validate the authenticity of incoming webhooks from Stripe.
|
||||
Webhooks are essential to keep the local database in sync with the payment provider. See [Webhooks](https://dashboard.stripe.com/webhooks).
|
||||
* `billing-contact` is an email address or website displayed in the "Upgrade tier" dialog to let people reach
|
||||
out with billing questions. If unset, nothing will be displayed.
|
||||
|
||||
In addition to setting these two options, you also need to define a [Stripe webhook](https://dashboard.stripe.com/webhooks)
|
||||
for the `customer.subscription.updated` and `customer.subscription.deleted` event, which points
|
||||
to `https://ntfy.example.com/v1/account/billing/webhook`.
|
||||
|
||||
Here's an example:
|
||||
|
||||
``` yaml
|
||||
stripe-secret-key: "sk_test_ZmhzZGtmbGhkc2tqZmhzYcO2a2hmbGtnaHNkbGtnaGRsc2hnbG"
|
||||
stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR"
|
||||
billing-contact: "phil@example.com"
|
||||
```
|
||||
|
||||
## Rate limiting
|
||||
!!! info
|
||||
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
||||
@@ -788,7 +888,15 @@ request every 5s (defined by `visitor-request-limit-replenish`)
|
||||
* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 5s.
|
||||
* `visitor-request-limit-exempt-hosts` is a comma-separated list of hostnames and IPs to be exempt from request rate
|
||||
limiting; hostnames are resolved at the time the server is started. Defaults to an empty list.
|
||||
|
||||
|
||||
### Message limits
|
||||
By default, the number of messages a visitor can send is governed entirely by the [request limit](#request-limits).
|
||||
For instance, if the request limit allows for 15,000 requests per day, and all of those requests are POST/PUT requests
|
||||
to publish messages, then that is the daily message limit.
|
||||
|
||||
To limit the number of daily messages per visitor, you can set `visitor-message-daily-limit`. This defines the number
|
||||
of messages a visitor can send in a day. This counter is reset every day at midnight (UTC).
|
||||
|
||||
### Attachment limits
|
||||
Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant
|
||||
per-visitor limits:
|
||||
@@ -962,18 +1070,67 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
|
||||
maxretry = 10
|
||||
```
|
||||
|
||||
## Debugging/tracing
|
||||
## 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.
|
||||
|
||||
## Logging & debugging
|
||||
By default, ntfy logs to the console (stderr), with an `info` log level, and in a human-readable text format.
|
||||
|
||||
ntfy supports five different log levels, can also write to a file, log as JSON, and even supports granular
|
||||
log level overrides for easier debugging. Some options (`log-level` and `log-level-overrides`) can be hot reloaded
|
||||
by calling `kill -HUP $pid` or `systemctl reload ntfy`.
|
||||
|
||||
The following config options define the logging behavior:
|
||||
|
||||
* `log-format` defines the output format, can be `text` (default) or `json`
|
||||
* `log-file` is a filename to write logs to. If this is not set, ntfy logs to stderr.
|
||||
* `log-level` defines the default log level, can be one of `trace`, `debug`, `info` (default), `warn` or `error`.
|
||||
Be aware that `debug` (and particularly `trace`) can be **very verbose**. Only turn them on briefly for debugging purposes.
|
||||
* `log-level-overrides` lets you override the log level if certain fields match. This is incredibly powerful
|
||||
for debugging certain parts of the system (e.g. only the account management, or only a certain visitor).
|
||||
This is an array of strings in the format:
|
||||
- `field=value -> level` to match a value exactly, e.g. `tag=manager -> trace`
|
||||
- `field -> level` to match any value, e.g. `time_taken_ms -> debug`
|
||||
|
||||
**Logging config (good for production use):**
|
||||
``` yaml
|
||||
log-level: info
|
||||
log-format: json
|
||||
log-file: /var/log/ntfy.log
|
||||
```
|
||||
|
||||
**Temporary debugging:**
|
||||
If something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level`
|
||||
to `DEBUG` or `TRACE`. The `DEBUG` setting will output information about each published message, but not the message
|
||||
contents. The `TRACE` setting will also print the message contents.
|
||||
to `debug` or `trace`. The `debug` setting will output information about each published message, but not the message
|
||||
contents. The `trace` setting will also print the message contents.
|
||||
|
||||
Alternatively, you can set `log-level-overrides` for only certain fields, such as a visitor's IP address (`visitor_ip`),
|
||||
a username (`user_name`), or a tag (`tag`). There are dozens of fields you can use to override log levels. To learn what
|
||||
they are, either turn the log-level to `trace` and observe, or reference the [source code](https://github.com/binwiederhier/ntfy).
|
||||
|
||||
Here's an example that will output only `info` log events, except when they match either of the defined overrides:
|
||||
``` yaml
|
||||
log-level: info
|
||||
log-level-overrides:
|
||||
- "tag=manager -> trace"
|
||||
- "visitor_ip=1.2.3.4 -> debug"
|
||||
- "time_taken_ms -> debug"
|
||||
```
|
||||
|
||||
!!! warning
|
||||
Both options are very verbose and should only be enabled in production for short periods of time. Otherwise,
|
||||
you're going to run out of disk space pretty quickly.
|
||||
The `debug` and `trace` log levels are very verbose, and using `log-level-overrides` has a
|
||||
performance penalty. Only use it for temporary debugging.
|
||||
|
||||
You can also hot-reload the `log-level` by sending the `SIGHUP` signal to the process after editing the `server.yml` file.
|
||||
You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd), or by calling `kill -HUP $(pidof ntfy)`.
|
||||
If successful, you'll see something like this:
|
||||
You can also hot-reload the `log-level` and `log-level-overrides` by sending the `SIGHUP` signal to the process after
|
||||
editing the `server.yml` file. You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd),
|
||||
or by calling `kill -HUP $(pidof ntfy)`. If successful, you'll see something like this:
|
||||
|
||||
```
|
||||
$ ntfy serve
|
||||
@@ -1029,16 +1186,18 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
|
||||
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
|
||||
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
|
||||
| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. |
|
||||
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
|
||||
| `visitor-request-limit-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) |
|
||||
| `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_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
|
||||
| `enable-login` | `NTFY_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
|
||||
| `enable-reservations` | `NTFY_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
|
||||
| `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.
|
||||
@@ -1057,58 +1216,72 @@ CATEGORY:
|
||||
|
||||
DESCRIPTION:
|
||||
Run the ntfy server and listen for incoming requests
|
||||
|
||||
|
||||
The command will load the configuration from /etc/ntfy/server.yml. Config options can
|
||||
be overridden using the command line options.
|
||||
|
||||
|
||||
Examples:
|
||||
ntfy serve # Starts server in the foreground (on port 80)
|
||||
ntfy serve --listen-http :8080 # Starts server with alternate port
|
||||
|
||||
OPTIONS:
|
||||
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
||||
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
||||
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
||||
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
|
||||
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
||||
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
||||
--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]
|
||||
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
||||
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
||||
--cache-batch-size value, --cache_batch_size value max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE]
|
||||
--cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT]
|
||||
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
|
||||
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
||||
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
||||
--debug, -d enable debug logging (default: false) [$NTFY_DEBUG]
|
||||
--firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
||||
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
||||
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
||||
--key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
|
||||
--listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
||||
--listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
||||
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
|
||||
--log-level value, --log_level value set log level (default: "INFO") [$NTFY_LOG_LEVEL]
|
||||
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
||||
--no-log-dates, --no_log_dates disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES]
|
||||
--smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
|
||||
--smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
|
||||
--smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
|
||||
--smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
|
||||
--smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
|
||||
--smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
|
||||
--smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
|
||||
--trace enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE]
|
||||
--upstream-base-url value, --upstream_base_url value forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL]
|
||||
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
||||
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
||||
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
||||
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
||||
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
|
||||
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
||||
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
||||
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
|
||||
--debug, -d enable debug logging (default: false) [$NTFY_DEBUG]
|
||||
--trace enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE]
|
||||
--no-log-dates, --no_log_dates disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES]
|
||||
--log-level value, --log_level value set log level (default: "INFO") [$NTFY_LOG_LEVEL]
|
||||
--log-level-overrides value, --log_level_overrides value [ --log-level-overrides value, --log_level_overrides value ] set log level overrides [$NTFY_LOG_LEVEL_OVERRIDES]
|
||||
--log-format value, --log_format value set log format (default: "text") [$NTFY_LOG_FORMAT]
|
||||
--log-file value, --log_file value set log file, default is STDOUT [$NTFY_LOG_FILE]
|
||||
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
||||
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
||||
--listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
||||
--listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
||||
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
|
||||
--listen-unix-mode value, --listen_unix_mode value file permissions of unix socket, e.g. 0700 (default: system default) [$NTFY_LISTEN_UNIX_MODE]
|
||||
--key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
|
||||
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
||||
--firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
||||
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
||||
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
||||
--cache-batch-size value, --cache_batch_size value max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE]
|
||||
--cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT]
|
||||
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
|
||||
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
||||
--auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
|
||||
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
|
||||
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
||||
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
||||
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
||||
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
||||
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
||||
--disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ] topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS]
|
||||
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
|
||||
--enable-signup, --enable_signup allows users to sign up via the web app, or API (default: false) [$NTFY_ENABLE_SIGNUP]
|
||||
--enable-login, --enable_login allows users to log in via the web app, or API (default: false) [$NTFY_ENABLE_LOGIN]
|
||||
--enable-reservations, --enable_reservations allows users to reserve topics (if their tier allows it) (default: false) [$NTFY_ENABLE_RESERVATIONS]
|
||||
--upstream-base-url value, --upstream_base_url value forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL]
|
||||
--smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
|
||||
--smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
|
||||
--smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
|
||||
--smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
|
||||
--smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
|
||||
--smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
|
||||
--smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
|
||||
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
||||
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
||||
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
||||
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
||||
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
||||
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
|
||||
--visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT]
|
||||
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
||||
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
||||
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
||||
--stripe-secret-key value, --stripe_secret_key value key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY]
|
||||
--stripe-webhook-key value, --stripe_webhook_key value key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY]
|
||||
--billing-contact value, --billing_contact value e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT]
|
||||
--help, -h show help (default: false)
|
||||
```
|
||||
|
||||
|
||||
@@ -92,7 +92,6 @@ sudo apt install \
|
||||
gcc-arm-linux-gnueabi \
|
||||
gcc-aarch64-linux-gnu \
|
||||
python3-pip \
|
||||
upx \
|
||||
git
|
||||
```
|
||||
|
||||
@@ -328,7 +327,76 @@ To build your own version with Firebase, you must:
|
||||
```
|
||||
|
||||
## iOS app
|
||||
The ntfy iOS app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-ios).
|
||||
Building the iOS app is very involved. Please report any inconsistencies or issues with it. The requirements are
|
||||
strictly based off of my development on this app. There may be other versions of macOS / XCode that work.
|
||||
|
||||
### Requirements
|
||||
1. macOS Monterey or later
|
||||
1. XCode 13.2+
|
||||
1. A physical iOS device (for push notifications, Firebase does not work in the XCode simulator)
|
||||
1. Firebase account
|
||||
1. Apple Developer license? (I forget if it's possible to do testing without purchasing the license)
|
||||
|
||||
### Apple setup
|
||||
|
||||
!!! info
|
||||
I haven't had time to move the build instructions here. Please check out the repository instead.
|
||||
Along with this step, the [PLIST Deployment](#plist-deployment-and-configuration) step is also required
|
||||
for these changes to take effect in the iOS app.
|
||||
|
||||
1. [Create a new key in Apple Developer Member Center](https://developer.apple.com/account/resources/authkeys/add)
|
||||
1. Select "Apple Push Notifications service (APNs)"
|
||||
1. Download the newly created key (should have a file name similar to `AuthKey_ZZZZZZ.p8`, where `ZZZZZZ` is the **Key ID**)
|
||||
1. Record your **Team ID** - it can be seen in the top-right corner of the page, or on your Account > Membership page
|
||||
1. Next, navigate to "Project Settings" in the firebase console for your project, and select the iOS app you created. Then, click "Cloud Messaging" in the left sidebar, and scroll down to the "APNs Authentication Key" section. Click "Upload Key", and upload the key you downloaded from Apple Developer.
|
||||
|
||||
!!! warning
|
||||
If you don't do the above setups for APNS, **notifications will not post instantly or sometimes at all**. This is because of the missing APNS key, which is required for firebase to send notifications to the iOS app. See below for a snip from the firebase docs.
|
||||
|
||||
If you don't have an APNs authentication key, you can still send notifications to iOS devices, but they won't be delivered
|
||||
instantly. Instead, they'll be delivered when the device wakes up to check for new notifications or when your application
|
||||
sends a firebase request to check for them. The time to check for new notifications can vary from a few seconds to hours,
|
||||
days or even weeks. Enabling APNs authentication keys ensures that notifications are delivered instantly and is strongly
|
||||
recommended.
|
||||
|
||||
### Firebase setup
|
||||
|
||||
1. If you haven't already, create a Google / Firebase account
|
||||
1. Visit the [Firebase console](https://console.firebase.google.com)
|
||||
1. Create a new Firebase project:
|
||||
1. Enter a project name
|
||||
1. Disable Google Analytics (currently iOS app does not support analytics)
|
||||
1. On the "Project settings" page, add an iOS app
|
||||
1. Apple bundle ID - "com.copephobia.ntfy-ios" (this can be changed to match XCode's ntfy.sh target > "Bundle Identifier" value)
|
||||
1. Register the app
|
||||
1. Download the config file - GoogleInfo.plist (this will need to be included in the ntfy-ios repository / XCode)
|
||||
1. Generate a new service account private key for the ntfy server
|
||||
1. Go to "Project settings" > "Service accounts"
|
||||
1. Click "Generate new private key" to generate and download a private key to use for sending messages via the ntfy server
|
||||
|
||||
### ntfy server
|
||||
Note that the ntfy server is not officially supported on macOS. It should, however, be able to run on macOS using these
|
||||
steps:
|
||||
|
||||
1. If not already made, make the `/etc/ntfy/` directory and move the service account private key to that folder
|
||||
1. Copy the `server/server.yml` file from the ntfy repository to `/etc/ntfy/`
|
||||
1. Modify the `/etc/ntfy/server.yml` file `firebase-key-file` value to the path of the private key
|
||||
1. Install go: `brew install go`
|
||||
1. In the ntfy repository, run `make cli-darwin-server`.
|
||||
|
||||
### XCode setup
|
||||
|
||||
1. Follow step 4 of [https://firebase.google.com/docs/ios/setup](Add Firebase to your Apple project) to install the
|
||||
`firebase-ios-sdk` in XCode, if it's not already present - you can select any packages in addition to Firebase Core / Firebase Messaging
|
||||
1. Similarly, install the SQLite.swift package dependency in XCode
|
||||
1. When running the debug build, ensure XCode is pointed to the connected iOS device - registering for push notifications does not work in the iOS simulators
|
||||
|
||||
### PLIST config
|
||||
To have instant notifications/better notification delivery when using firebase, you will need to add the
|
||||
`GoogleService-Info.plist` file to your project. Here's how to do that:
|
||||
|
||||
1. In XCode, find the NTFY app target. **Not** the NSE app target.
|
||||
1. Find the Asset/ folder in the project navigator
|
||||
1. Drag the `GoogleService-Info.plist` file into the Asset/ folder that you get from the firebase console. It can be
|
||||
found in the "Project settings" > "General" > "Your apps" with a button labled "GoogleService-Info.plist"
|
||||
|
||||
After that, you should be all set!
|
||||
|
||||
@@ -413,7 +413,8 @@ alerting:
|
||||
|
||||
## Jellyseerr/Overseerr webhook
|
||||
Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook
|
||||
JSON payload. Remember to change the `https://requests.example.com` to your jellyseerr/overseerr URL.
|
||||
JSON payload. Remember to change the `https://request.example.com` to your URL as the value of the JSON key click.
|
||||
And if you're not using the request `topic`, make sure to change it in the JSON payload to your topic.
|
||||
|
||||
``` json
|
||||
{
|
||||
@@ -571,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>
|
||||
```
|
||||
|
||||
@@ -47,6 +47,11 @@ or you use *instant delivery* (Android only), the app has to maintain a constant
|
||||
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
|
||||
can (and should do that). The paid plans I am offering are for people that do not want to self-host, and/or need higher
|
||||
limits.
|
||||
|
||||
## What is instant delivery?
|
||||
[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the
|
||||
server and listens for incoming notifications. This consumes additional battery (see above),
|
||||
|
||||
@@ -26,37 +26,37 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_1.30.1_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_1.30.1_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_2.1.1_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_2.1.1_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_1.30.1_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_1.30.1_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.1.1_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.1.1_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_1.30.1_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_1.30.1_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.1.1_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.1.1_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.1_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_1.30.1_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_1.30.1_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.1.1_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.1.1_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.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/v1.30.1/ntfy_1.30.1_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.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/v1.30.1/ntfy_1.30.1_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.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/v1.30.1/ntfy_1.30.1_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.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/v1.30.1/ntfy_1.30.1_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.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/v1.30.1/ntfy_1.30.1_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.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/v1.30.1/ntfy_1.30.1_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -189,18 +189,18 @@ 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/v1.30.1/ntfy_1.30.1_macOS_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.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/v1.30.1/ntfy_1.30.1_macOS_all.tar.gz > ntfy_1.30.1_macOS_all.tar.gz
|
||||
tar zxvf ntfy_1.30.1_macOS_all.tar.gz
|
||||
sudo cp -a ntfy_1.30.1_macOS_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.1_macOS_all.tar.gz > ntfy_2.1.1_macOS_all.tar.gz
|
||||
tar zxvf ntfy_2.1.1_macOS_all.tar.gz
|
||||
sudo cp -a ntfy_2.1.1_macOS_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_1.30.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.1.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -212,7 +212,7 @@ ntfy --help
|
||||
|
||||
## 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/v1.30.1/ntfy_1.30.1_windows_x86_64.zip),
|
||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.1.1/ntfy_2.1.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 +266,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,10 +284,16 @@ 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
|
||||
```
|
||||
|
||||
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files to the same uid/gid.
|
||||
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files and attachments directory to the same uid/gid.
|
||||
|
||||
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
|
||||
```
|
||||
|
||||
@@ -32,8 +32,11 @@ and uptime of third party servers, so use of each server is **at your own discre
|
||||
- [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard
|
||||
- [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.
|
||||
- [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
|
||||
|
||||
@@ -71,6 +74,7 @@ and uptime of third party servers, so use of each server is **at your own discre
|
||||
## Projects + scripts
|
||||
|
||||
- [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)
|
||||
- [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)
|
||||
@@ -107,9 +111,19 @@ and uptime of third party servers, so use of each server is **at your own discre
|
||||
- [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java)
|
||||
- [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python)
|
||||
- [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)
|
||||
|
||||
## Blog + forum posts
|
||||
|
||||
- [enviar notificaciones automáticas usando ntfy.sh](https://osiux.com/2023-02-15-send-automatic-notifications-using-ntfy.html) - osiux.com - 2/2023
|
||||
- [Carnet IP动态解析以及通过ntfy推送IP信息](https://blog.wslll.cn/index.php/archives/201/) - blog.wslll.cn - 2/2023
|
||||
- [Open-Source-Brieftaube: ntfy verschickt Push-Meldungen auf Smartphone und PC](https://www.heise.de/news/Open-Source-Brieftaube-ntfy-verschickt-Push-Meldungen-auf-Smartphone-und-PC-7521583.html) ⭐ - heise.de - 2/2023
|
||||
- [Video: Simple Push Notifications ntfy](https://www.youtube.com/watch?v=u9EcWrsjE20) ⭐ - youtube.com - 2/2023
|
||||
- [Use ntfy.sh with Home Assistant](https://diecknet.de/en/2023/02/12/ntfy-sh-with-homeassistant/) - diecknet.de - 2/2023
|
||||
- [On installe Ntfy sur Synology Docker](https://www.maison-et-domotique.com/140356-serveur-notification-jeedom-ntfy-synology-docker/) - maison-et-domotique.co - 1/2023
|
||||
- [January 2023 Developer Update](https://community.nodebb.org/topic/16908/january-2023-developer-update) - nodebb.org - 1/2023
|
||||
- [Comment envoyer des notifications push sur votre téléphone facilement et gratuitement?](https://korben.info/notifications-push-telephone.html) - 1/2023
|
||||
- [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022
|
||||
- [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022
|
||||
@@ -127,6 +141,7 @@ and uptime of third party servers, so use of each server is **at your own discre
|
||||
- [Ntfy.sh – Send push notifications to your phone via PUT/POST](https://news.ycombinator.com/item?id=33517944) ⭐ - news.ycombinator.com - 11/2022
|
||||
- [Ntfy et Jeedom : un plugin](https://lunarok-domotique.com/2022/11/ntfy-et-jeedom/) - lunarok-domotique.com - 11/2022
|
||||
- [Crea tu propio servidor de notificaciones con Ntfy](https://blog.parravidales.es/crea-tu-propio-servidor-de-notificaciones-con-ntfy/) - blog.parravidales.es - 11/2022
|
||||
- [unRAID Notifications with ntfy.sh](https://lder.dev/posts/ntfy-Notifications-With-unRAID/) - lder.dev - 10/2022
|
||||
- [Zero-cost push notifications to your phone or desktop via PUT/POST ](https://lobste.rs/s/41dq13/zero_cost_push_notifications_your_phone) - lobste.rs - 10/2022
|
||||
- [A nifty push notification system: ntfy](https://jpmens.net/2022/10/30/a-nifty-push-notification-system-ntfy/) - jpmens.net - 10/2022
|
||||
- [Alarmanlage der dritten Art (YouTube video)](https://www.youtube.com/watch?v=altb5QLHbaU&feature=youtu.be) - youtube.com - 10/2022
|
||||
|
||||
224
docs/publish.md
224
docs/publish.md
@@ -1292,7 +1292,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
|
||||
@@ -2582,6 +2582,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)):
|
||||
@@ -2591,23 +2596,22 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
|
||||
<figcaption>Publishing a message via e-mail</figcaption>
|
||||
</figure>
|
||||
|
||||
## Advanced features
|
||||
|
||||
### Authentication
|
||||
## Authentication
|
||||
Depending on whether the server is configured to support [access control](config.md#access-control), some topics
|
||||
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
|
||||
To publish/subscribe to protected topics, you can:
|
||||
|
||||
* Use [basic auth](#basic-auth), e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`
|
||||
* or use the [`auth` query parameter](#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw`
|
||||
* Use [username & password](#username-password) via Basic auth, e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`
|
||||
* Use [access tokens](#bearer-auth) via Bearer/Basic auth, e.g. `Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2`
|
||||
* or use either with the [`auth` query parameter](#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw`
|
||||
|
||||
!!! warning
|
||||
Base64 only encodes username and password. It **is not encrypting it**. For your self-hosted server,
|
||||
**be sure to use HTTPS to avoid eavesdropping** and exposing your password.
|
||||
When using Basic auth, base64 only encodes username and password. It **is not encrypting it**. For your
|
||||
self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing your password.
|
||||
|
||||
#### Basic auth
|
||||
Here's an example using [Basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication), with a user `testuser`
|
||||
and password `fakepassword`:
|
||||
### Username & password
|
||||
The simplest way to authenticate against a ntfy server is to use [Basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication).
|
||||
Here's an example with a user `testuser` and password `fakepassword`:
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
@@ -2701,7 +2705,172 @@ The following command will generate the appropriate value for you on *nix system
|
||||
echo "Basic $(echo -n 'testuser:fakepassword' | base64)"
|
||||
```
|
||||
|
||||
#### Query param
|
||||
### Access tokens
|
||||
In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful
|
||||
to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may
|
||||
want to use a dedicated token to publish from your backup host, and one from your home automation system.
|
||||
|
||||
You can create access tokens using the `ntfy token` command, or in the web app in the "Account" section (when logged in).
|
||||
See [access tokens](config.md#access-tokens) for details.
|
||||
|
||||
Once an access token is created, you can use it to authenticate against the ntfy server, e.g. when you publish or
|
||||
subscribe to topics. Here's an example using [Bearer auth](https://swagger.io/docs/specification/authentication/bearer-authentication/),
|
||||
with the token `tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2`:
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
curl \
|
||||
-H "Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" \
|
||||
-d "Look ma, with auth" \
|
||||
https://ntfy.example.com/mysecrets
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
```
|
||||
ntfy publish \
|
||||
--token tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
|
||||
ntfy.example.com/mysecrets \
|
||||
"Look ma, with auth"
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
POST /mysecrets HTTP/1.1
|
||||
Host: ntfy.example.com
|
||||
Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
|
||||
Look ma, with auth
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
fetch('https://ntfy.example.com/mysecrets', {
|
||||
method: 'POST', // PUT works too
|
||||
body: 'Look ma, with auth',
|
||||
headers: {
|
||||
'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
``` go
|
||||
req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets",
|
||||
strings.NewReader("Look ma, with auth"))
|
||||
req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2")
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
|
||||
=== "PowerShell"
|
||||
``` 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
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
``` python
|
||||
requests.post("https://ntfy.example.com/mysecrets",
|
||||
data="Look ma, with auth",
|
||||
headers={
|
||||
"Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2"
|
||||
})
|
||||
```
|
||||
|
||||
=== "PHP"
|
||||
``` php-inline
|
||||
file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST', // PUT also works
|
||||
'header' =>
|
||||
'Content-Type: text/plain\r\n' .
|
||||
'Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2',
|
||||
'content' => 'Look ma, with auth'
|
||||
]
|
||||
]));
|
||||
```
|
||||
|
||||
Alternatively, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) to send the
|
||||
access token. When sending an empty username, the basic auth password is treated by the ntfy server as an
|
||||
access token. This is primarily useful to make `curl` calls easier, e.g. `curl -u:tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 ...`:
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
curl \
|
||||
-u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
|
||||
-d "Look ma, with auth" \
|
||||
https://ntfy.example.com/mysecrets
|
||||
```
|
||||
|
||||
=== "ntfy CLI"
|
||||
```
|
||||
ntfy publish \
|
||||
--token tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \
|
||||
ntfy.example.com/mysecrets \
|
||||
"Look ma, with auth"
|
||||
```
|
||||
|
||||
=== "HTTP"
|
||||
``` http
|
||||
POST /mysecrets HTTP/1.1
|
||||
Host: ntfy.example.com
|
||||
Authorization: Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy
|
||||
|
||||
Look ma, with auth
|
||||
```
|
||||
|
||||
=== "JavaScript"
|
||||
``` javascript
|
||||
fetch('https://ntfy.example.com/mysecrets', {
|
||||
method: 'POST', // PUT works too
|
||||
body: 'Look ma, with auth',
|
||||
headers: {
|
||||
'Authorization': 'Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
=== "Go"
|
||||
``` go
|
||||
req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets",
|
||||
strings.NewReader("Look ma, with auth"))
|
||||
req.Header.Set("Authorization", "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy")
|
||||
http.DefaultClient.Do(req)
|
||||
```
|
||||
|
||||
=== "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
|
||||
```
|
||||
|
||||
=== "Python"
|
||||
``` python
|
||||
requests.post("https://ntfy.example.com/mysecrets",
|
||||
data="Look ma, with auth",
|
||||
headers={
|
||||
"Authorization": "Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy"
|
||||
})
|
||||
```
|
||||
|
||||
=== "PHP"
|
||||
``` php-inline
|
||||
file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST', // PUT also works
|
||||
'header' =>
|
||||
'Content-Type: text/plain\r\n' .
|
||||
'Authorization: Basic OnRrX0FnUWRxN21WQm9GRDM3elFWTjI5Umh1TXpOSXoy',
|
||||
'content' => 'Look ma, with auth'
|
||||
]
|
||||
]));
|
||||
```
|
||||
|
||||
|
||||
### Query param
|
||||
Here's an example using the `auth` query parameter:
|
||||
|
||||
=== "Command line (curl)"
|
||||
@@ -2766,7 +2935,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:
|
||||
|
||||
@@ -2786,6 +2955,8 @@ The following command will generate the appropriate value for you on *nix system
|
||||
echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '='
|
||||
```
|
||||
|
||||
## Advanced features
|
||||
|
||||
### Message caching
|
||||
!!! info
|
||||
If `Cache: no` is used, messages will only be delivered to connected subscribers, and won't be re-delivered if a
|
||||
@@ -2984,25 +3155,26 @@ that you can use to try out what [authentication and access control](#authentica
|
||||
|------------------------------------------------|-----------------------------------|------------------------------------------------------|--------------------------------------|
|
||||
| [announcements](https://ntfy.sh/announcements) | `*` (unauthenticated) | Read-only for everyone | Release announcements and such |
|
||||
| [stats](https://ntfy.sh/stats) | `*` (unauthenticated) | Read-only for everyone | Daily statistics about ntfy.sh usage |
|
||||
| [mytopic-rw](https://ntfy.sh/mytopic-rw) | `testuser` (password: `testuser`) | Read-write for `testuser`, no access for anyone else | Test topic |
|
||||
| [mytopic-ro](https://ntfy.sh/mytopic-ro) | `testuser` (password: `testuser`) | Read-only for `testuser`, no access for anyone else | Test topic |
|
||||
| [mytopic-wo](https://ntfy.sh/mytopic-wo) | `testuser` (password: `testuser`) | Write-only for `testuser`, no access for anyone else | Test topic |
|
||||
|
||||
## Limitations
|
||||
There are a few limitations to the API to prevent abuse and to keep the server healthy. Almost all of these settings
|
||||
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**,
|
||||
|
||||
153
docs/releases.md
153
docs/releases.md
@@ -2,7 +2,151 @@
|
||||
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 v1.31.0 (UNRELEASED)
|
||||
## ntfy server v2.1.1
|
||||
Released March 1, 2023
|
||||
|
||||
This is a tiny release with a few bug fixes, but it's big for me personally. After almost three months of work,
|
||||
**today I am finally launching the paid plans on ntfy.sh** 🥳 🎉.
|
||||
|
||||
You are now able to purchase one of three plans that'll give you **higher rate limits** (messages, emails, attachment sizes, ...),
|
||||
as well as the ability to **reserve topic names** for your personal use, while at the same time supporting me and the
|
||||
ntfy open source project ❤️. You can check out the pricing, and [purchase plans through the web app](https://ntfy.sh/app) (use
|
||||
promo code `MYTOPIC` for a **50% discount**, limited time only).
|
||||
|
||||
And as I've said many times: Do not worry. **ntfy will always stay open source**, and that includes all features. There
|
||||
are no closed-source features. So if you'd like to run your own server, you can!
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix panic when using Firebase without users ([#641](https://github.com/binwiederhier/ntfy/issues/641), thanks to [u/heavybell](https://www.reddit.com/user/heavybell/) for reporting)
|
||||
* Remove health check from `Dockerfile` and [document it](config.md#health-checks) ([#635](https://github.com/binwiederhier/ntfy/issues/635), thanks to [@Andersbiha](https://github.com/Andersbiha))
|
||||
* Upgrade dialog: Disable submit button for free tier (no ticket)
|
||||
* Allow multiple `log-level-overrides` on the same field (no ticket)
|
||||
* Actually remove `ntfy publish --env-topic` flag (as per [deprecations](deprecations.md), no ticket)
|
||||
* Added `billing-contact` config option (no ticket)
|
||||
|
||||
## ntfy server v2.1.0
|
||||
Released February 25, 2023
|
||||
|
||||
This release changes the way UnifiedPush (UP) topics are rate limited from publisher-based rate limiting to subscriber-based
|
||||
rate limiting. This allows UP application servers to send higher volumes, since the subscribers carry the rate limits.
|
||||
However, it also means that UP clients have to subscribe to a topic first before they are allowed to publish. If they do
|
||||
no, clients will receive an HTTP 507 response from the server.
|
||||
|
||||
We also fixed another issue with UnifiedPush: Some Mastodon servers were sending unsupported `Authorization` headers,
|
||||
which ntfy rejected with an HTTP 401. We now ignore unsupported header values.
|
||||
|
||||
As of this release, ntfy also supports sending emails to protected topics, and it ships code to support annual billing
|
||||
cycles (not live yet).
|
||||
|
||||
As part of this release, I also enabled sign-up and login (free accounts only), and I also started reducing the rate
|
||||
limits for anonymous & free users a bit. With the next release and the launch of the paid plan, I'll reduce the limits
|
||||
a bit more. For 90% of users, you should not feel the difference.
|
||||
|
||||
**Features:**
|
||||
|
||||
* UnifiedPush: Subscriber-based rate limiting for `up*` topics ([#584](https://github.com/binwiederhier/ntfy/pull/584)/[#609](https://github.com/binwiederhier/ntfy/pull/609)/[#633](https://github.com/binwiederhier/ntfy/pull/633), thanks to [@karmanyaahm](https://github.com/karmanyaahm))
|
||||
* Support for publishing to protected topics via email with access tokens ([#612](https://github.com/binwiederhier/ntfy/pull/621), thanks to [@tamcore](https://github.com/tamcore))
|
||||
* Support for base64-encoded and nested multipart emails ([#610](https://github.com/binwiederhier/ntfy/issues/610), thanks to [@Robert-litts](https://github.com/Robert-litts))
|
||||
* Payments: Add support for annual billing intervals (no ticket)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Web: Do not disable "Reserve topic" checkbox for admins (no ticket, thanks to @xenrox for reporting)
|
||||
* UnifiedPush: Treat non-Basic/Bearer `Authorization` header like header was not sent ([#629](https://github.com/binwiederhier/ntfy/issues/629), thanks to [@Boebbele](https://github.com/Boebbele) and [@S1m](https://github.com/S1m) for reporting)
|
||||
|
||||
**Documentation:**
|
||||
|
||||
* Added example for [Traccar](https://ntfy.sh/docs/examples/#traccar) ([#631](https://github.com/binwiederhier/ntfy/pull/631), thanks to [tamcore](https://github.com/tamcore))
|
||||
|
||||
**Additional languages:**
|
||||
|
||||
* Arabic (thanks to [@ButterflyOfFire](https://hosted.weblate.org/user/ButterflyOfFire/))
|
||||
|
||||
## ntfy server v2.0.1
|
||||
Released February 17, 2023
|
||||
|
||||
This is a quick bugfix release to address a panic that happens when `attachment-cache-dir` is not set.
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Avoid panic in manager when `attachment-cache-dir` is not set ([#617](https://github.com/binwiederhier/ntfy/issues/617), thanks to [@ksurl](https://github.com/ksurl))
|
||||
* Ensure that calls to standard logger `log.Println` also output JSON (no ticket)
|
||||
|
||||
## ntfy server v2.0.0
|
||||
Released February 16, 2023
|
||||
|
||||
This is the biggest ntfy server release I've ever done 🥳 . Lots of new and exciting features.
|
||||
|
||||
**Brand-new features:**
|
||||
|
||||
* **User signup/login & account sync**: If enabled, users can now register to create a user account, and then login to
|
||||
the web app. Once logged in, topic subscriptions and user settings are stored server-side in the user account (as
|
||||
opposed to only in the browser storage). So far, this is implemented only in the web app only. Once it's in the Android/iOS
|
||||
app, you can easily keep your account in sync. Relevant [config options](config.md#config-options) are `enable-signup` and
|
||||
`enable-login`.
|
||||
<div id="account-screenshots" class="screenshots">
|
||||
<a href="../../static/img/web-signup.png"><img src="../../static/img/web-signup.png"/></a>
|
||||
<a href="../../static/img/web-account.png"><img src="../../static/img/web-account.png"/></a>
|
||||
</div>
|
||||
* **Topic reservations** 🎉: If enabled, users can now **reserve topics and restrict access to other users**.
|
||||
Once this is fully rolled out, you may reserve `ntfy.sh/philbackups` and define access so that only you can publish/subscribe
|
||||
to the topic. Reservations let you claim ownership of a topic, and you can define access permissions for others as
|
||||
`deny-all` (only you have full access), `read-only` (you can publish/subscribe, others can subscribe), `write-only` (you
|
||||
can publish/subscribe, others can publish), `read-write` (everyone can publish/subscribe, but you remain the owner).
|
||||
Topic reservations can be [configured](config.md#config-options) in the web app if `enable-reservations` is enabled, and
|
||||
only if the user has a [tier](config.md#tiers) that supports reservations.
|
||||
<div id="reserve-screenshots" class="screenshots">
|
||||
<a href="../../static/img/web-reserve-topic.png"><img src="../../static/img/web-reserve-topic.png"/></a>
|
||||
<a href="../../static/img/web-reserve-topic-dialog.png"><img src="../../static/img/web-reserve-topic-dialog.png"/></a>
|
||||
</div>
|
||||
* **Access tokens:** It is now possible to create user access tokens for a user account. Access tokens are useful
|
||||
to avoid having to paste your password to various applications or scripts. For instance, you may want to use a
|
||||
dedicated token to publish from your backup host, and one from your home automation system. Tokens can be configured
|
||||
in the web app, or via the `ntfy token` command. See [creating tokens](config.md#access-tokens),
|
||||
and [publishing using tokens](publish.md#access-tokens).
|
||||
<div id="token-screenshots" class="screenshots">
|
||||
<a href="../../static/img/web-token-create.png"><img src="../../static/img/web-token-create.png"/></a>
|
||||
<a href="../../static/img/web-token-list.png"><img src="../../static/img/web-token-list.png"/></a>
|
||||
</div>
|
||||
* **Structured logging:** I've redone a lot of the logging to make it more structured, and to make it easier to debug and
|
||||
troubleshoot. Logs can now be written to a file, and as JSON (if configured). Each log event carries context fields
|
||||
that you can filter and search on using tools like `jq`. On top of that, you can override the log level if certain fields
|
||||
match. For instance, you can say `user_name=phil -> debug` to log everything related to a certain user with debug level.
|
||||
See [logging & debugging](config.md#logging-debugging).
|
||||
* **Tiers:** You can now define and associate usage tiers to users. Tiers can be used to grant users higher limits, such as
|
||||
daily message limits, attachment size, or make it possible for users to reserve topics. You could, for instance, have
|
||||
a tier `Standard` that allows 500 messages/day, 15 MB attachments and 5 allowed topic reservations, and another
|
||||
tier `Friends & Family` with much higher limits. For ntfy.sh, I'll mostly use these tiers to facilitate paid plans (see below).
|
||||
Tiers can be configured via the `ntfy tier ...` command. See [tiers](config.md#tiers).
|
||||
* **Paid tiers:** Starting very soon, I will be offering paid tiers for ntfy.sh on top of the free service. You'll be
|
||||
able to subscribe to tiers with higher rate limits (more daily messages, bigger attachments) and topic reservations.
|
||||
Paid tiers are facilitated by integrating [Stripe](https://stripe.com) as a payment provider. See [payments](config.md#payments)
|
||||
for details.
|
||||
|
||||
**ntfy is forever open source!**
|
||||
Yes, I will be offering some paid plans. But you don't need to panic! I won't be taking any features away, and everything
|
||||
will remain forever open source, so you can self-host if you like. Similar to the donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)
|
||||
and [Liberapay](https://en.liberapay.com/ntfy/), paid plans will help pay for the service and keep me motivated to keep
|
||||
going. It'll only make ntfy better.
|
||||
|
||||
**Other tickets:**
|
||||
|
||||
* User account signup, login, topic reservations, access tokens, tiers etc. ([#522](https://github.com/binwiederhier/ntfy/issues/522))
|
||||
* `OPTIONS` method calls are not serviced when the UI is disabled ([#598](https://github.com/binwiederhier/ntfy/issues/598), thanks to [@enticedwanderer](https://github.com/enticedwanderer) for reporting)
|
||||
|
||||
**Special thanks:**
|
||||
|
||||
A big Thank-you goes to everyone who tested the user account and payments work. I very much appreciate all the feedback,
|
||||
suggestions, and bug reports. Thank you, @nwithan8, @deadcade, @xenrox, @cmeis, @wunter8 and the others who I forgot.
|
||||
|
||||
## ntfy server v1.31.0
|
||||
Released February 14, 2023
|
||||
|
||||
This is a tiny release before the really big release, and also the last before the big v2.0.0. The most interesting
|
||||
things in this release are the new preliminary health endpoint to allow monitoring in K8s (and others), and the removal
|
||||
of `upx` binary packing (which was causing erroneous virus flagging). Aside from that, the `go-smtp` library did a
|
||||
breaking-change upgrade, which required some work to get working again.
|
||||
|
||||
**Features:**
|
||||
|
||||
@@ -13,12 +157,19 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
* Fix `chown` issues with RHEL-like based systems ([#566](https://github.com/binwiederhier/ntfy/issues/566)/[#565](https://github.com/binwiederhier/ntfy/pull/565), thanks to [@danieldemus](https://github.com/danieldemus))
|
||||
* Removed `upx` (binary packing) for all builds due to false virus warnings ([#576](https://github.com/binwiederhier/ntfy/issues/576), thanks to [@shawnhwei](https://github.com/shawnhwei) for reporting)
|
||||
* Upgraded `go-smtp` library and tests to v0.16.0 ([#569](https://github.com/binwiederhier/ntfy/issues/569))
|
||||
|
||||
**Documentation:**
|
||||
|
||||
* Add HTTP/2 and TLSv1.3 support to nginx docs ([#553](https://github.com/binwiederhier/ntfy/issues/553), thanks to [@bt90](https://github.com/bt90))
|
||||
* Small wording change for `client.yml` ([#562](https://github.com/binwiederhier/ntfy/pull/562), thanks to [@fleopaulD](https://github.com/fleopaulD))
|
||||
* Fix K8s install docs ([#582](https://github.com/binwiederhier/ntfy/pull/582), thanks to [@Remedan](https://github.com/Remedan))
|
||||
* Updated Jellyseer docs ([#604](https://github.com/binwiederhier/ntfy/pull/604), thanks to [@Y0ngg4n](https://github.com/Y0ngg4n))
|
||||
* Updated iOS developer docs ([#605](https://github.com/binwiederhier/ntfy/pull/605), thanks to [@SticksDev](https://github.com/SticksDev))
|
||||
|
||||
**Additional languages:**
|
||||
|
||||
* Portuguese (thanks to [@ssantos](https://hosted.weblate.org/user/ssantos/))
|
||||
|
||||
## ntfy server v1.30.1
|
||||
Released December 23, 2022 🎅
|
||||
|
||||
29
docs/static/css/extra.css
vendored
29
docs/static/css/extra.css
vendored
@@ -2,16 +2,13 @@
|
||||
--md-primary-fg-color: #338574;
|
||||
--md-primary-fg-color--light: #338574;
|
||||
--md-primary-fg-color--dark: #338574;
|
||||
--md-footer-bg-color: #353744;
|
||||
}
|
||||
|
||||
.md-header__button.md-logo :is(img, svg) {
|
||||
width: unset !important;
|
||||
}
|
||||
|
||||
header {
|
||||
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%); filter: drop-shadow(0 5px 10px #ccc);
|
||||
}
|
||||
|
||||
.md-header__topic:first-child {
|
||||
font-weight: 400;
|
||||
}
|
||||
@@ -34,12 +31,30 @@ figure img, figure video {
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="default"] figure img, body[data-md-color-scheme="default"] figure video {
|
||||
header {
|
||||
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%);
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="default"] header {
|
||||
filter: drop-shadow(0 5px 10px #ccc);
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] header {
|
||||
filter: drop-shadow(0 5px 10px #333);
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="default"] figure img,
|
||||
body[data-md-color-scheme="default"] figure video,
|
||||
body[data-md-color-scheme="default"] .screenshots img,
|
||||
body[data-md-color-scheme="default"] .screenshots video {
|
||||
filter: drop-shadow(3px 3px 3px #ccc);
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] figure img, body[data-md-color-scheme="slate"] figure video {
|
||||
filter: drop-shadow(3px 3px 3px #1a1313);
|
||||
body[data-md-color-scheme="slate"] figure img,
|
||||
body[data-md-color-scheme="slate"] figure video,
|
||||
body[data-md-color-scheme="slate"] .screenshots img,
|
||||
body[data-md-color-scheme="slate"] .screenshots video {
|
||||
filter: drop-shadow(3px 3px 3px #353744);
|
||||
}
|
||||
|
||||
figure video {
|
||||
|
||||
BIN
docs/static/img/web-account.png
vendored
Normal file
BIN
docs/static/img/web-account.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
docs/static/img/web-reserve-topic-dialog.png
vendored
Normal file
BIN
docs/static/img/web-reserve-topic-dialog.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
docs/static/img/web-reserve-topic.png
vendored
Normal file
BIN
docs/static/img/web-reserve-topic.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/static/img/web-signup.png
vendored
Normal file
BIN
docs/static/img/web-signup.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
docs/static/img/web-token-create.png
vendored
Normal file
BIN
docs/static/img/web-token-create.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
BIN
docs/static/img/web-token-list.png
vendored
Normal file
BIN
docs/static/img/web-token-list.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -319,7 +319,7 @@ format of the message. It's very straight forward:
|
||||
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||
| `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 |
|
||||
|
||||
@@ -18,3 +18,10 @@ is to pin the tab so that it's always open, but sort of out of the way:
|
||||
{ width=500 }
|
||||
<figcaption>Pin web app to move it out of the way</figcaption>
|
||||
</figure>
|
||||
|
||||
If topic reservations are enabled, you can claim ownership over topics and define access to it:
|
||||
|
||||
<div id="reserve-screenshots" class="screenshots">
|
||||
<a href="../../static/img/web-reserve-topic.png"><img src="../../static/img/web-reserve-topic.png"/></a>
|
||||
<a href="../../static/img/web-reserve-topic-dialog.png"><img src="../../static/img/web-reserve-topic-dialog.png"/></a>
|
||||
</div>
|
||||
|
||||
38
go.mod
38
go.mod
@@ -4,22 +4,22 @@ go 1.18
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.9.0 // indirect
|
||||
cloud.google.com/go/storage v1.28.1 // indirect
|
||||
cloud.google.com/go/storage v1.29.0 // 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.15.0
|
||||
github.com/emersion/go-smtp v0.16.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.1
|
||||
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.23.7
|
||||
golang.org/x/crypto v0.4.0
|
||||
golang.org/x/oauth2 v0.3.0 // indirect
|
||||
github.com/urfave/cli/v2 v2.24.4
|
||||
golang.org/x/crypto v0.6.0
|
||||
golang.org/x/oauth2 v0.5.0 // indirect
|
||||
golang.org/x/sync v0.1.0
|
||||
golang.org/x/term v0.3.0
|
||||
golang.org/x/term v0.5.0
|
||||
golang.org/x/time v0.3.0
|
||||
google.golang.org/api v0.105.0
|
||||
google.golang.org/api v0.111.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -27,39 +27,39 @@ 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.5.0
|
||||
github.com/stripe/stripe-go/v74 v74.9.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.107.0 // indirect
|
||||
cloud.google.com/go/compute v1.14.0 // indirect
|
||||
cloud.google.com/go v0.110.0 // indirect
|
||||
cloud.google.com/go/compute v1.18.0 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
||||
cloud.google.com/go/iam v0.9.0 // indirect
|
||||
cloud.google.com/go/longrunning v0.3.0 // indirect
|
||||
cloud.google.com/go/iam v0.12.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/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/google/go-cmp v0.5.9 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.2.1 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.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.4.0 // indirect
|
||||
golang.org/x/sys v0.3.0 // indirect
|
||||
golang.org/x/text v0.5.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/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-20221227171554-f9683d7f8bef // indirect
|
||||
google.golang.org/grpc v1.51.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230227214838-9b19f0bdc514 // indirect
|
||||
google.golang.org/grpc v1.53.0 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
78
go.sum
78
go.sum
@@ -1,18 +1,18 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.107.0 h1:qkj22L7bgkl6vIeZDlOY2po43Mx/TIa2Wsa7VR+PEww=
|
||||
cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I=
|
||||
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
|
||||
cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo=
|
||||
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/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.9.0 h1:bK6Or6mxhuL8lnj1i9j0yMo2wE/IeTO2cWlfUrf/TZs=
|
||||
cloud.google.com/go/iam v0.9.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM=
|
||||
cloud.google.com/go/longrunning v0.3.0 h1:NjljC+FYPV3uh5/OwWT6pVU+doBqMg2x/rZlE+CamDs=
|
||||
cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc=
|
||||
cloud.google.com/go/storage v1.28.1 h1:F5QDG5ChchaAVQhINh24U99OWHURqrW8OmQcGKXcbgI=
|
||||
cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y=
|
||||
cloud.google.com/go/iam v0.12.0 h1:DRtTY29b75ciH6Ov1PHb4/iat2CLCvrOm40Q0a6DFpE=
|
||||
cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY=
|
||||
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=
|
||||
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=
|
||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||
@@ -33,8 +33,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
|
||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
|
||||
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||
github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8=
|
||||
github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||
github.com/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=
|
||||
@@ -42,8 +42,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
|
||||
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/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=
|
||||
@@ -71,12 +71,12 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1VLQ=
|
||||
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
|
||||
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.1 h1:RY7tHKZcRlk788d5WSo/e83gOyyy742E8GSs771ySpg=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
|
||||
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/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
@@ -101,18 +101,18 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
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.5.0 h1:YyqTvVQdS34KYGCfVB87EMn9eDV3FCFkSwfdOQhiVL4=
|
||||
github.com/stripe/stripe-go/v74 v74.5.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
|
||||
github.com/urfave/cli/v2 v2.23.7 h1:YHDQ46s3VghFHFf1DdF+Sh7H4RqhcM+t0TmZRJx4oJY=
|
||||
github.com/urfave/cli/v2 v2.23.7/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
||||
github.com/stripe/stripe-go/v74 v74.9.0 h1:yQ3O8jmtoAjKARzjLGmwYj2ZxqYbdtWVjFeovNGDtjg=
|
||||
github.com/stripe/stripe-go/v74 v74.9.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/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
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.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
|
||||
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
|
||||
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/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=
|
||||
@@ -127,11 +127,11 @@ golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/
|
||||
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-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
|
||||
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
||||
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/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.3.0 h1:6l90koy8/LaBLmLu8jpHeHexzMwEita0zFfYlggy2F8=
|
||||
golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk=
|
||||
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/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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -144,17 +144,17 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
|
||||
golang.org/x/sys v0.3.0/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/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
|
||||
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
||||
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/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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
|
||||
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
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/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=
|
||||
@@ -165,8 +165,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/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.105.0 h1:t6P9Jj+6XTn4U9I2wycQai6Q/Kz7iOT+QzjJ3G2V4x8=
|
||||
google.golang.org/api v0.105.0/go.mod h1:qh7eD5FJks5+BcE+cjBIm6Gz8vioK7EHvnlniqXBnqI=
|
||||
google.golang.org/api v0.111.0 h1:bwKi+z2BsdwYFRKrqwutM+axAlYLz83gt5pDSXCJT+0=
|
||||
google.golang.org/api v0.111.0/go.mod h1:qtFHvU9mhgTJegR31csQ+rwxyUTHOKFqCKWp1J0fdw0=
|
||||
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=
|
||||
@@ -176,15 +176,15 @@ google.golang.org/appengine/v2 v2.0.2/go.mod h1:PkgRUWz4o1XOvbqtWTkBtCitEJ5Tp4Ho
|
||||
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-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef h1:uQ2vjV/sHTsWSqdKeLqmwitzgvjMl7o4IdtHwUDXSJY=
|
||||
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
|
||||
google.golang.org/genproto v0.0.0-20230227214838-9b19f0bdc514 h1:rtNKfB++wz5mtDY2t5C8TXlU5y52ojSu7tZo0z7u8eQ=
|
||||
google.golang.org/genproto v0.0.0-20230227214838-9b19f0bdc514/go.mod h1:TvhZT5f700eVlTNwND1xoEZQeWTB2RY/65kplwl/bFA=
|
||||
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.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.51.0 h1:E1eGv1FTqoLIdnBCZufiSHgKjlqG6fKFf6pPWtMTh8U=
|
||||
google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww=
|
||||
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/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=
|
||||
|
||||
240
log/event.go
Normal file
240
log/event.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
fieldTag = "tag"
|
||||
fieldError = "error"
|
||||
fieldTimeTaken = "time_taken_ms"
|
||||
fieldExitCode = "exit_code"
|
||||
tagStdLog = "stdlog"
|
||||
timestampFormat = "2006-01-02T15:04:05.999Z07:00"
|
||||
)
|
||||
|
||||
// Event represents a single log event
|
||||
type Event struct {
|
||||
Timestamp string `json:"time"`
|
||||
Level Level `json:"level"`
|
||||
Message string `json:"message"`
|
||||
time time.Time
|
||||
contexters []Contexter
|
||||
fields Context
|
||||
}
|
||||
|
||||
// newEvent creates a new log event
|
||||
//
|
||||
// We delay allocations and processing for efficiency, because most log events
|
||||
// are never actually rendered, so we don't format the time, or allocate a fields map.
|
||||
func newEvent() *Event {
|
||||
return &Event{
|
||||
time: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Fatal logs the event as FATAL, and exits the program with exit code 1
|
||||
func (e *Event) Fatal(message string, v ...any) {
|
||||
e.Field(fieldExitCode, 1).maybeLog(FatalLevel, message, v...)
|
||||
fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Error logs the event with log level error
|
||||
func (e *Event) Error(message string, v ...any) {
|
||||
e.maybeLog(ErrorLevel, message, v...)
|
||||
}
|
||||
|
||||
// Warn logs the event with log level warn
|
||||
func (e *Event) Warn(message string, v ...any) {
|
||||
e.maybeLog(WarnLevel, message, v...)
|
||||
}
|
||||
|
||||
// Info logs the event with log level info
|
||||
func (e *Event) Info(message string, v ...any) {
|
||||
e.maybeLog(InfoLevel, message, v...)
|
||||
}
|
||||
|
||||
// Debug logs the event with log level debug
|
||||
func (e *Event) Debug(message string, v ...any) {
|
||||
e.maybeLog(DebugLevel, message, v...)
|
||||
}
|
||||
|
||||
// Trace logs the event with log level trace
|
||||
func (e *Event) Trace(message string, v ...any) {
|
||||
e.maybeLog(TraceLevel, message, v...)
|
||||
}
|
||||
|
||||
// Tag adds a "tag" field to the log event
|
||||
func (e *Event) Tag(tag string) *Event {
|
||||
return e.Field(fieldTag, tag)
|
||||
}
|
||||
|
||||
// Time sets the time field
|
||||
func (e *Event) Time(t time.Time) *Event {
|
||||
e.time = t
|
||||
return e
|
||||
}
|
||||
|
||||
// Timing runs f and records the time if took to execute it in "time_taken_ms"
|
||||
func (e *Event) Timing(f func()) *Event {
|
||||
start := time.Now()
|
||||
f()
|
||||
return e.Field(fieldTimeTaken, time.Since(start).Milliseconds())
|
||||
}
|
||||
|
||||
// Err adds an "error" field to the log event
|
||||
func (e *Event) Err(err error) *Event {
|
||||
if err == nil {
|
||||
return e
|
||||
} else if c, ok := err.(Contexter); ok {
|
||||
return e.With(c)
|
||||
}
|
||||
return e.Field(fieldError, err.Error())
|
||||
}
|
||||
|
||||
// Field adds a custom field and value to the log event
|
||||
func (e *Event) Field(key string, value any) *Event {
|
||||
if e.fields == nil {
|
||||
e.fields = make(Context)
|
||||
}
|
||||
e.fields[key] = value
|
||||
return e
|
||||
}
|
||||
|
||||
// Fields adds a map of fields to the log event
|
||||
func (e *Event) Fields(fields Context) *Event {
|
||||
if e.fields == nil {
|
||||
e.fields = make(Context)
|
||||
}
|
||||
for k, v := range fields {
|
||||
e.fields[k] = v
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// 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 = contexters
|
||||
} else {
|
||||
e.contexters = append(e.contexters, contexters...)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// 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) Render(l Level, message string, v ...any) string {
|
||||
appliedContexters := e.maybeApplyContexters()
|
||||
if !e.shouldLog(l) {
|
||||
return ""
|
||||
}
|
||||
e.Message = fmt.Sprintf(message, v...)
|
||||
e.Level = l
|
||||
e.Timestamp = e.time.Format(timestampFormat)
|
||||
if !appliedContexters {
|
||||
e.applyContexters()
|
||||
}
|
||||
if CurrentFormat() == JSONFormat {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Loggable returns true if the given log level is lower or equal to the current log level
|
||||
func (e *Event) Loggable(l Level) bool {
|
||||
return e.globalLevelWithOverride() <= l
|
||||
}
|
||||
|
||||
// IsTrace returns true if the current log level is TraceLevel
|
||||
func (e *Event) IsTrace() bool {
|
||||
return e.Loggable(TraceLevel)
|
||||
}
|
||||
|
||||
// IsDebug returns true if the current log level is DebugLevel or below
|
||||
func (e *Event) IsDebug() bool {
|
||||
return e.Loggable(DebugLevel)
|
||||
}
|
||||
|
||||
// JSON returns the event as a JSON representation
|
||||
func (e *Event) JSON() string {
|
||||
b, _ := json.Marshal(e)
|
||||
s := string(b)
|
||||
if len(e.fields) > 0 {
|
||||
b, _ := json.Marshal(e.fields)
|
||||
s = fmt.Sprintf("{%s,%s}", s[1:len(s)-1], string(b[1:len(b)-1]))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// String returns the event as a string
|
||||
func (e *Event) String() string {
|
||||
if len(e.fields) == 0 {
|
||||
return fmt.Sprintf("%s %s", e.Level.String(), e.Message)
|
||||
}
|
||||
fields := make([]string, 0)
|
||||
for k, v := range e.fields {
|
||||
fields = append(fields, fmt.Sprintf("%s=%v", k, v))
|
||||
}
|
||||
sort.Strings(fields)
|
||||
return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", "))
|
||||
}
|
||||
|
||||
func (e *Event) shouldLog(l Level) bool {
|
||||
return e.globalLevelWithOverride() <= l
|
||||
}
|
||||
|
||||
func (e *Event) globalLevelWithOverride() Level {
|
||||
mu.RLock()
|
||||
l, ov := level, overrides
|
||||
mu.RUnlock()
|
||||
if e.fields == nil {
|
||||
return l
|
||||
}
|
||||
for field, fieldOverrides := range ov {
|
||||
value, exists := e.fields[field]
|
||||
if exists {
|
||||
for _, o := range fieldOverrides {
|
||||
if o.value == "" || o.value == value || o.value == fmt.Sprintf("%v", value) {
|
||||
return o.level
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func (e *Event) maybeApplyContexters() bool {
|
||||
mu.RLock()
|
||||
hasOverrides := len(overrides) > 0
|
||||
mu.RUnlock()
|
||||
if hasOverrides {
|
||||
e.applyContexters()
|
||||
}
|
||||
return hasOverrides // = applied
|
||||
}
|
||||
|
||||
func (e *Event) applyContexters() {
|
||||
for _, c := range e.contexters {
|
||||
e.Fields(c.Context())
|
||||
}
|
||||
}
|
||||
223
log/log.go
223
log/log.go
@@ -1,78 +1,102 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Level is a well-known log level, as defined below
|
||||
type Level int
|
||||
|
||||
// Well known log levels
|
||||
const (
|
||||
TraceLevel Level = iota
|
||||
DebugLevel
|
||||
InfoLevel
|
||||
WarnLevel
|
||||
ErrorLevel
|
||||
// Defaults for package level variables
|
||||
var (
|
||||
DefaultLevel = InfoLevel
|
||||
DefaultFormat = TextFormat
|
||||
DefaultOutput = &peekLogWriter{os.Stderr}
|
||||
)
|
||||
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case TraceLevel:
|
||||
return "TRACE"
|
||||
case DebugLevel:
|
||||
return "DEBUG"
|
||||
case InfoLevel:
|
||||
return "INFO"
|
||||
case WarnLevel:
|
||||
return "WARN"
|
||||
case ErrorLevel:
|
||||
return "ERROR"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
var (
|
||||
level = InfoLevel
|
||||
mu = &sync.Mutex{}
|
||||
level = DefaultLevel
|
||||
format = DefaultFormat
|
||||
overrides = make(map[string][]*levelOverride)
|
||||
output io.Writer = DefaultOutput
|
||||
filename = ""
|
||||
mu = &sync.RWMutex{}
|
||||
)
|
||||
|
||||
// Trace prints the given message, if the current log level is TRACE
|
||||
func Trace(message string, v ...any) {
|
||||
logIf(TraceLevel, message, v...)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Debug prints the given message, if the current log level is DEBUG or lower
|
||||
func Debug(message string, v ...any) {
|
||||
logIf(DebugLevel, message, v...)
|
||||
}
|
||||
|
||||
// Info prints the given message, if the current log level is INFO or lower
|
||||
func Info(message string, v ...any) {
|
||||
logIf(InfoLevel, message, v...)
|
||||
}
|
||||
|
||||
// Warn prints the given message, if the current log level is WARN or lower
|
||||
func Warn(message string, v ...any) {
|
||||
logIf(WarnLevel, message, v...)
|
||||
// Fatal prints the given message, and exits the program
|
||||
func Fatal(message string, v ...any) {
|
||||
newEvent().Fatal(message, v...)
|
||||
}
|
||||
|
||||
// Error prints the given message, if the current log level is ERROR or lower
|
||||
func Error(message string, v ...any) {
|
||||
logIf(ErrorLevel, message, v...)
|
||||
newEvent().Error(message, v...)
|
||||
}
|
||||
|
||||
// Fatal prints the given message, and exits the program
|
||||
func Fatal(v ...any) {
|
||||
log.Fatalln(v...)
|
||||
// Warn prints the given message, if the current log level is WARN or lower
|
||||
func Warn(message string, v ...any) {
|
||||
newEvent().Warn(message, v...)
|
||||
}
|
||||
|
||||
// Info prints the given message, if the current log level is INFO or lower
|
||||
func Info(message string, v ...any) {
|
||||
newEvent().Info(message, v...)
|
||||
}
|
||||
|
||||
// Debug prints the given message, if the current log level is DEBUG or lower
|
||||
func Debug(message string, v ...any) {
|
||||
newEvent().Debug(message, v...)
|
||||
}
|
||||
|
||||
// Trace prints the given message, if the current log level is TRACE
|
||||
func Trace(message string, v ...any) {
|
||||
newEvent().Trace(message, v...)
|
||||
}
|
||||
|
||||
// With creates a new log event and adds the fields of the given Contexter structs
|
||||
func With(contexts ...Contexter) *Event {
|
||||
return newEvent().With(contexts...)
|
||||
}
|
||||
|
||||
// Field creates a new log event and adds a custom field and value to it
|
||||
func Field(key string, value any) *Event {
|
||||
return newEvent().Field(key, value)
|
||||
}
|
||||
|
||||
// Fields creates a new log event and adds a map of fields to it
|
||||
func Fields(fields Context) *Event {
|
||||
return newEvent().Fields(fields)
|
||||
}
|
||||
|
||||
// Tag creates a new log event and adds a "tag" field to it
|
||||
func Tag(tag string) *Event {
|
||||
return newEvent().Tag(tag)
|
||||
}
|
||||
|
||||
// Time creates a new log event and sets the time field
|
||||
func Time(time time.Time) *Event {
|
||||
return newEvent().Time(time)
|
||||
}
|
||||
|
||||
// Timing runs f and records the time if took to execute it in "time_taken_ms"
|
||||
func Timing(f func()) *Event {
|
||||
return newEvent().Timing(f)
|
||||
}
|
||||
|
||||
// CurrentLevel returns the current log level
|
||||
func CurrentLevel() Level {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return level
|
||||
}
|
||||
|
||||
@@ -83,30 +107,72 @@ func SetLevel(newLevel Level) {
|
||||
level = newLevel
|
||||
}
|
||||
|
||||
// SetLevelOverride adds a log override for the given field
|
||||
func SetLevelOverride(field string, value string, level Level) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
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)
|
||||
}
|
||||
|
||||
// CurrentFormat returns the current log format
|
||||
func CurrentFormat() Format {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return format
|
||||
}
|
||||
|
||||
// SetFormat sets a new log format
|
||||
func SetFormat(newFormat Format) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
format = newFormat
|
||||
if newFormat == JSONFormat {
|
||||
DisableDates()
|
||||
}
|
||||
}
|
||||
|
||||
// SetOutput sets the log output writer
|
||||
func SetOutput(w io.Writer) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
output = &peekLogWriter{w}
|
||||
if f, ok := w.(*os.File); ok {
|
||||
filename = f.Name()
|
||||
} else {
|
||||
filename = ""
|
||||
}
|
||||
log.SetOutput(output)
|
||||
}
|
||||
|
||||
// File returns the log file, if any, or an empty string otherwise
|
||||
func File() string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return filename
|
||||
}
|
||||
|
||||
// IsFile returns true if the output is a non-default file
|
||||
func IsFile() bool {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return filename != ""
|
||||
}
|
||||
|
||||
// DisableDates disables the date/time prefix
|
||||
func DisableDates() {
|
||||
log.SetFlags(0)
|
||||
}
|
||||
|
||||
// ToLevel converts a string to a Level. It returns InfoLevel if the string
|
||||
// does not match any known log levels.
|
||||
func ToLevel(s string) Level {
|
||||
switch strings.ToUpper(s) {
|
||||
case "TRACE":
|
||||
return TraceLevel
|
||||
case "DEBUG":
|
||||
return DebugLevel
|
||||
case "INFO":
|
||||
return InfoLevel
|
||||
case "WARN", "WARNING":
|
||||
return WarnLevel
|
||||
case "ERROR":
|
||||
return ErrorLevel
|
||||
default:
|
||||
return InfoLevel
|
||||
}
|
||||
}
|
||||
|
||||
// Loggable returns true if the given log level is lower or equal to the current log level
|
||||
func Loggable(l Level) bool {
|
||||
return CurrentLevel() <= l
|
||||
@@ -122,8 +188,19 @@ func IsDebug() bool {
|
||||
return Loggable(DebugLevel)
|
||||
}
|
||||
|
||||
func logIf(l Level, message string, v ...any) {
|
||||
if CurrentLevel() <= l {
|
||||
log.Printf(l.String()+" "+message, v...)
|
||||
}
|
||||
// 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"))
|
||||
}
|
||||
|
||||
279
log/log_test.go
Normal file
279
log/log_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
exitCode := m.Run()
|
||||
resetState()
|
||||
SetLevel(ErrorLevel) // For other modules!
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
|
||||
func TestLog_TagContextFieldFields(t *testing.T) {
|
||||
t.Cleanup(resetState)
|
||||
v := &fakeVisitor{
|
||||
UserID: "u_abc",
|
||||
IP: "1.2.3.4",
|
||||
}
|
||||
err := &fakeError{
|
||||
Code: 123,
|
||||
Message: "some error",
|
||||
}
|
||||
var out bytes.Buffer
|
||||
SetOutput(&out)
|
||||
SetFormat(JSONFormat)
|
||||
SetLevelOverride("tag", "stripe", DebugLevel)
|
||||
SetLevelOverride("number", "5", DebugLevel)
|
||||
|
||||
Tag("mytag").
|
||||
Field("field2", 123).
|
||||
Field("field1", "value1").
|
||||
Time(time.Unix(123, 999000000).UTC()).
|
||||
Info("hi there %s", "phil")
|
||||
|
||||
Tag("not-stripe").
|
||||
Debug("this message will not appear")
|
||||
|
||||
With(v).
|
||||
Fields(Context{
|
||||
"stripe_customer_id": "acct_123",
|
||||
"stripe_subscription_id": "sub_123",
|
||||
}).
|
||||
Tag("stripe").
|
||||
Err(err).
|
||||
Time(time.Unix(456, 123000000).UTC()).
|
||||
Debug("Subscription status %s", "active")
|
||||
|
||||
Field("number", 5).
|
||||
Time(time.Unix(777, 001000000).UTC()).
|
||||
Debug("The number 5 is an int, but the level override is a string")
|
||||
|
||||
expected := `{"time":"1970-01-01T00:02:03.999Z","level":"INFO","message":"hi there phil","field1":"value1","field2":123,"tag":"mytag"}
|
||||
{"time":"1970-01-01T00:07:36.123Z","level":"DEBUG","message":"Subscription status active","error":"some error","error_code":123,"stripe_customer_id":"acct_123","stripe_subscription_id":"sub_123","tag":"stripe","user_id":"u_abc","visitor_ip":"1.2.3.4"}
|
||||
{"time":"1970-01-01T00:12:57Z","level":"DEBUG","message":"The number 5 is an int, but the level override is a string","number":5}
|
||||
`
|
||||
require.Equal(t, expected, out.String())
|
||||
}
|
||||
|
||||
func TestLog_NoAllocIfNotPrinted(t *testing.T) {
|
||||
t.Cleanup(resetState)
|
||||
v := &fakeVisitor{
|
||||
UserID: "u_abc",
|
||||
IP: "1.2.3.4",
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
SetOutput(&out)
|
||||
SetFormat(JSONFormat)
|
||||
|
||||
// Do not log, do not call contexters (because global level is INFO)
|
||||
v.contextCalled = false
|
||||
ev := With(v)
|
||||
ev.Debug("some message")
|
||||
require.False(t, v.contextCalled)
|
||||
require.Equal(t, "", ev.Timestamp)
|
||||
require.Equal(t, Level(0), ev.Level)
|
||||
require.Equal(t, "", ev.Message)
|
||||
require.Nil(t, ev.fields)
|
||||
|
||||
// Logged because info level, contexters called
|
||||
v.contextCalled = false
|
||||
ev = With(v).Time(time.Unix(1111, 0).UTC())
|
||||
ev.Info("some message")
|
||||
require.True(t, v.contextCalled)
|
||||
require.NotNil(t, ev.fields)
|
||||
require.Equal(t, "1.2.3.4", ev.fields["visitor_ip"])
|
||||
|
||||
// Not logged, but contexters called, because overrides exist
|
||||
SetLevel(DebugLevel)
|
||||
SetLevelOverride("tag", "overridetag", TraceLevel)
|
||||
v.contextCalled = false
|
||||
ev = Tag("sometag").Field("field", "value").With(v).Time(time.Unix(123, 0).UTC())
|
||||
ev.Trace("some debug message")
|
||||
require.True(t, v.contextCalled) // If there are overrides, we must call the context to determine the filter fields
|
||||
require.Equal(t, "", ev.Timestamp)
|
||||
require.Equal(t, Level(0), ev.Level)
|
||||
require.Equal(t, "", ev.Message)
|
||||
require.Equal(t, 4, len(ev.fields))
|
||||
require.Equal(t, "value", ev.fields["field"])
|
||||
require.Equal(t, "sometag", ev.fields["tag"])
|
||||
|
||||
// Logged because of override tag, and contexters called
|
||||
v.contextCalled = false
|
||||
ev = Tag("overridetag").Field("field", "value").With(v).Time(time.Unix(123, 0).UTC())
|
||||
ev.Trace("some trace message")
|
||||
require.True(t, v.contextCalled)
|
||||
require.Equal(t, "1970-01-01T00:02:03Z", ev.Timestamp)
|
||||
require.Equal(t, TraceLevel, ev.Level)
|
||||
require.Equal(t, "some trace message", ev.Message)
|
||||
|
||||
// Logged because of field override, and contexters called
|
||||
ResetLevelOverrides()
|
||||
SetLevelOverride("visitor_ip", "1.2.3.4", TraceLevel)
|
||||
v.contextCalled = false
|
||||
ev = With(v).Time(time.Unix(124, 0).UTC())
|
||||
ev.Trace("some trace message with override")
|
||||
require.True(t, v.contextCalled)
|
||||
require.Equal(t, "1970-01-01T00:02:04Z", ev.Timestamp)
|
||||
require.Equal(t, TraceLevel, ev.Level)
|
||||
require.Equal(t, "some trace message with override", ev.Message)
|
||||
|
||||
expected := `{"time":"1970-01-01T00:18:31Z","level":"INFO","message":"some message","user_id":"u_abc","visitor_ip":"1.2.3.4"}
|
||||
{"time":"1970-01-01T00:02:03Z","level":"TRACE","message":"some trace message","field":"value","tag":"overridetag","user_id":"u_abc","visitor_ip":"1.2.3.4"}
|
||||
{"time":"1970-01-01T00:02:04Z","level":"TRACE","message":"some trace message with override","user_id":"u_abc","visitor_ip":"1.2.3.4"}
|
||||
`
|
||||
require.Equal(t, expected, out.String())
|
||||
}
|
||||
|
||||
func TestLog_Timing(t *testing.T) {
|
||||
t.Cleanup(resetState)
|
||||
|
||||
var out bytes.Buffer
|
||||
SetOutput(&out)
|
||||
SetFormat(JSONFormat)
|
||||
|
||||
Timing(func() { time.Sleep(300 * time.Millisecond) }).
|
||||
Time(time.Unix(12, 0).UTC()).
|
||||
Info("A thing that takes a while")
|
||||
|
||||
var ev struct {
|
||||
TimeTakenMs int64 `json:"time_taken_ms"`
|
||||
}
|
||||
require.Nil(t, json.Unmarshal(out.Bytes(), &ev))
|
||||
require.True(t, ev.TimeTakenMs >= 300)
|
||||
require.Contains(t, out.String(), `{"time":"1970-01-01T00:00:12Z","level":"INFO","message":"A thing that takes a while","time_taken_ms":`)
|
||||
}
|
||||
|
||||
func TestLog_LevelOverrideAny(t *testing.T) {
|
||||
t.Cleanup(resetState)
|
||||
|
||||
var out bytes.Buffer
|
||||
SetOutput(&out)
|
||||
SetFormat(JSONFormat)
|
||||
SetLevelOverride("this_one", "", DebugLevel)
|
||||
SetLevelOverride("time_taken_ms", "", TraceLevel)
|
||||
|
||||
Time(time.Unix(11, 0).UTC()).Field("this_one", "11").Debug("this is logged")
|
||||
Time(time.Unix(12, 0).UTC()).Field("not_this", "11").Debug("this is not logged")
|
||||
Time(time.Unix(13, 0).UTC()).Field("this_too", "11").Info("this is also logged")
|
||||
Time(time.Unix(14, 0).UTC()).Field("time_taken_ms", 0).Info("this is also logged")
|
||||
|
||||
expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"this is logged","this_one":"11"}
|
||||
{"time":"1970-01-01T00:00:13Z","level":"INFO","message":"this is also logged","this_too":"11"}
|
||||
{"time":"1970-01-01T00:00:14Z","level":"INFO","message":"this is also logged","time_taken_ms":0}
|
||||
`
|
||||
require.Equal(t, expected, out.String())
|
||||
require.False(t, IsFile())
|
||||
require.Equal(t, "", File())
|
||||
}
|
||||
|
||||
func TestLog_LevelOverride_ManyOnSameField(t *testing.T) {
|
||||
t.Cleanup(resetState)
|
||||
|
||||
var out bytes.Buffer
|
||||
SetOutput(&out)
|
||||
SetFormat(JSONFormat)
|
||||
SetLevelOverride("tag", "manager", DebugLevel)
|
||||
SetLevelOverride("tag", "publish", DebugLevel)
|
||||
|
||||
Time(time.Unix(11, 0).UTC()).Field("tag", "manager").Debug("this is logged")
|
||||
Time(time.Unix(12, 0).UTC()).Field("tag", "no-match").Debug("this is not logged")
|
||||
Time(time.Unix(13, 0).UTC()).Field("tag", "publish").Info("this is also logged")
|
||||
|
||||
expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"this is logged","tag":"manager"}
|
||||
{"time":"1970-01-01T00:00:13Z","level":"INFO","message":"this is also logged","tag":"publish"}
|
||||
`
|
||||
require.Equal(t, expected, out.String())
|
||||
require.False(t, IsFile())
|
||||
require.Equal(t, "", File())
|
||||
}
|
||||
|
||||
func TestLog_UsingStdLogger_JSON(t *testing.T) {
|
||||
t.Cleanup(resetState)
|
||||
|
||||
var out bytes.Buffer
|
||||
SetOutput(&out)
|
||||
SetFormat(JSONFormat)
|
||||
|
||||
log.Println("Some other library is using the standard Go logger")
|
||||
require.Contains(t, out.String(), `,"level":"INFO","message":"Some other library is using the standard Go logger","tag":"stdlog"}`+"\n")
|
||||
}
|
||||
|
||||
func TestLog_UsingStdLogger_Text(t *testing.T) {
|
||||
t.Cleanup(resetState)
|
||||
|
||||
var out bytes.Buffer
|
||||
SetOutput(&out)
|
||||
|
||||
log.Println("Some other library is using the standard Go logger")
|
||||
require.Contains(t, out.String(), `Some other library is using the standard Go logger`+"\n")
|
||||
require.NotContains(t, out.String(), `{`)
|
||||
}
|
||||
|
||||
func TestLog_File(t *testing.T) {
|
||||
t.Cleanup(resetState)
|
||||
|
||||
logfile := filepath.Join(t.TempDir(), "ntfy.log")
|
||||
f, err := os.OpenFile(logfile, os.O_CREATE|os.O_WRONLY, 0600)
|
||||
require.Nil(t, err)
|
||||
SetOutput(f)
|
||||
SetFormat(JSONFormat)
|
||||
require.True(t, IsFile())
|
||||
require.Equal(t, logfile, File())
|
||||
|
||||
Time(time.Unix(11, 0).UTC()).Field("this_one", "11").Info("this is logged")
|
||||
require.Nil(t, f.Close())
|
||||
|
||||
f, err = os.Open(logfile)
|
||||
require.Nil(t, err)
|
||||
contents, err := io.ReadAll(f)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, `{"time":"1970-01-01T00:00:11Z","level":"INFO","message":"this is logged","this_one":"11"}`+"\n", string(contents))
|
||||
}
|
||||
|
||||
type fakeError struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e fakeError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func (e fakeError) Context() Context {
|
||||
return Context{
|
||||
"error": e.Message,
|
||||
"error_code": e.Code,
|
||||
}
|
||||
}
|
||||
|
||||
type fakeVisitor struct {
|
||||
UserID string
|
||||
IP string
|
||||
contextCalled bool
|
||||
}
|
||||
|
||||
func (v *fakeVisitor) Context() Context {
|
||||
v.contextCalled = true
|
||||
return Context{
|
||||
"user_id": v.UserID,
|
||||
"visitor_ip": v.IP,
|
||||
}
|
||||
}
|
||||
|
||||
func resetState() {
|
||||
SetLevel(DefaultLevel)
|
||||
SetFormat(DefaultFormat)
|
||||
SetOutput(DefaultOutput)
|
||||
ResetLevelOverrides()
|
||||
}
|
||||
115
log/types.go
Normal file
115
log/types.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Level is a well-known log level, as defined below
|
||||
type Level int
|
||||
|
||||
// Well known log levels
|
||||
const (
|
||||
TraceLevel Level = iota
|
||||
DebugLevel
|
||||
InfoLevel
|
||||
WarnLevel
|
||||
ErrorLevel
|
||||
FatalLevel
|
||||
)
|
||||
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case TraceLevel:
|
||||
return "TRACE"
|
||||
case DebugLevel:
|
||||
return "DEBUG"
|
||||
case InfoLevel:
|
||||
return "INFO"
|
||||
case WarnLevel:
|
||||
return "WARN"
|
||||
case ErrorLevel:
|
||||
return "ERROR"
|
||||
case FatalLevel:
|
||||
return "FATAL"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// MarshalJSON converts a level to a JSON string
|
||||
func (l Level) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(l.String())
|
||||
}
|
||||
|
||||
// ToLevel converts a string to a Level. It returns InfoLevel if the string
|
||||
// does not match any known log levels.
|
||||
func ToLevel(s string) Level {
|
||||
switch strings.ToUpper(s) {
|
||||
case "TRACE":
|
||||
return TraceLevel
|
||||
case "DEBUG":
|
||||
return DebugLevel
|
||||
case "INFO":
|
||||
return InfoLevel
|
||||
case "WARN", "WARNING":
|
||||
return WarnLevel
|
||||
case "ERROR":
|
||||
return ErrorLevel
|
||||
case "FATAL":
|
||||
return FatalLevel
|
||||
default:
|
||||
return InfoLevel
|
||||
}
|
||||
}
|
||||
|
||||
// Format is a well-known log format
|
||||
type Format int
|
||||
|
||||
// Log formats
|
||||
const (
|
||||
TextFormat Format = iota
|
||||
JSONFormat
|
||||
)
|
||||
|
||||
func (f Format) String() string {
|
||||
switch f {
|
||||
case TextFormat:
|
||||
return "text"
|
||||
case JSONFormat:
|
||||
return "json"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// ToFormat converts a string to a Format. It returns TextFormat if the string
|
||||
// does not match any known log formats.
|
||||
func ToFormat(s string) Format {
|
||||
switch strings.ToLower(s) {
|
||||
case "text":
|
||||
return TextFormat
|
||||
case "json":
|
||||
return JSONFormat
|
||||
default:
|
||||
return TextFormat
|
||||
}
|
||||
}
|
||||
|
||||
// Contexter allows structs to export a key-value pairs in the form of a Context
|
||||
type Contexter interface {
|
||||
Context() Context
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -10,6 +10,7 @@ edit_uri: blob/main/docs/
|
||||
theme:
|
||||
name: material
|
||||
language: en
|
||||
custom_dir: docs/_overrides
|
||||
logo: static/img/ntfy.png
|
||||
favicon: static/img/favicon.png
|
||||
include_search_page: false
|
||||
@@ -76,7 +77,7 @@ nav:
|
||||
- "Sending messages": publish.md
|
||||
- "Subscribing":
|
||||
- "From your phone": subscribe/phone.md
|
||||
- "From the Web UI": subscribe/web.md
|
||||
- "From the Web app": subscribe/web.md
|
||||
- "From the CLI": subscribe/cli.md
|
||||
- "Using the API": subscribe/api.md
|
||||
- "Self-hosting":
|
||||
|
||||
@@ -19,7 +19,7 @@ const (
|
||||
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery
|
||||
DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
|
||||
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
|
||||
DefaultStripePriceCacheDuration = time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed
|
||||
DefaultStripePriceCacheDuration = 3 * time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed
|
||||
)
|
||||
|
||||
// Defines all global and per-visitor limits
|
||||
@@ -44,10 +44,13 @@ const (
|
||||
DefaultVisitorSubscriptionLimit = 30
|
||||
DefaultVisitorRequestLimitBurst = 60
|
||||
DefaultVisitorRequestLimitReplenish = 5 * time.Second
|
||||
DefaultVisitorMessageDailyLimit = 0
|
||||
DefaultVisitorEmailLimitBurst = 16
|
||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||
DefaultVisitorAccountCreateLimitBurst = 3
|
||||
DefaultVisitorAccountCreateLimitReplenish = 24 * time.Hour
|
||||
DefaultVisitorAccountCreationLimitBurst = 3
|
||||
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
|
||||
DefaultVisitorAuthFailureLimitBurst = 10
|
||||
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
||||
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
||||
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
||||
)
|
||||
@@ -55,10 +58,15 @@ const (
|
||||
var (
|
||||
// DefaultVisitorStatsResetTime defines the time at which visitor stats are reset (wall clock only)
|
||||
DefaultVisitorStatsResetTime = time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// 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"}
|
||||
)
|
||||
|
||||
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
||||
type Config struct {
|
||||
File string // Config file, only used for testing
|
||||
BaseURL string
|
||||
ListenHTTP string
|
||||
ListenHTTPS string
|
||||
@@ -75,12 +83,15 @@ type Config struct {
|
||||
AuthFile string
|
||||
AuthStartupQueries string
|
||||
AuthDefault user.Permission
|
||||
AuthBcryptCost int
|
||||
AuthStatsQueueWriterInterval time.Duration
|
||||
AttachmentCacheDir string
|
||||
AttachmentTotalSizeLimit int64
|
||||
AttachmentFileSizeLimit int64
|
||||
AttachmentExpiryDuration time.Duration
|
||||
KeepaliveInterval time.Duration
|
||||
ManagerInterval time.Duration
|
||||
DisallowedTopics []string
|
||||
WebRootIsApp bool
|
||||
DelayedSenderInterval time.Duration
|
||||
FirebaseKeepaliveInterval time.Duration
|
||||
@@ -101,19 +112,23 @@ type Config struct {
|
||||
TotalAttachmentSizeLimit int64
|
||||
VisitorSubscriptionLimit int
|
||||
VisitorAttachmentTotalSizeLimit int64
|
||||
VisitorAttachmentDailyBandwidthLimit int
|
||||
VisitorAttachmentDailyBandwidthLimit int64
|
||||
VisitorRequestLimitBurst int
|
||||
VisitorRequestLimitReplenish time.Duration
|
||||
VisitorRequestExemptIPAddrs []netip.Prefix
|
||||
VisitorMessageDailyLimit int
|
||||
VisitorEmailLimitBurst int
|
||||
VisitorEmailLimitReplenish time.Duration
|
||||
VisitorAccountCreateLimitBurst int
|
||||
VisitorAccountCreateLimitReplenish time.Duration
|
||||
VisitorAccountCreationLimitBurst int
|
||||
VisitorAccountCreationLimitReplenish time.Duration
|
||||
VisitorAuthFailureLimitBurst int
|
||||
VisitorAuthFailureLimitReplenish time.Duration
|
||||
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
|
||||
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
|
||||
@@ -125,6 +140,7 @@ type Config struct {
|
||||
// NewConfig instantiates a default new server config
|
||||
func NewConfig() *Config {
|
||||
return &Config{
|
||||
File: "", // Only used for testing
|
||||
BaseURL: "",
|
||||
ListenHTTP: DefaultListenHTTP,
|
||||
ListenHTTPS: "",
|
||||
@@ -140,13 +156,16 @@ func NewConfig() *Config {
|
||||
CacheBatchTimeout: 0,
|
||||
AuthFile: "",
|
||||
AuthStartupQueries: "",
|
||||
AuthDefault: user.NewPermission(true, true),
|
||||
AuthDefault: user.PermissionReadWrite,
|
||||
AuthBcryptCost: user.DefaultUserPasswordBcryptCost,
|
||||
AuthStatsQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
|
||||
AttachmentCacheDir: "",
|
||||
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
||||
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
||||
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||
ManagerInterval: DefaultManagerInterval,
|
||||
DisallowedTopics: DefaultDisallowedTopics,
|
||||
WebRootIsApp: false,
|
||||
DelayedSenderInterval: DefaultDelayedSenderInterval,
|
||||
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
|
||||
@@ -171,10 +190,13 @@ func NewConfig() *Config {
|
||||
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
||||
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
||||
VisitorRequestExemptIPAddrs: make([]netip.Prefix, 0),
|
||||
VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit,
|
||||
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
||||
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||
VisitorAccountCreateLimitBurst: DefaultVisitorAccountCreateLimitBurst,
|
||||
VisitorAccountCreateLimitReplenish: DefaultVisitorAccountCreateLimitReplenish,
|
||||
VisitorAccountCreationLimitBurst: DefaultVisitorAccountCreationLimitBurst,
|
||||
VisitorAccountCreationLimitReplenish: DefaultVisitorAccountCreationLimitReplenish,
|
||||
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
|
||||
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
|
||||
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
||||
BehindProxy: false,
|
||||
StripeSecretKey: "",
|
||||
|
||||
154
server/errors.go
154
server/errors.go
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -12,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 {
|
||||
@@ -23,61 +25,107 @@ func (e errHTTP) JSON() string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
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) Context() 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 (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 (
|
||||
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 disallowed", ""}
|
||||
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", ""}
|
||||
errHTTPBadRequestMakesNoSenseForAdmin = &errHTTP{40026, http.StatusBadRequest, "invalid request: this makes no sense for admins", ""}
|
||||
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", ""}
|
||||
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", "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: too many messages", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
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}
|
||||
errHTTPInsufficientStorage = &errHTTP{50701, http.StatusInsufficientStorage, "internal server error: cannot publish to UnifiedPush topic without previously active subscriber", "", nil}
|
||||
)
|
||||
|
||||
@@ -44,6 +44,7 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (in
|
||||
if !fileIDRegex.MatchString(id) {
|
||||
return 0, errInvalidFileID
|
||||
}
|
||||
log.Tag(tagFileCache).Field("message_id", id).Debug("Writing attachment")
|
||||
file := filepath.Join(c.dir, id)
|
||||
if _, err := os.Stat(file); err == nil {
|
||||
return 0, errFileExists
|
||||
@@ -75,10 +76,10 @@ func (c *fileCache) Remove(ids ...string) error {
|
||||
if !fileIDRegex.MatchString(id) {
|
||||
return errInvalidFileID
|
||||
}
|
||||
log.Debug("File Cache: Deleting attachment %s", id)
|
||||
log.Tag(tagFileCache).Field("message_id", id).Debug("Deleting attachment")
|
||||
file := filepath.Join(c.dir, id)
|
||||
if err := os.Remove(file); err != nil {
|
||||
log.Debug("File Cache: Error deleting attachment %s: %s", id, err.Error())
|
||||
log.Tag(tagFileCache).Field("message_id", id).Err(err).Debug("Error deleting attachment")
|
||||
}
|
||||
}
|
||||
size, err := dirSize(c.dir)
|
||||
|
||||
123
server/log.go
Normal file
123
server/log.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/gorilla/websocket"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Log tags
|
||||
const (
|
||||
tagStartup = "startup"
|
||||
tagHTTP = "http"
|
||||
tagPublish = "publish"
|
||||
tagSubscribe = "subscribe"
|
||||
tagFirebase = "firebase"
|
||||
tagSMTP = "smtp" // Receive email
|
||||
tagEmail = "email" // Send email
|
||||
tagFileCache = "file_cache"
|
||||
tagMessageCache = "message_cache"
|
||||
tagStripe = "stripe"
|
||||
tagAccount = "account"
|
||||
tagManager = "manager"
|
||||
tagResetter = "resetter"
|
||||
tagWebsocket = "websocket"
|
||||
tagMatrix = "matrix"
|
||||
)
|
||||
|
||||
var (
|
||||
normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusInsufficientStorage}
|
||||
rateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge}
|
||||
)
|
||||
|
||||
// logr creates a new log event with HTTP request fields
|
||||
func logr(r *http.Request) *log.Event {
|
||||
return log.Tag(tagHTTP).Fields(httpContext(r)) // Tag may be overwritten
|
||||
}
|
||||
|
||||
// logv creates a new log event with visitor fields
|
||||
func logv(v *visitor) *log.Event {
|
||||
return log.With(v)
|
||||
}
|
||||
|
||||
// logvr creates a new log event with HTTP request and visitor fields
|
||||
func logvr(v *visitor, r *http.Request) *log.Event {
|
||||
return logr(r).With(v)
|
||||
}
|
||||
|
||||
// logvrm creates a new log event with HTTP request, visitor fields and message fields
|
||||
func logvrm(v *visitor, r *http.Request, m *message) *log.Event {
|
||||
return logvr(v, r).With(m)
|
||||
}
|
||||
|
||||
// logvrm creates a new log event with visitor fields and message fields
|
||||
func logvm(v *visitor, m *message) *log.Event {
|
||||
return logv(v).With(m)
|
||||
}
|
||||
|
||||
// logem creates a new log event with email fields
|
||||
func logem(smtpConn *smtp.Conn) *log.Event {
|
||||
ev := log.Tag(tagSMTP).Field("smtp_hostname", smtpConn.Hostname())
|
||||
if smtpConn.Conn() != nil {
|
||||
ev.Field("smtp_remote_addr", smtpConn.Conn().RemoteAddr().String())
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
func httpContext(r *http.Request) log.Context {
|
||||
requestURI := r.RequestURI
|
||||
if requestURI == "" {
|
||||
requestURI = r.URL.Path
|
||||
}
|
||||
return log.Context{
|
||||
"http_method": r.Method,
|
||||
"http_path": requestURI,
|
||||
}
|
||||
}
|
||||
|
||||
func websocketErrorContext(err error) log.Context {
|
||||
if c, ok := err.(*websocket.CloseError); ok {
|
||||
return log.Context{
|
||||
"error": c.Error(),
|
||||
"error_code": c.Code,
|
||||
"error_type": "websocket.CloseError",
|
||||
}
|
||||
}
|
||||
return log.Context{
|
||||
"error": err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
func renderHTTPRequest(r *http.Request) string {
|
||||
peekLimit := 4096
|
||||
lines := fmt.Sprintf("%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
|
||||
for key, values := range r.Header {
|
||||
for _, value := range values {
|
||||
lines += fmt.Sprintf("%s: %s\n", key, value)
|
||||
}
|
||||
}
|
||||
lines += "\n"
|
||||
body, err := util.Peek(r.Body, peekLimit)
|
||||
if err != nil {
|
||||
lines = fmt.Sprintf("(could not read body: %s)\n", err.Error())
|
||||
} else if utf8.Valid(body.PeekedBytes) {
|
||||
lines += string(body.PeekedBytes)
|
||||
if body.LimitReached {
|
||||
lines += fmt.Sprintf(" ... (peeked %d bytes)", peekLimit)
|
||||
}
|
||||
lines += "\n"
|
||||
} else {
|
||||
if body.LimitReached {
|
||||
lines += fmt.Sprintf("(peeked bytes not UTF-8, peek limit of %d bytes reached, hex: %x ...)\n", peekLimit, body.PeekedBytes)
|
||||
} else {
|
||||
lines += fmt.Sprintf("(peeked bytes not UTF-8, %d bytes, hex: %x)\n", len(body.PeekedBytes), body.PeekedBytes)
|
||||
}
|
||||
}
|
||||
r.Body = body // Important: Reset body, so it can be re-read
|
||||
return strings.TrimSpace(lines)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
var (
|
||||
errUnexpectedMessageType = errors.New("unexpected message type")
|
||||
errMessageNotFound = errors.New("message not found")
|
||||
)
|
||||
|
||||
// Messages cache
|
||||
@@ -50,6 +51,8 @@ const (
|
||||
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
|
||||
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
||||
COMMIT;
|
||||
`
|
||||
@@ -60,7 +63,12 @@ const (
|
||||
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
||||
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||
selectMessagesSinceTimeQuery = `
|
||||
selectMessagesByIDQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
|
||||
FROM messages
|
||||
WHERE mid = ?
|
||||
`
|
||||
selectMessagesSinceTimeQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ? AND published = 1
|
||||
@@ -98,8 +106,8 @@ const (
|
||||
|
||||
updateAttachmentDeleted = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?`
|
||||
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 sender = ? AND attachment_expires >= ?`
|
||||
selectAttachmentsSizeByUserQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`
|
||||
selectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?`
|
||||
selectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`
|
||||
)
|
||||
|
||||
// Schema management queries
|
||||
@@ -209,6 +217,8 @@ const (
|
||||
ALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0');
|
||||
ALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0');
|
||||
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||
CREATE INDEX IF NOT EXISTS idx_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);
|
||||
`
|
||||
migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`
|
||||
@@ -363,10 +373,10 @@ func (c *messageCache) addMessages(ms []*message) error {
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Error("Message Cache: Writing %d message(s) failed (took %v)", len(ms), time.Since(start))
|
||||
log.Tag(tagMessageCache).Err(err).Error("Writing %d message(s) failed (took %v)", len(ms), time.Since(start))
|
||||
return err
|
||||
}
|
||||
log.Debug("Message Cache: Wrote %d message(s) in %v", len(ms), time.Since(start))
|
||||
log.Tag(tagMessageCache).Debug("Wrote %d message(s) in %v", len(ms), time.Since(start))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -448,6 +458,18 @@ func (c *messageCache) MessagesExpired() ([]string, error) {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (c *messageCache) Message(id string) (*message, error) {
|
||||
rows, err := c.db.Query(selectMessagesByIDQuery, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !rows.Next() {
|
||||
return nil, errMessageNotFound
|
||||
}
|
||||
defer rows.Close()
|
||||
return readMessage(rows)
|
||||
}
|
||||
|
||||
func (c *messageCache) MarkPublished(m *message) error {
|
||||
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
||||
return err
|
||||
@@ -514,7 +536,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
|
||||
}
|
||||
}
|
||||
@@ -563,8 +585,8 @@ func (c *messageCache) AttachmentBytesUsedBySender(sender string) (int64, error)
|
||||
return c.readAttachmentBytesUsed(rows)
|
||||
}
|
||||
|
||||
func (c *messageCache) AttachmentBytesUsedByUser(user string) (int64, error) {
|
||||
rows, err := c.db.Query(selectAttachmentsSizeByUserQuery, user, time.Now().Unix())
|
||||
func (c *messageCache) AttachmentBytesUsedByUser(userID string) (int64, error) {
|
||||
rows, err := c.db.Query(selectAttachmentsSizeByUserIDQuery, userID, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -591,7 +613,7 @@ func (c *messageCache) processMessageBatches() {
|
||||
}
|
||||
for messages := range c.queue.Dequeue() {
|
||||
if err := c.addMessages(messages); err != nil {
|
||||
log.Error("Message Cache: %s", err.Error())
|
||||
log.Tag(tagMessageCache).Err(err).Error("Cannot write message batch")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -600,75 +622,11 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||
defer rows.Close()
|
||||
messages := make([]*message, 0)
|
||||
for rows.Next() {
|
||||
var timestamp, expires, attachmentSize, attachmentExpires int64
|
||||
var priority int
|
||||
var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, encoding string
|
||||
err := rows.Scan(
|
||||
&id,
|
||||
×tamp,
|
||||
&expires,
|
||||
&topic,
|
||||
&msg,
|
||||
&title,
|
||||
&priority,
|
||||
&tagsStr,
|
||||
&click,
|
||||
&icon,
|
||||
&actionsStr,
|
||||
&attachmentName,
|
||||
&attachmentType,
|
||||
&attachmentSize,
|
||||
&attachmentExpires,
|
||||
&attachmentURL,
|
||||
&sender,
|
||||
&user,
|
||||
&encoding,
|
||||
)
|
||||
m, err := readMessage(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tags []string
|
||||
if tagsStr != "" {
|
||||
tags = strings.Split(tagsStr, ",")
|
||||
}
|
||||
var actions []*action
|
||||
if actionsStr != "" {
|
||||
if err := json.Unmarshal([]byte(actionsStr), &actions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
senderIP, err := netip.ParseAddr(sender)
|
||||
if err != nil {
|
||||
senderIP = netip.Addr{} // if no IP stored in database, return invalid address
|
||||
}
|
||||
var att *attachment
|
||||
if attachmentName != "" && attachmentURL != "" {
|
||||
att = &attachment{
|
||||
Name: attachmentName,
|
||||
Type: attachmentType,
|
||||
Size: attachmentSize,
|
||||
Expires: attachmentExpires,
|
||||
URL: attachmentURL,
|
||||
}
|
||||
}
|
||||
messages = append(messages, &message{
|
||||
ID: id,
|
||||
Time: timestamp,
|
||||
Expires: expires,
|
||||
Event: messageEvent,
|
||||
Topic: topic,
|
||||
Message: msg,
|
||||
Title: title,
|
||||
Priority: priority,
|
||||
Tags: tags,
|
||||
Click: click,
|
||||
Icon: icon,
|
||||
Actions: actions,
|
||||
Attachment: att,
|
||||
Sender: senderIP, // Must parse assuming database must be correct
|
||||
User: user,
|
||||
Encoding: encoding,
|
||||
})
|
||||
messages = append(messages, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
@@ -676,6 +634,82 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func readMessage(rows *sql.Rows) (*message, error) {
|
||||
var timestamp, expires, attachmentSize, attachmentExpires int64
|
||||
var priority int
|
||||
var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, encoding string
|
||||
err := rows.Scan(
|
||||
&id,
|
||||
×tamp,
|
||||
&expires,
|
||||
&topic,
|
||||
&msg,
|
||||
&title,
|
||||
&priority,
|
||||
&tagsStr,
|
||||
&click,
|
||||
&icon,
|
||||
&actionsStr,
|
||||
&attachmentName,
|
||||
&attachmentType,
|
||||
&attachmentSize,
|
||||
&attachmentExpires,
|
||||
&attachmentURL,
|
||||
&sender,
|
||||
&user,
|
||||
&encoding,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tags []string
|
||||
if tagsStr != "" {
|
||||
tags = strings.Split(tagsStr, ",")
|
||||
}
|
||||
var actions []*action
|
||||
if actionsStr != "" {
|
||||
if err := json.Unmarshal([]byte(actionsStr), &actions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
senderIP, err := netip.ParseAddr(sender)
|
||||
if err != nil {
|
||||
senderIP = netip.Addr{} // if no IP stored in database, return invalid address
|
||||
}
|
||||
var att *attachment
|
||||
if attachmentName != "" && attachmentURL != "" {
|
||||
att = &attachment{
|
||||
Name: attachmentName,
|
||||
Type: attachmentType,
|
||||
Size: attachmentSize,
|
||||
Expires: attachmentExpires,
|
||||
URL: attachmentURL,
|
||||
}
|
||||
}
|
||||
return &message{
|
||||
ID: id,
|
||||
Time: timestamp,
|
||||
Expires: expires,
|
||||
Event: messageEvent,
|
||||
Topic: topic,
|
||||
Message: msg,
|
||||
Title: title,
|
||||
Priority: priority,
|
||||
Tags: tags,
|
||||
Click: click,
|
||||
Icon: icon,
|
||||
Actions: actions,
|
||||
Attachment: att,
|
||||
Sender: senderIP, // Must parse assuming database must be correct
|
||||
User: user,
|
||||
Encoding: encoding,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *messageCache) Close() error {
|
||||
return c.db.Close()
|
||||
}
|
||||
|
||||
func setupDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
|
||||
// Run startup queries
|
||||
if startupQueries != "" {
|
||||
@@ -736,7 +770,7 @@ func setupNewCacheDB(db *sql.DB) error {
|
||||
}
|
||||
|
||||
func migrateFrom0(db *sql.DB, _ time.Duration) error {
|
||||
log.Info("Migrating cache database schema: from 0 to 1")
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1")
|
||||
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -750,7 +784,7 @@ func migrateFrom0(db *sql.DB, _ time.Duration) error {
|
||||
}
|
||||
|
||||
func migrateFrom1(db *sql.DB, _ time.Duration) error {
|
||||
log.Info("Migrating cache database schema: from 1 to 2")
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2")
|
||||
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -761,7 +795,7 @@ func migrateFrom1(db *sql.DB, _ time.Duration) error {
|
||||
}
|
||||
|
||||
func migrateFrom2(db *sql.DB, _ time.Duration) error {
|
||||
log.Info("Migrating cache database schema: from 2 to 3")
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3")
|
||||
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -772,7 +806,7 @@ func migrateFrom2(db *sql.DB, _ time.Duration) error {
|
||||
}
|
||||
|
||||
func migrateFrom3(db *sql.DB, _ time.Duration) error {
|
||||
log.Info("Migrating cache database schema: from 3 to 4")
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4")
|
||||
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -783,7 +817,7 @@ func migrateFrom3(db *sql.DB, _ time.Duration) error {
|
||||
}
|
||||
|
||||
func migrateFrom4(db *sql.DB, _ time.Duration) error {
|
||||
log.Info("Migrating cache database schema: from 4 to 5")
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5")
|
||||
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -794,7 +828,7 @@ func migrateFrom4(db *sql.DB, _ time.Duration) error {
|
||||
}
|
||||
|
||||
func migrateFrom5(db *sql.DB, _ time.Duration) error {
|
||||
log.Info("Migrating cache database schema: from 5 to 6")
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6")
|
||||
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -805,7 +839,7 @@ func migrateFrom5(db *sql.DB, _ time.Duration) error {
|
||||
}
|
||||
|
||||
func migrateFrom6(db *sql.DB, _ time.Duration) error {
|
||||
log.Info("Migrating cache database schema: from 6 to 7")
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7")
|
||||
if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -816,7 +850,7 @@ func migrateFrom6(db *sql.DB, _ time.Duration) error {
|
||||
}
|
||||
|
||||
func migrateFrom7(db *sql.DB, _ time.Duration) error {
|
||||
log.Info("Migrating cache database schema: from 7 to 8")
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8")
|
||||
if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -827,7 +861,7 @@ func migrateFrom7(db *sql.DB, _ time.Duration) error {
|
||||
}
|
||||
|
||||
func migrateFrom8(db *sql.DB, _ time.Duration) error {
|
||||
log.Info("Migrating cache database schema: from 8 to 9")
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9")
|
||||
if _, err := db.Exec(migrate8To9AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -838,7 +872,7 @@ func migrateFrom8(db *sql.DB, _ time.Duration) error {
|
||||
}
|
||||
|
||||
func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
|
||||
log.Info("Migrating cache database schema: from 9 to 10")
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 9 to 10")
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -853,8 +887,5 @@ func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
|
||||
if _, err := tx.Exec(updateSchemaVersion, 10); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil // Update this when a new version is added
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -12,10 +12,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
exampleIP1234 = netip.MustParseAddr("1.2.3.4")
|
||||
)
|
||||
|
||||
func TestSqliteCache_Messages(t *testing.T) {
|
||||
testCacheMessages(t, newSqliteTestCache(t))
|
||||
}
|
||||
@@ -294,10 +290,10 @@ func TestMemCache_Attachments(t *testing.T) {
|
||||
}
|
||||
|
||||
func testCacheAttachments(t *testing.T, c *messageCache) {
|
||||
expires1 := time.Now().Add(-4 * time.Hour).Unix()
|
||||
expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
|
||||
m := newDefaultMessage("mytopic", "flower for you")
|
||||
m.ID = "m1"
|
||||
m.Sender = exampleIP1234
|
||||
m.Sender = netip.MustParseAddr("1.2.3.4")
|
||||
m.Attachment = &attachment{
|
||||
Name: "flower.jpg",
|
||||
Type: "image/jpeg",
|
||||
@@ -310,7 +306,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
||||
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
|
||||
m = newDefaultMessage("mytopic", "sending you a car")
|
||||
m.ID = "m2"
|
||||
m.Sender = exampleIP1234
|
||||
m.Sender = netip.MustParseAddr("1.2.3.4")
|
||||
m.Attachment = &attachment{
|
||||
Name: "car.jpg",
|
||||
Type: "image/jpeg",
|
||||
@@ -323,7 +319,8 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
||||
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
|
||||
m = newDefaultMessage("another-topic", "sending you another car")
|
||||
m.ID = "m3"
|
||||
m.Sender = exampleIP1234
|
||||
m.User = "u_BAsbaAa"
|
||||
m.Sender = netip.MustParseAddr("5.6.7.8")
|
||||
m.Attachment = &attachment{
|
||||
Name: "another-car.jpg",
|
||||
Type: "image/jpeg",
|
||||
@@ -355,11 +352,15 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
||||
|
||||
size, err := c.AttachmentBytesUsedBySender("1.2.3.4")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(30000), size)
|
||||
require.Equal(t, int64(10000), size)
|
||||
|
||||
size, err = c.AttachmentBytesUsedBySender("5.6.7.8")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), size)
|
||||
require.Equal(t, int64(0), size) // Accounted to the user, not the IP!
|
||||
|
||||
size, err = c.AttachmentBytesUsedByUser("u_BAsbaAa")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(20000), size)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Attachments_Expired(t *testing.T) {
|
||||
|
||||
790
server/server.go
790
server/server.go
File diff suppressed because it is too large
Load Diff
@@ -80,6 +80,8 @@
|
||||
# - auth-file is the SQLite user/access database; it is created automatically if it doesn't already exist
|
||||
# - auth-default-access defines the default/fallback access if no access control entry is found; it can be
|
||||
# set to "read-write" (default), "read-only", "write-only" or "deny-all".
|
||||
# - auth-startup-queries allows you to run commands when the database is initialized, e.g. to enable
|
||||
# WAL mode. This is similar to cache-startup-queries. See above for details.
|
||||
#
|
||||
# Debian/RPM package users:
|
||||
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
|
||||
@@ -91,6 +93,7 @@
|
||||
#
|
||||
# auth-file: <filename>
|
||||
# auth-default-access: "read-write"
|
||||
# auth-startup-queries:
|
||||
|
||||
# If set, the X-Forwarded-For header is used to determine the visitor IP address
|
||||
# instead of the remote address of the connection.
|
||||
@@ -152,6 +155,17 @@
|
||||
#
|
||||
# manager-interval: "1m"
|
||||
|
||||
# Defines topic names that are not allowed, because they are otherwise used. There are a few default topics
|
||||
# that cannot be used (e.g. app, account, settings, ...). To extend the default list, define them here.
|
||||
#
|
||||
# Example:
|
||||
# disallowed-topics:
|
||||
# - about
|
||||
# - pricing
|
||||
# - contact
|
||||
#
|
||||
# disallowed-topics:
|
||||
|
||||
# Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the
|
||||
# web app. If you self-host, you don't want to change this.
|
||||
# Can be "app" (default), "home" or "disable" to disable the web app entirely.
|
||||
@@ -200,6 +214,12 @@
|
||||
# visitor-request-limit-replenish: "5s"
|
||||
# visitor-request-limit-exempt-hosts: ""
|
||||
|
||||
# Rate limiting: Hard daily limit of messages per visitor and day. The limit is reset
|
||||
# every day at midnight UTC. If the limit is not set (or set to zero), the request
|
||||
# limit (see above) governs the upper limit.
|
||||
#
|
||||
# visitor-message-daily-limit: 0
|
||||
|
||||
# Rate limiting: Allowed emails per visitor:
|
||||
# - visitor-email-limit-burst is the initial bucket of emails each visitor has
|
||||
# - visitor-email-limit-replenish is the rate at which the bucket is refilled
|
||||
@@ -220,14 +240,43 @@
|
||||
# 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:
|
||||
|
||||
# Log level, can be TRACE, DEBUG, INFO, WARN or ERROR
|
||||
# This option can be hot-reloaded by calling "kill -HUP $pid" or "systemctl reload ntfy".
|
||||
# Logging options
|
||||
#
|
||||
# Be aware that DEBUG (and particularly TRACE) can be VERY CHATTY. Only turn them on for
|
||||
# debugging purposes, or your disk will fill up quickly.
|
||||
# By default, ntfy logs to the console (stderr), with an "info" log level, and in a human-readable text format.
|
||||
# ntfy supports five different log levels, can also write to a file, log as JSON, and even supports granular
|
||||
# log level overrides for easier debugging. Some options (log-level and log-level-overrides) can be hot reloaded
|
||||
# by calling "kill -HUP $pid" or "systemctl reload ntfy".
|
||||
#
|
||||
# log-level: INFO
|
||||
# - log-format defines the output format, can be "text" (default) or "json"
|
||||
# - log-file is a filename to write logs to. If this is not set, ntfy logs to stderr.
|
||||
# - log-level defines the default log level, can be one of "trace", "debug", "info" (default), "warn" or "error".
|
||||
# Be aware that "debug" (and particularly "trace") can be VERY CHATTY. Only turn them on briefly for debugging purposes.
|
||||
# - log-level-overrides lets you override the log level if certain fields match. This is incredibly powerful
|
||||
# for debugging certain parts of the system (e.g. only the account management, or only a certain visitor).
|
||||
# This is an array of strings in the format:
|
||||
# - "field=value -> level" to match a value exactly, e.g. "tag=manager -> trace"
|
||||
# - "field -> level" to match any value, e.g. "time_taken_ms -> debug"
|
||||
# Warning: Using log-level-overrides has a performance penalty. Only use it for temporary debugging.
|
||||
#
|
||||
# Example (good for production):
|
||||
# log-level: info
|
||||
# log-format: json
|
||||
# log-file: /var/log/ntfy.log
|
||||
#
|
||||
# Example level overrides (for debugging, only use temporarily):
|
||||
# log-level-overrides:
|
||||
# - "tag=manager -> trace"
|
||||
# - "visitor_ip=1.2.3.4 -> debug"
|
||||
# - "time_taken_ms -> debug"
|
||||
#
|
||||
# log-level: info
|
||||
# log-level-overrides:
|
||||
# log-format: text
|
||||
# log-file:
|
||||
|
||||
@@ -2,60 +2,65 @@ package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/user"
|
||||
"heckel.io/ntfy/util"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
subscriptionIDLength = 16
|
||||
createdByAPI = "api"
|
||||
syncTopicAccountSyncEvent = "sync"
|
||||
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
||||
)
|
||||
|
||||
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
admin := v.user != nil && v.user.Role == user.RoleAdmin
|
||||
if !admin {
|
||||
u := v.User()
|
||||
if !u.IsAdmin() { // u may be nil, but that's fine
|
||||
if !s.config.EnableSignup {
|
||||
return errHTTPBadRequestSignupNotEnabled
|
||||
} else if v.user != nil {
|
||||
} else if u != nil {
|
||||
return errHTTPUnauthorized // Cannot create account from user context
|
||||
}
|
||||
if !v.AccountCreationAllowed() {
|
||||
return errHTTPTooManyRequestsLimitAccountCreation
|
||||
}
|
||||
}
|
||||
newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit)
|
||||
newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {
|
||||
return errHTTPConflictUserExists
|
||||
}
|
||||
if v.accountLimiter != nil && !v.accountLimiter.Allow() {
|
||||
return errHTTPTooManyRequestsLimitAccountCreation
|
||||
}
|
||||
if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser, createdByAPI); err != nil { // TODO this should return a User
|
||||
logvr(v, r).Tag(tagAccount).Field("user_name", newAccount.Username).Info("Creating user %s", newAccount.Username)
|
||||
if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser); err != nil {
|
||||
return err
|
||||
}
|
||||
v.AccountCreated()
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||
func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
info, err := v.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Fields(visitorExtendedInfoContext(info)).Debug("Retrieving account stats")
|
||||
limits, stats := info.Limits, info.Stats
|
||||
response := &apiAccountResponse{
|
||||
Limits: &apiAccountLimits{
|
||||
Basis: string(limits.Basis),
|
||||
Messages: limits.MessagesLimit,
|
||||
MessagesExpiryDuration: int64(limits.MessagesExpiryDuration.Seconds()),
|
||||
Emails: limits.EmailsLimit,
|
||||
Messages: limits.MessageLimit,
|
||||
MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()),
|
||||
Emails: limits.EmailLimit,
|
||||
Reservations: limits.ReservationsLimit,
|
||||
AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
|
||||
AttachmentFileSize: limits.AttachmentFileSizeLimit,
|
||||
AttachmentExpiryDuration: int64(limits.AttachmentExpiryDuration.Seconds()),
|
||||
AttachmentBandwidth: limits.AttachmentBandwidthLimit,
|
||||
},
|
||||
Stats: &apiAccountStats{
|
||||
Messages: stats.Messages,
|
||||
@@ -68,37 +73,39 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
||||
AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
|
||||
},
|
||||
}
|
||||
if v.user != nil {
|
||||
response.Username = v.user.Name
|
||||
response.Role = string(v.user.Role)
|
||||
response.SyncTopic = v.user.SyncTopic
|
||||
if v.user.Prefs != nil {
|
||||
if v.user.Prefs.Language != "" {
|
||||
response.Language = v.user.Prefs.Language
|
||||
u := v.User()
|
||||
if u != nil {
|
||||
response.Username = u.Name
|
||||
response.Role = string(u.Role)
|
||||
response.SyncTopic = u.SyncTopic
|
||||
if u.Prefs != nil {
|
||||
if u.Prefs.Language != nil {
|
||||
response.Language = *u.Prefs.Language
|
||||
}
|
||||
if v.user.Prefs.Notification != nil {
|
||||
response.Notification = v.user.Prefs.Notification
|
||||
if u.Prefs.Notification != nil {
|
||||
response.Notification = u.Prefs.Notification
|
||||
}
|
||||
if v.user.Prefs.Subscriptions != nil {
|
||||
response.Subscriptions = v.user.Prefs.Subscriptions
|
||||
if u.Prefs.Subscriptions != nil {
|
||||
response.Subscriptions = u.Prefs.Subscriptions
|
||||
}
|
||||
}
|
||||
if v.user.Tier != nil {
|
||||
if u.Tier != nil {
|
||||
response.Tier = &apiAccountTier{
|
||||
Code: v.user.Tier.Code,
|
||||
Name: v.user.Tier.Name,
|
||||
Code: u.Tier.Code,
|
||||
Name: u.Tier.Name,
|
||||
}
|
||||
}
|
||||
if v.user.Billing.StripeCustomerID != "" {
|
||||
if u.Billing.StripeCustomerID != "" {
|
||||
response.Billing = &apiAccountBilling{
|
||||
Customer: true,
|
||||
Subscription: v.user.Billing.StripeSubscriptionID != "",
|
||||
Status: string(v.user.Billing.StripeSubscriptionStatus),
|
||||
PaidUntil: v.user.Billing.StripeSubscriptionPaidUntil.Unix(),
|
||||
CancelAt: v.user.Billing.StripeSubscriptionCancelAt.Unix(),
|
||||
Subscription: u.Billing.StripeSubscriptionID != "",
|
||||
Status: string(u.Billing.StripeSubscriptionStatus),
|
||||
Interval: string(u.Billing.StripeSubscriptionInterval),
|
||||
PaidUntil: u.Billing.StripeSubscriptionPaidUntil.Unix(),
|
||||
CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(),
|
||||
}
|
||||
}
|
||||
reservations, err := s.userManager.Reservations(v.user.Name)
|
||||
reservations, err := s.userManager.Reservations(u.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -111,6 +118,26 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
||||
})
|
||||
}
|
||||
}
|
||||
tokens, err := s.userManager.Tokens(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tokens) > 0 {
|
||||
response.Tokens = make([]*apiAccountTokenResponse, 0)
|
||||
for _, t := range tokens {
|
||||
var lastOrigin string
|
||||
if t.LastOrigin != netip.IPv4Unspecified() {
|
||||
lastOrigin = t.LastOrigin.String()
|
||||
}
|
||||
response.Tokens = append(response.Tokens, &apiAccountTokenResponse{
|
||||
Token: t.Value,
|
||||
Label: t.Label,
|
||||
LastAccess: t.LastAccess.Unix(),
|
||||
LastOrigin: lastOrigin,
|
||||
Expires: t.Expires.Unix(),
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response.Username = user.Everyone
|
||||
response.Role = string(user.RoleAnonymous)
|
||||
@@ -118,149 +145,213 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
||||
return s.writeJSON(w, response)
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountDelete(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||
if v.user.Billing.StripeSubscriptionID != "" {
|
||||
log.Info("Deleting user %s (billing customer: %s, billing subscription: %s)", v.user.Name, v.user.Billing.StripeCustomerID, v.user.Billing.StripeSubscriptionID)
|
||||
if v.user.Billing.StripeSubscriptionID != "" {
|
||||
if _, err := s.stripe.CancelSubscription(v.user.Billing.StripeSubscriptionID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Info("Deleting user %s", v.user.Name)
|
||||
func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiAccountDeleteRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if req.Password == "" {
|
||||
return errHTTPBadRequest
|
||||
}
|
||||
if err := s.userManager.RemoveUser(v.user.Name); err != nil {
|
||||
u := v.User()
|
||||
if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
|
||||
return errHTTPBadRequestIncorrectPasswordConfirmation
|
||||
}
|
||||
if u.Billing.StripeSubscriptionID != "" {
|
||||
logvr(v, r).Tag(tagStripe).Info("Canceling billing subscription for user %s", u.Name)
|
||||
if _, err := s.stripe.CancelSubscription(u.Billing.StripeSubscriptionID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.maybeRemoveMessagesAndExcessReservations(r, v, u, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Info("Marking user %s as deleted", u.Name)
|
||||
if err := s.userManager.MarkUserRemoved(u); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
newPassword, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit)
|
||||
req, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if req.Password == "" || req.NewPassword == "" {
|
||||
return errHTTPBadRequest
|
||||
}
|
||||
if err := s.userManager.ChangePassword(v.user.Name, newPassword.Password); err != nil {
|
||||
u := v.User()
|
||||
if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
|
||||
return errHTTPBadRequestIncorrectPasswordConfirmation
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Debug("Changing password for user %s", u.Name)
|
||||
if err := s.userManager.ChangePassword(u.Name, req.NewPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountTokenIssue(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||
// TODO rate limit
|
||||
token, err := s.userManager.CreateToken(v.user)
|
||||
func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var label string
|
||||
if req.Label != nil {
|
||||
label = *req.Label
|
||||
}
|
||||
expires := time.Now().Add(tokenExpiryDuration)
|
||||
if req.Expires != nil {
|
||||
expires = time.Unix(*req.Expires, 0)
|
||||
}
|
||||
u := v.User()
|
||||
logvr(v, r).
|
||||
Tag(tagAccount).
|
||||
Fields(log.Context{
|
||||
"token_label": label,
|
||||
"token_expires": expires,
|
||||
}).
|
||||
Debug("Creating token for user %s", u.Name)
|
||||
token, err := s.userManager.CreateToken(u.ID, label, expires, v.IP())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response := &apiAccountTokenResponse{
|
||||
Token: token.Value,
|
||||
Expires: token.Expires.Unix(),
|
||||
Token: token.Value,
|
||||
Label: token.Label,
|
||||
LastAccess: token.LastAccess.Unix(),
|
||||
LastOrigin: token.LastOrigin.String(),
|
||||
Expires: token.Expires.Unix(),
|
||||
}
|
||||
return s.writeJSON(w, response)
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountTokenExtend(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||
// TODO rate limit
|
||||
if v.user == nil {
|
||||
return errHTTPUnauthorized
|
||||
} else if v.user.Token == "" {
|
||||
return errHTTPBadRequestNoTokenProvided
|
||||
func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
|
||||
if err != nil {
|
||||
return err
|
||||
} else if req.Token == "" {
|
||||
req.Token = u.Token
|
||||
if req.Token == "" {
|
||||
return errHTTPBadRequestNoTokenProvided
|
||||
}
|
||||
}
|
||||
token, err := s.userManager.ExtendToken(v.user)
|
||||
var expires *time.Time
|
||||
if req.Expires != nil {
|
||||
expires = util.Time(time.Unix(*req.Expires, 0))
|
||||
} else if req.Label == nil {
|
||||
expires = util.Time(time.Now().Add(tokenExpiryDuration)) // If label/expires not set, extend token by 72 hours
|
||||
}
|
||||
logvr(v, r).
|
||||
Tag(tagAccount).
|
||||
Fields(log.Context{
|
||||
"token_label": req.Label,
|
||||
"token_expires": expires,
|
||||
}).
|
||||
Debug("Updating token for user %s as deleted", u.Name)
|
||||
token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response := &apiAccountTokenResponse{
|
||||
Token: token.Value,
|
||||
Expires: token.Expires.Unix(),
|
||||
Token: token.Value,
|
||||
Label: token.Label,
|
||||
LastAccess: token.LastAccess.Unix(),
|
||||
LastOrigin: token.LastOrigin.String(),
|
||||
Expires: token.Expires.Unix(),
|
||||
}
|
||||
return s.writeJSON(w, response)
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||
// TODO rate limit
|
||||
if v.user.Token == "" {
|
||||
return errHTTPBadRequestNoTokenProvided
|
||||
func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
token := readParam(r, "X-Token", "Token") // DELETEs cannot have a body, and we don't want it in the path
|
||||
if token == "" {
|
||||
token = u.Token
|
||||
if token == "" {
|
||||
return errHTTPBadRequestNoTokenProvided
|
||||
}
|
||||
}
|
||||
if err := s.userManager.RemoveToken(v.user); err != nil {
|
||||
if err := s.userManager.RemoveToken(u.ID, token); err != nil {
|
||||
return err
|
||||
}
|
||||
logvr(v, r).
|
||||
Tag(tagAccount).
|
||||
Field("token", token).
|
||||
Debug("Deleted token for user %s", u.Name)
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit)
|
||||
newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v.user.Prefs == nil {
|
||||
v.user.Prefs = &user.Prefs{}
|
||||
u := v.User()
|
||||
if u.Prefs == nil {
|
||||
u.Prefs = &user.Prefs{}
|
||||
}
|
||||
prefs := v.user.Prefs
|
||||
if newPrefs.Language != "" {
|
||||
prefs := u.Prefs
|
||||
if newPrefs.Language != nil {
|
||||
prefs.Language = newPrefs.Language
|
||||
}
|
||||
if newPrefs.Notification != nil {
|
||||
if prefs.Notification == nil {
|
||||
prefs.Notification = &user.NotificationPrefs{}
|
||||
}
|
||||
if newPrefs.Notification.DeleteAfter > 0 {
|
||||
if newPrefs.Notification.DeleteAfter != nil {
|
||||
prefs.Notification.DeleteAfter = newPrefs.Notification.DeleteAfter
|
||||
}
|
||||
if newPrefs.Notification.Sound != "" {
|
||||
if newPrefs.Notification.Sound != nil {
|
||||
prefs.Notification.Sound = newPrefs.Notification.Sound
|
||||
}
|
||||
if newPrefs.Notification.MinPriority > 0 {
|
||||
if newPrefs.Notification.MinPriority != nil {
|
||||
prefs.Notification.MinPriority = newPrefs.Notification.MinPriority
|
||||
}
|
||||
}
|
||||
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Debug("Changing account settings for user %s", u.Name)
|
||||
if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit)
|
||||
newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v.user.Prefs == nil {
|
||||
v.user.Prefs = &user.Prefs{}
|
||||
u := v.User()
|
||||
prefs := u.Prefs
|
||||
if prefs == nil {
|
||||
prefs = &user.Prefs{}
|
||||
}
|
||||
newSubscription.ID = "" // Client cannot set ID
|
||||
for _, subscription := range v.user.Prefs.Subscriptions {
|
||||
for _, subscription := range prefs.Subscriptions {
|
||||
if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic {
|
||||
newSubscription = subscription
|
||||
break
|
||||
return errHTTPConflictSubscriptionExists
|
||||
}
|
||||
}
|
||||
if newSubscription.ID == "" {
|
||||
newSubscription.ID = util.RandomString(subscriptionIDLength)
|
||||
v.user.Prefs.Subscriptions = append(v.user.Prefs.Subscriptions, newSubscription)
|
||||
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
||||
return err
|
||||
}
|
||||
prefs.Subscriptions = append(prefs.Subscriptions, newSubscription)
|
||||
logvr(v, r).Tag(tagAccount).With(newSubscription).Debug("Adding subscription for user %s", u.Name)
|
||||
if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSubscription)
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
matches := apiAccountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
|
||||
if len(matches) != 2 {
|
||||
return errHTTPInternalErrorInvalidPath
|
||||
}
|
||||
subscriptionID := matches[1]
|
||||
updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit)
|
||||
updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil {
|
||||
u := v.User()
|
||||
prefs := u.Prefs
|
||||
if prefs == nil || prefs.Subscriptions == nil {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
var subscription *user.Subscription
|
||||
for _, sub := range v.user.Prefs.Subscriptions {
|
||||
if sub.ID == subscriptionID {
|
||||
for _, sub := range prefs.Subscriptions {
|
||||
if sub.BaseURL == updatedSubscription.BaseURL && sub.Topic == updatedSubscription.Topic {
|
||||
sub.DisplayName = updatedSubscription.DisplayName
|
||||
subscription = sub
|
||||
break
|
||||
@@ -269,41 +360,45 @@ func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.
|
||||
if subscription == nil {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).With(subscription).Debug("Changing subscription for user %s", u.Name)
|
||||
if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, subscription)
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
matches := apiAccountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
|
||||
if len(matches) != 2 {
|
||||
return errHTTPInternalErrorInvalidPath
|
||||
}
|
||||
subscriptionID := matches[1]
|
||||
if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil {
|
||||
// DELETEs cannot have a body, and we don't want it in the path
|
||||
deleteBaseURL := readParam(r, "X-BaseURL", "BaseURL")
|
||||
deleteTopic := readParam(r, "X-Topic", "Topic")
|
||||
u := v.User()
|
||||
prefs := u.Prefs
|
||||
if prefs == nil || prefs.Subscriptions == nil {
|
||||
return nil
|
||||
}
|
||||
newSubscriptions := make([]*user.Subscription, 0)
|
||||
for _, subscription := range v.user.Prefs.Subscriptions {
|
||||
if subscription.ID != subscriptionID {
|
||||
newSubscriptions = append(newSubscriptions, subscription)
|
||||
for _, sub := range u.Prefs.Subscriptions {
|
||||
if sub.BaseURL == deleteBaseURL && sub.Topic == deleteTopic {
|
||||
logvr(v, r).Tag(tagAccount).With(sub).Debug("Removing subscription for user %s", u.Name)
|
||||
} else {
|
||||
newSubscriptions = append(newSubscriptions, sub)
|
||||
}
|
||||
}
|
||||
if len(newSubscriptions) < len(v.user.Prefs.Subscriptions) {
|
||||
v.user.Prefs.Subscriptions = newSubscriptions
|
||||
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
||||
if len(newSubscriptions) < len(prefs.Subscriptions) {
|
||||
prefs.Subscriptions = newSubscriptions
|
||||
if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// handleAccountReservationAdd adds a topic reservation for the logged-in user, but only if the user has a tier
|
||||
// with enough remaining reservations left, or if the user is an admin. Admins can always reserve a topic, unless
|
||||
// it is already reserved by someone else.
|
||||
func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if v.user != nil && v.user.Role == user.RoleAdmin {
|
||||
return errHTTPBadRequestMakesNoSenseForAdmin
|
||||
}
|
||||
req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit)
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -314,30 +409,46 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
|
||||
if err != nil {
|
||||
return errHTTPBadRequestPermissionInvalid
|
||||
}
|
||||
if v.user.Tier == nil {
|
||||
// Check if we are allowed to reserve this topic
|
||||
if u.IsUser() && u.Tier == nil {
|
||||
return errHTTPUnauthorized
|
||||
}
|
||||
if err := s.userManager.CheckAllowAccess(v.user.Name, req.Topic); err != nil {
|
||||
} else if err := s.userManager.AllowReservation(u.Name, req.Topic); err != nil {
|
||||
return errHTTPConflictTopicReserved
|
||||
} else if u.IsUser() {
|
||||
hasReservation, err := s.userManager.HasReservation(u.Name, req.Topic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasReservation {
|
||||
reservations, err := s.userManager.ReservationsCount(u.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if reservations >= u.Tier.ReservationLimit {
|
||||
return errHTTPTooManyRequestsLimitReservations
|
||||
}
|
||||
}
|
||||
}
|
||||
hasReservation, err := s.userManager.HasReservation(v.user.Name, req.Topic)
|
||||
// Actually add the reservation
|
||||
logvr(v, r).
|
||||
Tag(tagAccount).
|
||||
Fields(log.Context{
|
||||
"topic": req.Topic,
|
||||
"everyone": everyone.String(),
|
||||
}).
|
||||
Debug("Adding topic reservation")
|
||||
if err := s.userManager.AddReservation(u.Name, req.Topic, everyone); err != nil {
|
||||
return err
|
||||
}
|
||||
// Kill existing subscribers
|
||||
t, err := s.topicFromID(req.Topic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasReservation {
|
||||
reservations, err := s.userManager.ReservationsCount(v.user.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if reservations >= v.user.Tier.ReservationsLimit {
|
||||
return errHTTPTooManyRequestsLimitReservations
|
||||
}
|
||||
}
|
||||
if err := s.userManager.ReserveAccess(v.user.Name, req.Topic, everyone); err != nil {
|
||||
return err
|
||||
}
|
||||
t.CancelSubscribers(u.ID)
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// handleAccountReservationDelete deletes a topic reservation if it is owned by the current user
|
||||
func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
matches := apiAccountReservationSingleRegex.FindStringSubmatch(r.URL.Path)
|
||||
if len(matches) != 2 {
|
||||
@@ -347,30 +458,79 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R
|
||||
if !topicRegex.MatchString(topic) {
|
||||
return errHTTPBadRequestTopicInvalid
|
||||
}
|
||||
authorized, err := s.userManager.HasReservation(v.user.Name, topic)
|
||||
u := v.User()
|
||||
authorized, err := s.userManager.HasReservation(u.Name, topic)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !authorized {
|
||||
return errHTTPUnauthorized
|
||||
}
|
||||
if err := s.userManager.RemoveReservations(v.user.Name, topic); err != nil {
|
||||
deleteMessages := readBoolParam(r, false, "X-Delete-Messages", "Delete-Messages")
|
||||
logvr(v, r).
|
||||
Tag(tagAccount).
|
||||
Fields(log.Context{
|
||||
"topic": topic,
|
||||
"delete_messages": deleteMessages,
|
||||
}).
|
||||
Debug("Removing topic reservation")
|
||||
if err := s.userManager.RemoveReservations(u.Name, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
if deleteMessages {
|
||||
if err := s.messageCache.ExpireMessages(topic); err != nil {
|
||||
return err
|
||||
}
|
||||
s.pruneMessages()
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) publishSyncEvent(v *visitor) error {
|
||||
if v.user == nil || v.user.SyncTopic == "" {
|
||||
return nil
|
||||
}
|
||||
log.Trace("Publishing sync event to user %s's sync topic %s", v.user.Name, v.user.SyncTopic)
|
||||
topics, err := s.topicsFromIDs(v.user.SyncTopic)
|
||||
// maybeRemoveMessagesAndExcessReservations deletes topic reservations for the given user (if too many for tier),
|
||||
// and marks associated messages for the topics as deleted. This also eventually deletes attachments.
|
||||
// The process relies on the manager to perform the actual deletions (see runManager).
|
||||
func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *visitor, u *user.User, reservationsLimit int64) error {
|
||||
reservations, err := s.userManager.Reservations(u.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if int64(len(reservations)) <= reservationsLimit {
|
||||
logvr(v, r).Tag(tagAccount).Debug("No excess reservations to remove")
|
||||
return nil
|
||||
}
|
||||
topics := make([]string, 0)
|
||||
for i := int64(len(reservations)) - 1; i >= reservationsLimit; i-- {
|
||||
topics = append(topics, reservations[i].Topic)
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Info("Removing excess reservations for topics %s", strings.Join(topics, ", "))
|
||||
if err := s.userManager.RemoveReservations(u.Name, topics...); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.messageCache.ExpireMessages(topics...); err != nil {
|
||||
return err
|
||||
}
|
||||
go s.pruneMessages()
|
||||
return nil
|
||||
}
|
||||
|
||||
// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
|
||||
func (s *Server) publishSyncEventAsync(v *visitor) {
|
||||
go func() {
|
||||
if err := s.publishSyncEvent(v); err != nil {
|
||||
logv(v).Err(err).Trace("Error publishing to user's sync topic")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// publishSyncEvent publishes a sync message to the user's sync topic
|
||||
func (s *Server) publishSyncEvent(v *visitor) error {
|
||||
u := v.User()
|
||||
if u == nil || u.SyncTopic == "" {
|
||||
return nil
|
||||
}
|
||||
logv(v).Field("sync_topic", u.SyncTopic).Trace("Publishing sync event to user's sync topic")
|
||||
syncTopic, err := s.topicFromID(u.SyncTopic)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if len(topics) == 0 {
|
||||
return errors.New("cannot retrieve sync topic")
|
||||
}
|
||||
syncTopic := topics[0]
|
||||
messageBytes, err := json.Marshal(&apiAccountSyncTopicResponse{Event: syncTopicAccountSyncEvent})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -381,14 +541,3 @@ func (s *Server) publishSyncEvent(v *visitor) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) publishSyncEventAsync(v *visitor) {
|
||||
go func() {
|
||||
if v.user == nil || v.user.SyncTopic == "" {
|
||||
return
|
||||
}
|
||||
if err := s.publishSyncEvent(v); err != nil {
|
||||
log.Trace("Error publishing to user %s's sync topic %s: %s", v.user.Name, v.user.SyncTopic, err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -3,9 +3,13 @@ package server
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/user"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -14,6 +18,7 @@ func TestAccount_Signup_Success(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
@@ -25,6 +30,10 @@ func TestAccount_Signup_Success(t *testing.T) {
|
||||
token, _ := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
||||
require.NotEmpty(t, token.Token)
|
||||
require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires)
|
||||
require.True(t, strings.HasPrefix(token.Token, "tk_"))
|
||||
require.Equal(t, "9.9.9.9", token.LastOrigin)
|
||||
require.True(t, token.LastAccess > time.Now().Unix()-2)
|
||||
require.True(t, token.LastAccess < time.Now().Unix()+2)
|
||||
|
||||
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||
"Authorization": util.BearerAuth(token.Token),
|
||||
@@ -33,12 +42,20 @@ func TestAccount_Signup_Success(t *testing.T) {
|
||||
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Equal(t, "phil", account.Username)
|
||||
require.Equal(t, "user", account.Role)
|
||||
|
||||
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("", token.Token), // We allow a fake basic auth to make curl-ing easier (curl -u :<token>)
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Equal(t, "phil", account.Username)
|
||||
}
|
||||
|
||||
func TestAccount_Signup_UserExists(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
@@ -52,6 +69,7 @@ func TestAccount_Signup_LimitReached(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
|
||||
@@ -66,15 +84,18 @@ func TestAccount_Signup_AsUser(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, "unit-test"))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, "unit-test"))
|
||||
|
||||
log.Info("1")
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
|
||||
log.Info("2")
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
||||
log.Info("3")
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"emma", "password":"emma"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
log.Info("4")
|
||||
rr = request(t, s, "POST", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
@@ -85,12 +106,27 @@ func TestAccount_Signup_Disabled(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.EnableSignup = false
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 40022, toHTTPError(t, rr.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestAccount_Signup_Rate_Limit(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
|
||||
require.Equal(t, 200, rr.Code, "failed on iteration %d", i)
|
||||
}
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"notallowed", "password":"mypass"}`, nil)
|
||||
require.Equal(t, 429, rr.Code)
|
||||
require.Equal(t, 42906, toHTTPError(t, rr.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestAccount_Get_Anonymous(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.VisitorRequestLimitReplenish = 86 * time.Second
|
||||
@@ -99,6 +135,7 @@ func TestAccount_Get_Anonymous(t *testing.T) {
|
||||
conf.AttachmentFileSizeLimit = 512
|
||||
s := newTestServer(t, conf)
|
||||
s.smtpSender = &testMailer{}
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
@@ -133,9 +170,11 @@ func TestAccount_Get_Anonymous(t *testing.T) {
|
||||
|
||||
func TestAccount_ChangeSettings(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||
user, _ := s.userManager.User("phil")
|
||||
token, _ := s.userManager.CreateToken(user)
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
u, _ := s.userManager.User("phil")
|
||||
token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0), netip.IPv4Unspecified())
|
||||
|
||||
rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
@@ -153,14 +192,16 @@ func TestAccount_ChangeSettings(t *testing.T) {
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Equal(t, "de", account.Language)
|
||||
require.Equal(t, 86400, account.Notification.DeleteAfter)
|
||||
require.Equal(t, "juntos", account.Notification.Sound)
|
||||
require.Equal(t, 0, account.Notification.MinPriority) // Not set
|
||||
require.Equal(t, util.Int(86400), account.Notification.DeleteAfter)
|
||||
require.Equal(t, util.String("juntos"), account.Notification.Sound)
|
||||
require.Nil(t, account.Notification.MinPriority) // Not set
|
||||
}
|
||||
|
||||
func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
@@ -173,13 +214,11 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Equal(t, 1, len(account.Subscriptions))
|
||||
require.NotEmpty(t, account.Subscriptions[0].ID)
|
||||
require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
|
||||
require.Equal(t, "def", account.Subscriptions[0].Topic)
|
||||
require.Equal(t, "", account.Subscriptions[0].DisplayName)
|
||||
require.Nil(t, account.Subscriptions[0].DisplayName)
|
||||
|
||||
subscriptionID := account.Subscriptions[0].ID
|
||||
rr = request(t, s, "PATCH", "/v1/account/subscription/"+subscriptionID, `{"display_name": "ding dong"}`, map[string]string{
|
||||
rr = request(t, s, "PATCH", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def", "display_name": "ding dong"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
@@ -190,13 +229,14 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Equal(t, 1, len(account.Subscriptions))
|
||||
require.Equal(t, subscriptionID, account.Subscriptions[0].ID)
|
||||
require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
|
||||
require.Equal(t, "def", account.Subscriptions[0].Topic)
|
||||
require.Equal(t, "ding dong", account.Subscriptions[0].DisplayName)
|
||||
require.Equal(t, util.String("ding dong"), account.Subscriptions[0].DisplayName)
|
||||
|
||||
rr = request(t, s, "DELETE", "/v1/account/subscription/"+subscriptionID, "", map[string]string{
|
||||
rr = request(t, s, "DELETE", "/v1/account/subscription", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
"X-BaseURL": "http://abc.com",
|
||||
"X-Topic": "def",
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
@@ -210,9 +250,22 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
||||
|
||||
func TestAccount_ChangePassword(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/password", `{"password": "new password"}`, map[string]string{
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/password", `{"password": "WRONG", "new_password": ""}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 400, rr.Code)
|
||||
|
||||
rr = request(t, s, "POST", "/v1/account/password", `{"password": "WRONG", "new_password": "new password"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code)
|
||||
|
||||
rr = request(t, s, "POST", "/v1/account/password", `{"password": "phil", "new_password": "new password"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
@@ -230,14 +283,18 @@ func TestAccount_ChangePassword(t *testing.T) {
|
||||
|
||||
func TestAccount_ChangePassword_NoAccount(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/password", `{"password": "new password"}`, nil)
|
||||
require.Equal(t, 401, rr.Code)
|
||||
}
|
||||
|
||||
func TestAccount_ExtendToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
@@ -256,11 +313,24 @@ func TestAccount_ExtendToken(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, token.Token, extendedToken.Token)
|
||||
require.True(t, token.Expires < extendedToken.Expires)
|
||||
|
||||
expires := time.Now().Add(999 * time.Hour)
|
||||
body := fmt.Sprintf(`{"token":"%s", "label":"some label", "expires": %d}`, token.Token, expires.Unix())
|
||||
rr = request(t, s, "PATCH", "/v1/account/token", body, map[string]string{
|
||||
"Authorization": util.BearerAuth(token.Token),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
token, err = util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "some label", token.Label)
|
||||
require.Equal(t, expires.Unix(), token.Expires)
|
||||
}
|
||||
|
||||
func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
|
||||
rr := request(t, s, "PATCH", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"), // Not Bearer!
|
||||
@@ -271,7 +341,9 @@ func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) {
|
||||
|
||||
func TestAccount_DeleteToken(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
@@ -279,6 +351,7 @@ func TestAccount_DeleteToken(t *testing.T) {
|
||||
require.Equal(t, 200, rr.Code)
|
||||
token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
||||
require.Nil(t, err)
|
||||
require.True(t, token.Expires > time.Now().Add(71*time.Hour).Unix())
|
||||
|
||||
// Delete token failure (using basic auth)
|
||||
rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{
|
||||
@@ -319,15 +392,20 @@ func TestAccount_Delete_Success(t *testing.T) {
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
rr = request(t, s, "DELETE", "/v1/account", "", map[string]string{
|
||||
rr = request(t, s, "DELETE", "/v1/account", `{"password":"mypass"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Account was marked deleted
|
||||
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Cannot re-create account, since still exists
|
||||
rr = request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||
require.Equal(t, 409, rr.Code)
|
||||
}
|
||||
|
||||
func TestAccount_Delete_Not_Allowed(t *testing.T) {
|
||||
@@ -340,6 +418,15 @@ func TestAccount_Delete_Not_Allowed(t *testing.T) {
|
||||
|
||||
rr = request(t, s, "DELETE", "/v1/account", "", nil)
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
rr = request(t, s, "DELETE", "/v1/account", `{"password":"mypass"}`, nil)
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
rr = request(t, s, "DELETE", "/v1/account", `{"password":"INCORRECT"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestAccount_Reservation_AddWithoutTierFails(t *testing.T) {
|
||||
@@ -360,13 +447,52 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
require.Nil(t, s.userManager.AddUser("phil", "adminpass", user.RoleAdmin, "unit-test"))
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{
|
||||
// A user, an admin, and a reservation walk into a bar
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
ReservationLimit: 2,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("noadmin1", "pass", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("noadmin1", "pro"))
|
||||
require.Nil(t, s.userManager.AddReservation("noadmin1", "mytopic", user.PermissionDenyAll))
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("noadmin2", "pass", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("noadmin2", "pro"))
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "adminpass", user.RoleAdmin))
|
||||
|
||||
// Admin can reserve topic
|
||||
rr := request(t, s, "POST", "/v1/account/reservation", `{"topic":"sometopic","everyone":"deny-all"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "adminpass"),
|
||||
})
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// User cannot reserve already reserved topic
|
||||
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("noadmin2", "pass"),
|
||||
})
|
||||
require.Equal(t, 409, rr.Code)
|
||||
|
||||
// Admin cannot reserve already reserved topic
|
||||
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "adminpass"),
|
||||
})
|
||||
require.Equal(t, 409, rr.Code)
|
||||
|
||||
reservations, err := s.userManager.Reservations("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(reservations))
|
||||
require.Equal(t, "sometopic", reservations[0].Topic)
|
||||
|
||||
reservations, err = s.userManager.Reservations("noadmin1")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(reservations))
|
||||
require.Equal(t, "mytopic", reservations[0].Topic)
|
||||
|
||||
reservations, err = s.userManager.Reservations("noadmin2")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(reservations))
|
||||
}
|
||||
|
||||
func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||
@@ -379,16 +505,16 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Create a tier
|
||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
Paid: false,
|
||||
MessagesLimit: 123,
|
||||
MessagesExpiryDuration: 86400 * time.Second,
|
||||
EmailsLimit: 32,
|
||||
ReservationsLimit: 2,
|
||||
MessageLimit: 123,
|
||||
MessageExpiryDuration: 86400 * time.Second,
|
||||
EmailLimit: 32,
|
||||
ReservationLimit: 2,
|
||||
AttachmentFileSizeLimit: 1231231,
|
||||
AttachmentTotalSizeLimit: 123123,
|
||||
AttachmentExpiryDuration: 10800 * time.Second,
|
||||
AttachmentBandwidthLimit: 21474836480,
|
||||
}))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
|
||||
@@ -429,6 +555,7 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||
require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
|
||||
require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)
|
||||
require.Equal(t, int64(10800), account.Limits.AttachmentExpiryDuration)
|
||||
require.Equal(t, int64(21474836480), account.Limits.AttachmentBandwidth)
|
||||
require.Equal(t, 2, len(account.Reservations))
|
||||
require.Equal(t, "another", account.Reservations[0].Topic)
|
||||
require.Equal(t, "write-only", account.Reservations[0].Everyone)
|
||||
@@ -460,10 +587,10 @@ func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessagesLimit: 20,
|
||||
ReservationsLimit: 2,
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 20,
|
||||
ReservationLimit: 2,
|
||||
}))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
|
||||
@@ -483,3 +610,157 @@ func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {
|
||||
rr = request(t, s, "POST", "/mytopic", `Howdy`, nil)
|
||||
require.Equal(t, 403, rr.Code)
|
||||
}
|
||||
|
||||
func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||
t.Parallel()
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.AuthDefault = user.PermissionReadWrite
|
||||
s := newTestServer(t, conf)
|
||||
|
||||
// Create user with tier
|
||||
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 20,
|
||||
MessageExpiryDuration: time.Hour,
|
||||
ReservationLimit: 2,
|
||||
AttachmentTotalSizeLimit: 10000,
|
||||
AttachmentFileSizeLimit: 10000,
|
||||
AttachmentExpiryDuration: time.Hour,
|
||||
AttachmentBandwidthLimit: 10000,
|
||||
}))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
|
||||
// Reserve two topics "mytopic1" and "mytopic2"
|
||||
rr := request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic1", "everyone":"deny-all"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic2", "everyone":"deny-all"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Publish a message with attachment to each topic
|
||||
rr = request(t, s, "POST", "/mytopic1?f=attach.txt", `Howdy`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
m1 := toMessage(t, rr.Body.String())
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
||||
|
||||
rr = request(t, s, "POST", "/mytopic2?f=attach.txt", `Howdy`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
m2 := toMessage(t, rr.Body.String())
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
|
||||
|
||||
// Pre-verify message count and file
|
||||
ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(ms))
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
||||
|
||||
ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(ms))
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
|
||||
|
||||
// Delete reservation
|
||||
rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic1", ``, map[string]string{
|
||||
"X-Delete-Messages": "true",
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic2", ``, map[string]string{
|
||||
"X-Delete-Messages": "false",
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Verify that messages and attachments were deleted
|
||||
// This does not explicitly call the manager!
|
||||
waitFor(t, func() bool {
|
||||
ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
return len(ms) == 0 && !util.FileExists(filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
||||
})
|
||||
|
||||
ms, err = s.messageCache.Messages("mytopic1", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(ms))
|
||||
require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
||||
|
||||
ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(ms))
|
||||
require.Equal(t, m2.ID, ms[0].ID)
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
|
||||
}
|
||||
|
||||
func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
t.Parallel()
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.AuthDefault = user.PermissionReadWrite
|
||||
conf.AuthStatsQueueWriterInterval = 100 * time.Millisecond
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create user with tier
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "starter",
|
||||
MessageLimit: 10,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 20,
|
||||
}))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "starter"))
|
||||
|
||||
// Publish a message
|
||||
rr := request(t, s, "POST", "/mytopic", "hi", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// 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"))
|
||||
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Equal(t, int64(1), account.Stats.Messages) // Is not reset!
|
||||
|
||||
// Publish another message
|
||||
rr = request(t, s, "POST", "/mytopic", "hi", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Verify that message stats were persisted
|
||||
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{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Equal(t, int64(2), account.Stats.Messages) // Is not reset!
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"firebase.google.com/go/v4/messaging"
|
||||
"fmt"
|
||||
"google.golang.org/api/option"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/user"
|
||||
"heckel.io/ntfy/util"
|
||||
"strings"
|
||||
@@ -39,19 +38,23 @@ func newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClien
|
||||
}
|
||||
|
||||
func (c *firebaseClient) Send(v *visitor, m *message) error {
|
||||
if err := v.FirebaseAllowed(); err != nil {
|
||||
if !v.FirebaseAllowed() {
|
||||
return errFirebaseTemporarilyBanned
|
||||
}
|
||||
fbm, err := toFirebaseMessage(m, c.auther)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if log.IsTrace() {
|
||||
log.Trace("%s Firebase message: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(fbm))
|
||||
ev := logvm(v, m).Tag(tagFirebase)
|
||||
if ev.IsTrace() {
|
||||
ev.Field("firebase_message", util.MaybeMarshalJSON(fbm)).Trace("Firebase message")
|
||||
}
|
||||
err = c.sender.Send(fbm)
|
||||
if err == errFirebaseQuotaExceeded {
|
||||
log.Warn("%s Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor", logMessagePrefix(v, m))
|
||||
logvm(v, m).
|
||||
Tag(tagFirebase).
|
||||
Err(err).
|
||||
Warn("Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor")
|
||||
v.FirebaseTemporarilyDeny()
|
||||
}
|
||||
return err
|
||||
|
||||
166
server/server_manager.go
Normal file
166
server/server_manager.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"heckel.io/ntfy/log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) execManager() {
|
||||
// WARNING: Make sure to only selectively lock with the mutex, and be aware that this
|
||||
// there is no mutex for the entire function.
|
||||
|
||||
// Prune all the things
|
||||
s.pruneVisitors()
|
||||
s.pruneTokens()
|
||||
s.pruneAttachments()
|
||||
s.pruneMessages()
|
||||
|
||||
// Message count per topic
|
||||
var messagesCached int
|
||||
messageCounts, err := s.messageCache.MessageCounts()
|
||||
if err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Cannot get message counts")
|
||||
messageCounts = make(map[string]int) // Empty, so we can continue
|
||||
}
|
||||
for _, count := range messageCounts {
|
||||
messagesCached += count
|
||||
}
|
||||
|
||||
// Remove subscriptions without subscribers
|
||||
var emptyTopics, subscribers int
|
||||
log.
|
||||
Tag(tagManager).
|
||||
Timing(func() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, t := range s.topics {
|
||||
subs := t.SubscribersCount()
|
||||
log.Tag(tagManager).With(t).Trace("- topic %s: %d subscribers", t.ID, subs)
|
||||
msgs, exists := messageCounts[t.ID]
|
||||
if t.Stale() && (!exists || msgs == 0) {
|
||||
log.Tag(tagManager).With(t).Trace("Deleting empty topic %s", t.ID)
|
||||
emptyTopics++
|
||||
delete(s.topics, t.ID)
|
||||
continue
|
||||
}
|
||||
subscribers += subs
|
||||
}
|
||||
}).
|
||||
Debug("Removed %d empty topic(s)", emptyTopics)
|
||||
|
||||
// Mail stats
|
||||
var receivedMailTotal, receivedMailSuccess, receivedMailFailure int64
|
||||
if s.smtpServerBackend != nil {
|
||||
receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()
|
||||
}
|
||||
var sentMailTotal, sentMailSuccess, sentMailFailure int64
|
||||
if s.smtpSender != nil {
|
||||
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
|
||||
}
|
||||
|
||||
// Print stats
|
||||
s.mu.Lock()
|
||||
messagesCount, topicsCount, visitorsCount := s.messages, len(s.topics), len(s.visitors)
|
||||
s.mu.Unlock()
|
||||
log.
|
||||
Tag(tagManager).
|
||||
Fields(log.Context{
|
||||
"messages_published": messagesCount,
|
||||
"messages_cached": messagesCached,
|
||||
"topics_active": topicsCount,
|
||||
"subscribers": subscribers,
|
||||
"visitors": visitorsCount,
|
||||
"emails_received": receivedMailTotal,
|
||||
"emails_received_success": receivedMailSuccess,
|
||||
"emails_received_failure": receivedMailFailure,
|
||||
"emails_sent": sentMailTotal,
|
||||
"emails_sent_success": sentMailSuccess,
|
||||
"emails_sent_failure": sentMailFailure,
|
||||
}).
|
||||
Info("Server stats")
|
||||
}
|
||||
|
||||
func (s *Server) pruneVisitors() {
|
||||
staleVisitors := 0
|
||||
log.
|
||||
Tag(tagManager).
|
||||
Timing(func() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for ip, v := range s.visitors {
|
||||
if v.Stale() {
|
||||
log.Tag(tagManager).With(v).Trace("Deleting stale visitor")
|
||||
delete(s.visitors, ip)
|
||||
staleVisitors++
|
||||
}
|
||||
}
|
||||
}).
|
||||
Field("stale_visitors", staleVisitors).
|
||||
Debug("Deleted %d stale visitor(s)", staleVisitors)
|
||||
}
|
||||
|
||||
func (s *Server) pruneTokens() {
|
||||
if s.userManager != nil {
|
||||
log.
|
||||
Tag(tagManager).
|
||||
Timing(func() {
|
||||
if err := s.userManager.RemoveExpiredTokens(); err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error expiring user tokens")
|
||||
}
|
||||
if err := s.userManager.RemoveDeletedUsers(); err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error deleting soft-deleted users")
|
||||
}
|
||||
}).
|
||||
Debug("Removed expired tokens and users")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pruneAttachments() {
|
||||
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() {
|
||||
log.
|
||||
Tag(tagManager).
|
||||
Timing(func() {
|
||||
expiredMessageIDs, err := s.messageCache.MessagesExpired()
|
||||
if err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error retrieving expired messages")
|
||||
} else if len(expiredMessageIDs) > 0 {
|
||||
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")
|
||||
}
|
||||
} else {
|
||||
log.Tag(tagManager).Debug("No expired messages to delete")
|
||||
}
|
||||
}).
|
||||
Debug("Pruned messages")
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -72,25 +71,19 @@ type matrixResponse struct {
|
||||
Rejected []string `json:"rejected"`
|
||||
}
|
||||
|
||||
// errMatrix represents an error when handing Matrix gateway messages
|
||||
type errMatrix struct {
|
||||
pushKey string
|
||||
err error
|
||||
// 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 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)
|
||||
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)
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// 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.
|
||||
//
|
||||
@@ -123,17 +116,16 @@ 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)
|
||||
return newRequest, nil
|
||||
}
|
||||
|
||||
@@ -145,12 +137,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 {
|
||||
log.Debug("%s Matrix gateway error: %s", logHTTPPrefix(v, r), err.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())
|
||||
}
|
||||
|
||||
@@ -2,8 +2,52 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
type contextKey int
|
||||
|
||||
const (
|
||||
contextRateVisitor contextKey = iota + 2586
|
||||
contextTopic
|
||||
)
|
||||
|
||||
func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) {
|
||||
return next(w, r, v)
|
||||
} else if !v.RequestAllowed() {
|
||||
return errHTTPTooManyRequestsLimitRequests
|
||||
}
|
||||
return next(w, r, v)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -24,7 +68,7 @@ func (s *Server) ensureUserManager(next handleFunc) handleFunc {
|
||||
|
||||
func (s *Server) ensureUser(next handleFunc) handleFunc {
|
||||
return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if v.user == nil {
|
||||
if v.User() == nil {
|
||||
return errHTTPUnauthorized
|
||||
}
|
||||
return next(w, r, v)
|
||||
@@ -42,7 +86,7 @@ func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
|
||||
|
||||
func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc {
|
||||
return s.ensureUser(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if v.user.Billing.StripeCustomerID == "" {
|
||||
if v.User().Billing.StripeCustomerID == "" {
|
||||
return errHTTPBadRequestNotAPaidUser
|
||||
}
|
||||
return next(w, r, v)
|
||||
@@ -51,9 +95,6 @@ func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc {
|
||||
|
||||
func (s *Server) withAccountSync(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if v.user == nil {
|
||||
return next(w, r, v)
|
||||
}
|
||||
err := next(w, r, v)
|
||||
if err == nil {
|
||||
s.publishSyncEventAsync(v)
|
||||
|
||||
@@ -2,7 +2,6 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
@@ -21,12 +20,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
errNotAPaidTier = errors.New("tier does not have billing price identifier")
|
||||
errMultipleBillingSubscriptions = errors.New("cannot have multiple billing subscriptions")
|
||||
errNoBillingSubscription = errors.New("user does not have an active billing subscription")
|
||||
)
|
||||
|
||||
// Payments in ntfy are done via Stripe.
|
||||
//
|
||||
// Pretty much all payments related things are in this file. The following processes
|
||||
@@ -49,21 +42,32 @@ var (
|
||||
// This is used to keep the local user database fields up to date. Stripe is the source of truth.
|
||||
// What Stripe says is mirrored and not questioned.
|
||||
|
||||
var (
|
||||
errNotAPaidTier = errors.New("tier does not have billing price identifier")
|
||||
errMultipleBillingSubscriptions = errors.New("cannot have multiple billing subscriptions")
|
||||
errNoBillingSubscription = errors.New("user does not have an active billing subscription")
|
||||
)
|
||||
|
||||
var (
|
||||
retryUserDelays = []time.Duration{3 * time.Second, 5 * time.Second, 7 * time.Second}
|
||||
)
|
||||
|
||||
// handleBillingTiersGet returns all available paid tiers, and the free tier. This is to populate the upgrade dialog
|
||||
// in the UI. Note that this endpoint does NOT have a user context (no v.user!).
|
||||
// in the UI. Note that this endpoint does NOT have a user context (no u!).
|
||||
func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
tiers, err := s.userManager.Tiers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
freeTier := defaultVisitorLimits(s.config)
|
||||
freeTier := configBasedVisitorLimits(s.config)
|
||||
response := []*apiAccountBillingTier{
|
||||
{
|
||||
// This is a bit of a hack: This is the "Free" tier. It has no tier code, name or price.
|
||||
Limits: &apiAccountLimits{
|
||||
Messages: freeTier.MessagesLimit,
|
||||
MessagesExpiryDuration: int64(freeTier.MessagesExpiryDuration.Seconds()),
|
||||
Emails: freeTier.EmailsLimit,
|
||||
Basis: string(visitorLimitBasisIP),
|
||||
Messages: freeTier.MessageLimit,
|
||||
MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()),
|
||||
Emails: freeTier.EmailLimit,
|
||||
Reservations: freeTier.ReservationsLimit,
|
||||
AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit,
|
||||
AttachmentFileSize: freeTier.AttachmentFileSizeLimit,
|
||||
@@ -76,19 +80,23 @@ 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{
|
||||
Messages: tier.MessagesLimit,
|
||||
MessagesExpiryDuration: int64(tier.MessagesExpiryDuration.Seconds()),
|
||||
Emails: tier.EmailsLimit,
|
||||
Reservations: tier.ReservationsLimit,
|
||||
Basis: string(visitorLimitBasisTier),
|
||||
Messages: tier.MessageLimit,
|
||||
MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()),
|
||||
Emails: tier.EmailLimit,
|
||||
Reservations: tier.ReservationLimit,
|
||||
AttachmentTotalSize: tier.AttachmentTotalSizeLimit,
|
||||
AttachmentFileSize: tier.AttachmentFileSizeLimit,
|
||||
AttachmentExpiryDuration: int64(tier.AttachmentExpiryDuration.Seconds()),
|
||||
@@ -101,24 +109,38 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
|
||||
// handleAccountBillingSubscriptionCreate creates a Stripe checkout flow to create a user subscription. The tier
|
||||
// will be updated by a subsequent webhook from Stripe, once the subscription becomes active.
|
||||
func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if v.user.Billing.StripeSubscriptionID != "" {
|
||||
u := v.User()
|
||||
if u.Billing.StripeSubscriptionID != "" {
|
||||
return errHTTPBadRequestBillingSubscriptionExists
|
||||
}
|
||||
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit)
|
||||
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
log.Info("Stripe: No existing subscription, creating checkout flow")
|
||||
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
|
||||
if v.user.Billing.StripeCustomerID != "" {
|
||||
stripeCustomerID = &v.user.Billing.StripeCustomerID
|
||||
stripeCustomer, err := s.stripe.GetCustomer(v.user.Billing.StripeCustomerID)
|
||||
if u.Billing.StripeCustomerID != "" {
|
||||
stripeCustomerID = &u.Billing.StripeCustomerID
|
||||
stripeCustomer, err := s.stripe.GetCustomer(u.Billing.StripeCustomerID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if stripeCustomer.Subscriptions != nil && len(stripeCustomer.Subscriptions.Data) > 0 {
|
||||
@@ -128,19 +150,19 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
|
||||
successURL := s.config.BaseURL + apiAccountBillingSubscriptionCheckoutSuccessTemplate
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
Customer: stripeCustomerID, // A user may have previously deleted their subscription
|
||||
ClientReferenceID: &v.user.Name,
|
||||
ClientReferenceID: &u.ID,
|
||||
SuccessURL: &successURL,
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
AllowPromotionCodes: stripe.Bool(true),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(tier.StripePriceID),
|
||||
Price: stripe.String(priceID),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
/*AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{
|
||||
AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{
|
||||
Enabled: stripe.Bool(true),
|
||||
},*/
|
||||
},
|
||||
}
|
||||
sess, err := s.stripe.NewCheckoutSession(params)
|
||||
if err != nil {
|
||||
@@ -155,8 +177,8 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
|
||||
// handleAccountBillingSubscriptionCreateSuccess is called after the Stripe checkout session has succeeded. We use
|
||||
// the session ID in the URL to retrieve the Stripe subscription and update the local database. This is the first
|
||||
// and only time we can map the local username with the Stripe customer ID.
|
||||
func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
// We don't have a v.user in this endpoint, only a userManager!
|
||||
func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
// We don't have v.User() in this endpoint, only a userManager!
|
||||
matches := apiAccountBillingSubscriptionCheckoutSuccessRegex.FindStringSubmatch(r.URL.Path)
|
||||
if len(matches) != 2 {
|
||||
return errHTTPInternalErrorInvalidPath
|
||||
@@ -166,23 +188,48 @@ 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
|
||||
}
|
||||
u, err := s.userManager.User(sess.ClientReferenceID)
|
||||
u, err := s.userManager.UserByID(sess.ClientReferenceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.updateSubscriptionAndTier(u, tier, sess.Customer.ID, sub.ID, string(sub.Status), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
|
||||
v.SetUser(u)
|
||||
logvr(v, r).
|
||||
With(tier).
|
||||
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")
|
||||
customerParams := &stripe.CustomerParams{
|
||||
Params: stripe.Params{
|
||||
Metadata: map[string]string{
|
||||
"user_id": u.ID,
|
||||
"user_name": u.Name,
|
||||
},
|
||||
},
|
||||
}
|
||||
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), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
|
||||
return err
|
||||
}
|
||||
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
|
||||
@@ -192,10 +239,11 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
|
||||
// handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates
|
||||
// a user's tier accordingly. This endpoint only works if there is an existing subscription.
|
||||
func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if v.user.Billing.StripeSubscriptionID == "" {
|
||||
u := v.User()
|
||||
if u.Billing.StripeSubscriptionID == "" {
|
||||
return errNoBillingSubscription
|
||||
}
|
||||
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit)
|
||||
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -203,18 +251,37 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Info("Stripe: Changing tier and subscription to %s", tier.Code)
|
||||
sub, err := s.stripe.GetSubscription(v.user.Billing.StripeSubscriptionID)
|
||||
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_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, %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 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),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -226,13 +293,16 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r
|
||||
}
|
||||
|
||||
// handleAccountBillingSubscriptionDelete facilitates downgrading a paid user to a tier-less user,
|
||||
// and cancelling the Stripe subscription entirely
|
||||
// and cancelling the Stripe subscription entirely. Note that this does not actually change the tier.
|
||||
// That is done by a webhook at the period end (in X days).
|
||||
func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if v.user.Billing.StripeSubscriptionID != "" {
|
||||
logvr(v, r).Tag(tagStripe).Info("Deleting Stripe subscription")
|
||||
u := v.User()
|
||||
if u.Billing.StripeSubscriptionID != "" {
|
||||
params := &stripe.SubscriptionParams{
|
||||
CancelAtPeriodEnd: stripe.Bool(true),
|
||||
}
|
||||
_, err := s.stripe.UpdateSubscription(v.user.Billing.StripeSubscriptionID, params)
|
||||
_, err := s.stripe.UpdateSubscription(u.Billing.StripeSubscriptionID, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -243,11 +313,13 @@ func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r
|
||||
// handleAccountBillingPortalSessionCreate creates a session to the customer billing portal, and returns the
|
||||
// redirect URL. The billing portal allows customers to change their payment methods, and cancel the subscription.
|
||||
func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if v.user.Billing.StripeCustomerID == "" {
|
||||
logvr(v, r).Tag(tagStripe).Info("Creating Stripe billing portal session")
|
||||
u := v.User()
|
||||
if u.Billing.StripeCustomerID == "" {
|
||||
return errHTTPBadRequestNotAPaidUser
|
||||
}
|
||||
params := &stripe.BillingPortalSessionParams{
|
||||
Customer: stripe.String(v.user.Billing.StripeCustomerID),
|
||||
Customer: stripe.String(u.Billing.StripeCustomerID),
|
||||
ReturnURL: stripe.String(s.config.BaseURL),
|
||||
}
|
||||
ps, err := s.stripe.NewPortalSession(params)
|
||||
@@ -262,8 +334,8 @@ func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter,
|
||||
|
||||
// handleAccountBillingWebhook handles incoming Stripe webhooks. It mainly keeps the local user database in sync
|
||||
// with the Stripe view of the world. This endpoint is authorized via the Stripe webhook secret. Note that the
|
||||
// visitor (v) in this endpoint is the Stripe API, so we don't have v.user available.
|
||||
func (s *Server) handleAccountBillingWebhook(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
// visitor (v) in this endpoint is the Stripe API, so we don't have u available.
|
||||
func (s *Server) handleAccountBillingWebhook(_ http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
stripeSignature := r.Header.Get("Stripe-Signature")
|
||||
if stripeSignature == "" {
|
||||
return errHTTPBadRequestBillingRequestInvalid
|
||||
@@ -280,89 +352,108 @@ func (s *Server) handleAccountBillingWebhook(w http.ResponseWriter, r *http.Requ
|
||||
} else if event.Data == nil || event.Data.Raw == nil {
|
||||
return errHTTPBadRequestBillingRequestInvalid
|
||||
}
|
||||
|
||||
log.Info("Stripe: webhook event %s received", event.Type)
|
||||
switch event.Type {
|
||||
case "customer.subscription.updated":
|
||||
return s.handleAccountBillingWebhookSubscriptionUpdated(event.Data.Raw)
|
||||
return s.handleAccountBillingWebhookSubscriptionUpdated(r, v, event)
|
||||
case "customer.subscription.deleted":
|
||||
return s.handleAccountBillingWebhookSubscriptionDeleted(event.Data.Raw)
|
||||
return s.handleAccountBillingWebhookSubscriptionDeleted(r, v, event)
|
||||
default:
|
||||
logvr(v, r).
|
||||
Tag(tagStripe).
|
||||
Field("stripe_webhook_type", event.Type).
|
||||
Warn("Unhandled Stripe webhook event %s received", event.Type)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(event json.RawMessage) error {
|
||||
r, err := util.UnmarshalJSON[apiStripeSubscriptionUpdatedEvent](io.NopCloser(bytes.NewReader(event)))
|
||||
func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(r *http.Request, v *visitor, event stripe.Event) error {
|
||||
ev, err := util.UnmarshalJSON[apiStripeSubscriptionUpdatedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw)))
|
||||
if err != nil {
|
||||
return err
|
||||
} else if r.ID == "" || r.Customer == "" || r.Status == "" || r.CurrentPeriodEnd == 0 || r.Items == nil || len(r.Items.Data) != 1 || r.Items.Data[0].Price == nil || r.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 := r.ID, r.Items.Data[0].Price.ID
|
||||
log.Info("Stripe: customer %s: Updating subscription to status %s, with price %s", r.Customer, r.Status, priceID)
|
||||
u, err := s.userManager.UserByStripeCustomer(r.Customer)
|
||||
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,
|
||||
}).
|
||||
Info("Updating subscription to status %s, with price %s", ev.Status, priceID)
|
||||
userFn := func() (*user.User, error) {
|
||||
return s.userManager.UserByStripeCustomer(ev.Customer)
|
||||
}
|
||||
// We retry the user retrieval function, because during the Stripe checkout, there a race between the browser
|
||||
// checkout success redirect (see handleAccountBillingSubscriptionCreateSuccess), and this webhook. The checkout
|
||||
// success call is the one that updates the user with the Stripe customer ID.
|
||||
u, err := util.Retry[user.User](userFn, retryUserDelays...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v.SetUser(u)
|
||||
tier, err := s.userManager.TierByStripePrice(priceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.updateSubscriptionAndTier(u, tier, r.Customer, subscriptionID, r.Status, r.CurrentPeriodEnd, r.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.visitorFromUser(u, netip.IPv4Unspecified()))
|
||||
s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(event json.RawMessage) error {
|
||||
r, err := util.UnmarshalJSON[apiStripeSubscriptionDeletedEvent](io.NopCloser(bytes.NewReader(event)))
|
||||
func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(r *http.Request, v *visitor, event stripe.Event) error {
|
||||
ev, err := util.UnmarshalJSON[apiStripeSubscriptionDeletedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw)))
|
||||
if err != nil {
|
||||
return err
|
||||
} else if r.Customer == "" {
|
||||
} else if ev.Customer == "" {
|
||||
return errHTTPBadRequestBillingRequestInvalid
|
||||
}
|
||||
log.Info("Stripe: customer %s: subscription deleted, downgrading to unpaid tier", r.Customer)
|
||||
u, err := s.userManager.UserByStripeCustomer(r.Customer)
|
||||
u, err := s.userManager.UserByStripeCustomer(ev.Customer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.updateSubscriptionAndTier(u, nil, r.Customer, "", "", 0, 0); err != nil {
|
||||
v.SetUser(u)
|
||||
logvr(v, r).
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
s.publishSyncEventAsync(s.visitorFromUser(u, netip.IPv4Unspecified()))
|
||||
s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateSubscriptionAndTier(u *user.User, tier *user.Tier, customerID, subscriptionID, status string, paidUntil, cancelAt int64) error {
|
||||
// Remove excess reservations (if too many for tier), and mark associated messages deleted
|
||||
reservations, err := s.userManager.Reservations(u.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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.ReservationsLimit
|
||||
reservationsLimit = tier.ReservationLimit
|
||||
}
|
||||
if int64(len(reservations)) > reservationsLimit {
|
||||
topics := make([]string, 0)
|
||||
for i := int64(len(reservations)) - 1; i >= reservationsLimit; i-- {
|
||||
topics = append(topics, reservations[i].Topic)
|
||||
}
|
||||
if err := s.userManager.RemoveReservations(u.Name, topics...); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.messageCache.ExpireMessages(topics...); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.maybeRemoveMessagesAndExcessReservations(r, v, u, reservationsLimit); err != nil {
|
||||
return err
|
||||
}
|
||||
// Change or remove tier
|
||||
if tier == nil {
|
||||
if tier == nil && u.Tier != nil {
|
||||
logvr(v, r).Tag(tagStripe).Info("Resetting tier for user %s", u.Name)
|
||||
if err := s.userManager.ResetTier(u.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
} else if tier != nil && u.TierID() != tier.ID {
|
||||
logvr(v, r).
|
||||
Tag(tagStripe).
|
||||
Fields(log.Context{
|
||||
"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 {
|
||||
return err
|
||||
}
|
||||
@@ -372,6 +463,7 @@ func (s *Server) updateSubscriptionAndTier(u *user.User, tier *user.Tier, custom
|
||||
StripeCustomerID: customerID,
|
||||
StripeSubscriptionID: subscriptionID,
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatus(status),
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringInterval(interval),
|
||||
StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0),
|
||||
StripeSubscriptionCancelAt: time.Unix(cancelAt, 0),
|
||||
}
|
||||
@@ -383,20 +475,16 @@ func (s *Server) updateSubscriptionAndTier(u *user.User, tier *user.Tier, custom
|
||||
|
||||
// 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
|
||||
@@ -410,6 +498,7 @@ type stripeAPI interface {
|
||||
GetCustomer(id string) (*stripe.Customer, error)
|
||||
GetSession(id string) (*stripe.CheckoutSession, error)
|
||||
GetSubscription(id string) (*stripe.Subscription, error)
|
||||
UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error)
|
||||
UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error)
|
||||
CancelSubscription(id string) (*stripe.Subscription, error)
|
||||
ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error)
|
||||
@@ -456,6 +545,10 @@ func (s *realStripeAPI) GetSubscription(id string) (*stripe.Subscription, error)
|
||||
return subscription.Get(id, nil)
|
||||
}
|
||||
|
||||
func (s *realStripeAPI) UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error) {
|
||||
return customer.Update(id, params)
|
||||
}
|
||||
|
||||
func (s *realStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) {
|
||||
return subscription.Update(id, params)
|
||||
}
|
||||
|
||||
@@ -5,15 +5,128 @@ import (
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"golang.org/x/time/rate"
|
||||
"heckel.io/ntfy/user"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPayments_Tiers(t *testing.T) {
|
||||
stripeMock := &testStripeAPI{}
|
||||
defer stripeMock.AssertExpectations(t)
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.StripeSecretKey = "secret key"
|
||||
c.StripeWebhookKey = "webhook key"
|
||||
c.VisitorRequestLimitReplenish = 12 * time.Hour
|
||||
c.CacheDuration = 13 * time.Hour
|
||||
c.AttachmentFileSizeLimit = 111
|
||||
c.VisitorAttachmentTotalSizeLimit = 222
|
||||
c.AttachmentExpiryDuration = 123 * time.Second
|
||||
s := newTestServer(t, c)
|
||||
s.stripe = stripeMock
|
||||
|
||||
// Define how the mock should react
|
||||
stripeMock.
|
||||
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)
|
||||
|
||||
// Create tiers
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_1",
|
||||
Code: "admin",
|
||||
Name: "Admin",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_123",
|
||||
Code: "pro",
|
||||
Name: "Pro",
|
||||
MessageLimit: 1000,
|
||||
MessageExpiryDuration: time.Hour,
|
||||
EmailLimit: 123,
|
||||
ReservationLimit: 777,
|
||||
AttachmentFileSizeLimit: 999,
|
||||
AttachmentTotalSizeLimit: 888,
|
||||
AttachmentExpiryDuration: time.Minute,
|
||||
StripeMonthlyPriceID: "price_123",
|
||||
StripeYearlyPriceID: "price_124",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_444",
|
||||
Code: "business",
|
||||
Name: "Business",
|
||||
MessageLimit: 2000,
|
||||
MessageExpiryDuration: 10 * time.Hour,
|
||||
EmailLimit: 123123,
|
||||
ReservationLimit: 777333,
|
||||
AttachmentFileSizeLimit: 999111,
|
||||
AttachmentTotalSizeLimit: 888111,
|
||||
AttachmentExpiryDuration: time.Hour,
|
||||
StripeMonthlyPriceID: "price_456",
|
||||
StripeYearlyPriceID: "price_457",
|
||||
}))
|
||||
response := request(t, s, "GET", "/v1/tiers", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
var tiers []apiAccountBillingTier
|
||||
require.Nil(t, json.NewDecoder(response.Body).Decode(&tiers))
|
||||
require.Equal(t, 3, len(tiers))
|
||||
|
||||
// Free tier
|
||||
tier := tiers[0]
|
||||
require.Equal(t, "", tier.Code)
|
||||
require.Equal(t, "", tier.Name)
|
||||
require.Equal(t, "ip", tier.Limits.Basis)
|
||||
require.Equal(t, int64(0), tier.Limits.Reservations)
|
||||
require.Equal(t, int64(2), tier.Limits.Messages) // :-(
|
||||
require.Equal(t, int64(13*3600), tier.Limits.MessagesExpiryDuration)
|
||||
require.Equal(t, int64(24), tier.Limits.Emails)
|
||||
require.Equal(t, int64(111), tier.Limits.AttachmentFileSize)
|
||||
require.Equal(t, int64(222), tier.Limits.AttachmentTotalSize)
|
||||
require.Equal(t, int64(123), tier.Limits.AttachmentExpiryDuration)
|
||||
|
||||
// Admin tier is not included, because it is not paid!
|
||||
|
||||
tier = tiers[1]
|
||||
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)
|
||||
require.Equal(t, int64(123), tier.Limits.Emails)
|
||||
require.Equal(t, int64(999), tier.Limits.AttachmentFileSize)
|
||||
require.Equal(t, int64(888), tier.Limits.AttachmentTotalSize)
|
||||
require.Equal(t, int64(60), tier.Limits.AttachmentExpiryDuration)
|
||||
|
||||
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)
|
||||
require.Equal(t, int64(36000), tier.Limits.MessagesExpiryDuration)
|
||||
require.Equal(t, int64(123123), tier.Limits.Emails)
|
||||
require.Equal(t, int64(999111), tier.Limits.AttachmentFileSize)
|
||||
require.Equal(t, int64(888111), tier.Limits.AttachmentTotalSize)
|
||||
require.Equal(t, int64(3600), tier.Limits.AttachmentExpiryDuration)
|
||||
}
|
||||
|
||||
func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) {
|
||||
stripeMock := &testStripeAPI{}
|
||||
defer stripeMock.AssertExpectations(t)
|
||||
@@ -30,14 +143,15 @@ func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) {
|
||||
Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil)
|
||||
|
||||
// Create tier and user
|
||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||
Code: "pro",
|
||||
StripePriceID: "price_123",
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_123",
|
||||
Code: "pro",
|
||||
StripeMonthlyPriceID: "price_123",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||
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)
|
||||
@@ -65,11 +179,12 @@ func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) {
|
||||
Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil)
|
||||
|
||||
// Create tier and user
|
||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||
Code: "pro",
|
||||
StripePriceID: "price_123",
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_123",
|
||||
Code: "pro",
|
||||
StripeMonthlyPriceID: "price_123",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
@@ -80,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)
|
||||
@@ -106,11 +221,12 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
|
||||
Return(&stripe.Subscription{}, nil)
|
||||
|
||||
// Create tier and user
|
||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||
Code: "pro",
|
||||
StripePriceID: "price_123",
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_123",
|
||||
Code: "pro",
|
||||
StripeMonthlyPriceID: "price_123",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
@@ -122,7 +238,7 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
|
||||
require.Nil(t, s.userManager.ChangeBilling(u.Name, billing))
|
||||
|
||||
// Delete account
|
||||
rr := request(t, s, "DELETE", "/v1/account", "", map[string]string{
|
||||
rr := request(t, s, "DELETE", "/v1/account", `{"password": "phil"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
@@ -133,7 +249,174 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
|
||||
require.Equal(t, 401, rr.Code)
|
||||
}
|
||||
|
||||
func TestPayments_Checkout_Success_And_Increase_Rate_Limits_Reset_Visitor(t *testing.T) {
|
||||
// This test is too overloaded, but it's also a great end-to-end a test.
|
||||
//
|
||||
// It tests:
|
||||
// - A successful checkout flow (not a paying customer -> paying customer)
|
||||
// - Tier-changes reset the rate limits for the user
|
||||
// - The request limits for tier-less user and a tier-user
|
||||
// - The message limits for a tier-user
|
||||
|
||||
stripeMock := &testStripeAPI{}
|
||||
defer stripeMock.AssertExpectations(t)
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.StripeSecretKey = "secret key"
|
||||
c.StripeWebhookKey = "webhook key"
|
||||
c.VisitorRequestLimitBurst = 5
|
||||
c.VisitorRequestLimitReplenish = time.Hour
|
||||
c.CacheBatchSize = 500
|
||||
c.CacheBatchTimeout = time.Second
|
||||
s := newTestServer(t, c)
|
||||
s.stripe = stripeMock
|
||||
|
||||
// Create a user with a Stripe subscription and 3 reservations
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_123",
|
||||
Code: "starter",
|
||||
StripeMonthlyPriceID: "price_1234",
|
||||
ReservationLimit: 1,
|
||||
MessageLimit: 220, // 220 * 5% = 11 requests before rate limiting kicks in
|
||||
MessageExpiryDuration: time.Hour,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) // No tier
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
// Define how the mock should react
|
||||
stripeMock.
|
||||
On("GetSession", "SOMETOKEN").
|
||||
Return(&stripe.CheckoutSession{
|
||||
ClientReferenceID: u.ID, // ntfy user ID
|
||||
Customer: &stripe.Customer{
|
||||
ID: "acct_5555",
|
||||
},
|
||||
Subscription: &stripe.Subscription{
|
||||
ID: "sub_1234",
|
||||
},
|
||||
}, nil)
|
||||
stripeMock.
|
||||
On("GetSubscription", "sub_1234").
|
||||
Return(&stripe.Subscription{
|
||||
ID: "sub_1234",
|
||||
Status: stripe.SubscriptionStatusActive,
|
||||
CurrentPeriodEnd: 123456789,
|
||||
CancelAt: 0,
|
||||
Items: &stripe.SubscriptionItemList{
|
||||
Data: []*stripe.SubscriptionItem{
|
||||
{
|
||||
Price: &stripe.Price{
|
||||
ID: "price_1234",
|
||||
Recurring: &stripe.PriceRecurring{
|
||||
Interval: stripe.PriceRecurringIntervalMonth,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
stripeMock.
|
||||
On("UpdateCustomer", "acct_5555", &stripe.CustomerParams{
|
||||
Params: stripe.Params{
|
||||
Metadata: map[string]string{
|
||||
"user_id": u.ID,
|
||||
"user_name": u.Name,
|
||||
},
|
||||
},
|
||||
}).
|
||||
Return(&stripe.Customer{}, nil)
|
||||
|
||||
// Send messages until rate limit of free tier is hit
|
||||
for i := 0; i < 5; i++ {
|
||||
rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 429, rr.Code)
|
||||
|
||||
// Verify some "before-stats"
|
||||
u, err = s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, u.Tier)
|
||||
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!
|
||||
require.Equal(t, int64(0), u.Stats.Emails)
|
||||
|
||||
// Simulate Stripe success return URL call (no user context)
|
||||
rr = request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||
require.Equal(t, 303, rr.Code)
|
||||
|
||||
// Verify that database columns were updated
|
||||
u, err = s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
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)
|
||||
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)
|
||||
require.Equal(t, int64(0), u.Stats.Emails)
|
||||
|
||||
// Now for the fun part: Verify that new rate limits are immediately applied
|
||||
// This only tests the request limiter, which kicks in before the message limiter.
|
||||
for i := 0; i < 11; i++ {
|
||||
rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code, "failed on iteration %d", i)
|
||||
}
|
||||
rr = request(t, s, "PUT", "/mytopic", "some message", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 429, rr.Code)
|
||||
|
||||
// Now let's test the message limiter by faking a ridiculously generous rate limiter
|
||||
v := s.visitor(netip.MustParseAddr("9.9.9.9"), u)
|
||||
v.requestLimiter = rate.NewLimiter(rate.Every(time.Millisecond), 1000000)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 209; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
rr := request(t, s, "PUT", "/mytopic", "some message", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code, "Failed on %d", i)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
rr = request(t, s, "PUT", "/mytopic", "some message", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 429, rr.Code)
|
||||
|
||||
// And now let's cross-check that the stats are correct too
|
||||
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Equal(t, int64(220), account.Limits.Messages)
|
||||
require.Equal(t, int64(220), account.Stats.Messages)
|
||||
require.Equal(t, int64(0), account.Stats.MessagesRemaining)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -154,30 +437,34 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
||||
Return(jsonToStripeEvent(t, subscriptionUpdatedEventJSON), nil)
|
||||
|
||||
// Create a user with a Stripe subscription and 3 reservations
|
||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_1",
|
||||
Code: "starter",
|
||||
StripePriceID: "price_1234", // !
|
||||
ReservationsLimit: 1, // !
|
||||
MessagesLimit: 100,
|
||||
MessagesExpiryDuration: time.Hour,
|
||||
StripeMonthlyPriceID: "price_1234", // !
|
||||
ReservationLimit: 1, // !
|
||||
MessageLimit: 100,
|
||||
MessageExpiryDuration: time.Hour,
|
||||
AttachmentExpiryDuration: time.Hour,
|
||||
AttachmentFileSizeLimit: 1000000,
|
||||
AttachmentTotalSizeLimit: 1000000,
|
||||
AttachmentBandwidthLimit: 1000000,
|
||||
}))
|
||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_2",
|
||||
Code: "pro",
|
||||
StripePriceID: "price_1111", // !
|
||||
ReservationsLimit: 3, // !
|
||||
MessagesLimit: 200,
|
||||
MessagesExpiryDuration: time.Hour,
|
||||
StripeMonthlyPriceID: "price_1111", // !
|
||||
ReservationLimit: 3, // !
|
||||
MessageLimit: 200,
|
||||
MessageExpiryDuration: time.Hour,
|
||||
AttachmentExpiryDuration: time.Hour,
|
||||
AttachmentFileSizeLimit: 1000000,
|
||||
AttachmentTotalSizeLimit: 1000000,
|
||||
AttachmentBandwidthLimit: 1000000,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
require.Nil(t, s.userManager.ReserveAccess("phil", "atopic", user.PermissionDenyAll))
|
||||
require.Nil(t, s.userManager.ReserveAccess("phil", "ztopic", user.PermissionDenyAll))
|
||||
require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll))
|
||||
require.Nil(t, s.userManager.AddReservation("phil", "ztopic", user.PermissionDenyAll))
|
||||
|
||||
// Add billing details
|
||||
u, err := s.userManager.User("phil")
|
||||
@@ -187,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),
|
||||
}
|
||||
@@ -229,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")
|
||||
@@ -254,10 +543,208 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
||||
require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, z2.ID))
|
||||
}
|
||||
|
||||
func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
|
||||
// This tests incoming webhooks from Stripe to delete a subscription. It verifies that the database is
|
||||
// updated (all Stripe fields are deleted, and the tier is removed).
|
||||
//
|
||||
// It doesn't fully test the message/attachment deletion. That is tested above in the subscription update call.
|
||||
|
||||
stripeMock := &testStripeAPI{}
|
||||
defer stripeMock.AssertExpectations(t)
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.StripeSecretKey = "secret key"
|
||||
c.StripeWebhookKey = "webhook key"
|
||||
s := newTestServer(t, c)
|
||||
s.stripe = stripeMock
|
||||
|
||||
// Define how the mock should react
|
||||
stripeMock.
|
||||
On("ConstructWebhookEvent", mock.Anything, "stripe signature", "webhook key").
|
||||
Return(jsonToStripeEvent(t, subscriptionDeletedEventJSON), nil)
|
||||
|
||||
// Create a user with a Stripe subscription and 3 reservations
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
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"))
|
||||
require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll))
|
||||
|
||||
// Add billing details
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.ChangeBilling(u.Name, &user.Billing{
|
||||
StripeCustomerID: "acct_5555",
|
||||
StripeSubscriptionID: "sub_1234",
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
||||
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
||||
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
||||
}))
|
||||
|
||||
// Call the webhook: This does all the magic
|
||||
rr := request(t, s, "POST", "/v1/account/billing/webhook", "dummy", map[string]string{
|
||||
"Stripe-Signature": "stripe signature",
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Verify that database columns were updated
|
||||
u, err = s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, u.Tier)
|
||||
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||
require.Equal(t, "", u.Billing.StripeSubscriptionID)
|
||||
require.Equal(t, stripe.SubscriptionStatus(""), u.Billing.StripeSubscriptionStatus)
|
||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionPaidUntil.Unix())
|
||||
require.Equal(t, int64(0), u.Billing.StripeSubscriptionCancelAt.Unix())
|
||||
|
||||
// Verify that reservations were deleted
|
||||
r, err := s.userManager.Reservations("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(r))
|
||||
}
|
||||
|
||||
func TestPayments_Subscription_Update_Different_Tier(t *testing.T) {
|
||||
stripeMock := &testStripeAPI{}
|
||||
defer stripeMock.AssertExpectations(t)
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.StripeSecretKey = "secret key"
|
||||
c.StripeWebhookKey = "webhook key"
|
||||
s := newTestServer(t, c)
|
||||
s.stripe = stripeMock
|
||||
|
||||
// Define how the mock should react
|
||||
stripeMock.
|
||||
On("GetSubscription", "sub_123").
|
||||
Return(&stripe.Subscription{
|
||||
ID: "sub_123",
|
||||
Items: &stripe.SubscriptionItemList{
|
||||
Data: []*stripe.SubscriptionItem{
|
||||
{
|
||||
ID: "someid_123",
|
||||
Price: &stripe.Price{ID: "price_123"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
stripeMock.
|
||||
On("UpdateSubscription", "sub_123", &stripe.SubscriptionParams{
|
||||
CancelAtPeriodEnd: stripe.Bool(false),
|
||||
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorAlwaysInvoice)),
|
||||
Items: []*stripe.SubscriptionItemsParams{
|
||||
{
|
||||
ID: stripe.String("someid_123"),
|
||||
Price: stripe.String("price_457"),
|
||||
},
|
||||
},
|
||||
}).
|
||||
Return(&stripe.Subscription{}, nil)
|
||||
|
||||
// Create tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_123",
|
||||
Code: "pro",
|
||||
StripeMonthlyPriceID: "price_123",
|
||||
StripeYearlyPriceID: "price_124",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
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"))
|
||||
require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{
|
||||
StripeCustomerID: "acct_123",
|
||||
StripeSubscriptionID: "sub_123",
|
||||
}))
|
||||
|
||||
// Call endpoint to change subscription
|
||||
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)
|
||||
}
|
||||
|
||||
func TestPayments_Subscription_Delete_At_Period_End(t *testing.T) {
|
||||
stripeMock := &testStripeAPI{}
|
||||
defer stripeMock.AssertExpectations(t)
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.StripeSecretKey = "secret key"
|
||||
c.StripeWebhookKey = "webhook key"
|
||||
s := newTestServer(t, c)
|
||||
s.stripe = stripeMock
|
||||
|
||||
// Define how the mock should react
|
||||
stripeMock.
|
||||
On("UpdateSubscription", "sub_123", mock.MatchedBy(func(s *stripe.SubscriptionParams) bool {
|
||||
return *s.CancelAtPeriodEnd // Is true
|
||||
})).
|
||||
Return(&stripe.Subscription{}, nil)
|
||||
|
||||
// Create user
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{
|
||||
StripeCustomerID: "acct_123",
|
||||
StripeSubscriptionID: "sub_123",
|
||||
}))
|
||||
|
||||
// Delete subscription
|
||||
rr := request(t, s, "DELETE", "/v1/account/billing/subscription", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
func TestPayments_CreatePortalSession(t *testing.T) {
|
||||
stripeMock := &testStripeAPI{}
|
||||
defer stripeMock.AssertExpectations(t)
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.StripeSecretKey = "secret key"
|
||||
c.StripeWebhookKey = "webhook key"
|
||||
s := newTestServer(t, c)
|
||||
s.stripe = stripeMock
|
||||
|
||||
// Define how the mock should react
|
||||
stripeMock.
|
||||
On("NewPortalSession", &stripe.BillingPortalSessionParams{
|
||||
Customer: stripe.String("acct_123"),
|
||||
ReturnURL: stripe.String(s.config.BaseURL),
|
||||
}).
|
||||
Return(&stripe.BillingPortalSession{
|
||||
URL: "https://billing.stripe.com/blablabla",
|
||||
}, nil)
|
||||
|
||||
// Create user
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.ChangeBilling("phil", &user.Billing{
|
||||
StripeCustomerID: "acct_123",
|
||||
StripeSubscriptionID: "sub_123",
|
||||
}))
|
||||
|
||||
// Create portal session
|
||||
rr := request(t, s, "POST", "/v1/account/billing/portal", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
ps, _ := util.UnmarshalJSON[apiAccountBillingPortalRedirectResponse](io.NopCloser(rr.Body))
|
||||
require.Equal(t, "https://billing.stripe.com/blablabla", ps.RedirectURL)
|
||||
}
|
||||
|
||||
type testStripeAPI struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
var _ stripeAPI = (*testStripeAPI)(nil)
|
||||
|
||||
func (s *testStripeAPI) NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
|
||||
args := s.Called(params)
|
||||
return args.Get(0).(*stripe.CheckoutSession), args.Error(1)
|
||||
@@ -288,8 +775,13 @@ func (s *testStripeAPI) GetSubscription(id string) (*stripe.Subscription, error)
|
||||
return args.Get(0).(*stripe.Subscription), args.Error(1)
|
||||
}
|
||||
|
||||
func (s *testStripeAPI) UpdateCustomer(id string, params *stripe.CustomerParams) (*stripe.Customer, error) {
|
||||
args := s.Called(id, params)
|
||||
return args.Get(0).(*stripe.Customer), args.Error(1)
|
||||
}
|
||||
|
||||
func (s *testStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) {
|
||||
args := s.Called(id)
|
||||
args := s.Called(id, params)
|
||||
return args.Get(0).(*stripe.Subscription), args.Error(1)
|
||||
}
|
||||
|
||||
@@ -303,8 +795,6 @@ func (s *testStripeAPI) ConstructWebhookEvent(payload []byte, header string, sec
|
||||
return args.Get(0).(stripe.Event), args.Error(1)
|
||||
}
|
||||
|
||||
var _ stripeAPI = (*testStripeAPI)(nil)
|
||||
|
||||
func jsonToStripeEvent(t *testing.T, v string) stripe.Event {
|
||||
var e stripe.Event
|
||||
if err := json.Unmarshal([]byte(v), &e); err != nil {
|
||||
@@ -327,7 +817,36 @@ const subscriptionUpdatedEventJSON = `
|
||||
"data": [
|
||||
{
|
||||
"price": {
|
||||
"id": "price_1234"
|
||||
"id": "price_1234",
|
||||
"recurring": {
|
||||
"interval": "year"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
const subscriptionDeletedEventJSON = `
|
||||
{
|
||||
"type": "customer.subscription.deleted",
|
||||
"data": {
|
||||
"object": {
|
||||
"id": "sub_1234",
|
||||
"customer": "acct_5555",
|
||||
"status": "active",
|
||||
"current_period_end": 1674268231,
|
||||
"cancel_at": 1674299999,
|
||||
"items": {
|
||||
"data": [
|
||||
{
|
||||
"price": {
|
||||
"id": "price_1234",
|
||||
"recurring": {
|
||||
"interval": "month"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,8 +37,18 @@ func (s *smtpSender) Send(v *visitor, m *message, to string) error {
|
||||
return err
|
||||
}
|
||||
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
||||
log.Debug("%s Sending mail: via=%s, user=%s, pass=***, to=%s", logMessagePrefix(v, m), s.config.SMTPSenderAddr, s.config.SMTPSenderUser, to)
|
||||
log.Trace("%s Mail body: %s", logMessagePrefix(v, m), message)
|
||||
ev := logvm(v, m).
|
||||
Tag(tagEmail).
|
||||
Fields(log.Context{
|
||||
"email_via": s.config.SMTPSenderAddr,
|
||||
"email_user": s.config.SMTPSenderUser,
|
||||
"email_to": to,
|
||||
})
|
||||
if ev.IsTrace() {
|
||||
ev.Field("email_body", message).Trace("Sending email")
|
||||
} else if ev.IsDebug() {
|
||||
ev.Debug("Sending email")
|
||||
}
|
||||
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
|
||||
})
|
||||
}
|
||||
@@ -54,7 +64,7 @@ func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Debug("%s Sending mail failed: %s", logMessagePrefix(v, m), err.Error())
|
||||
logvm(v, m).Err(err).Debug("Sending mail failed")
|
||||
s.failure++
|
||||
} else {
|
||||
s.success++
|
||||
|
||||
@@ -2,10 +2,10 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/emersion/go-smtp"
|
||||
"heckel.io/ntfy/log"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
@@ -22,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
|
||||
@@ -34,6 +39,9 @@ type smtpBackend struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ smtp.Backend = (*smtpBackend)(nil)
|
||||
var _ smtp.Session = (*smtpSession)(nil)
|
||||
|
||||
func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend {
|
||||
return &smtpBackend{
|
||||
config: conf,
|
||||
@@ -41,14 +49,9 @@ func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Reques
|
||||
}
|
||||
}
|
||||
|
||||
func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
|
||||
log.Debug("%s Incoming mail, login with user %s", logSMTPPrefix(state), username)
|
||||
return &smtpSession{backend: b, state: state}, nil
|
||||
}
|
||||
|
||||
func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
|
||||
log.Debug("%s Incoming mail, anonymous login", logSMTPPrefix(state))
|
||||
return &smtpSession{backend: b, state: state}, nil
|
||||
func (b *smtpBackend) NewSession(conn *smtp.Conn) (smtp.Session, error) {
|
||||
logem(conn).Debug("Incoming mail")
|
||||
return &smtpSession{backend: b, conn: conn}, nil
|
||||
}
|
||||
|
||||
func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
|
||||
@@ -60,24 +63,26 @@ func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
|
||||
// smtpSession is returned after EHLO.
|
||||
type smtpSession struct {
|
||||
backend *smtpBackend
|
||||
state *smtp.ConnectionState
|
||||
conn *smtp.Conn
|
||||
topic string
|
||||
token string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *smtpSession) AuthPlain(username, password string) error {
|
||||
log.Debug("%s AUTH PLAIN (with username %s)", logSMTPPrefix(s.state), username)
|
||||
func (s *smtpSession) AuthPlain(username, _ string) error {
|
||||
logem(s.conn).Field("smtp_username", username).Debug("AUTH PLAIN (with username %s)", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error {
|
||||
log.Debug("%s MAIL FROM: %s (with options: %#v)", logSMTPPrefix(s.state), from, opts)
|
||||
func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
|
||||
logem(s.conn).Field("smtp_mail_from", from).Debug("MAIL FROM: %s", from)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *smtpSession) Rcpt(to string) error {
|
||||
log.Debug("%s RCPT TO: %s", logSMTPPrefix(s.state), to)
|
||||
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 {
|
||||
@@ -89,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
|
||||
})
|
||||
@@ -113,16 +127,17 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if log.IsTrace() {
|
||||
log.Trace("%s DATA: %s", logSMTPPrefix(s.state), string(b))
|
||||
} else if log.IsDebug() {
|
||||
log.Debug("%s DATA: %d byte(s)", logSMTPPrefix(s.state), len(b))
|
||||
ev := logem(s.conn)
|
||||
if ev.IsTrace() {
|
||||
ev.Field("smtp_data", string(b)).Trace("DATA")
|
||||
} else if ev.IsDebug() {
|
||||
ev.Field("smtp_data_len", len(b)).Debug("DATA")
|
||||
}
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := readMailBody(msg)
|
||||
body, err := readMailBody(msg.Body, msg.Header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -156,11 +171,10 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
|
||||
func (s *smtpSession) publishMessage(m *message) error {
|
||||
// Extract remote address (for rate limiting)
|
||||
remoteAddr, _, err := net.SplitHostPort(s.state.RemoteAddr.String())
|
||||
remoteAddr, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String())
|
||||
if err != nil {
|
||||
remoteAddr = s.state.RemoteAddr.String()
|
||||
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))
|
||||
@@ -173,6 +187,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,54 +215,58 @@ func (s *smtpSession) withFailCount(fn func() error) error {
|
||||
if err != nil {
|
||||
// Almost all of these errors are parse errors, and user input errors.
|
||||
// We do not want to spam the log with WARN messages.
|
||||
log.Debug("%s Incoming mail error: %s", logSMTPPrefix(s.state), err.Error())
|
||||
logem(s.conn).Err(err).Debug("Incoming mail error")
|
||||
s.backend.failure++
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/stretchr/testify/require"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSmtpBackend_Multipart(t *testing.T) {
|
||||
email := `MIME-Version: 1.0
|
||||
email := `EHLO example.com
|
||||
MAIL FROM: phil@example.com
|
||||
RCPT TO: ntfy-mytopic@ntfy.sh
|
||||
DATA
|
||||
MIME-Version: 1.0
|
||||
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||
Subject: and one more
|
||||
@@ -28,20 +35,25 @@ Content-Type: text/html; charset="UTF-8"
|
||||
|
||||
<div dir="ltr">what's up<br clear="all"><div><br></div></div>
|
||||
|
||||
--000000000000f3320b05d42915c9--`
|
||||
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
--000000000000f3320b05d42915c9--
|
||||
.
|
||||
`
|
||||
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "and one more", r.Header.Get("Title"))
|
||||
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||
})
|
||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||
defer s.Close()
|
||||
defer c.Close()
|
||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||
}
|
||||
|
||||
func TestSmtpBackend_MultipartNoBody(t *testing.T) {
|
||||
email := `MIME-Version: 1.0
|
||||
email := `EHLO example.com
|
||||
MAIL FROM: phil@example.com
|
||||
RCPT TO: ntfy-emailtest@ntfy.sh
|
||||
DATA
|
||||
MIME-Version: 1.0
|
||||
Date: Tue, 28 Dec 2021 01:33:34 +0100
|
||||
Message-ID: <CAAvm7ABCDsi9vsuu0WTRXzZQBC8dXrDOLT8iCWdqrsmg@mail.gmail.com>
|
||||
Subject: This email has a subject but no body
|
||||
@@ -59,20 +71,25 @@ Content-Type: text/html; charset="UTF-8"
|
||||
|
||||
<div dir="ltr"><br></div>
|
||||
|
||||
--000000000000bcf4a405d429f8d4--`
|
||||
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
--000000000000bcf4a405d429f8d4--
|
||||
.
|
||||
`
|
||||
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/emailtest", r.URL.Path)
|
||||
require.Equal(t, "", r.Header.Get("Title")) // We flipped message and body
|
||||
require.Equal(t, "This email has a subject but no body", readAll(t, r.Body))
|
||||
})
|
||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||
require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh"))
|
||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||
defer s.Close()
|
||||
defer c.Close()
|
||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||
}
|
||||
|
||||
func TestSmtpBackend_Plaintext(t *testing.T) {
|
||||
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||
email := `EHLO example.com
|
||||
MAIL FROM: phil@example.com
|
||||
RCPT TO: mytopic@ntfy.sh
|
||||
DATA
|
||||
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||
Subject: and one more
|
||||
From: Phil <phil@example.com>
|
||||
@@ -80,56 +97,68 @@ To: mytopic@ntfy.sh
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
|
||||
what's up
|
||||
.
|
||||
`
|
||||
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "and one more", r.Header.Get("Title"))
|
||||
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||
})
|
||||
conf.SMTPServerAddrPrefix = ""
|
||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||
defer s.Close()
|
||||
defer c.Close()
|
||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||
}
|
||||
|
||||
func TestSmtpBackend_Plaintext_No_ContentType(t *testing.T) {
|
||||
email := `Subject: Very short mail
|
||||
email := `EHLO example.com
|
||||
MAIL FROM: phil@example.com
|
||||
RCPT TO: mytopic@ntfy.sh
|
||||
DATA
|
||||
Subject: Very short mail
|
||||
|
||||
what's up
|
||||
.
|
||||
`
|
||||
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
s, c, conf, 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, "what's up", readAll(t, r.Body))
|
||||
})
|
||||
conf.SMTPServerAddrPrefix = ""
|
||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||
defer s.Close()
|
||||
defer c.Close()
|
||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||
}
|
||||
|
||||
func TestSmtpBackend_Plaintext_EncodedSubject(t *testing.T) {
|
||||
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||
email := `EHLO example.com
|
||||
MAIL FROM: phil@example.com
|
||||
RCPT TO: ntfy-mytopic@ntfy.sh
|
||||
DATA
|
||||
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||
Subject: =?UTF-8?B?VGhyZWUgc2FudGFzIPCfjoXwn46F8J+OhQ==?=
|
||||
From: Phil <phil@example.com>
|
||||
To: ntfy-mytopic@ntfy.sh
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
|
||||
what's up
|
||||
.
|
||||
`
|
||||
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "Three santas 🎅🎅🎅", r.Header.Get("Title"))
|
||||
})
|
||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||
defer s.Close()
|
||||
defer c.Close()
|
||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||
}
|
||||
|
||||
func TestSmtpBackend_Plaintext_TooLongTruncate(t *testing.T) {
|
||||
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||
email := `EHLO example.com
|
||||
MAIL FROM: phil@example.com
|
||||
RCPT TO: mytopic@ntfy.sh
|
||||
DATA
|
||||
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||
Subject: and one more
|
||||
From: Phil <phil@example.com>
|
||||
@@ -148,60 +177,61 @@ so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
|
||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||
that should do it
|
||||
.
|
||||
`
|
||||
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
expected := `you know this is a string.
|
||||
it's a long string.
|
||||
it's supposed to be longer than the max message length
|
||||
@@ -214,68 +244,71 @@ so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
|
||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
......................................................................
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
||||
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||
BBBBBBBBBBBBBBBBBBBBBBBBB`
|
||||
require.Equal(t, 4096, len(expected)) // Sanity check
|
||||
require.Equal(t, expected, readAll(t, r.Body))
|
||||
})
|
||||
defer s.Close()
|
||||
defer c.Close()
|
||||
conf.SMTPServerAddrPrefix = ""
|
||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||
}
|
||||
|
||||
func TestSmtpBackend_Unsupported(t *testing.T) {
|
||||
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||
email := `EHLO example.com
|
||||
MAIL FROM: phil@example.com
|
||||
RCPT TO: ntfy-mytopic@ntfy.sh
|
||||
DATA
|
||||
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||
Subject: and one more
|
||||
From: Phil <phil@example.com>
|
||||
@@ -283,34 +316,254 @@ To: mytopic@ntfy.sh
|
||||
Content-Type: text/SOMETHINGELSE
|
||||
|
||||
what's up
|
||||
.
|
||||
`
|
||||
conf, backend := newTestBackend(t, func(http.ResponseWriter, *http.Request) {
|
||||
// Nothing.
|
||||
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("This should not be called")
|
||||
})
|
||||
conf.SMTPServerAddrPrefix = ""
|
||||
session, _ := backend.Login(fakeConnState(t, "1.2.3.4"), "user", "pass")
|
||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||
require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email)))
|
||||
defer s.Close()
|
||||
defer c.Close()
|
||||
writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: unsupported content type")
|
||||
}
|
||||
|
||||
func newTestBackend(t *testing.T, handler func(http.ResponseWriter, *http.Request)) (*Config, *smtpBackend) {
|
||||
conf := newTestConfig(t)
|
||||
func TestSmtpBackend_InvalidAddress(t *testing.T) {
|
||||
email := `EHLO example.com
|
||||
MAIL FROM: phil@example.com
|
||||
RCPT TO: unsupported@ntfy.sh
|
||||
DATA
|
||||
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||
Subject: and one more
|
||||
From: Phil <phil@example.com>
|
||||
To: mytopic@ntfy.sh
|
||||
Content-Type: text/plain
|
||||
|
||||
what's up
|
||||
.
|
||||
`
|
||||
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, "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) {
|
||||
conf = newTestConfig(t)
|
||||
conf.SMTPServerListen = ":25"
|
||||
conf.SMTPServerDomain = "ntfy.sh"
|
||||
conf.SMTPServerAddrPrefix = "ntfy-"
|
||||
backend := newMailBackend(conf, handler)
|
||||
return conf, backend
|
||||
}
|
||||
|
||||
func fakeConnState(t *testing.T, remoteAddr string) *smtp.ConnectionState {
|
||||
ip, err := net.ResolveIPAddr("ip", remoteAddr)
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &smtp.ConnectionState{
|
||||
Hostname: "myhostname",
|
||||
LocalAddr: ip,
|
||||
RemoteAddr: ip,
|
||||
s = smtp.NewServer(backend)
|
||||
s.Domain = conf.SMTPServerDomain
|
||||
s.AllowInsecureAuth = true
|
||||
go func() {
|
||||
require.Nil(t, s.Serve(l))
|
||||
}()
|
||||
c, err = net.Dial("tcp", l.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scanner = bufio.NewScanner(c)
|
||||
return
|
||||
}
|
||||
|
||||
func writeAndReadUntilLine(t *testing.T, email string, conn net.Conn, scanner *bufio.Scanner, expectedLine string) {
|
||||
_, err := io.WriteString(conn, email)
|
||||
require.Nil(t, err)
|
||||
readUntilLine(t, conn, scanner, expectedLine)
|
||||
}
|
||||
|
||||
func readUntilLine(t *testing.T, conn net.Conn, scanner *bufio.Scanner, expectedLine string) {
|
||||
cancelChan := make(chan bool)
|
||||
go func() {
|
||||
select {
|
||||
case <-cancelChan:
|
||||
case <-time.After(3 * time.Second):
|
||||
conn.Close()
|
||||
t.Error("Failed waiting for expected output")
|
||||
}
|
||||
}()
|
||||
var output string
|
||||
for scanner.Scan() {
|
||||
text := scanner.Text()
|
||||
if strings.TrimSpace(text) == expectedLine {
|
||||
cancelChan <- true
|
||||
return
|
||||
}
|
||||
output += text + "\n"
|
||||
//fmt.Println(text)
|
||||
}
|
||||
t.Fatalf("Expected line '%s' not found in output:\n%s", expectedLine, output)
|
||||
}
|
||||
|
||||
104
server/topic.go
104
server/topic.go
@@ -10,8 +10,15 @@ import (
|
||||
// can publish a message
|
||||
type topic struct {
|
||||
ID string
|
||||
subscribers map[int]subscriber
|
||||
mu sync.Mutex
|
||||
subscribers map[int]*topicSubscriber
|
||||
rateVisitor *visitor
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type topicSubscriber struct {
|
||||
userID string // User ID associated with this subscription, may be empty
|
||||
subscriber subscriber
|
||||
cancel func()
|
||||
}
|
||||
|
||||
// subscriber is a function that is called for every new message on a topic
|
||||
@@ -21,19 +28,47 @@ type subscriber func(v *visitor, msg *message) error
|
||||
func newTopic(id string) *topic {
|
||||
return &topic{
|
||||
ID: id,
|
||||
subscribers: make(map[int]subscriber),
|
||||
subscribers: make(map[int]*topicSubscriber),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe subscribes to this topic
|
||||
func (t *topic) Subscribe(s subscriber) int {
|
||||
func (t *topic) Subscribe(s subscriber, userID string, cancel func()) int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
subscriberID := rand.Int()
|
||||
t.subscribers[subscriberID] = s
|
||||
t.subscribers[subscriberID] = &topicSubscriber{
|
||||
userID: userID, // May be empty
|
||||
subscriber: s,
|
||||
cancel: cancel,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (t *topic) SetRateVisitor(v *visitor) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.rateVisitor = v
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -48,18 +83,18 @@ func (t *topic) Publish(v *visitor, m *message) error {
|
||||
// subscribers map here. Actually sending out the messages then doesn't have to lock.
|
||||
subscribers := t.subscribersCopy()
|
||||
if len(subscribers) > 0 {
|
||||
log.Debug("%s Forwarding to %d subscriber(s)", logMessagePrefix(v, m), len(subscribers))
|
||||
logvm(v, m).Tag(tagPublish).Debug("Forwarding to %d subscriber(s)", len(subscribers))
|
||||
for _, s := range subscribers {
|
||||
// We call the subscriber functions in their own Go routines because they are blocking, and
|
||||
// we don't want individual slow subscribers to be able to block others.
|
||||
go func(s subscriber) {
|
||||
if err := s(v, m); err != nil {
|
||||
log.Warn("%s Error forwarding to subscriber", logMessagePrefix(v, m))
|
||||
logvm(v, m).Tag(tagPublish).Err(err).Warn("Error forwarding to subscriber")
|
||||
}
|
||||
}(s)
|
||||
}(s.subscriber)
|
||||
}
|
||||
} else {
|
||||
log.Trace("%s No stream or WebSocket subscribers, not forwarding", logMessagePrefix(v, m))
|
||||
logvm(v, m).Tag(tagPublish).Trace("No stream or WebSocket subscribers, not forwarding")
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
@@ -67,18 +102,55 @@ func (t *topic) Publish(v *visitor, m *message) error {
|
||||
|
||||
// SubscribersCount returns the number of subscribers to this topic
|
||||
func (t *topic) SubscribersCount() int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return len(t.subscribers)
|
||||
}
|
||||
|
||||
// subscribersCopy returns a shallow copy of the subscribers map
|
||||
func (t *topic) subscribersCopy() map[int]subscriber {
|
||||
// CancelSubscribers calls the cancel function for all subscribers, forcing
|
||||
func (t *topic) CancelSubscribers(exceptUserID string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
subscribers := make(map[int]subscriber)
|
||||
for k, v := range t.subscribers {
|
||||
subscribers[k] = v
|
||||
for _, s := range t.subscribers {
|
||||
if s.userID != exceptUserID {
|
||||
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),
|
||||
}
|
||||
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()
|
||||
defer t.mu.Unlock()
|
||||
subscribers := make(map[int]*topicSubscriber)
|
||||
for k, sub := range t.subscribers {
|
||||
subscribers[k] = &topicSubscriber{
|
||||
userID: sub.userID,
|
||||
subscriber: sub.subscriber,
|
||||
cancel: sub.cancel,
|
||||
}
|
||||
}
|
||||
return subscribers
|
||||
}
|
||||
|
||||
30
server/topic_test.go
Normal file
30
server/topic_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/user"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -42,6 +43,23 @@ type message struct {
|
||||
User string `json:"-"` // Username of the uploader, used to associated attachments
|
||||
}
|
||||
|
||||
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_body_size": len(m.Message),
|
||||
}
|
||||
if m.Sender.IsValid() {
|
||||
fields["message_sender"] = m.Sender.String()
|
||||
}
|
||||
if m.User != "" {
|
||||
fields["message_user"] = m.User
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
type attachment struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
@@ -227,12 +245,31 @@ type apiAccountCreateRequest struct {
|
||||
}
|
||||
|
||||
type apiAccountPasswordChangeRequest struct {
|
||||
Password string `json:"password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
type apiAccountDeleteRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type apiAccountTokenIssueRequest struct {
|
||||
Label *string `json:"label"`
|
||||
Expires *int64 `json:"expires"` // Unix timestamp
|
||||
}
|
||||
|
||||
type apiAccountTokenUpdateRequest struct {
|
||||
Token string `json:"token"`
|
||||
Label *string `json:"label"`
|
||||
Expires *int64 `json:"expires"` // Unix timestamp
|
||||
}
|
||||
|
||||
type apiAccountTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
Expires int64 `json:"expires"`
|
||||
Token string `json:"token"`
|
||||
Label string `json:"label,omitempty"`
|
||||
LastAccess int64 `json:"last_access,omitempty"`
|
||||
LastOrigin string `json:"last_origin,omitempty"`
|
||||
Expires int64 `json:"expires,omitempty"` // Unix timestamp
|
||||
}
|
||||
|
||||
type apiAccountTier struct {
|
||||
@@ -241,7 +278,7 @@ type apiAccountTier struct {
|
||||
}
|
||||
|
||||
type apiAccountLimits struct {
|
||||
Basis string `json:"basis,omitempty"` // "ip", "role" or "tier"
|
||||
Basis string `json:"basis,omitempty"` // "ip" or "tier"
|
||||
Messages int64 `json:"messages"`
|
||||
MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
|
||||
Emails int64 `json:"emails"`
|
||||
@@ -249,6 +286,7 @@ type apiAccountLimits struct {
|
||||
AttachmentTotalSize int64 `json:"attachment_total_size"`
|
||||
AttachmentFileSize int64 `json:"attachment_file_size"`
|
||||
AttachmentExpiryDuration int64 `json:"attachment_expiry_duration"`
|
||||
AttachmentBandwidth int64 `json:"attachment_bandwidth"`
|
||||
}
|
||||
|
||||
type apiAccountStats struct {
|
||||
@@ -271,22 +309,24 @@ 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"`
|
||||
}
|
||||
|
||||
type apiAccountResponse struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role,omitempty"`
|
||||
SyncTopic string `json:"sync_topic,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
||||
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
||||
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
||||
Tier *apiAccountTier `json:"tier,omitempty"`
|
||||
Limits *apiAccountLimits `json:"limits,omitempty"`
|
||||
Stats *apiAccountStats `json:"stats,omitempty"`
|
||||
Billing *apiAccountBilling `json:"billing,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role,omitempty"`
|
||||
SyncTopic string `json:"sync_topic,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
||||
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
||||
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
||||
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
|
||||
Tier *apiAccountTier `json:"tier,omitempty"`
|
||||
Limits *apiAccountLimits `json:"limits,omitempty"`
|
||||
Stats *apiAccountStats `json:"stats,omitempty"`
|
||||
Billing *apiAccountBilling `json:"billing,omitempty"`
|
||||
}
|
||||
|
||||
type apiAccountReservationRequest struct {
|
||||
@@ -301,14 +341,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 {
|
||||
@@ -316,7 +362,8 @@ type apiAccountBillingSubscriptionCreateResponse struct {
|
||||
}
|
||||
|
||||
type apiAccountBillingSubscriptionChangeRequest struct {
|
||||
Tier string `json:"tier"`
|
||||
Tier string `json:"tier"`
|
||||
Interval string `json:"interval"`
|
||||
}
|
||||
|
||||
type apiAccountBillingPortalRedirectResponse struct {
|
||||
@@ -346,12 +393,16 @@ 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"`
|
||||
}
|
||||
|
||||
type apiStripeSubscriptionDeletedEvent struct {
|
||||
ID string `json:"id"`
|
||||
Customer string `json:"customer"`
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/emersion/go-smtp"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||
@@ -20,6 +18,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 != "" {
|
||||
@@ -48,51 +57,6 @@ func readQueryParam(r *http.Request, names ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func logMessagePrefix(v *visitor, m *message) string {
|
||||
return fmt.Sprintf("%s/%s/%s", v.ip, m.Topic, m.ID)
|
||||
}
|
||||
|
||||
func logHTTPPrefix(v *visitor, r *http.Request) string {
|
||||
requestURI := r.RequestURI
|
||||
if requestURI == "" {
|
||||
requestURI = r.URL.Path
|
||||
}
|
||||
return fmt.Sprintf("%s HTTP %s %s", v.ip, r.Method, requestURI)
|
||||
}
|
||||
|
||||
func logSMTPPrefix(state *smtp.ConnectionState) string {
|
||||
return fmt.Sprintf("%s/%s SMTP", state.Hostname, state.RemoteAddr.String())
|
||||
}
|
||||
|
||||
func renderHTTPRequest(r *http.Request) string {
|
||||
peekLimit := 4096
|
||||
lines := fmt.Sprintf("%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
|
||||
for key, values := range r.Header {
|
||||
for _, value := range values {
|
||||
lines += fmt.Sprintf("%s: %s\n", key, value)
|
||||
}
|
||||
}
|
||||
lines += "\n"
|
||||
body, err := util.Peek(r.Body, peekLimit)
|
||||
if err != nil {
|
||||
lines = fmt.Sprintf("(could not read body: %s)\n", err.Error())
|
||||
} else if utf8.Valid(body.PeekedBytes) {
|
||||
lines += string(body.PeekedBytes)
|
||||
if body.LimitReached {
|
||||
lines += fmt.Sprintf(" ... (peeked %d bytes)", peekLimit)
|
||||
}
|
||||
lines += "\n"
|
||||
} else {
|
||||
if body.LimitReached {
|
||||
lines += fmt.Sprintf("(peeked bytes not UTF-8, peek limit of %d bytes reached, hex: %x ...)\n", peekLimit, body.PeekedBytes)
|
||||
} else {
|
||||
lines += fmt.Sprintf("(peeked bytes not UTF-8, %d bytes, hex: %x)\n", len(body.PeekedBytes), body.PeekedBytes)
|
||||
}
|
||||
}
|
||||
r.Body = body // Important: Reset body, so it can be re-read
|
||||
return strings.TrimSpace(lines)
|
||||
}
|
||||
|
||||
func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
|
||||
remoteAddr := r.RemoteAddr
|
||||
addrPort, err := netip.ParseAddrPort(remoteAddr)
|
||||
@@ -103,7 +67,7 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
|
||||
if err != nil {
|
||||
ip = netip.IPv4Unspecified()
|
||||
if remoteAddr != "@" || !behindProxy { // RemoteAddr is @ when unix socket is used
|
||||
log.Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created %s", remoteAddr, err)
|
||||
logr(r).Err(err).Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created", remoteAddr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,7 +78,7 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
|
||||
ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",")
|
||||
realIP, err := netip.ParseAddr(strings.TrimSpace(util.LastString(ips, remoteAddr)))
|
||||
if err != nil {
|
||||
log.Error("invalid IP address %s received in X-Forwarded-For header: %s", ip, err.Error())
|
||||
logr(r).Err(err).Error("invalid IP address %s received in X-Forwarded-For header", ip)
|
||||
// Fall back to regular remote address if X-Forwarded-For is damaged
|
||||
} else {
|
||||
ip = realIP
|
||||
@@ -123,8 +87,8 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
|
||||
return ip
|
||||
}
|
||||
|
||||
func readJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
|
||||
obj, err := util.UnmarshalJSONWithLimit[T](r, limit)
|
||||
func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
|
||||
obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty)
|
||||
if err == util.ErrUnmarshalJSON {
|
||||
return nil, errHTTPBadRequestJSONInvalid
|
||||
} else if err == util.ErrTooLargeJSON {
|
||||
@@ -134,3 +98,19 @@ func readJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
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 r.WithContext(c)
|
||||
}
|
||||
|
||||
func fromContext[T any](r *http.Request, key contextKey) *T {
|
||||
t, ok := r.Context().Value(key).(*T)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("cannot find key %v in request context", key))
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/user"
|
||||
"net/netip"
|
||||
"sync"
|
||||
@@ -12,38 +13,56 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// oneDay is an approximation of a day as a time.Duration
|
||||
oneDay = 24 * time.Hour
|
||||
|
||||
// visitorExpungeAfter defines how long a visitor is active before it is removed from memory. This number
|
||||
// has to be very high to prevent e-mail abuse, but it doesn't really affect the other limits anyway, since
|
||||
// they are replenished faster (typically).
|
||||
visitorExpungeAfter = 24 * time.Hour
|
||||
visitorExpungeAfter = oneDay
|
||||
|
||||
// visitorDefaultReservationsLimit is the amount of topic names a user without a tier is allowed to reserve.
|
||||
// This number is zero, and changing it may have unintended consequences in the web app, or otherwise
|
||||
visitorDefaultReservationsLimit = int64(0)
|
||||
)
|
||||
|
||||
var (
|
||||
errVisitorLimitReached = errors.New("limit reached")
|
||||
// Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter
|
||||
// values (token bucket). This is only used to increase the values in server.yml, never decrease them.
|
||||
//
|
||||
// Example: Assuming a user.Tier's MessageLimit is 10,000:
|
||||
// - the allowed burst is 500 (= 10,000 * 5%), which is < 1000 (the max)
|
||||
// - the replenish rate is 2 * 10,000 / 24 hours
|
||||
const (
|
||||
visitorMessageToRequestLimitBurstRate = 0.05
|
||||
visitorMessageToRequestLimitBurstMax = 1000
|
||||
visitorMessageToRequestLimitReplenishFactor = 2
|
||||
)
|
||||
|
||||
// Constants used to convert a tier-user's EmailLimit (see user.Tier) into adequate email limiter
|
||||
// values (token bucket). Example: Assuming a user.Tier's EmailLimit is 200, the allowed burst is
|
||||
// 40 (= 200 * 20%), which is <150 (the max).
|
||||
const (
|
||||
visitorEmailLimitBurstRate = 0.2
|
||||
visitorEmailLimitBurstMax = 150
|
||||
)
|
||||
|
||||
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
||||
type visitor struct {
|
||||
config *Config
|
||||
messageCache *messageCache
|
||||
userManager *user.Manager // May be nil!
|
||||
ip netip.Addr
|
||||
user *user.User
|
||||
messages int64 // Number of messages sent, reset every day
|
||||
emails int64 // Number of emails sent, reset every day
|
||||
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
|
||||
messagesLimiter util.Limiter // Rate limiter for messages, may be nil
|
||||
emailsLimiter *rate.Limiter // Rate limiter for emails
|
||||
subscriptionLimiter util.Limiter // Fixed limiter for active subscriptions (ongoing connections)
|
||||
bandwidthLimiter util.Limiter // Limiter for attachment bandwidth downloads
|
||||
accountLimiter *rate.Limiter // Rate limiter for account creation
|
||||
firebase time.Time // Next allowed Firebase message
|
||||
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
|
||||
mu sync.Mutex
|
||||
userManager *user.Manager // May be nil
|
||||
ip netip.Addr // Visitor IP address
|
||||
user *user.User // Only set if authenticated user, otherwise nil
|
||||
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
|
||||
messagesLimiter *util.FixedLimiter // Rate limiter for messages
|
||||
emailsLimiter *util.RateLimiter // Rate limiter for emails
|
||||
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
|
||||
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
|
||||
accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil
|
||||
authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
|
||||
firebase time.Time // Next allowed Firebase message
|
||||
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type visitorInfo struct {
|
||||
@@ -53,13 +72,18 @@ type visitorInfo struct {
|
||||
|
||||
type visitorLimits struct {
|
||||
Basis visitorLimitBasis
|
||||
MessagesLimit int64
|
||||
MessagesExpiryDuration time.Duration
|
||||
EmailsLimit int64
|
||||
RequestLimitBurst int
|
||||
RequestLimitReplenish rate.Limit
|
||||
MessageLimit int64
|
||||
MessageExpiryDuration time.Duration
|
||||
EmailLimit int64
|
||||
EmailLimitBurst int
|
||||
EmailLimitReplenish rate.Limit
|
||||
ReservationsLimit int64
|
||||
AttachmentTotalSizeLimit int64
|
||||
AttachmentFileSizeLimit int64
|
||||
AttachmentExpiryDuration time.Duration
|
||||
AttachmentBandwidthLimit int64
|
||||
}
|
||||
|
||||
type visitorStats struct {
|
||||
@@ -83,56 +107,94 @@ const (
|
||||
)
|
||||
|
||||
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
|
||||
var messagesLimiter util.Limiter
|
||||
var requestLimiter, emailsLimiter, accountLimiter *rate.Limiter
|
||||
var messages, emails int64
|
||||
if user != nil {
|
||||
messages = user.Stats.Messages
|
||||
emails = user.Stats.Emails
|
||||
} else {
|
||||
accountLimiter = rate.NewLimiter(rate.Every(conf.VisitorAccountCreateLimitReplenish), conf.VisitorAccountCreateLimitBurst)
|
||||
}
|
||||
if user != nil && user.Tier != nil {
|
||||
requestLimiter = rate.NewLimiter(dailyLimitToRate(user.Tier.MessagesLimit), conf.VisitorRequestLimitBurst)
|
||||
messagesLimiter = util.NewFixedLimiter(user.Tier.MessagesLimit)
|
||||
emailsLimiter = rate.NewLimiter(dailyLimitToRate(user.Tier.EmailsLimit), conf.VisitorEmailLimitBurst)
|
||||
} else {
|
||||
requestLimiter = rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst)
|
||||
emailsLimiter = rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst)
|
||||
}
|
||||
return &visitor{
|
||||
v := &visitor{
|
||||
config: conf,
|
||||
messageCache: messageCache,
|
||||
userManager: userManager, // May be nil
|
||||
ip: ip,
|
||||
user: user,
|
||||
messages: messages,
|
||||
emails: emails,
|
||||
requestLimiter: requestLimiter,
|
||||
messagesLimiter: messagesLimiter, // May be nil
|
||||
emailsLimiter: emailsLimiter,
|
||||
subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
||||
bandwidthLimiter: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour),
|
||||
accountLimiter: accountLimiter, // May be nil
|
||||
firebase: time.Unix(0, 0),
|
||||
seen: time.Now(),
|
||||
subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
||||
requestLimiter: nil, // Set in resetLimiters
|
||||
messagesLimiter: nil, // Set in resetLimiters, may be nil
|
||||
emailsLimiter: nil, // Set in resetLimiters
|
||||
bandwidthLimiter: nil, // Set in resetLimiters
|
||||
accountLimiter: nil, // Set in resetLimiters, may be nil
|
||||
authLimiter: nil, // Set in resetLimiters, may be nil
|
||||
}
|
||||
v.resetLimitersNoLock(messages, emails, false)
|
||||
return v
|
||||
}
|
||||
|
||||
func (v *visitor) RequestAllowed() error {
|
||||
if !v.requestLimiter.Allow() {
|
||||
return errVisitorLimitReached
|
||||
}
|
||||
return nil
|
||||
func (v *visitor) Context() log.Context {
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
return v.contextNoLock()
|
||||
}
|
||||
|
||||
func (v *visitor) FirebaseAllowed() error {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if time.Now().Before(v.firebase) {
|
||||
return errVisitorLimitReached
|
||||
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_messages": info.Stats.Messages,
|
||||
"visitor_messages_limit": info.Limits.MessageLimit,
|
||||
"visitor_messages_remaining": info.Stats.MessagesRemaining,
|
||||
"visitor_emails": info.Stats.Emails,
|
||||
"visitor_emails_limit": info.Limits.EmailLimit,
|
||||
"visitor_emails_remaining": info.Stats.EmailsRemaining,
|
||||
"visitor_request_limiter_limit": v.requestLimiter.Limit(),
|
||||
"visitor_request_limiter_tokens": v.requestLimiter.Tokens(),
|
||||
}
|
||||
return nil
|
||||
if v.authLimiter != nil {
|
||||
fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit()
|
||||
fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens()
|
||||
}
|
||||
if v.user != nil {
|
||||
fields["user_id"] = v.user.ID
|
||||
fields["user_name"] = v.user.Name
|
||||
if v.user.Tier != nil {
|
||||
for field, value := range v.user.Tier.Context() {
|
||||
fields[field] = value
|
||||
}
|
||||
}
|
||||
if v.user.Billing.StripeCustomerID != "" {
|
||||
fields["stripe_customer_id"] = v.user.Billing.StripeCustomerID
|
||||
}
|
||||
if v.user.Billing.StripeSubscriptionID != "" {
|
||||
fields["stripe_subscription_id"] = v.user.Billing.StripeSubscriptionID
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func visitorExtendedInfoContext(info *visitorInfo) log.Context {
|
||||
return log.Context{
|
||||
"visitor_reservations": info.Stats.Reservations,
|
||||
"visitor_reservations_limit": info.Limits.ReservationsLimit,
|
||||
"visitor_reservations_remaining": info.Stats.ReservationsRemaining,
|
||||
"visitor_attachment_total_size": info.Stats.AttachmentTotalSize,
|
||||
"visitor_attachment_total_size_limit": info.Limits.AttachmentTotalSizeLimit,
|
||||
"visitor_attachment_total_size_remaining": info.Stats.AttachmentTotalSizeRemaining,
|
||||
}
|
||||
|
||||
}
|
||||
func (v *visitor) RequestAllowed() bool {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
return v.requestLimiter.Allow()
|
||||
}
|
||||
|
||||
func (v *visitor) FirebaseAllowed() bool {
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
return !time.Now().Before(v.firebase)
|
||||
}
|
||||
|
||||
func (v *visitor) FirebaseTemporarilyDeny() {
|
||||
@@ -141,33 +203,72 @@ func (v *visitor) FirebaseTemporarilyDeny() {
|
||||
v.firebase = time.Now().Add(v.config.FirebaseQuotaExceededPenaltyDuration)
|
||||
}
|
||||
|
||||
func (v *visitor) MessageAllowed() error {
|
||||
if v.messagesLimiter != nil && v.messagesLimiter.Allow(1) != nil {
|
||||
return errVisitorLimitReached
|
||||
}
|
||||
return nil
|
||||
func (v *visitor) MessageAllowed() bool {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
return v.messagesLimiter.Allow()
|
||||
}
|
||||
|
||||
func (v *visitor) EmailAllowed() error {
|
||||
if !v.emailsLimiter.Allow() {
|
||||
return errVisitorLimitReached
|
||||
}
|
||||
return nil
|
||||
func (v *visitor) EmailAllowed() bool {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
return v.emailsLimiter.Allow()
|
||||
}
|
||||
|
||||
func (v *visitor) SubscriptionAllowed() error {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if err := v.subscriptionLimiter.Allow(1); err != nil {
|
||||
return errVisitorLimitReached
|
||||
func (v *visitor) SubscriptionAllowed() bool {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
return v.subscriptionLimiter.Allow()
|
||||
}
|
||||
|
||||
// AuthAllowed returns true if an auth request can be attempted (> 1 token available)
|
||||
func (v *visitor) AuthAllowed() bool {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
if v.authLimiter == nil {
|
||||
return true
|
||||
}
|
||||
return nil
|
||||
return v.authLimiter.Tokens() > 1
|
||||
}
|
||||
|
||||
// AuthFailed records an auth failure
|
||||
func (v *visitor) AuthFailed() {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
if v.authLimiter != nil {
|
||||
v.authLimiter.Allow()
|
||||
}
|
||||
}
|
||||
|
||||
// AccountCreationAllowed returns true if a new account can be created
|
||||
func (v *visitor) AccountCreationAllowed() bool {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
if v.accountLimiter == nil || (v.accountLimiter != nil && v.accountLimiter.Tokens() < 1) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// AccountCreated decreases the account limiter. This is to be called after an account was created.
|
||||
func (v *visitor) AccountCreated() {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
if v.accountLimiter != nil {
|
||||
v.accountLimiter.Allow()
|
||||
}
|
||||
}
|
||||
|
||||
func (v *visitor) BandwidthAllowed(bytes int64) bool {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
return v.bandwidthLimiter.AllowN(bytes)
|
||||
}
|
||||
|
||||
func (v *visitor) RemoveSubscription() {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.subscriptionLimiter.Allow(-1)
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
v.subscriptionLimiter.AllowN(-1)
|
||||
}
|
||||
|
||||
func (v *visitor) Keepalive() {
|
||||
@@ -177,101 +278,204 @@ func (v *visitor) Keepalive() {
|
||||
}
|
||||
|
||||
func (v *visitor) BandwidthLimiter() util.Limiter {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
return v.bandwidthLimiter
|
||||
}
|
||||
|
||||
func (v *visitor) Stale() bool {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
return time.Since(v.seen) > visitorExpungeAfter
|
||||
}
|
||||
|
||||
func (v *visitor) IncrementMessages() {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.messages++
|
||||
if v.user != nil {
|
||||
v.user.Stats.Messages = v.messages
|
||||
}
|
||||
}
|
||||
|
||||
func (v *visitor) IncrementEmails() {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.emails++
|
||||
if v.user != nil {
|
||||
v.user.Stats.Emails = v.emails
|
||||
func (v *visitor) Stats() *user.Stats {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
return &user.Stats{
|
||||
Messages: v.messagesLimiter.Value(),
|
||||
Emails: v.emailsLimiter.Value(),
|
||||
}
|
||||
}
|
||||
|
||||
func (v *visitor) ResetStats() {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
v.emailsLimiter.Reset()
|
||||
v.messagesLimiter.Reset()
|
||||
}
|
||||
|
||||
// User returns the visitor user, or nil if there is none
|
||||
func (v *visitor) User() *user.User {
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
return v.user // May be nil
|
||||
}
|
||||
|
||||
// IP returns the visitor IP address
|
||||
func (v *visitor) IP() netip.Addr {
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
return v.ip
|
||||
}
|
||||
|
||||
// Authenticated returns true if a user successfully authenticated
|
||||
func (v *visitor) Authenticated() bool {
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
return v.user != nil
|
||||
}
|
||||
|
||||
// SetUser sets the visitors user to the given value
|
||||
func (v *visitor) SetUser(u *user.User) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
v.messages = 0
|
||||
v.emails = 0
|
||||
if v.user != nil {
|
||||
v.user.Stats.Messages = 0
|
||||
v.user.Stats.Emails = 0
|
||||
// v.messagesLimiter = ... // FIXME
|
||||
shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver
|
||||
v.user = u // u may be nil!
|
||||
if shouldResetLimiters {
|
||||
var messages, emails int64
|
||||
if u != nil {
|
||||
messages, emails = u.Stats.Messages, u.Stats.Emails
|
||||
}
|
||||
v.resetLimitersNoLock(messages, emails, true)
|
||||
}
|
||||
}
|
||||
|
||||
// MaybeUserID returns the user ID of the visitor (if any). If this is an anonymous visitor,
|
||||
// an empty string is returned.
|
||||
func (v *visitor) MaybeUserID() string {
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
if v.user != nil {
|
||||
return v.user.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (v *visitor) resetLimitersNoLock(messages, emails int64, enqueueUpdate bool) {
|
||||
limits := v.limitsNoLock()
|
||||
v.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst)
|
||||
v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages)
|
||||
v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails)
|
||||
v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay)
|
||||
if v.user == nil {
|
||||
v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst)
|
||||
v.authLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAuthFailureLimitReplenish), v.config.VisitorAuthFailureLimitBurst)
|
||||
} else {
|
||||
v.accountLimiter = nil // Users cannot create accounts when logged in
|
||||
v.authLimiter = nil // Users are already logged in, no need to limit requests
|
||||
}
|
||||
if enqueueUpdate && v.user != nil {
|
||||
go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{
|
||||
Messages: messages,
|
||||
Emails: emails,
|
||||
})
|
||||
}
|
||||
log.Fields(v.contextNoLock()).Debug("Rate limiters reset for visitor") // Must be after function, because contextNoLock() describes rate limiters
|
||||
}
|
||||
|
||||
func (v *visitor) Limits() *visitorLimits {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
limits := defaultVisitorLimits(v.config)
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
return v.limitsNoLock()
|
||||
}
|
||||
|
||||
func (v *visitor) limitsNoLock() *visitorLimits {
|
||||
if v.user != nil && v.user.Tier != nil {
|
||||
limits.Basis = visitorLimitBasisTier
|
||||
limits.MessagesLimit = v.user.Tier.MessagesLimit
|
||||
limits.MessagesExpiryDuration = v.user.Tier.MessagesExpiryDuration
|
||||
limits.EmailsLimit = v.user.Tier.EmailsLimit
|
||||
limits.ReservationsLimit = v.user.Tier.ReservationsLimit
|
||||
limits.AttachmentTotalSizeLimit = v.user.Tier.AttachmentTotalSizeLimit
|
||||
limits.AttachmentFileSizeLimit = v.user.Tier.AttachmentFileSizeLimit
|
||||
limits.AttachmentExpiryDuration = v.user.Tier.AttachmentExpiryDuration
|
||||
return tierBasedVisitorLimits(v.config, v.user.Tier)
|
||||
}
|
||||
return configBasedVisitorLimits(v.config)
|
||||
}
|
||||
|
||||
func tierBasedVisitorLimits(conf *Config, tier *user.Tier) *visitorLimits {
|
||||
return &visitorLimits{
|
||||
Basis: visitorLimitBasisTier,
|
||||
RequestLimitBurst: util.MinMax(int(float64(tier.MessageLimit)*visitorMessageToRequestLimitBurstRate), conf.VisitorRequestLimitBurst, visitorMessageToRequestLimitBurstMax),
|
||||
RequestLimitReplenish: util.Max(rate.Every(conf.VisitorRequestLimitReplenish), dailyLimitToRate(tier.MessageLimit*visitorMessageToRequestLimitReplenishFactor)),
|
||||
MessageLimit: tier.MessageLimit,
|
||||
MessageExpiryDuration: tier.MessageExpiryDuration,
|
||||
EmailLimit: tier.EmailLimit,
|
||||
EmailLimitBurst: util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax),
|
||||
EmailLimitReplenish: dailyLimitToRate(tier.EmailLimit),
|
||||
ReservationsLimit: tier.ReservationLimit,
|
||||
AttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit,
|
||||
AttachmentFileSizeLimit: tier.AttachmentFileSizeLimit,
|
||||
AttachmentExpiryDuration: tier.AttachmentExpiryDuration,
|
||||
AttachmentBandwidthLimit: tier.AttachmentBandwidthLimit,
|
||||
}
|
||||
}
|
||||
|
||||
func configBasedVisitorLimits(conf *Config) *visitorLimits {
|
||||
messagesLimit := replenishDurationToDailyLimit(conf.VisitorRequestLimitReplenish) // Approximation!
|
||||
if conf.VisitorMessageDailyLimit > 0 {
|
||||
messagesLimit = int64(conf.VisitorMessageDailyLimit)
|
||||
}
|
||||
return &visitorLimits{
|
||||
Basis: visitorLimitBasisIP,
|
||||
RequestLimitBurst: conf.VisitorRequestLimitBurst,
|
||||
RequestLimitReplenish: rate.Every(conf.VisitorRequestLimitReplenish),
|
||||
MessageLimit: messagesLimit,
|
||||
MessageExpiryDuration: conf.CacheDuration,
|
||||
EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation!
|
||||
EmailLimitBurst: conf.VisitorEmailLimitBurst,
|
||||
EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish),
|
||||
ReservationsLimit: visitorDefaultReservationsLimit,
|
||||
AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit,
|
||||
AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit,
|
||||
AttachmentExpiryDuration: conf.AttachmentExpiryDuration,
|
||||
AttachmentBandwidthLimit: conf.VisitorAttachmentDailyBandwidthLimit,
|
||||
}
|
||||
return limits
|
||||
}
|
||||
|
||||
func (v *visitor) Info() (*visitorInfo, error) {
|
||||
v.mu.Lock()
|
||||
messages := v.messages
|
||||
emails := v.emails
|
||||
v.mu.Unlock()
|
||||
v.mu.RLock()
|
||||
info := v.infoLightNoLock()
|
||||
v.mu.RUnlock()
|
||||
|
||||
// Attachment stats from database
|
||||
var attachmentsBytesUsed int64
|
||||
var err error
|
||||
if v.user != nil {
|
||||
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(v.user.Name)
|
||||
u := v.User()
|
||||
if u != nil {
|
||||
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(u.ID)
|
||||
} else {
|
||||
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedBySender(v.ip.String())
|
||||
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedBySender(v.IP().String())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info.Stats.AttachmentTotalSize = attachmentsBytesUsed
|
||||
info.Stats.AttachmentTotalSizeRemaining = zeroIfNegative(info.Limits.AttachmentTotalSizeLimit - attachmentsBytesUsed)
|
||||
|
||||
// Reservation stats from database
|
||||
var reservations int64
|
||||
if v.user != nil && v.userManager != nil {
|
||||
reservations, err = v.userManager.ReservationsCount(v.user.Name)
|
||||
if v.userManager != nil && u != nil {
|
||||
reservations, err = v.userManager.ReservationsCount(u.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
limits := v.Limits()
|
||||
info.Stats.Reservations = reservations
|
||||
info.Stats.ReservationsRemaining = zeroIfNegative(info.Limits.ReservationsLimit - reservations)
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (v *visitor) infoLightNoLock() *visitorInfo {
|
||||
messages := v.messagesLimiter.Value()
|
||||
emails := v.emailsLimiter.Value()
|
||||
limits := v.limitsNoLock()
|
||||
stats := &visitorStats{
|
||||
Messages: messages,
|
||||
MessagesRemaining: zeroIfNegative(limits.MessagesLimit - messages),
|
||||
Emails: emails,
|
||||
EmailsRemaining: zeroIfNegative(limits.EmailsLimit - emails),
|
||||
Reservations: reservations,
|
||||
ReservationsRemaining: zeroIfNegative(limits.ReservationsLimit - reservations),
|
||||
AttachmentTotalSize: attachmentsBytesUsed,
|
||||
AttachmentTotalSizeRemaining: zeroIfNegative(limits.AttachmentTotalSizeLimit - attachmentsBytesUsed),
|
||||
Messages: messages,
|
||||
MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages),
|
||||
Emails: emails,
|
||||
EmailsRemaining: zeroIfNegative(limits.EmailLimit - emails),
|
||||
}
|
||||
return &visitorInfo{
|
||||
Limits: limits,
|
||||
Stats: stats,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func zeroIfNegative(value int64) int64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
@@ -280,22 +484,16 @@ func zeroIfNegative(value int64) int64 {
|
||||
}
|
||||
|
||||
func replenishDurationToDailyLimit(duration time.Duration) int64 {
|
||||
return int64(24 * time.Hour / duration)
|
||||
return int64(oneDay / duration)
|
||||
}
|
||||
|
||||
func dailyLimitToRate(limit int64) rate.Limit {
|
||||
return rate.Limit(limit) * rate.Every(24*time.Hour)
|
||||
return rate.Limit(limit) * rate.Every(oneDay)
|
||||
}
|
||||
|
||||
func defaultVisitorLimits(conf *Config) *visitorLimits {
|
||||
return &visitorLimits{
|
||||
Basis: visitorLimitBasisIP,
|
||||
MessagesLimit: replenishDurationToDailyLimit(conf.VisitorRequestLimitReplenish),
|
||||
MessagesExpiryDuration: conf.CacheDuration,
|
||||
EmailsLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish),
|
||||
ReservationsLimit: visitorDefaultReservationsLimit,
|
||||
AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit,
|
||||
AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit,
|
||||
AttachmentExpiryDuration: conf.AttachmentExpiryDuration,
|
||||
func visitorID(ip netip.Addr, u *user.User) string {
|
||||
if u != nil && u.Tier != nil {
|
||||
return fmt.Sprintf("user:%s", u.ID)
|
||||
}
|
||||
return fmt.Sprintf("ip:%s", ip.String())
|
||||
}
|
||||
|
||||
763
user/manager.go
763
user/manager.go
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,12 @@ package user
|
||||
|
||||
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"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -12,9 +17,9 @@ import (
|
||||
const minBcryptTimingMillis = int64(50) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources
|
||||
|
||||
func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, "unit-test"))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
|
||||
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
||||
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
|
||||
@@ -92,20 +97,45 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
|
||||
|
||||
func TestManager_AddUser_Invalid(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin, "unit-test"))
|
||||
require.Equal(t, ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role", "unit-test"))
|
||||
require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin))
|
||||
require.Equal(t, ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role"))
|
||||
}
|
||||
|
||||
func TestManager_AddUser_Timing(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
|
||||
start := time.Now().UnixMilli()
|
||||
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, "unit-test"))
|
||||
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
|
||||
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||
}
|
||||
|
||||
func TestManager_AddUser_And_Query(t *testing.T) {
|
||||
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
|
||||
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
|
||||
require.Nil(t, a.ChangeBilling("user", &Billing{
|
||||
StripeCustomerID: "acct_123",
|
||||
StripeSubscriptionID: "sub_123",
|
||||
StripeSubscriptionStatus: stripe.SubscriptionStatusActive,
|
||||
StripeSubscriptionInterval: stripe.PriceRecurringIntervalMonth,
|
||||
StripeSubscriptionPaidUntil: time.Now().Add(time.Hour),
|
||||
StripeSubscriptionCancelAt: time.Unix(0, 0),
|
||||
}))
|
||||
|
||||
u, err := a.User("user")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "user", u.Name)
|
||||
|
||||
u2, err := a.UserByID(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, u.Name, u2.Name)
|
||||
|
||||
u3, err := a.UserByStripeCustomer("acct_123")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, u.ID, u3.ID)
|
||||
}
|
||||
|
||||
func TestManager_Authenticate_Timing(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, "unit-test"))
|
||||
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()
|
||||
@@ -126,10 +156,60 @@ func TestManager_Authenticate_Timing(t *testing.T) {
|
||||
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||
}
|
||||
|
||||
func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
|
||||
// Create user, add reservations and token
|
||||
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
|
||||
require.Nil(t, a.AddReservation("user", "mytopic", PermissionRead))
|
||||
|
||||
u, err := a.User("user")
|
||||
require.Nil(t, err)
|
||||
require.False(t, u.Deleted)
|
||||
|
||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified())
|
||||
require.Nil(t, err)
|
||||
|
||||
u, err = a.Authenticate("user", "pass")
|
||||
require.Nil(t, err)
|
||||
|
||||
_, err = a.AuthenticateToken(token.Value)
|
||||
require.Nil(t, err)
|
||||
|
||||
reservations, err := a.Reservations("user")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(reservations))
|
||||
|
||||
// Mark deleted: cannot auth anymore, and all reservations are gone
|
||||
require.Nil(t, a.MarkUserRemoved(u))
|
||||
|
||||
_, err = a.Authenticate("user", "pass")
|
||||
require.Equal(t, ErrUnauthenticated, err)
|
||||
|
||||
_, err = a.AuthenticateToken(token.Value)
|
||||
require.Equal(t, ErrUnauthenticated, err)
|
||||
|
||||
reservations, err = a.Reservations("user")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(reservations))
|
||||
|
||||
// Make sure user is still there
|
||||
u, err = a.User("user")
|
||||
require.Nil(t, err)
|
||||
require.True(t, u.Deleted)
|
||||
|
||||
_, err = a.db.Exec("UPDATE user SET deleted = ? WHERE id = ?", time.Now().Add(-1*(userHardDeleteAfterDuration+time.Hour)).Unix(), u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, a.RemoveDeletedUsers())
|
||||
|
||||
_, err = a.User("user")
|
||||
require.Equal(t, ErrUserNotFound, err)
|
||||
}
|
||||
|
||||
func TestManager_UserManagement(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, "unit-test"))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
|
||||
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
||||
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
|
||||
@@ -141,7 +221,7 @@ func TestManager_UserManagement(t *testing.T) {
|
||||
phil, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "phil", phil.Name)
|
||||
require.True(t, strings.HasPrefix(phil.Hash, "$2a$10$"))
|
||||
require.True(t, strings.HasPrefix(phil.Hash, "$2a$04$")) // Min cost for testing
|
||||
require.Equal(t, RoleAdmin, phil.Role)
|
||||
|
||||
philGrants, err := a.Grants("phil")
|
||||
@@ -151,7 +231,7 @@ func TestManager_UserManagement(t *testing.T) {
|
||||
ben, err := a.User("ben")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "ben", ben.Name)
|
||||
require.True(t, strings.HasPrefix(ben.Hash, "$2a$10$"))
|
||||
require.True(t, strings.HasPrefix(ben.Hash, "$2a$04$")) // Min cost for testing
|
||||
require.Equal(t, RoleUser, ben.Role)
|
||||
|
||||
benGrants, err := a.Grants("ben")
|
||||
@@ -219,7 +299,7 @@ func TestManager_UserManagement(t *testing.T) {
|
||||
|
||||
func TestManager_ChangePassword(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, "unit-test"))
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))
|
||||
|
||||
_, err := a.Authenticate("phil", "phil")
|
||||
require.Nil(t, err)
|
||||
@@ -233,7 +313,7 @@ func TestManager_ChangePassword(t *testing.T) {
|
||||
|
||||
func TestManager_ChangeRole(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
|
||||
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
||||
|
||||
@@ -258,9 +338,10 @@ func TestManager_ChangeRole(t *testing.T) {
|
||||
|
||||
func TestManager_Reservations(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||
require.Nil(t, a.ReserveAccess("ben", "ztopic", PermissionDenyAll))
|
||||
require.Nil(t, a.ReserveAccess("ben", "readme", PermissionRead))
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||
require.Nil(t, a.AddReservation("ben", "ztopic", PermissionDenyAll))
|
||||
require.Nil(t, a.AddReservation("ben", "readme", PermissionRead))
|
||||
require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead))
|
||||
|
||||
reservations, err := a.Reservations("ben")
|
||||
@@ -276,35 +357,67 @@ func TestManager_Reservations(t *testing.T) {
|
||||
Owner: PermissionReadWrite,
|
||||
Everyone: PermissionDenyAll,
|
||||
}, reservations[1])
|
||||
|
||||
b, err := a.HasReservation("ben", "readme")
|
||||
require.Nil(t, err)
|
||||
require.True(t, b)
|
||||
|
||||
b, err = a.HasReservation("notben", "readme")
|
||||
require.Nil(t, err)
|
||||
require.False(t, b)
|
||||
|
||||
b, err = a.HasReservation("ben", "something-else")
|
||||
require.Nil(t, err)
|
||||
require.False(t, b)
|
||||
|
||||
count, err := a.ReservationsCount("ben")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(2), count)
|
||||
|
||||
count, err = a.ReservationsCount("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), count)
|
||||
|
||||
err = a.AllowReservation("phil", "readme")
|
||||
require.Equal(t, errTopicOwnedByOthers, err)
|
||||
|
||||
err = a.AllowReservation("phil", "not-reserved")
|
||||
require.Nil(t, err)
|
||||
|
||||
// Now remove them again
|
||||
require.Nil(t, a.RemoveReservations("ben", "ztopic", "readme"))
|
||||
|
||||
count, err = a.ReservationsCount("ben")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
require.Nil(t, a.CreateTier(&Tier{
|
||||
require.Nil(t, a.AddTier(&Tier{
|
||||
Code: "pro",
|
||||
Name: "ntfy Pro",
|
||||
StripePriceID: "price123",
|
||||
MessagesLimit: 5_000,
|
||||
MessagesExpiryDuration: 3 * 24 * time.Hour,
|
||||
EmailsLimit: 50,
|
||||
ReservationsLimit: 5,
|
||||
StripeMonthlyPriceID: "price123",
|
||||
MessageLimit: 5_000,
|
||||
MessageExpiryDuration: 3 * 24 * time.Hour,
|
||||
EmailLimit: 50,
|
||||
ReservationLimit: 5,
|
||||
AttachmentFileSizeLimit: 52428800,
|
||||
AttachmentTotalSizeLimit: 524288000,
|
||||
AttachmentExpiryDuration: 24 * time.Hour,
|
||||
}))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||
require.Nil(t, a.ChangeTier("ben", "pro"))
|
||||
require.Nil(t, a.ReserveAccess("ben", "mytopic", PermissionDenyAll))
|
||||
require.Nil(t, a.AddReservation("ben", "mytopic", PermissionDenyAll))
|
||||
|
||||
ben, err := a.User("ben")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, RoleUser, ben.Role)
|
||||
require.Equal(t, "pro", ben.Tier.Code)
|
||||
require.Equal(t, true, ben.Tier.Paid)
|
||||
require.Equal(t, int64(5000), ben.Tier.MessagesLimit)
|
||||
require.Equal(t, 3*24*time.Hour, ben.Tier.MessagesExpiryDuration)
|
||||
require.Equal(t, int64(50), ben.Tier.EmailsLimit)
|
||||
require.Equal(t, int64(5), ben.Tier.ReservationsLimit)
|
||||
require.Equal(t, int64(5000), ben.Tier.MessageLimit)
|
||||
require.Equal(t, 3*24*time.Hour, ben.Tier.MessageExpiryDuration)
|
||||
require.Equal(t, int64(50), ben.Tier.EmailLimit)
|
||||
require.Equal(t, int64(5), ben.Tier.ReservationLimit)
|
||||
require.Equal(t, int64(52428800), ben.Tier.AttachmentFileSizeLimit)
|
||||
require.Equal(t, int64(524288000), ben.Tier.AttachmentTotalSizeLimit)
|
||||
require.Equal(t, 24*time.Hour, ben.Tier.AttachmentExpiryDuration)
|
||||
@@ -340,15 +453,16 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
|
||||
|
||||
func TestManager_Token_Valid(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||
|
||||
u, err := a.User("ben")
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create token for user
|
||||
token, err := a.CreateToken(u)
|
||||
token, err := a.CreateToken(u.ID, "some label", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||
require.Nil(t, err)
|
||||
require.NotEmpty(t, token.Value)
|
||||
require.Equal(t, "some label", token.Label)
|
||||
require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires.Unix())
|
||||
|
||||
u2, err := a.AuthenticateToken(token.Value)
|
||||
@@ -356,16 +470,34 @@ func TestManager_Token_Valid(t *testing.T) {
|
||||
require.Equal(t, u.Name, u2.Name)
|
||||
require.Equal(t, token.Value, u2.Token)
|
||||
|
||||
token2, err := a.Token(u.ID, token.Value)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, token.Value, token2.Value)
|
||||
require.Equal(t, "some label", token2.Label)
|
||||
|
||||
tokens, err := a.Tokens(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(tokens))
|
||||
require.Equal(t, "some label", tokens[0].Label)
|
||||
|
||||
tokens, err = a.Tokens("u_notauser")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(tokens))
|
||||
|
||||
// Remove token and auth again
|
||||
require.Nil(t, a.RemoveToken(u2))
|
||||
require.Nil(t, a.RemoveToken(u2.ID, u2.Token))
|
||||
u3, err := a.AuthenticateToken(token.Value)
|
||||
require.Equal(t, ErrUnauthenticated, err)
|
||||
require.Nil(t, u3)
|
||||
|
||||
tokens, err = a.Tokens(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(tokens))
|
||||
}
|
||||
|
||||
func TestManager_Token_Invalid(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||
|
||||
u, err := a.AuthenticateToken(strings.Repeat("x", 32)) // 32 == token length
|
||||
require.Nil(t, u)
|
||||
@@ -376,20 +508,26 @@ func TestManager_Token_Invalid(t *testing.T) {
|
||||
require.Equal(t, ErrUnauthenticated, err)
|
||||
}
|
||||
|
||||
func TestManager_Token_NotFound(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
_, err := a.Token("u_bla", "notfound")
|
||||
require.Equal(t, ErrTokenNotFound, err)
|
||||
}
|
||||
|
||||
func TestManager_Token_Expire(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||
|
||||
u, err := a.User("ben")
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create tokens for user
|
||||
token1, err := a.CreateToken(u)
|
||||
token1, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||
require.Nil(t, err)
|
||||
require.NotEmpty(t, token1.Value)
|
||||
require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix())
|
||||
|
||||
token2, err := a.CreateToken(u)
|
||||
token2, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||
require.Nil(t, err)
|
||||
require.NotEmpty(t, token2.Value)
|
||||
require.NotEqual(t, token1.Value, token2.Value)
|
||||
@@ -426,34 +564,34 @@ func TestManager_Token_Expire(t *testing.T) {
|
||||
|
||||
func TestManager_Token_Extend(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||
|
||||
// Try to extend token for user without token
|
||||
u, err := a.User("ben")
|
||||
require.Nil(t, err)
|
||||
|
||||
_, err = a.ExtendToken(u)
|
||||
_, err = a.ChangeToken(u.ID, u.Token, util.String("some label"), util.Time(time.Now().Add(time.Hour)))
|
||||
require.Equal(t, errNoTokenProvided, err)
|
||||
|
||||
// Create token for user
|
||||
token, err := a.CreateToken(u)
|
||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||
require.Nil(t, err)
|
||||
require.NotEmpty(t, token.Value)
|
||||
|
||||
userWithToken, err := a.AuthenticateToken(token.Value)
|
||||
require.Nil(t, err)
|
||||
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
||||
extendedToken, err := a.ExtendToken(userWithToken)
|
||||
extendedToken, err := a.ChangeToken(userWithToken.ID, userWithToken.Token, util.String("changed label"), util.Time(time.Now().Add(100*time.Hour)))
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, token.Value, extendedToken.Value)
|
||||
require.Equal(t, "changed label", extendedToken.Label)
|
||||
require.True(t, token.Expires.Unix() < extendedToken.Expires.Unix())
|
||||
require.True(t, time.Now().Add(99*time.Hour).Unix() < extendedToken.Expires.Unix())
|
||||
}
|
||||
|
||||
func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||
|
||||
// Try to extend token for user without token
|
||||
u, err := a.User("ben")
|
||||
@@ -462,8 +600,8 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
||||
// Tokens
|
||||
baseTime := time.Now().Add(24 * time.Hour)
|
||||
tokens := make([]string, 0)
|
||||
for i := 0; i < 12; i++ {
|
||||
token, err := a.CreateToken(u)
|
||||
for i := 0; i < 22; i++ {
|
||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||
require.Nil(t, err)
|
||||
require.NotEmpty(t, token.Value)
|
||||
tokens = append(tokens, token.Value)
|
||||
@@ -479,7 +617,7 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
||||
_, err = a.AuthenticateToken(tokens[1])
|
||||
require.Equal(t, ErrUnauthenticated, err)
|
||||
|
||||
for i := 2; i < 12; i++ {
|
||||
for i := 2; i < 22; i++ {
|
||||
userWithToken, err := a.AuthenticateToken(tokens[i])
|
||||
require.Nil(t, err, "token[%d]=%s failed", i, tokens[i])
|
||||
require.Equal(t, "ben", userWithToken.Name)
|
||||
@@ -491,23 +629,23 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.True(t, rows.Next())
|
||||
require.Nil(t, rows.Scan(&count))
|
||||
require.Equal(t, 10, count)
|
||||
require.Equal(t, 20, count)
|
||||
}
|
||||
|
||||
func TestManager_EnqueueStats(t *testing.T) {
|
||||
a, err := newManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, 1500*time.Millisecond)
|
||||
func TestManager_EnqueueStats_ResetStats(t *testing.T) {
|
||||
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||
|
||||
// Baseline: No messages or emails
|
||||
u, err := a.User("ben")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), u.Stats.Messages)
|
||||
require.Equal(t, int64(0), u.Stats.Emails)
|
||||
|
||||
u.Stats.Messages = 11
|
||||
u.Stats.Emails = 2
|
||||
a.EnqueueStats(u)
|
||||
a.EnqueueUserStats(u.ID, &Stats{
|
||||
Messages: 11,
|
||||
Emails: 2,
|
||||
})
|
||||
|
||||
// Still no change, because it's queued asynchronously
|
||||
u, err = a.User("ben")
|
||||
@@ -522,49 +660,260 @@ func TestManager_EnqueueStats(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(11), u.Stats.Messages)
|
||||
require.Equal(t, int64(2), u.Stats.Emails)
|
||||
|
||||
// Now reset stats (enqueued stats will be thrown out)
|
||||
a.EnqueueUserStats(u.ID, &Stats{
|
||||
Messages: 99,
|
||||
Emails: 23,
|
||||
})
|
||||
require.Nil(t, a.ResetStats())
|
||||
|
||||
u, err = a.User("ben")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), u.Stats.Messages)
|
||||
require.Equal(t, int64(0), u.Stats.Emails)
|
||||
}
|
||||
|
||||
func TestManager_EnqueueTokenUpdate(t *testing.T) {
|
||||
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 500*time.Millisecond)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||
|
||||
// Create user and token
|
||||
u, err := a.User("ben")
|
||||
require.Nil(t, err)
|
||||
|
||||
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified())
|
||||
require.Nil(t, err)
|
||||
|
||||
// Queue token update
|
||||
a.EnqueueTokenUpdate(token.Value, &TokenUpdate{
|
||||
LastAccess: time.Unix(111, 0).UTC(),
|
||||
LastOrigin: netip.MustParseAddr("1.2.3.3"),
|
||||
})
|
||||
|
||||
// Token has not changed yet.
|
||||
token2, err := a.Token(u.ID, token.Value)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, token.LastAccess.Unix(), token2.LastAccess.Unix())
|
||||
require.Equal(t, token.LastOrigin, token2.LastOrigin)
|
||||
|
||||
// After a second or so they should be persisted
|
||||
time.Sleep(time.Second)
|
||||
|
||||
token3, err := a.Token(u.ID, token.Value)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, time.Unix(111, 0).UTC().Unix(), token3.LastAccess.Unix())
|
||||
require.Equal(t, netip.MustParseAddr("1.2.3.3"), token3.LastOrigin)
|
||||
}
|
||||
|
||||
func TestManager_ChangeSettings(t *testing.T) {
|
||||
a, err := newManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, 1500*time.Millisecond)
|
||||
a, err := NewManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, bcrypt.MinCost, 1500*time.Millisecond)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||
|
||||
// No settings
|
||||
u, err := a.User("ben")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, u.Prefs.Subscriptions)
|
||||
require.Nil(t, u.Prefs.Notification)
|
||||
require.Equal(t, "", u.Prefs.Language)
|
||||
require.Nil(t, u.Prefs.Language)
|
||||
|
||||
// Save with new settings
|
||||
u.Prefs = &Prefs{
|
||||
Language: "de",
|
||||
prefs := &Prefs{
|
||||
Language: util.String("de"),
|
||||
Notification: &NotificationPrefs{
|
||||
Sound: "ding",
|
||||
MinPriority: 2,
|
||||
Sound: util.String("ding"),
|
||||
MinPriority: util.Int(2),
|
||||
},
|
||||
Subscriptions: []*Subscription{
|
||||
{
|
||||
ID: "someID",
|
||||
BaseURL: "https://ntfy.sh",
|
||||
Topic: "mytopic",
|
||||
DisplayName: "My Topic",
|
||||
DisplayName: util.String("My Topic"),
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Nil(t, a.ChangeSettings(u))
|
||||
require.Nil(t, a.ChangeSettings(u.ID, prefs))
|
||||
|
||||
// Read again
|
||||
u, err = a.User("ben")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "de", u.Prefs.Language)
|
||||
require.Equal(t, "ding", u.Prefs.Notification.Sound)
|
||||
require.Equal(t, 2, u.Prefs.Notification.MinPriority)
|
||||
require.Equal(t, 0, u.Prefs.Notification.DeleteAfter)
|
||||
require.Equal(t, "someID", u.Prefs.Subscriptions[0].ID)
|
||||
require.Equal(t, util.String("de"), u.Prefs.Language)
|
||||
require.Equal(t, util.String("ding"), u.Prefs.Notification.Sound)
|
||||
require.Equal(t, util.Int(2), u.Prefs.Notification.MinPriority)
|
||||
require.Nil(t, u.Prefs.Notification.DeleteAfter)
|
||||
require.Equal(t, "https://ntfy.sh", u.Prefs.Subscriptions[0].BaseURL)
|
||||
require.Equal(t, "mytopic", u.Prefs.Subscriptions[0].Topic)
|
||||
require.Equal(t, "My Topic", u.Prefs.Subscriptions[0].DisplayName)
|
||||
require.Equal(t, util.String("My Topic"), u.Prefs.Subscriptions[0].DisplayName)
|
||||
}
|
||||
|
||||
func TestManager_Tier_Create_Update_List_Delete(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
|
||||
// Create tier and user
|
||||
require.Nil(t, a.AddTier(&Tier{
|
||||
Code: "supporter",
|
||||
Name: "Supporter",
|
||||
MessageLimit: 1,
|
||||
MessageExpiryDuration: time.Second,
|
||||
EmailLimit: 1,
|
||||
ReservationLimit: 1,
|
||||
AttachmentFileSizeLimit: 1,
|
||||
AttachmentTotalSizeLimit: 1,
|
||||
AttachmentExpiryDuration: time.Second,
|
||||
AttachmentBandwidthLimit: 1,
|
||||
StripeMonthlyPriceID: "price_1",
|
||||
}))
|
||||
require.Nil(t, a.AddTier(&Tier{
|
||||
Code: "pro",
|
||||
Name: "Pro",
|
||||
MessageLimit: 123,
|
||||
MessageExpiryDuration: 86400 * time.Second,
|
||||
EmailLimit: 32,
|
||||
ReservationLimit: 2,
|
||||
AttachmentFileSizeLimit: 1231231,
|
||||
AttachmentTotalSizeLimit: 123123,
|
||||
AttachmentExpiryDuration: 10800 * time.Second,
|
||||
AttachmentBandwidthLimit: 21474836480,
|
||||
StripeMonthlyPriceID: "price_2",
|
||||
}))
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
|
||||
require.Nil(t, a.ChangeTier("phil", "pro"))
|
||||
|
||||
ti, err := a.Tier("pro")
|
||||
require.Nil(t, err)
|
||||
|
||||
u, err := a.User("phil")
|
||||
require.Nil(t, err)
|
||||
|
||||
// These are populated by different SQL queries
|
||||
require.Equal(t, ti, u.Tier)
|
||||
|
||||
// Fields
|
||||
require.True(t, strings.HasPrefix(ti.ID, "ti_"))
|
||||
require.Equal(t, "pro", ti.Code)
|
||||
require.Equal(t, "Pro", ti.Name)
|
||||
require.Equal(t, int64(123), ti.MessageLimit)
|
||||
require.Equal(t, 86400*time.Second, ti.MessageExpiryDuration)
|
||||
require.Equal(t, int64(32), ti.EmailLimit)
|
||||
require.Equal(t, int64(2), ti.ReservationLimit)
|
||||
require.Equal(t, int64(1231231), ti.AttachmentFileSizeLimit)
|
||||
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.StripeMonthlyPriceID)
|
||||
|
||||
// Update tier
|
||||
ti.EmailLimit = 999999
|
||||
require.Nil(t, a.UpdateTier(ti))
|
||||
|
||||
// List tiers
|
||||
tiers, err := a.Tiers()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(tiers))
|
||||
|
||||
ti = tiers[0]
|
||||
require.Equal(t, "supporter", ti.Code)
|
||||
require.Equal(t, "Supporter", ti.Name)
|
||||
require.Equal(t, int64(1), ti.MessageLimit)
|
||||
require.Equal(t, time.Second, ti.MessageExpiryDuration)
|
||||
require.Equal(t, int64(1), ti.EmailLimit)
|
||||
require.Equal(t, int64(1), ti.ReservationLimit)
|
||||
require.Equal(t, int64(1), ti.AttachmentFileSizeLimit)
|
||||
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.StripeMonthlyPriceID)
|
||||
|
||||
ti = tiers[1]
|
||||
require.Equal(t, "pro", ti.Code)
|
||||
require.Equal(t, "Pro", ti.Name)
|
||||
require.Equal(t, int64(123), ti.MessageLimit)
|
||||
require.Equal(t, 86400*time.Second, ti.MessageExpiryDuration)
|
||||
require.Equal(t, int64(999999), ti.EmailLimit) // Updatedd!
|
||||
require.Equal(t, int64(2), ti.ReservationLimit)
|
||||
require.Equal(t, int64(1231231), ti.AttachmentFileSizeLimit)
|
||||
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.StripeMonthlyPriceID)
|
||||
|
||||
ti, err = a.TierByStripePrice("price_1")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "supporter", ti.Code)
|
||||
require.Equal(t, "Supporter", ti.Name)
|
||||
require.Equal(t, int64(1), ti.MessageLimit)
|
||||
require.Equal(t, time.Second, ti.MessageExpiryDuration)
|
||||
require.Equal(t, int64(1), ti.EmailLimit)
|
||||
require.Equal(t, int64(1), ti.ReservationLimit)
|
||||
require.Equal(t, int64(1), ti.AttachmentFileSizeLimit)
|
||||
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.StripeMonthlyPriceID)
|
||||
|
||||
// Cannot remove tier, since user has this tier
|
||||
require.Error(t, a.RemoveTier("pro"))
|
||||
|
||||
// CAN remove this tier
|
||||
require.Nil(t, a.RemoveTier("supporter"))
|
||||
|
||||
tiers, err = a.Tiers()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(tiers))
|
||||
require.Equal(t, "pro", tiers[0].Code)
|
||||
require.Equal(t, "pro", tiers[0].Code)
|
||||
}
|
||||
|
||||
func TestAccount_Tier_Create_With_ID(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
|
||||
require.Nil(t, a.AddTier(&Tier{
|
||||
ID: "ti_123",
|
||||
Code: "pro",
|
||||
}))
|
||||
|
||||
ti, err := a.Tier("pro")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "ti_123", ti.ID)
|
||||
}
|
||||
|
||||
func TestManager_Tier_Change_And_Reset(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
|
||||
// Create tier and user
|
||||
require.Nil(t, a.AddTier(&Tier{
|
||||
Code: "supporter",
|
||||
Name: "Supporter",
|
||||
ReservationLimit: 3,
|
||||
}))
|
||||
require.Nil(t, a.AddTier(&Tier{
|
||||
Code: "pro",
|
||||
Name: "Pro",
|
||||
ReservationLimit: 4,
|
||||
}))
|
||||
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
|
||||
require.Nil(t, a.ChangeTier("phil", "pro"))
|
||||
|
||||
// Add 10 reservations (pro tier allows that)
|
||||
for i := 0; i < 4; i++ {
|
||||
require.Nil(t, a.AddReservation("phil", fmt.Sprintf("topic%d", i), PermissionWrite))
|
||||
}
|
||||
|
||||
// Downgrading will not work (too many reservations)
|
||||
require.Equal(t, ErrTooManyReservations, a.ChangeTier("phil", "supporter"))
|
||||
|
||||
// Downgrade after removing a reservation
|
||||
require.Nil(t, a.RemoveReservations("phil", "topic0"))
|
||||
require.Nil(t, a.ChangeTier("phil", "supporter"))
|
||||
|
||||
// Resetting will not work (too many reservations)
|
||||
require.Equal(t, ErrTooManyReservations, a.ResetTier("phil"))
|
||||
|
||||
// Resetting after removing all reservations
|
||||
require.Nil(t, a.RemoveReservations("phil", "topic1", "topic2", "topic3"))
|
||||
require.Nil(t, a.ResetTier("phil"))
|
||||
}
|
||||
|
||||
func TestSqliteCache_Migration_From1(t *testing.T) {
|
||||
@@ -609,7 +958,7 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create manager to trigger migration
|
||||
a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, userStatsQueueWriterInterval)
|
||||
a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval)
|
||||
checkSchemaVersion(t, a.db)
|
||||
|
||||
users, err := a.Users()
|
||||
@@ -626,11 +975,14 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
|
||||
everyoneGrants, err := a.Grants(Everyone)
|
||||
require.Nil(t, err)
|
||||
|
||||
require.True(t, strings.HasPrefix(phil.ID, "u_"))
|
||||
require.Equal(t, "phil", phil.Name)
|
||||
require.Equal(t, RoleAdmin, phil.Role)
|
||||
require.Equal(t, syncTopicLength, len(phil.SyncTopic))
|
||||
require.Equal(t, 0, len(philGrants))
|
||||
|
||||
require.True(t, strings.HasPrefix(ben.ID, "u_"))
|
||||
require.NotEqual(t, phil.ID, ben.ID)
|
||||
require.Equal(t, "ben", ben.Name)
|
||||
require.Equal(t, RoleUser, ben.Role)
|
||||
require.Equal(t, syncTopicLength, len(ben.SyncTopic))
|
||||
@@ -641,6 +993,7 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
|
||||
require.Equal(t, "secret", benGrants[1].TopicPattern)
|
||||
require.Equal(t, PermissionRead, benGrants[1].Allow)
|
||||
|
||||
require.Equal(t, "u_everyone", everyone.ID)
|
||||
require.Equal(t, Everyone, everyone.Name)
|
||||
require.Equal(t, RoleAnonymous, everyone.Role)
|
||||
require.Equal(t, 1, len(everyoneGrants))
|
||||
@@ -660,11 +1013,11 @@ func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
||||
}
|
||||
|
||||
func newTestManager(t *testing.T, defaultAccess Permission) *Manager {
|
||||
return newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", defaultAccess, userStatsQueueWriterInterval)
|
||||
return newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", defaultAccess, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval)
|
||||
}
|
||||
|
||||
func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, statsWriterInterval time.Duration) *Manager {
|
||||
a, err := newManager(filename, startupQueries, defaultAccess, statsWriterInterval)
|
||||
func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager {
|
||||
a, err := NewManager(filename, startupQueries, defaultAccess, bcryptCost, statsWriterInterval)
|
||||
require.Nil(t, err)
|
||||
return a
|
||||
}
|
||||
|
||||
104
user/types.go
104
user/types.go
@@ -1,15 +1,18 @@
|
||||
// Package user deals with authentication and authorization against topics
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"heckel.io/ntfy/log"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User is a struct that represents a user
|
||||
type User struct {
|
||||
ID string
|
||||
Name string
|
||||
Hash string // password hash (bcrypt)
|
||||
Token string // Only set if token was used to log in
|
||||
@@ -19,8 +22,26 @@ type User struct {
|
||||
Stats *Stats
|
||||
Billing *Billing
|
||||
SyncTopic string
|
||||
Created time.Time
|
||||
LastSeen time.Time
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
// TierID returns the ID of the User.Tier, or an empty string if the user has no tier,
|
||||
// or if the user itself is nil.
|
||||
func (u *User) TierID() string {
|
||||
if u == nil || u.Tier == nil {
|
||||
return ""
|
||||
}
|
||||
return u.Tier.ID
|
||||
}
|
||||
|
||||
// IsAdmin returns true if the user is an admin
|
||||
func (u *User) IsAdmin() bool {
|
||||
return u != nil && u.Role == RoleAdmin
|
||||
}
|
||||
|
||||
// IsUser returns true if the user is a regular user, not an admin
|
||||
func (u *User) IsUser() bool {
|
||||
return u != nil && u.Role == RoleUser
|
||||
}
|
||||
|
||||
// Auther is an interface for authentication and authorization
|
||||
@@ -37,45 +58,73 @@ type Auther interface {
|
||||
|
||||
// Token represents a user token, including expiry date
|
||||
type Token struct {
|
||||
Value string
|
||||
Expires time.Time
|
||||
Value string
|
||||
Label string
|
||||
LastAccess time.Time
|
||||
LastOrigin netip.Addr
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
// TokenUpdate holds information about the last access time and origin IP address of a token
|
||||
type TokenUpdate struct {
|
||||
LastAccess time.Time
|
||||
LastOrigin netip.Addr
|
||||
}
|
||||
|
||||
// Prefs represents a user's configuration settings
|
||||
type Prefs struct {
|
||||
Language string `json:"language,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
Notification *NotificationPrefs `json:"notification,omitempty"`
|
||||
Subscriptions []*Subscription `json:"subscriptions,omitempty"`
|
||||
}
|
||||
|
||||
// Tier represents a user's account type, including its account limits
|
||||
type Tier struct {
|
||||
Code string
|
||||
Name string
|
||||
Paid bool
|
||||
MessagesLimit int64
|
||||
MessagesExpiryDuration time.Duration
|
||||
EmailsLimit int64
|
||||
ReservationsLimit int64
|
||||
AttachmentFileSizeLimit int64
|
||||
AttachmentTotalSizeLimit int64
|
||||
AttachmentExpiryDuration time.Duration
|
||||
StripePriceID string
|
||||
ID string // Tier identifier (ti_...)
|
||||
Code string // Code of the tier
|
||||
Name string // Name of the tier
|
||||
MessageLimit int64 // Daily message limit
|
||||
MessageExpiryDuration time.Duration // Cache duration for messages
|
||||
EmailLimit int64 // Daily email limit
|
||||
ReservationLimit int64 // Number of topic reservations allowed by user
|
||||
AttachmentFileSizeLimit int64 // Max file size per file (bytes)
|
||||
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
|
||||
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_monthly_price_id": t.StripeMonthlyPriceID,
|
||||
"stripe_yearly_price_id": t.StripeYearlyPriceID,
|
||||
}
|
||||
}
|
||||
|
||||
// Subscription represents a user's topic subscription
|
||||
type Subscription struct {
|
||||
ID string `json:"id"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Topic string `json:"topic"`
|
||||
DisplayName string `json:"display_name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Topic string `json:"topic"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
}
|
||||
|
||||
// Context returns fields for the log
|
||||
func (s *Subscription) Context() log.Context {
|
||||
return log.Context{
|
||||
"base_url": s.BaseURL,
|
||||
"topic": s.Topic,
|
||||
}
|
||||
}
|
||||
|
||||
// NotificationPrefs represents the user's notification settings
|
||||
type NotificationPrefs struct {
|
||||
Sound string `json:"sound,omitempty"`
|
||||
MinPriority int `json:"min_priority,omitempty"`
|
||||
DeleteAfter int `json:"delete_after,omitempty"`
|
||||
Sound *string `json:"sound,omitempty"`
|
||||
MinPriority *int `json:"min_priority,omitempty"`
|
||||
DeleteAfter *int `json:"delete_after,omitempty"`
|
||||
}
|
||||
|
||||
// Stats is a struct holding daily user statistics
|
||||
@@ -89,6 +138,7 @@ type Billing struct {
|
||||
StripeCustomerID string
|
||||
StripeSubscriptionID string
|
||||
StripeSubscriptionStatus stripe.SubscriptionStatus
|
||||
StripeSubscriptionInterval stripe.PriceRecurringInterval
|
||||
StripeSubscriptionPaidUntil time.Time
|
||||
StripeSubscriptionCancelAt time.Time
|
||||
}
|
||||
@@ -131,7 +181,7 @@ func NewPermission(read, write bool) Permission {
|
||||
|
||||
// ParsePermission parses the string representation and returns a Permission
|
||||
func ParsePermission(s string) (Permission, error) {
|
||||
switch s {
|
||||
switch strings.ToLower(s) {
|
||||
case "read-write", "rw":
|
||||
return NewPermission(true, true), nil
|
||||
case "read-only", "read", "ro":
|
||||
@@ -184,7 +234,8 @@ const (
|
||||
|
||||
// Everyone is a special username representing anonymous users
|
||||
const (
|
||||
Everyone = "*"
|
||||
Everyone = "*"
|
||||
everyoneID = "u_everyone"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -226,5 +277,6 @@ var (
|
||||
ErrInvalidArgument = errors.New("invalid argument")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrTierNotFound = errors.New("tier not found")
|
||||
ErrTokenNotFound = errors.New("token not found")
|
||||
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
||||
)
|
||||
|
||||
63
user/types_test.go
Normal file
63
user/types_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPermission(t *testing.T) {
|
||||
require.Equal(t, PermissionReadWrite, NewPermission(true, true))
|
||||
require.Equal(t, PermissionRead, NewPermission(true, false))
|
||||
require.Equal(t, PermissionWrite, NewPermission(false, true))
|
||||
require.Equal(t, PermissionDenyAll, NewPermission(false, false))
|
||||
require.True(t, PermissionReadWrite.IsReadWrite())
|
||||
require.True(t, PermissionReadWrite.IsRead())
|
||||
require.True(t, PermissionReadWrite.IsWrite())
|
||||
require.True(t, PermissionRead.IsRead())
|
||||
require.True(t, PermissionWrite.IsWrite())
|
||||
}
|
||||
|
||||
func TestParsePermission(t *testing.T) {
|
||||
_, err := ParsePermission("no")
|
||||
require.NotNil(t, err)
|
||||
|
||||
p, err := ParsePermission("read-write")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, PermissionReadWrite, p)
|
||||
|
||||
p, err = ParsePermission("rw")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, PermissionReadWrite, p)
|
||||
|
||||
p, err = ParsePermission("read-only")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, PermissionRead, p)
|
||||
|
||||
p, err = ParsePermission("WRITE")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, PermissionWrite, p)
|
||||
|
||||
p, err = ParsePermission("deny-all")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, PermissionDenyAll, p)
|
||||
}
|
||||
|
||||
func TestAllowedTier(t *testing.T) {
|
||||
require.False(t, AllowedTier(" no"))
|
||||
require.True(t, AllowedTier("yes"))
|
||||
}
|
||||
|
||||
func TestTierContext(t *testing.T) {
|
||||
tier := &Tier{
|
||||
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_monthly_price_id"])
|
||||
require.Equal(t, "price_456", context["stripe_yearly_price_id"])
|
||||
|
||||
}
|
||||
108
util/limit.go
108
util/limit.go
@@ -13,8 +13,17 @@ var ErrLimitReached = errors.New("limit reached")
|
||||
|
||||
// Limiter is an interface that implements a rate limiting mechanism, e.g. based on time or a fixed value
|
||||
type Limiter interface {
|
||||
// Allow adds n to the limiters internal value, or returns ErrLimitReached if the limit has been reached
|
||||
Allow(n int64) error
|
||||
// Allow adds one to the limiters value, or returns false if the limit has been reached
|
||||
Allow() bool
|
||||
|
||||
// AllowN adds n to the limiters value, or returns false if the limit has been reached
|
||||
AllowN(n int64) bool
|
||||
|
||||
// Value returns the current internal limiter value
|
||||
Value() int64
|
||||
|
||||
// Reset resets the state of the limiter
|
||||
Reset()
|
||||
}
|
||||
|
||||
// FixedLimiter is a helper that allows adding values up to a well-defined limit. Once the limit is reached
|
||||
@@ -25,33 +34,78 @@ type FixedLimiter struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ Limiter = (*FixedLimiter)(nil)
|
||||
|
||||
// NewFixedLimiter creates a new Limiter
|
||||
func NewFixedLimiter(limit int64) *FixedLimiter {
|
||||
return NewFixedLimiterWithValue(limit, 0)
|
||||
}
|
||||
|
||||
// NewFixedLimiterWithValue creates a new Limiter and sets the initial value
|
||||
func NewFixedLimiterWithValue(limit, value int64) *FixedLimiter {
|
||||
return &FixedLimiter{
|
||||
limit: limit,
|
||||
value: value,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow adds n to the limiters internal value, but only if the limit has not been reached. If the limit was
|
||||
// exceeded after adding n, ErrLimitReached is returned.
|
||||
func (l *FixedLimiter) Allow(n int64) error {
|
||||
// Allow adds one to the limiters internal value, but only if the limit has not been reached. If the limit was
|
||||
// exceeded, false is returned.
|
||||
func (l *FixedLimiter) Allow() bool {
|
||||
return l.AllowN(1)
|
||||
}
|
||||
|
||||
// AllowN adds n to the limiters internal value, but only if the limit has not been reached. If the limit was
|
||||
// exceeded after adding n, false is returned.
|
||||
func (l *FixedLimiter) AllowN(n int64) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.value+n > l.limit {
|
||||
return ErrLimitReached
|
||||
return false
|
||||
}
|
||||
l.value += n
|
||||
return nil
|
||||
return true
|
||||
}
|
||||
|
||||
// Value returns the current limiter value
|
||||
func (l *FixedLimiter) Value() int64 {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.value
|
||||
}
|
||||
|
||||
// Reset sets the limiter's value back to zero
|
||||
func (l *FixedLimiter) Reset() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.value = 0
|
||||
}
|
||||
|
||||
// RateLimiter is a Limiter that wraps a rate.Limiter, allowing a floating time-based limit.
|
||||
type RateLimiter struct {
|
||||
r rate.Limit
|
||||
b int
|
||||
value int64
|
||||
limiter *rate.Limiter
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ Limiter = (*RateLimiter)(nil)
|
||||
|
||||
// NewRateLimiter creates a new RateLimiter
|
||||
func NewRateLimiter(r rate.Limit, b int) *RateLimiter {
|
||||
return NewRateLimiterWithValue(r, b, 0)
|
||||
}
|
||||
|
||||
// NewRateLimiterWithValue creates a new RateLimiter with the given starting value.
|
||||
//
|
||||
// Note that the starting value only has informational value. It does not impact the underlying
|
||||
// value of the rate.Limiter.
|
||||
func NewRateLimiterWithValue(r rate.Limit, b int, value int64) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
r: r,
|
||||
b: b,
|
||||
value: value,
|
||||
limiter: rate.NewLimiter(r, b),
|
||||
}
|
||||
}
|
||||
@@ -62,16 +116,40 @@ func NewBytesLimiter(bytes int, interval time.Duration) *RateLimiter {
|
||||
return NewRateLimiter(rate.Limit(bytes)*rate.Every(interval), bytes)
|
||||
}
|
||||
|
||||
// Allow adds n to the limiters internal value, but only if the limit has not been reached. If the limit was
|
||||
// exceeded after adding n, ErrLimitReached is returned.
|
||||
func (l *RateLimiter) Allow(n int64) error {
|
||||
// Allow adds one to the limiters internal value, but only if the limit has not been reached. If the limit was
|
||||
// exceeded, false is returned.
|
||||
func (l *RateLimiter) Allow() bool {
|
||||
return l.AllowN(1)
|
||||
}
|
||||
|
||||
// AllowN adds n to the limiters internal value, but only if the limit has not been reached. If the limit was
|
||||
// exceeded after adding n, false is returned.
|
||||
func (l *RateLimiter) AllowN(n int64) bool {
|
||||
if n <= 0 {
|
||||
return nil // No-op. Can't take back bytes you're written!
|
||||
return false // No-op. Can't take back bytes you're written!
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if !l.limiter.AllowN(time.Now(), int(n)) {
|
||||
return ErrLimitReached
|
||||
return false
|
||||
}
|
||||
return nil
|
||||
l.value += n
|
||||
return true
|
||||
}
|
||||
|
||||
// Value returns the current limiter value
|
||||
func (l *RateLimiter) Value() int64 {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.value
|
||||
}
|
||||
|
||||
// Reset sets the limiter's value back to zero, and resets the underlying rate.Limiter
|
||||
func (l *RateLimiter) Reset() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.limiter = rate.NewLimiter(l.r, l.b)
|
||||
l.value = 0
|
||||
}
|
||||
|
||||
// LimitWriter implements an io.Writer that will pass through all Write calls to the underlying
|
||||
@@ -97,9 +175,9 @@ func (w *LimitWriter) Write(p []byte) (n int, err error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
for i := 0; i < len(w.limiters); i++ {
|
||||
if err := w.limiters[i].Allow(int64(len(p))); err != nil {
|
||||
if !w.limiters[i].AllowN(int64(len(p))) {
|
||||
for j := i - 1; j >= 0; j-- {
|
||||
w.limiters[j].Allow(-int64(len(p))) // Revert limiters limits if allowed
|
||||
w.limiters[j].AllowN(-int64(len(p))) // Revert limiters limits if not allowed
|
||||
}
|
||||
return 0, ErrLimitReached
|
||||
}
|
||||
|
||||
@@ -7,26 +7,31 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFixedLimiter_Add(t *testing.T) {
|
||||
func TestFixedLimiter_AllowValueReset(t *testing.T) {
|
||||
l := NewFixedLimiter(10)
|
||||
if err := l.Allow(5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.Allow(5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.Allow(5); err != ErrLimitReached {
|
||||
t.Fatalf("expected ErrLimitReached, got %#v", err)
|
||||
}
|
||||
require.True(t, l.AllowN(5))
|
||||
require.Equal(t, int64(5), l.Value())
|
||||
|
||||
require.True(t, l.AllowN(5))
|
||||
require.Equal(t, int64(10), l.Value())
|
||||
|
||||
require.False(t, l.Allow())
|
||||
require.Equal(t, int64(10), l.Value())
|
||||
|
||||
l.Reset()
|
||||
require.Equal(t, int64(0), l.Value())
|
||||
require.True(t, l.Allow())
|
||||
require.True(t, l.AllowN(9))
|
||||
require.False(t, l.Allow())
|
||||
}
|
||||
|
||||
func TestFixedLimiter_AddSub(t *testing.T) {
|
||||
l := NewFixedLimiter(10)
|
||||
l.Allow(5)
|
||||
l.AllowN(5)
|
||||
if l.value != 5 {
|
||||
t.Fatalf("expected value to be %d, got %d", 5, l.value)
|
||||
}
|
||||
l.Allow(-2)
|
||||
l.AllowN(-2)
|
||||
if l.value != 3 {
|
||||
t.Fatalf("expected value to be %d, got %d", 7, l.value)
|
||||
}
|
||||
@@ -34,17 +39,22 @@ func TestFixedLimiter_AddSub(t *testing.T) {
|
||||
|
||||
func TestBytesLimiter_Add_Simple(t *testing.T) {
|
||||
l := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h
|
||||
require.Nil(t, l.Allow(100*1024*1024))
|
||||
require.Nil(t, l.Allow(100*1024*1024))
|
||||
require.Equal(t, ErrLimitReached, l.Allow(300*1024*1024))
|
||||
require.True(t, l.AllowN(100*1024*1024))
|
||||
require.Equal(t, int64(100*1024*1024), l.Value())
|
||||
|
||||
require.True(t, l.AllowN(100*1024*1024))
|
||||
require.Equal(t, int64(200*1024*1024), l.Value())
|
||||
|
||||
require.False(t, l.AllowN(300*1024*1024))
|
||||
require.Equal(t, int64(200*1024*1024), l.Value())
|
||||
}
|
||||
|
||||
func TestBytesLimiter_Add_Wait(t *testing.T) {
|
||||
l := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h (~ 303 bytes per 100ms)
|
||||
require.Nil(t, l.Allow(250*1024*1024))
|
||||
require.Equal(t, ErrLimitReached, l.Allow(400))
|
||||
require.True(t, l.AllowN(250*1024*1024))
|
||||
require.False(t, l.AllowN(400))
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
require.Nil(t, l.Allow(400))
|
||||
require.True(t, l.AllowN(400))
|
||||
}
|
||||
|
||||
func TestLimitWriter_WriteNoLimiter(t *testing.T) {
|
||||
|
||||
@@ -10,14 +10,14 @@ import (
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// lookup := func() (string, error) {
|
||||
// r, _ := http.Get("...")
|
||||
// s, _ := io.ReadAll(r.Body)
|
||||
// return string(s), nil
|
||||
// }
|
||||
// c := NewLookupCache[string](lookup, time.Hour)
|
||||
// fmt.Println(c.Get()) // Fetches the string via HTTP
|
||||
// fmt.Println(c.Get()) // Uses cached value
|
||||
// lookup := func() (string, error) {
|
||||
// r, _ := http.Get("...")
|
||||
// s, _ := io.ReadAll(r.Body)
|
||||
// return string(s), nil
|
||||
// }
|
||||
// c := NewLookupCache[string](lookup, time.Hour)
|
||||
// fmt.Println(c.Get()) // Fetches the string via HTTP
|
||||
// fmt.Println(c.Get()) // Uses cached value
|
||||
type LookupCache[T any] struct {
|
||||
value *T
|
||||
lookup func() (T, error)
|
||||
@@ -26,8 +26,12 @@ type LookupCache[T any] struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// LookupFunc is a function that is called by the LookupCache if the underlying
|
||||
// value is out-of-date. It returns the new value, or an error.
|
||||
type LookupFunc[T any] func() (T, error)
|
||||
|
||||
// NewLookupCache creates a new LookupCache with a given time-to-live (TTL)
|
||||
func NewLookupCache[T any](lookup func() (T, error), ttl time.Duration) *LookupCache[T] {
|
||||
func NewLookupCache[T any](lookup LookupFunc[T], ttl time.Duration) *LookupCache[T] {
|
||||
return &LookupCache[T]{
|
||||
value: nil,
|
||||
lookup: lookup,
|
||||
|
||||
22
util/time.go
22
util/time.go
@@ -17,7 +17,7 @@ var (
|
||||
// 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 {
|
||||
hour, minute, seconds := timeOfDay.Clock()
|
||||
hour, minute, seconds := timeOfDay.UTC().Clock()
|
||||
now := base.UTC()
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, seconds, 0, time.UTC)
|
||||
if next.Before(now) {
|
||||
@@ -45,15 +45,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 +74,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)
|
||||
}
|
||||
|
||||
77
util/util.go
77
util/util.go
@@ -1,10 +1,12 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"golang.org/x/time/rate"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/netip"
|
||||
@@ -107,13 +109,18 @@ func LastString(s []string, def string) string {
|
||||
|
||||
// RandomString returns a random string with a given length
|
||||
func RandomString(length int) string {
|
||||
return RandomStringPrefix("", length)
|
||||
}
|
||||
|
||||
// RandomStringPrefix returns a random string with a given length, with a prefix
|
||||
func RandomStringPrefix(prefix string, length int) string {
|
||||
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
|
||||
defer randomMutex.Unlock()
|
||||
b := make([]byte, length)
|
||||
b := make([]byte, length-len(prefix))
|
||||
for i := range b {
|
||||
b[i] = randomStringCharset[random.Intn(len(randomStringCharset))]
|
||||
}
|
||||
return string(b)
|
||||
return prefix + string(b)
|
||||
}
|
||||
|
||||
// ValidRandomString returns true if the given string matches the format created by RandomString
|
||||
@@ -216,6 +223,20 @@ func ParseSize(s string) (int64, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// FormatSize formats bytes into a human-readable notation, e.g. 2.1 MB
|
||||
func FormatSize(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d bytes", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the
|
||||
// input characters to the screen. If not, it'll just read using normal readline semantics (useful for testing).
|
||||
func ReadPassword(in io.Reader) ([]byte, error) {
|
||||
@@ -305,7 +326,7 @@ func UnmarshalJSON[T any](body io.ReadCloser) (*T, error) {
|
||||
}
|
||||
|
||||
// UnmarshalJSONWithLimit reads the given io.ReadCloser into a struct, but only until limit is reached
|
||||
func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
|
||||
func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
|
||||
defer r.Close()
|
||||
p, err := Peek(r, limit)
|
||||
if err != nil {
|
||||
@@ -314,8 +335,56 @@ func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
|
||||
return nil, ErrTooLargeJSON
|
||||
}
|
||||
var obj T
|
||||
if err := json.NewDecoder(p).Decode(&obj); err != nil {
|
||||
if len(bytes.TrimSpace(p.PeekedBytes)) == 0 && allowEmpty {
|
||||
return &obj, nil
|
||||
} else if err := json.NewDecoder(p).Decode(&obj); err != nil {
|
||||
return nil, ErrUnmarshalJSON
|
||||
}
|
||||
return &obj, nil
|
||||
}
|
||||
|
||||
// Retry executes function f until if succeeds, and then returns t. If f fails, it sleeps
|
||||
// and tries again. The sleep durations are passed as the after params.
|
||||
func Retry[T any](f func() (*T, error), after ...time.Duration) (t *T, err error) {
|
||||
for _, delay := range after {
|
||||
if t, err = f(); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
time.Sleep(delay)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// MinMax returns value if it is between min and max, or either
|
||||
// min or max if it is out of range
|
||||
func MinMax[T int | int64](value, min, max T) T {
|
||||
if value < min {
|
||||
return min
|
||||
} else if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// Max returns the maximum value of the two given values
|
||||
func Max[T int | int64 | rate.Limit](a, b T) T {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// String turns a string into a pointer of a string
|
||||
func String(v string) *string {
|
||||
return &v
|
||||
}
|
||||
|
||||
// Int turns an int into a pointer of an int
|
||||
func Int(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
// Time turns a time.Time into a pointer
|
||||
func Time(v time.Time) *time.Time {
|
||||
return &v
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"golang.org/x/time/rate"
|
||||
"io"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -190,13 +193,79 @@ func TestReadJSON_Failure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReadJSONWithLimit_Success(t *testing.T) {
|
||||
v, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 100)
|
||||
v, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 100, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "some name", v.Name)
|
||||
require.Equal(t, 99, v.Something)
|
||||
}
|
||||
|
||||
func TestReadJSONWithLimit_FailureTooLong(t *testing.T) {
|
||||
_, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 10)
|
||||
_, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 10, false)
|
||||
require.Equal(t, ErrTooLargeJSON, err)
|
||||
}
|
||||
|
||||
func TestReadJSONWithLimit_AllowEmpty(t *testing.T) {
|
||||
v, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(` `)), 10, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "", v.Name)
|
||||
require.Equal(t, 0, v.Something)
|
||||
}
|
||||
|
||||
func TestReadJSONWithLimit_NoAllowEmpty(t *testing.T) {
|
||||
_, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(` `)), 10, false)
|
||||
require.Equal(t, ErrUnmarshalJSON, err)
|
||||
}
|
||||
|
||||
func TestRetry_Succeeds(t *testing.T) {
|
||||
start := time.Now()
|
||||
delays, i := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond, time.Second}, 0
|
||||
fn := func() (*int, error) {
|
||||
i++
|
||||
if i < len(delays) {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
return Int(99), nil
|
||||
}
|
||||
result, err := Retry[int](fn, delays...)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 99, *result)
|
||||
require.True(t, time.Since(start).Milliseconds() > 150)
|
||||
}
|
||||
|
||||
func TestRetry_Fails(t *testing.T) {
|
||||
fn := func() (*int, error) {
|
||||
return nil, errors.New("fails")
|
||||
}
|
||||
_, err := Retry[int](fn, 10*time.Millisecond)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMinMax(t *testing.T) {
|
||||
require.Equal(t, 10, MinMax(9, 10, 99))
|
||||
require.Equal(t, 99, MinMax(100, 10, 99))
|
||||
require.Equal(t, 50, MinMax(50, 10, 99))
|
||||
}
|
||||
|
||||
func TestMax(t *testing.T) {
|
||||
require.Equal(t, 9, Max(1, 9))
|
||||
require.Equal(t, 9, Max(9, 1))
|
||||
require.Equal(t, rate.Every(time.Minute), Max(rate.Every(time.Hour), rate.Every(time.Minute)))
|
||||
}
|
||||
|
||||
func TestPointerFunctions(t *testing.T) {
|
||||
i, s, ti := Int(99), String("abc"), Time(time.Unix(99, 0))
|
||||
require.Equal(t, 99, *i)
|
||||
require.Equal(t, "abc", *s)
|
||||
require.Equal(t, time.Unix(99, 0), *ti)
|
||||
}
|
||||
|
||||
func TestMaybeMarshalJSON(t *testing.T) {
|
||||
require.Equal(t, `"aa"`, MaybeMarshalJSON("aa"))
|
||||
require.Equal(t, `[
|
||||
"aa",
|
||||
"bb"
|
||||
]`, MaybeMarshalJSON([]string{"aa", "bb"}))
|
||||
require.Equal(t, "<cannot serialize>", MaybeMarshalJSON(func() {}))
|
||||
require.Equal(t, `"`+strings.Repeat("x", 4999), MaybeMarshalJSON(strings.Repeat("x", 6000)))
|
||||
|
||||
}
|
||||
|
||||
13837
web/package-lock.json
generated
13837
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: "http://localhost:2586", // window.location.origin FIXME update before merging
|
||||
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,
|
||||
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "pricing", "signup", "login", "reset-password"]
|
||||
billing_contact: "",
|
||||
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]
|
||||
};
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
<meta charset="UTF-8">
|
||||
<title>ntfy web</title>
|
||||
|
||||
<link rel="stylesheet" href="static/css/home.css" type="text/css">
|
||||
|
||||
<!-- Mobile view -->
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
@@ -31,7 +29,8 @@
|
||||
<!-- Never index -->
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<!-- Style overrides & fonts -->
|
||||
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/app.css" type="text/css">
|
||||
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/fonts.css" type="text/css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
10
web/public/static/css/app.css
Normal file
10
web/public/static/css/app.css
Normal file
@@ -0,0 +1,10 @@
|
||||
/* web app styling overrides */
|
||||
|
||||
a, a:visited {
|
||||
color: #338574;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
color: #317f6f;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/* general styling */
|
||||
|
||||
#site {
|
||||
html, body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1.1em;
|
||||
@@ -9,16 +9,22 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#site a, a:visited {
|
||||
html {
|
||||
/* prevent scrollbar from repositioning website:
|
||||
* https://www.w3docs.com/snippets/css/how-to-prevent-scrollbar-from-repositioning-web-page.html */
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
a, a:visited {
|
||||
color: #338574;
|
||||
}
|
||||
|
||||
#site a:hover {
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
color: #317f6f;
|
||||
}
|
||||
|
||||
#site h1 {
|
||||
h1 {
|
||||
margin-top: 35px;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
@@ -28,7 +34,7 @@
|
||||
color: #666;
|
||||
}
|
||||
|
||||
#site h2 {
|
||||
h2 {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 1.8em;
|
||||
@@ -36,7 +42,7 @@
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#site h3 {
|
||||
h3 {
|
||||
margin-top: 25px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 1.3em;
|
||||
@@ -44,28 +50,28 @@
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#site p {
|
||||
p {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
line-height: 160%;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#site p.smallMarginBottom {
|
||||
p.smallMarginBottom {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#site b {
|
||||
b {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#site tt {
|
||||
tt {
|
||||
background: #eee;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#site code {
|
||||
code {
|
||||
display: block;
|
||||
background: #eee;
|
||||
font-family: monospace;
|
||||
@@ -79,18 +85,18 @@
|
||||
|
||||
/* Main page */
|
||||
|
||||
#site #main {
|
||||
#main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto 50px auto;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
#site #error {
|
||||
#error {
|
||||
color: darkred;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#site #ironicCenterTagDontFreakOut {
|
||||
#ironicCenterTagDontFreakOut {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@@ -114,22 +120,22 @@
|
||||
|
||||
/* Figures */
|
||||
|
||||
#site figure {
|
||||
figure {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#site figure img, figure video {
|
||||
figure img, figure video {
|
||||
filter: drop-shadow(3px 3px 3px #ccc);
|
||||
border-radius: 7px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#site figure video {
|
||||
figure video {
|
||||
width: 100%;
|
||||
max-height: 450px;
|
||||
}
|
||||
|
||||
#site figcaption {
|
||||
figcaption {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
padding-top: 10px;
|
||||
@@ -137,18 +143,18 @@
|
||||
|
||||
/* Screenshots */
|
||||
|
||||
#site #screenshots {
|
||||
#screenshots {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#site #screenshots img {
|
||||
#screenshots img {
|
||||
height: 190px;
|
||||
margin: 3px;
|
||||
border-radius: 5px;
|
||||
filter: drop-shadow(2px 2px 2px #ddd);
|
||||
}
|
||||
|
||||
#site #screenshots .nowrap {
|
||||
#screenshots .nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -214,60 +220,52 @@
|
||||
|
||||
/* Header */
|
||||
|
||||
#site #header {
|
||||
#header {
|
||||
background: #338574;
|
||||
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%); filter: drop-shadow(0 5px 10px #ccc);
|
||||
height: 70px;
|
||||
height: 130px;
|
||||
}
|
||||
|
||||
#site #header #headerBox {
|
||||
#header #headerBox {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
#site #header #logo {
|
||||
margin-top: 14px;
|
||||
width: 48px;
|
||||
#header #logo {
|
||||
margin-top: 23px;
|
||||
float: left;
|
||||
}
|
||||
|
||||
#site #header #name {
|
||||
#header #name {
|
||||
float: left;
|
||||
color: white;
|
||||
font-size: 1.7em;
|
||||
font-weight: 400;
|
||||
margin: 12px 0 0 10px;
|
||||
font-size: 2.6em;
|
||||
font-weight: 300;
|
||||
margin: 35px 0 0 20px;
|
||||
}
|
||||
|
||||
#site #header #menu {
|
||||
#header ol {
|
||||
list-style-type: none;
|
||||
float: right;
|
||||
margin-top: 16px;
|
||||
margin-top: 80px;
|
||||
}
|
||||
|
||||
#site #header #menu li {
|
||||
#header ol li {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
margin: 0 10px;
|
||||
font-weight: 400;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#site #header #menu li {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
#site #header #menu li a,
|
||||
#site #header #menu li a:visited {
|
||||
#header ol li a, nav ol li a:visited {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#site #header #menu li:hover {
|
||||
background: #3f9a86;
|
||||
#header ol li a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#site li {
|
||||
li {
|
||||
padding: 4px 0;
|
||||
margin: 4px 0;
|
||||
font-size: 0.9em;
|
||||
@@ -276,7 +274,7 @@
|
||||
|
||||
/* Hide top menu SMALL SCREEN */
|
||||
@media only screen and (max-width: 780px) {
|
||||
#header #menu {
|
||||
#header ol {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
281
web/public/static/langs/ar.json
Normal file
281
web/public/static/langs/ar.json
Normal file
@@ -0,0 +1,281 @@
|
||||
{
|
||||
"action_bar_logo_alt": "شعار ntfy",
|
||||
"action_bar_settings": "اﻹعدادات",
|
||||
"action_bar_clear_notifications": "محو كافة الإشعارات",
|
||||
"action_bar_unsubscribe": "إلغاء الاشتراك",
|
||||
"message_bar_show_dialog": "إظهار مربع حوار النشر",
|
||||
"message_bar_publish": "نشر الرسالة",
|
||||
"nav_topics_title": "المواضيع التي تم الاشتراك فيها",
|
||||
"nav_button_all_notifications": "كافة الإشعارات",
|
||||
"nav_button_settings": "اﻹعدادات",
|
||||
"nav_button_documentation": "الدليل",
|
||||
"nav_button_publish_message": "نشر الإشعار",
|
||||
"nav_button_subscribe": "اشترك في الموضوع",
|
||||
"nav_button_connecting": "جارٍ الاتصال",
|
||||
"alert_grant_title": "تم تعطيل الإشعارات",
|
||||
"alert_grant_description": "امنح متصفحك الإذن لعرض إشعارات سطح المكتب.",
|
||||
"notifications_list": "قائمة الإشعارات",
|
||||
"notifications_list_item": "إشعار",
|
||||
"notifications_mark_read": "وضع علامة كمقروء",
|
||||
"notifications_tags": "الوسوم",
|
||||
"notifications_priority_x": "الأولوية {{priority}}",
|
||||
"notifications_new_indicator": "إشعار جديد",
|
||||
"notifications_attachment_image": "صورة مرفقة",
|
||||
"notifications_attachment_copy_url_button": "نسخ عنوان URL",
|
||||
"notifications_attachment_open_title": "انتقل إلى {{url}}",
|
||||
"notifications_attachment_link_expires": "تنتهي صلاحية الرابط {{date}}",
|
||||
"notifications_attachment_link_expired": "انتهت صلاحية رابط التنزيل",
|
||||
"notifications_attachment_file_image": "ملف الصورة",
|
||||
"notifications_attachment_file_video": "ملف فيديو",
|
||||
"notifications_attachment_file_audio": "ملف صوتي",
|
||||
"notifications_attachment_file_app": "ملف تطبيق Android",
|
||||
"notifications_attachment_file_document": "وثيقة أخرى",
|
||||
"notifications_click_copy_url_button": "نسخ الرابط",
|
||||
"notifications_click_open_button": "فتح الرابط",
|
||||
"notifications_actions_open_url_title": "انتقل إلى {{url}}",
|
||||
"notifications_actions_not_supported": "هذا الإجراء غير مدعوم في تطبيق الويب",
|
||||
"action_bar_send_test_notification": "إرسال إشعار للاختبار",
|
||||
"action_bar_show_menu": "عرض القائمة",
|
||||
"message_bar_type_message": "اكتب رسالة هنا",
|
||||
"alert_not_supported_title": "الإشعارات غير مدعومة",
|
||||
"alert_not_supported_description": "الإشعارات غير مدعومة في متصفحك.",
|
||||
"message_bar_error_publishing": "خطأ أثناء نشر الإشعار",
|
||||
"notifications_delete": "حذف",
|
||||
"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": "{{messages}} رسائل يومية",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{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": "قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر."
|
||||
}
|
||||
@@ -114,8 +114,8 @@
|
||||
"prefs_users_table_user_header": "Потребител",
|
||||
"prefs_users_dialog_title_edit": "Промяна на потребител",
|
||||
"prefs_users_dialog_base_url_label": "Адрес на услугата, e.g. https://ntfy.sh",
|
||||
"prefs_users_dialog_button_cancel": "Отказ",
|
||||
"prefs_users_dialog_button_save": "Запазване",
|
||||
"common_cancel": "Отказ",
|
||||
"common_save": "Запазване",
|
||||
"prefs_appearance_language_title": "Език",
|
||||
"subscribe_dialog_login_password_label": "Парола",
|
||||
"subscribe_dialog_login_button_login": "Вход",
|
||||
@@ -128,7 +128,7 @@
|
||||
"prefs_users_dialog_title_add": "Добавяне на потребител",
|
||||
"prefs_notifications_delete_after_one_month": "След един месец",
|
||||
"prefs_users_dialog_username_label": "Потребител, напр. phil",
|
||||
"prefs_users_dialog_button_add": "Добавяне",
|
||||
"common_add": "Добавяне",
|
||||
"error_boundary_title": "О, не, ntfy се срина",
|
||||
"error_boundary_description": "Това очевидно не трябва да се случва. Много съжаляваме!<br/>Ако имате минута, <githubLink>докладвайте в GitHub</githubLink> или ни уведомете в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
|
||||
"error_boundary_stack_trace": "Следа от стека",
|
||||
@@ -187,5 +187,46 @@
|
||||
"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": "Парола"
|
||||
}
|
||||
|
||||
@@ -116,9 +116,9 @@
|
||||
"prefs_users_add_button": "Přidat uživatele",
|
||||
"prefs_users_table_user_header": "Uživatel",
|
||||
"prefs_users_table_base_url_header": "URL služby",
|
||||
"prefs_users_dialog_button_cancel": "Zrušit",
|
||||
"prefs_users_dialog_button_add": "Přidat",
|
||||
"prefs_users_dialog_button_save": "Uložit",
|
||||
"common_cancel": "Zrušit",
|
||||
"common_add": "Přidat",
|
||||
"common_save": "Uložit",
|
||||
"priority_min": "nejnižší",
|
||||
"priority_low": "nízká",
|
||||
"priority_default": "výchozí",
|
||||
@@ -187,5 +187,158 @@
|
||||
"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ě účtován nebo vrácen v následující faktuře. Další fakturu obdržíte až na konci dalšího zúčtovacího 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": "{{reservations}} rezervovaných témat",
|
||||
"account_upgrade_dialog_tier_features_messages": "{{messages}} denních zpráv",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{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"
|
||||
}
|
||||
|
||||
1
web/public/static/langs/da.json
Normal file
1
web/public/static/langs/da.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -15,9 +15,9 @@
|
||||
"prefs_notifications_min_priority_max_only": "Nur höchste Priorität",
|
||||
"prefs_notifications_delete_after_never": "Nie",
|
||||
"prefs_users_dialog_password_label": "Kennwort",
|
||||
"prefs_users_dialog_button_cancel": "Abbrechen",
|
||||
"prefs_users_dialog_button_add": "Hinzufügen",
|
||||
"prefs_users_dialog_button_save": "Speichern",
|
||||
"common_cancel": "Abbrechen",
|
||||
"common_add": "Hinzufügen",
|
||||
"common_save": "Speichern",
|
||||
"prefs_appearance_language_title": "Sprache",
|
||||
"notifications_none_for_any_description": "Um Benachrichtigungen an ein Thema zu senden, schicke einen PUT/POST-Request an die Themen-URL. Hier ist ein Beispiel mit einem Deiner Themen.",
|
||||
"publish_dialog_message_placeholder": "Gib hier eine Nachricht ein",
|
||||
@@ -82,7 +82,7 @@
|
||||
"publish_dialog_attach_placeholder": "Datei von URL anhängen, z.B. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_filename_placeholder": "Dateiname des Anhangs",
|
||||
"publish_dialog_delay_label": "Verzögerung",
|
||||
"publish_dialog_email_placeholder": "E-Mail-Adresse, an die die Benachrichtigung gesendet werden soll, z.B. phil@example.com",
|
||||
"publish_dialog_email_placeholder": "E-Mail-Adresse, an die die Benachrichtigung gesendet werden soll, z. B. phil@example.com",
|
||||
"publish_dialog_chip_click_label": "Klick-URL",
|
||||
"publish_dialog_button_cancel_sending": "Senden abbrechen",
|
||||
"publish_dialog_drop_file_here": "Datei hierher ziehen",
|
||||
@@ -187,5 +187,158 @@
|
||||
"publish_dialog_emoji_picker_show": "Emoji wählen",
|
||||
"publish_dialog_topic_reset": "Thema zurücksetzen",
|
||||
"publish_dialog_attach_reset": "angehängte URL entfernen",
|
||||
"publish_dialog_click_reset": "Klick-URL entfernen"
|
||||
"publish_dialog_click_reset": "Klick-URL entfernen",
|
||||
"account_tokens_delete_dialog_description": "Stelle vor dem Löschen eines Access-Tokens sicher, dass keine Anwendung oder Skripte dieses Token verwenden. <strong>Diese Aktion kann nicht rückgängig gemacht werden</strong>.",
|
||||
"account_upgrade_dialog_cancel_warning": "Dies wird <strong>Dein Abo stornieren</strong> und Dein Konto am {{date}} herabstufen. An diesem Datum werden reservierte Themen und auch auf dem Server gecachte Nachrichten <strong>gelöscht</strong>.",
|
||||
"prefs_reservations_table_everyone_read_write": "Jeder kann veröffentlichen und lesen",
|
||||
"prefs_reservations_table_everyone_read_only": "Ich kann veröffentlichen und lesen, jeder kann lesen",
|
||||
"prefs_reservations_table_access_header": "Zugriff",
|
||||
"account_tokens_dialog_button_cancel": "Abbrechen",
|
||||
"account_tokens_dialog_expires_x_hours": "Token verfällt in {{hours}} Stunden",
|
||||
"account_tokens_dialog_expires_never": "Token verfällt nie",
|
||||
"signup_form_username": "Benutzername",
|
||||
"signup_form_button_submit": "Konto anlegen",
|
||||
"signup_already_have_account": "Du hast schon ein Konto? Melde Dich an!",
|
||||
"signup_disabled": "Die Anmeldung ist deaktiviert",
|
||||
"login_title": "Melde Dich mit Deinem ntfy-Konto an",
|
||||
"login_form_button_submit": "Anmelden",
|
||||
"login_link_signup": "Konto erstellen",
|
||||
"login_disabled": "Anmeldung ist deaktiviert",
|
||||
"action_bar_account": "Konto",
|
||||
"action_bar_change_display_name": "Anzeigenamen ändern",
|
||||
"action_bar_reservation_add": "Thema reservieren",
|
||||
"action_bar_reservation_edit": "Reservierung ändern",
|
||||
"action_bar_reservation_delete": "Reservierung löschen",
|
||||
"action_bar_reservation_limit_reached": "Grenze erreicht",
|
||||
"action_bar_profile_title": "Profil",
|
||||
"action_bar_profile_settings": "Einstellungen",
|
||||
"action_bar_profile_logout": "Abmelden",
|
||||
"action_bar_sign_in": "Anmelden",
|
||||
"signup_form_password": "Kennwort",
|
||||
"signup_form_toggle_password_visibility": "Kennwort-Sichtbarkeit umschalten",
|
||||
"nav_button_account": "Konto",
|
||||
"nav_upgrade_banner_description": "Themen reservieren, mehr Nachrichten & Emails, größere Anhänge",
|
||||
"display_name_dialog_title": "Anzeigennamen ändern",
|
||||
"display_name_dialog_placeholder": "Anzeigename",
|
||||
"reserve_dialog_checkbox_label": "Thema reservieren und Zugriffsrechte konfigurieren",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Thema ist bereits reserviert",
|
||||
"account_basics_username_title": "Benutzername",
|
||||
"account_basics_username_description": "Hey, das bist Du ❤",
|
||||
"account_basics_password_description": "Konto-Kennwort ändern",
|
||||
"account_basics_password_dialog_title": "Kennwort ändern",
|
||||
"account_basics_password_dialog_current_password_label": "Aktuelles Kennwort",
|
||||
"account_basics_password_dialog_new_password_label": "Neues Kennwort",
|
||||
"account_basics_password_dialog_confirm_password_label": "Kennwort bestätigen",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Kennwort falsch",
|
||||
"account_usage_title": "Verbrauch",
|
||||
"account_usage_of_limit": "von {{limit}}",
|
||||
"account_usage_unlimited": "unbegrenzt",
|
||||
"account_usage_limits_reset_daily": "Verbrauchslimits werden täglich um Mitternacht (UTC) zurückgesetzt",
|
||||
"account_basics_password_title": "Kennwort",
|
||||
"account_basics_tier_description": "Der Funktionsumfang Deines Konto-Levels",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(mit Level {{tier}})",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(kein Level)",
|
||||
"account_basics_tier_admin": "Admin",
|
||||
"account_basics_tier_basic": "Basic",
|
||||
"account_basics_tier_free": "Kostenlos",
|
||||
"account_basics_tier_paid_until": "Abo bezahlt bis {{date}} mit automatischer Verlängerung",
|
||||
"account_basics_tier_payment_overdue": "Deine Zahlung ist überfällig. Bitte aktualisiere Deine Zahlungsmethode, oder Dein Konto wird herabgestuft.",
|
||||
"account_basics_tier_manage_billing_button": "Zahlung verwalten",
|
||||
"account_usage_messages_title": "Veröffentlichte Nachrichten",
|
||||
"account_usage_emails_title": "Gesendete Emails",
|
||||
"account_usage_reservations_title": "Reservierte Themen",
|
||||
"account_usage_reservations_none": "Keine reservierten Themen für dieses Konto",
|
||||
"account_usage_attachment_storage_title": "Speicherplatz für Anhänge",
|
||||
"account_usage_attachment_storage_description": "{{filesize}} pro Datei, Löschung nach {{expiry}}",
|
||||
"account_usage_cannot_create_portal_session": "Kann Abrechnungsportal nicht öffnen",
|
||||
"account_delete_title": "Konto löschen",
|
||||
"account_delete_description": "Konto endgültig löschen",
|
||||
"account_delete_dialog_label": "Kennwort",
|
||||
"account_delete_dialog_button_cancel": "Abbrechen",
|
||||
"account_delete_dialog_button_submit": "Lösche mein Konto endgültig",
|
||||
"account_basics_tier_change_button": "Wechseln",
|
||||
"account_basics_tier_canceled_subscription": "Dein Abo wurde storniert und wird am {{date}} auf ein kostenloses Konto herabgestuft.",
|
||||
"account_usage_basis_ip_description": "Nutzungsstatistiken und Limits für diesen Account basieren auf Deiner IP-Adresse, können also mit anderen Usern geteilt sein. Die oben gezeigten Limits sind Schätzungen basierend auf den bestehenden Limits.",
|
||||
"account_delete_dialog_billing_warning": "Das Löschen Deines Kontos storniert auch sofort Deine Zahlung. Du wirst dann keinen Zugang zum Abrechnungs-Dashboard haben.",
|
||||
"account_upgrade_dialog_title": "Konto-Level ändern",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Anrechnung</strong>: Wenn Du zwischen kostenpflichtigen Leveln wechselst wir die Differenz bei der nächsten Abrechnung nachberechnet oder erstattet. Du erhältst bis zum Ende der Abrechnungsperiode keine neue Rechnung.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. <strong>Bitte löschen vor dem Wechsel Deines Levels mindestens eine Reservierung</strong>. Du kannst Reservierungen in den <Link>Einstellungen</Link> löschen.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. <strong>Bitte löschen vor dem Wechsel Deines Levels mindestens {{count}} Reservierungen</strong>. Du kannst Reservierungen in den <Link>Einstellungen</Link> löschen.",
|
||||
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} reservierte Themen",
|
||||
"account_upgrade_dialog_tier_features_messages": "{{messages}} Nachrichten pro Tag",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{emails}} Emails pro Tag",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} pro Datei",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} gesamter Speicherplatz",
|
||||
"account_upgrade_dialog_tier_selected_label": "Ausgewählt",
|
||||
"account_upgrade_dialog_tier_current_label": "Aktuell",
|
||||
"account_upgrade_dialog_button_cancel": "Abbrechen",
|
||||
"account_upgrade_dialog_button_redirect_signup": "Jetzt ein Konto anlegen",
|
||||
"account_upgrade_dialog_button_pay_now": "Jetzt bezahlen und abonnieren",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "Abo stornieren",
|
||||
"account_upgrade_dialog_button_update_subscription": "Abo aktualisieren",
|
||||
"account_tokens_title": "Access-Token",
|
||||
"account_tokens_description": "Verwende Access-Token zum Versenden und Empfangen über die ntfy-API, um nicht Deine Zugangsdaten verwenden zu müssen. Lies die <Link>Dokumentation</Link> für mehr Info.",
|
||||
"account_tokens_table_token_header": "Token",
|
||||
"account_tokens_table_label_header": "Bezeichnung",
|
||||
"account_tokens_table_last_access_header": "Letzter Zugriff",
|
||||
"account_tokens_table_expires_header": "Verfällt",
|
||||
"account_tokens_table_never_expires": "Verfällt nie",
|
||||
"account_tokens_table_current_session": "Aktuelle Browser-Sitzung",
|
||||
"account_tokens_table_copy_to_clipboard": "In die Zwischenablage kopieren",
|
||||
"account_tokens_table_copied_to_clipboard": "Access-Token kopiert",
|
||||
"account_tokens_table_cannot_delete_or_edit": "Aktuelles Token kann nicht bearbeitet oder gelöscht werden",
|
||||
"account_tokens_table_create_token_button": "Access-Token erzeugen",
|
||||
"account_tokens_table_last_origin_tooltip": "Von IP-Adresse {{ip}}, klicke zum Nachschlagen",
|
||||
"account_tokens_dialog_title_create": "Access-Token erzeugen",
|
||||
"account_tokens_dialog_title_edit": "Access-Token bearbeiten",
|
||||
"account_tokens_dialog_title_delete": "Access-Token löschen",
|
||||
"account_tokens_dialog_label": "Bezeichnung, z.B. Radarr Benachrichtigungen",
|
||||
"account_tokens_dialog_button_create": "Token erzeugen",
|
||||
"account_tokens_dialog_button_update": "Token aktualisieren",
|
||||
"account_tokens_dialog_expires_label": "Access-Token verfällt in",
|
||||
"account_tokens_dialog_expires_unchanged": "Verfallsdatum nicht ändern",
|
||||
"account_tokens_dialog_expires_x_days": "Token verfällt in {{days}} Tagen",
|
||||
"account_tokens_delete_dialog_title": "Access-Token löschen",
|
||||
"account_tokens_delete_dialog_submit_button": "Token endgültig löschen",
|
||||
"prefs_users_description_no_sync": "Benutzernamen und Kennwörter werden nicht im Konto synchronisiert.",
|
||||
"prefs_users_table_cannot_delete_or_edit": "Angemeldeter Benutzer kann nicht gelöscht oder bearbeitet werden",
|
||||
"prefs_reservations_title": "Reservierte Themen",
|
||||
"prefs_reservations_description": "Du kannst hier Themen-Namen für Deine persönliche Verwendung reservieren. Das Reservieren eines Themas macht Dich zum Besitzer des Themas. Du kannst damit auch Zugriffsrechte für andere Benutzer auf das Thema festlegen.",
|
||||
"prefs_reservations_limit_reached": "Du hast Dein Limit an reservierten Themen erreicht.",
|
||||
"prefs_reservations_add_button": "Reserviertes Thema hinzufügen",
|
||||
"prefs_reservations_edit_button": "Zugriff auf Thema bearbeiten",
|
||||
"prefs_reservations_delete_button": "Zugriff auf Thema zurücksetzen",
|
||||
"prefs_reservations_table": "Übersicht reservierter Themen",
|
||||
"prefs_reservations_table_topic_header": "Thema",
|
||||
"prefs_reservations_table_everyone_deny_all": "Nur kann veröffentlichen und lesen",
|
||||
"prefs_reservations_table_everyone_write_only": "Ich kann veröffentlichen und lesen, jeder kann veröffentlichen",
|
||||
"prefs_reservations_table_not_subscribed": "Nicht abonniert",
|
||||
"prefs_reservations_table_click_to_subscribe": "Klicken um zu abonnieren",
|
||||
"prefs_reservations_dialog_title_add": "Thema reservieren",
|
||||
"prefs_reservations_dialog_title_edit": "Reserviertes Thema bearbeiten",
|
||||
"prefs_reservations_dialog_title_delete": "Thema-Reservierung löschen",
|
||||
"prefs_reservations_dialog_description": "Ein Thema zu reservieren macht Dich zum Besitzer des Themas, und erlaubt Dir Zugriffsrechte für andere auf dieses Thema festzulegen.",
|
||||
"prefs_reservations_dialog_topic_label": "Thema",
|
||||
"prefs_reservations_dialog_access_label": "Zugriff",
|
||||
"reservation_delete_dialog_description": "Mit dem Löschen einer Reservierung gibst du den Besitz des Themas auf und ermöglichst anderen, es zu reservieren. Du kannst vorhandene Nachrichten und Dateien behalten oder löschen.",
|
||||
"reservation_delete_dialog_action_keep_title": "Behalte gecachte Nachrichten und Dateien",
|
||||
"reservation_delete_dialog_action_keep_description": "Nachrichten und Dateien, die auf dem Server gecached sind, werden für alle sichtbar die den Themen-Namen kennen.",
|
||||
"reservation_delete_dialog_action_delete_title": "Löschen gecachte Nachrichten und Dateien",
|
||||
"reservation_delete_dialog_action_delete_description": "Gecachte Nachrichten und Dateien werden endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"reservation_delete_dialog_submit_button": "Reservierung löschen",
|
||||
"account_basics_password_dialog_button_submit": "Kennwort ändern",
|
||||
"account_basics_tier_title": "Kontotyp",
|
||||
"account_basics_tier_upgrade_button": "Upgrade auf Pro",
|
||||
"account_delete_dialog_description": "Hiermit wird Dein Konto endgültig gelöscht, inklusive aller Daten auf dem Server. Nach dem Löschen wird Dein Benutzername für 7 Tage gesperrt sein. Wenn Du fortfahren willst, bestätige das durch Eingabe Deines Kennwortes.",
|
||||
"signup_form_confirm_password": "Kennwort wiederholen",
|
||||
"signup_title": "Erstelle ein ntfy-Konto",
|
||||
"signup_error_username_taken": "Benutzername {{username}} ist bereits vergeben",
|
||||
"signup_error_creation_limit_reached": "Grenze der Account-Erstellung erreicht",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Namen erzeugen",
|
||||
"account_basics_title": "Konto",
|
||||
"action_bar_sign_up": "Konto erstellen",
|
||||
"nav_upgrade_banner_label": "Upgrade auf ntfy Pro",
|
||||
"alert_not_supported_context_description": "Benachrichtigungen werden nur über HTTPS unterstützt. Das ist eine Einschränkung der <mdnLink>Notifications API</mdnLink>.",
|
||||
"display_name_dialog_description": "Lege einen alternativen Namen für ein Thema fest, der in der Abo-Liste angezeigt wird. So kannst Du Themen mit komplizierten Namen leichter finden.",
|
||||
"account_basics_username_admin_tooltip": "Du bist Admin"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"common_cancel": "Cancel",
|
||||
"common_save": "Save",
|
||||
"common_add": "Add",
|
||||
"signup_title": "Create a ntfy account",
|
||||
"signup_form_username": "Username",
|
||||
"signup_form_password": "Password",
|
||||
@@ -9,15 +12,19 @@
|
||||
"signup_disabled": "Signup is disabled",
|
||||
"signup_error_username_taken": "Username {{username}} is already taken",
|
||||
"signup_error_creation_limit_reached": "Account creation limit reached",
|
||||
"signup_error_unknown": "Unknown error. Check logs for details.",
|
||||
"login_title": "Sign in to your ntfy account",
|
||||
"login_form_button_submit": "Sign in",
|
||||
"login_link_signup": "Sign up",
|
||||
"login_disabled": "Login is disabled",
|
||||
"action_bar_show_menu": "Show menu",
|
||||
"action_bar_logo_alt": "ntfy logo",
|
||||
"action_bar_settings": "Settings",
|
||||
"action_bar_account": "Account",
|
||||
"action_bar_subscription_settings": "Subscription settings",
|
||||
"action_bar_change_display_name": "Change display name",
|
||||
"action_bar_reservation_add": "Reserve topic",
|
||||
"action_bar_reservation_edit": "Change reservation",
|
||||
"action_bar_reservation_delete": "Remove reservation",
|
||||
"action_bar_reservation_limit_reached": "Limit reached",
|
||||
"action_bar_send_test_notification": "Send test notification",
|
||||
"action_bar_clear_notifications": "Clear all notifications",
|
||||
"action_bar_unsubscribe": "Unsubscribe",
|
||||
@@ -41,6 +48,8 @@
|
||||
"nav_button_subscribe": "Subscribe to topic",
|
||||
"nav_button_muted": "Notifications muted",
|
||||
"nav_button_connecting": "connecting",
|
||||
"nav_upgrade_banner_label": "Upgrade to ntfy Pro",
|
||||
"nav_upgrade_banner_description": "Reserve topics, more messages & emails, and larger attachments",
|
||||
"alert_grant_title": "Notifications are disabled",
|
||||
"alert_grant_description": "Grant your browser permission to display desktop notifications.",
|
||||
"alert_grant_button": "Grant now",
|
||||
@@ -81,12 +90,10 @@
|
||||
"notifications_no_subscriptions_description": "Click the \"{{linktext}}\" link to create or subscribe to a topic. After that, you can send messages via PUT or POST and you'll receive notifications here.",
|
||||
"notifications_example": "Example",
|
||||
"notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
|
||||
"subscription_settings_dialog_title": "Subscription settings",
|
||||
"subscription_settings_dialog_description": "Configure settings specifically for this topic subscription. Settings are currently only applied locally.",
|
||||
"subscription_settings_dialog_display_name_placeholder": "Display name",
|
||||
"subscription_settings_dialog_reserve_topic_label": "Reserve topic and configure access",
|
||||
"subscription_settings_button_cancel": "Cancel",
|
||||
"subscription_settings_button_save": "Save",
|
||||
"display_name_dialog_title": "Change display name",
|
||||
"display_name_dialog_description": "Set an alternative name for a topic that is displayed in the subscription list. This helps identify topics with complicated names more easily.",
|
||||
"display_name_dialog_placeholder": "Display name",
|
||||
"reserve_dialog_checkbox_label": "Reserve topic and configure access",
|
||||
"notifications_loading": "Loading notifications …",
|
||||
"publish_dialog_title_topic": "Publish to {{topic}}",
|
||||
"publish_dialog_title_no_topic": "Publish notification",
|
||||
@@ -170,55 +177,100 @@
|
||||
"account_basics_password_title": "Password",
|
||||
"account_basics_password_description": "Change your account password",
|
||||
"account_basics_password_dialog_title": "Change password",
|
||||
"account_basics_password_dialog_current_password_label": "Current password",
|
||||
"account_basics_password_dialog_new_password_label": "New password",
|
||||
"account_basics_password_dialog_confirm_password_label": "Confirm password",
|
||||
"account_basics_password_dialog_button_cancel": "Cancel",
|
||||
"account_basics_password_dialog_button_submit": "Change password",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Password incorrect",
|
||||
"account_usage_title": "Usage",
|
||||
"account_usage_of_limit": "of {{limit}}",
|
||||
"account_usage_unlimited": "Unlimited",
|
||||
"account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)",
|
||||
"account_usage_tier_title": "Account type",
|
||||
"account_usage_tier_description": "Your account's power level",
|
||||
"account_usage_tier_admin": "Admin",
|
||||
"account_usage_tier_basic": "Basic",
|
||||
"account_usage_tier_free": "Free",
|
||||
"account_usage_tier_upgrade_button": "Upgrade to Pro",
|
||||
"account_usage_tier_change_button": "Change",
|
||||
"account_usage_tier_paid_until": "Subscription paid until {{date}}, and will auto-renew",
|
||||
"account_usage_tier_payment_overdue": "Your payment is overdue. Please update your payment method, or your account will be downgraded soon.",
|
||||
"account_usage_tier_canceled_subscription": "Your subscription was canceled and will be downgraded to a free account on {{date}}.",
|
||||
"account_usage_manage_billing_button": "Manage billing",
|
||||
"account_basics_tier_title": "Account type",
|
||||
"account_basics_tier_description": "Your account's power level",
|
||||
"account_basics_tier_admin": "Admin",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(with {{tier}} tier)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(no tier)",
|
||||
"account_basics_tier_basic": "Basic",
|
||||
"account_basics_tier_free": "Free",
|
||||
"account_basics_tier_interval_monthly": "monthly",
|
||||
"account_basics_tier_interval_yearly": "annually",
|
||||
"account_basics_tier_upgrade_button": "Upgrade to Pro",
|
||||
"account_basics_tier_change_button": "Change",
|
||||
"account_basics_tier_paid_until": "Subscription paid until {{date}}, and will auto-renew",
|
||||
"account_basics_tier_payment_overdue": "Your payment is overdue. Please update your payment method, or your account will be downgraded soon.",
|
||||
"account_basics_tier_canceled_subscription": "Your subscription was canceled and will be downgraded to a free account on {{date}}.",
|
||||
"account_basics_tier_manage_billing_button": "Manage billing",
|
||||
"account_usage_messages_title": "Published messages",
|
||||
"account_usage_emails_title": "Emails sent",
|
||||
"account_usage_reservations_title": "Reserved topics",
|
||||
"account_usage_reservations_none": "No reserved topics for this account",
|
||||
"account_usage_attachment_storage_title": "Attachment storage",
|
||||
"account_usage_attachment_storage_description": "{{filesize}} per file, deleted after {{expiry}}",
|
||||
"account_usage_basis_ip_description": "Usage stats and limits for this account are based on your IP address, so they may be shared with other users. Limits shown above are approximates based on the existing rate limits.",
|
||||
"account_usage_cannot_create_portal_session": "Unable to open billing portal",
|
||||
"account_delete_title": "Delete account",
|
||||
"account_delete_description": "Permanently delete your account",
|
||||
"account_delete_dialog_description": "This will permanently delete your account, including all data that is stored on the server. If you really want to proceed, please type '{{username}}' in the text box below.",
|
||||
"account_delete_dialog_label": "Type '{{username}}' to delete account",
|
||||
"account_delete_dialog_description": "This will permanently delete your account, including all data that is stored on the server. After deletion, your username will be unavailable for 7 days. If you really want to proceed, please confirm with your password in the box below.",
|
||||
"account_delete_dialog_label": "Password",
|
||||
"account_delete_dialog_button_cancel": "Cancel",
|
||||
"account_delete_dialog_button_submit": "Permanently delete account",
|
||||
"account_delete_dialog_billing_warning": "Deleting your account also cancels your billing subscription immediately. You will not have access to the billing dashboard anymore.",
|
||||
"account_upgrade_dialog_title": "Change account tier",
|
||||
"account_upgrade_dialog_interval_monthly": "Monthly",
|
||||
"account_upgrade_dialog_interval_yearly": "Annually",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "save {{discount}}%",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "save up to {{discount}}%",
|
||||
"account_upgrade_dialog_cancel_warning": "This will <strong>cancel your subscription</strong>, and downgrade your account on {{date}}. On that date, topic reservations as well as messages cached on the server <strong>will be deleted</strong>.",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Proration</strong>: When switching between paid plans, the price difference will be charged or refunded in the next invoice. You will not receive another invoice until the end of the next billing period.",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Proration</strong>: When upgrading between paid plans, the price difference will be <strong>charged immediately</strong>. When downgrading to a lower tier, the balance will be used to pay for future billing periods.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "The selected tier allows fewer reserved topics than your current tier. Before changing your tier, <strong>please delete at least one reservation</strong>. You can remove reservations in the <Link>Settings</Link>.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "The selected tier allows fewer reserved topics than your current tier. Before changing your tier, <strong>please delete at least {{count}} reservations</strong>. You can remove reservations in the <Link>Settings</Link>.",
|
||||
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} reserved topics",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "No reserved topics",
|
||||
"account_upgrade_dialog_tier_features_messages": "{{messages}} daily messages",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{emails}} daily emails",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage",
|
||||
"account_upgrade_dialog_tier_price_per_month": "month",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per year. Billed monthly.",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} billed annually. Save {{save}}.",
|
||||
"account_upgrade_dialog_tier_selected_label": "Selected",
|
||||
"account_upgrade_dialog_tier_current_label": "Current",
|
||||
"account_upgrade_dialog_billing_contact_email": "For billing questions, please <Link>contact us</Link> directly.",
|
||||
"account_upgrade_dialog_billing_contact_website": "For billing questions, please refer to our <Link>website</Link>.",
|
||||
"account_upgrade_dialog_button_cancel": "Cancel",
|
||||
"account_upgrade_dialog_button_redirect_signup": "Sign up now",
|
||||
"account_upgrade_dialog_button_pay_now": "Pay now and subscribe",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "Cancel subscription",
|
||||
"account_upgrade_dialog_button_update_subscription": "Update subscription",
|
||||
"account_tokens_title": "Access tokens",
|
||||
"account_tokens_description": "Use access tokens when publishing and subscribing via the ntfy API, so you don't have to send your account credentials. Check out the <Link>documentation</Link> to learn more.",
|
||||
"account_tokens_table_token_header": "Token",
|
||||
"account_tokens_table_label_header": "Label",
|
||||
"account_tokens_table_last_access_header": "Last access",
|
||||
"account_tokens_table_expires_header": "Expires",
|
||||
"account_tokens_table_never_expires": "Never expires",
|
||||
"account_tokens_table_current_session": "Current browser session",
|
||||
"account_tokens_table_copy_to_clipboard": "Copy to clipboard",
|
||||
"account_tokens_table_copied_to_clipboard": "Access token copied",
|
||||
"account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token",
|
||||
"account_tokens_table_create_token_button": "Create access token",
|
||||
"account_tokens_table_last_origin_tooltip": "From IP address {{ip}}, click to lookup",
|
||||
"account_tokens_dialog_title_create": "Create access token",
|
||||
"account_tokens_dialog_title_edit": "Edit access token",
|
||||
"account_tokens_dialog_title_delete": "Delete access token",
|
||||
"account_tokens_dialog_label": "Label, e.g. Radarr notifications",
|
||||
"account_tokens_dialog_button_create": "Create token",
|
||||
"account_tokens_dialog_button_update": "Update token",
|
||||
"account_tokens_dialog_button_cancel": "Cancel",
|
||||
"account_tokens_dialog_expires_label": "Access token expires in",
|
||||
"account_tokens_dialog_expires_unchanged": "Leave expiry date unchanged",
|
||||
"account_tokens_dialog_expires_x_hours": "Token expires in {{hours}} hours",
|
||||
"account_tokens_dialog_expires_x_days": "Token expires in {{days}} days",
|
||||
"account_tokens_dialog_expires_never": "Token never expires",
|
||||
"account_tokens_delete_dialog_title": "Delete access token",
|
||||
"account_tokens_delete_dialog_description": "Before deleting an access token, be sure that no applications or scripts are actively using it. <strong>This action cannot be undone</strong>.",
|
||||
"account_tokens_delete_dialog_submit_button": "Permanently delete token",
|
||||
"prefs_notifications_title": "Notifications",
|
||||
"prefs_notifications_sound_title": "Notification sound",
|
||||
"prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive",
|
||||
@@ -260,9 +312,6 @@
|
||||
"prefs_users_dialog_base_url_label": "Service URL, e.g. https://ntfy.sh",
|
||||
"prefs_users_dialog_username_label": "Username, e.g. phil",
|
||||
"prefs_users_dialog_password_label": "Password",
|
||||
"prefs_users_dialog_button_cancel": "Cancel",
|
||||
"prefs_users_dialog_button_add": "Add",
|
||||
"prefs_users_dialog_button_save": "Save",
|
||||
"prefs_appearance_title": "Appearance",
|
||||
"prefs_appearance_language_title": "Language",
|
||||
"prefs_reservations_title": "Reserved topics",
|
||||
@@ -278,11 +327,20 @@
|
||||
"prefs_reservations_table_everyone_read_only": "I can publish and subscribe, everyone can subscribe",
|
||||
"prefs_reservations_table_everyone_write_only": "I can publish and subscribe, everyone can publish",
|
||||
"prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe",
|
||||
"prefs_reservations_table_not_subscribed": "Not subscribed",
|
||||
"prefs_reservations_table_click_to_subscribe": "Click to subscribe",
|
||||
"prefs_reservations_dialog_title_add": "Reserve topic",
|
||||
"prefs_reservations_dialog_title_edit": "Edit reserved topic",
|
||||
"prefs_reservations_dialog_title_delete": "Delete topic reservation",
|
||||
"prefs_reservations_dialog_description": "Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.",
|
||||
"prefs_reservations_dialog_topic_label": "Topic",
|
||||
"prefs_reservations_dialog_access_label": "Access",
|
||||
"reservation_delete_dialog_description": "Removing a reservation gives up ownership over the topic, and allows others to reserve it. You can keep, or delete existing messages and attachments.",
|
||||
"reservation_delete_dialog_action_keep_title": "Keep cached messages and attachments",
|
||||
"reservation_delete_dialog_action_keep_description": "Messages and attachments that are cached on the server will become publicly visible for people with knowledge of the topic name.",
|
||||
"reservation_delete_dialog_action_delete_title": "Delete cached messages and attachments",
|
||||
"reservation_delete_dialog_action_delete_description": "Cached messages and attachments will be permanently deleted. This action cannot be undone.",
|
||||
"reservation_delete_dialog_submit_button": "Delete reservation",
|
||||
"priority_min": "min",
|
||||
"priority_low": "low",
|
||||
"priority_default": "default",
|
||||
|
||||
@@ -101,8 +101,8 @@
|
||||
"prefs_users_add_button": "Añadir usuario",
|
||||
"prefs_users_dialog_title_edit": "Editar usuario",
|
||||
"prefs_users_dialog_base_url_label": "URL del servicio, ej. https://ntfy.sh",
|
||||
"prefs_users_dialog_button_add": "Añadir",
|
||||
"prefs_users_dialog_button_save": "Guardar",
|
||||
"common_add": "Añadir",
|
||||
"common_save": "Guardar",
|
||||
"prefs_appearance_title": "Apariencia",
|
||||
"prefs_appearance_language_title": "Idioma",
|
||||
"error_boundary_title": "Oh no, ntfy tuvo un error",
|
||||
@@ -134,7 +134,7 @@
|
||||
"prefs_users_dialog_password_label": "Contraseña",
|
||||
"error_boundary_description": "Obviamente, esto no debería ocurrir. Lo sentimos mucho.<br/>Si tienes un minuto, por favor <githubLink>informa de esto en GitHub</githubLink>, o avísanos vía <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.",
|
||||
"prefs_users_dialog_title_add": "Añadir usuario",
|
||||
"prefs_users_dialog_button_cancel": "Cancelar",
|
||||
"common_cancel": "Cancelar",
|
||||
"prefs_users_dialog_username_label": "Nombre de usuario, ej. phil",
|
||||
"priority_max": "máx",
|
||||
"priority_high": "alta",
|
||||
@@ -187,5 +187,58 @@
|
||||
"prefs_users_table": "Tabla de usuarios",
|
||||
"prefs_users_edit_button": "Editar usuario",
|
||||
"prefs_users_delete_button": "Eliminar usuario",
|
||||
"error_boundary_unsupported_indexeddb_title": "Navegación privada no soportada"
|
||||
"error_boundary_unsupported_indexeddb_title": "Navegación privada no soportada",
|
||||
"action_bar_profile_title": "Perfil",
|
||||
"action_bar_profile_settings": "Configuración",
|
||||
"signup_title": "Crear una cuenta ntfy",
|
||||
"signup_form_username": "Nombre de usuario",
|
||||
"signup_form_password": "Contraseña",
|
||||
"signup_form_confirm_password": "Confirmar contraseña",
|
||||
"signup_form_button_submit": "Registro",
|
||||
"signup_form_toggle_password_visibility": "Alternar la visibilidad de la contraseña",
|
||||
"signup_already_have_account": "¿Ya tienes una cuenta? ¡Iniciar sesión!",
|
||||
"signup_disabled": "El registro está deshabilitado",
|
||||
"signup_error_username_taken": "El nombre de usuario {{username}} ya está en uso",
|
||||
"signup_error_creation_limit_reached": "Límite de creación de cuenta alcanzado",
|
||||
"login_title": "Inicie sesión en su cuenta ntfy",
|
||||
"login_form_button_submit": "Iniciar sesión",
|
||||
"login_link_signup": "Registro",
|
||||
"login_disabled": "Inicio de sesión deshabilitado",
|
||||
"action_bar_account": "Cuenta",
|
||||
"action_bar_change_display_name": "Cambiar nombre de usuario",
|
||||
"action_bar_reservation_add": "Reservar tema",
|
||||
"action_bar_reservation_edit": "Modificar reserva",
|
||||
"action_bar_reservation_delete": "Quitar reserva",
|
||||
"action_bar_reservation_limit_reached": "Límite alcanzado",
|
||||
"action_bar_profile_logout": "Cerrar sesión",
|
||||
"action_bar_sign_in": "Iniciar sesión",
|
||||
"action_bar_sign_up": "Registro",
|
||||
"nav_button_account": "Cuenta",
|
||||
"nav_upgrade_banner_label": "Actualizar a ntfy Pro",
|
||||
"nav_upgrade_banner_description": "Reserve temas, más mensajes y correos electrónicos, y archivos adjuntos más grandes",
|
||||
"display_name_dialog_title": "Cambiar el nombre para mostrar",
|
||||
"display_name_dialog_description": "Establezca un nombre alternativo para un tópico que se muestra en la lista de suscripciones. Esto ayuda a identificar más fácilmente los temas con nombres complicados.",
|
||||
"display_name_dialog_placeholder": "Nombre para mostrar",
|
||||
"account_basics_username_admin_tooltip": "Eres Administrador",
|
||||
"account_basics_password_description": "Cambiar la contraseña de tu cuenta",
|
||||
"account_basics_password_dialog_confirm_password_label": "Confirmar contraseña",
|
||||
"account_basics_password_dialog_button_submit": "Cambiar contraseña",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Contraseña incorrecta",
|
||||
"account_usage_unlimited": "Ilimitado",
|
||||
"account_usage_title": "Uso",
|
||||
"account_usage_of_limit": "de {{límite}}",
|
||||
"account_usage_limits_reset_daily": "Los límites de uso se restablecen diariamente a la medianoche (UTC)",
|
||||
"account_basics_tier_description": "Nivel de poder de tu cuenta",
|
||||
"account_basics_tier_admin": "Administrador",
|
||||
"alert_not_supported_context_description": "Las notificaciones sólo se admiten a través de HTTPS. Esta es una limitante de la <mdnLink>API de notificaciones</mdnLink> .",
|
||||
"reserve_dialog_checkbox_label": "Reservar tópico y configurar el acceso",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Generar nombre",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Tópico ya reservado",
|
||||
"account_basics_title": "Cuenta",
|
||||
"account_basics_username_title": "Nombre de usuario",
|
||||
"account_basics_username_description": "Hey, ese eres tú ❤",
|
||||
"account_basics_password_title": "Contraseña",
|
||||
"account_basics_password_dialog_title": "Cambiar contraseña",
|
||||
"account_basics_password_dialog_current_password_label": "Contraseña actual",
|
||||
"account_basics_password_dialog_new_password_label": "Contraseña nueva"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"message_bar_type_message": "Tapez un message ici",
|
||||
"notifications_attachment_open_button": "Ouvrir la pièce jointe",
|
||||
"notifications_attachment_link_expires": "le lien expire {{date}}",
|
||||
"message_bar_error_publishing": "Notification d'erreur de publication",
|
||||
"message_bar_error_publishing": "Erreur lors de la publication de la notification",
|
||||
"nav_button_all_notifications": "Toutes les notifications",
|
||||
"nav_button_settings": "Paramètres",
|
||||
"nav_button_documentation": "Documentation",
|
||||
@@ -79,8 +79,8 @@
|
||||
"subscribe_dialog_subscribe_title": "S'abonner au sujet",
|
||||
"subscribe_dialog_login_title": "Connexion nécessaire",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Priorité basse et au-dessus",
|
||||
"prefs_users_dialog_button_cancel": "Annuler",
|
||||
"error_boundary_button_copy_stack_trace": "Copier la stack strace",
|
||||
"common_cancel": "Annuler",
|
||||
"error_boundary_button_copy_stack_trace": "Copier la trace d'appels",
|
||||
"publish_dialog_attached_file_title": "Fichier joint :",
|
||||
"publish_dialog_checkbox_publish_another": "Publier un autre",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Nom du fichier joint",
|
||||
@@ -128,8 +128,8 @@
|
||||
"prefs_users_description": "Ajoutez/supprimez des utilisateurs pour vos sujets protégés ici. Notez que cet utilisateur et ce mot de passe sont stockés dans le stockage local du navigateur.",
|
||||
"prefs_users_table_user_header": "Utilisateur",
|
||||
"prefs_users_dialog_title_edit": "Éditer l'utilisateur",
|
||||
"prefs_users_dialog_button_add": "Ajouter",
|
||||
"error_boundary_description": "Ceci ne devrait évidemment pas arriver. Désolé pour ça.<br/>Si vous avez une minute, merci de <githubLink>signaler ceci sur GitHub</githubLink>, ou faites-le nous savoir par <discordLink>Discord</discordLink> ou <matrixLink>Matric</matrixLink>.",
|
||||
"common_add": "Ajouter",
|
||||
"error_boundary_description": "Ceci ne devrait évidemment pas arriver. Désolé pour ça.<br/>Si vous avez une minute, merci de <githubLink>signaler ceci sur GitHub</githubLink>, ou faites-le nous savoir par <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
|
||||
"prefs_users_dialog_title_add": "Ajouter un utilisateur",
|
||||
"error_boundary_stack_trace": "Trace de pile d'appels",
|
||||
"error_boundary_gathering_info": "Récupérer plus d'information…",
|
||||
@@ -152,7 +152,7 @@
|
||||
"publish_dialog_chip_topic_label": "Changer de sujet",
|
||||
"publish_dialog_details_examples_description": "Pour des exemples et une description détaillée des fonctionnalités d'envoi, voir la <docsLink>documentation</docsLink>.",
|
||||
"publish_dialog_button_cancel_sending": "Annuler l'envoi",
|
||||
"prefs_users_dialog_button_save": "Enregistrer",
|
||||
"common_save": "Enregistrer",
|
||||
"notifications_new_indicator": "Nouvelle notification",
|
||||
"publish_dialog_delay_reset": "Retirer le délai de réception",
|
||||
"notifications_list_item": "Notification",
|
||||
@@ -187,5 +187,158 @@
|
||||
"prefs_users_edit_button": "Éditer l'utilisateur",
|
||||
"prefs_users_delete_button": "Supprimer l'utilisateur",
|
||||
"error_boundary_unsupported_indexeddb_title": "Navigation privée non prise en charge",
|
||||
"publish_dialog_attached_file_remove": "Retirer le fichier joint"
|
||||
"publish_dialog_attached_file_remove": "Retirer le fichier joint",
|
||||
"signup_form_password": "Mot de passe",
|
||||
"signup_form_confirm_password": "Confirmation du mot de passe",
|
||||
"signup_disabled": "L'inscription est désactivée",
|
||||
"signup_error_username_taken": "L'identifiant {{username}} est déjà utilisé",
|
||||
"signup_error_creation_limit_reached": "Limite de création de comptes atteinte",
|
||||
"login_title": "Se connecter à son compte Ntfy",
|
||||
"login_form_button_submit": "Connexion",
|
||||
"login_link_signup": "S'inscrire",
|
||||
"login_disabled": "La connection est désactivée",
|
||||
"action_bar_account": "Compte",
|
||||
"action_bar_profile_title": "Profil",
|
||||
"action_bar_profile_settings": "Paramètres",
|
||||
"action_bar_sign_in": "Connexion",
|
||||
"action_bar_sign_up": "Inscription",
|
||||
"nav_button_account": "Compte",
|
||||
"signup_title": "Créer un compte Ntfy",
|
||||
"signup_form_username": "Identifiant",
|
||||
"signup_form_button_submit": "S'inscrire",
|
||||
"signup_already_have_account": "Vous avez déjà un compte ? Connectez-vous !",
|
||||
"action_bar_profile_logout": "Se déconnecter",
|
||||
"signup_form_toggle_password_visibility": "Afficher le mot de passe",
|
||||
"action_bar_change_display_name": "Changer le nom affiché",
|
||||
"prefs_reservations_table_click_to_subscribe": "Cliquer pour s'abonner",
|
||||
"account_tokens_table_cannot_delete_or_edit": "Impossible d'éditer ou de supprimer le jeton de la session actuelle",
|
||||
"account_tokens_dialog_button_cancel": "Annuler",
|
||||
"prefs_users_table_cannot_delete_or_edit": "Impossible de supprimer ou de modifier un utilisateur connecté",
|
||||
"prefs_users_description_no_sync": "Les utilisateurs et les mots de passe ne sont pas synchronisés avec votre compte.",
|
||||
"account_tokens_dialog_button_update": "Mettre à jour un jeton",
|
||||
"nav_upgrade_banner_description": "Réservation de sujets, plus de messages et d'emails, et des pièces jointes plus larges",
|
||||
"display_name_dialog_description": "Mettre un nom supplémentaire pour un sujet qui est affiché dans la liste des abonnements. Cela aide à identifier plus facilement les sujets ayant des noms compliqués.",
|
||||
"account_usage_basis_ip_description": "Les statistiques d'utilisation et les limites pour ce compte sont basées sur votre adresse IP, donc elles peuvent être partagées avec d'autres utilisateurs. Les limites affichées plus haut sont approximativement basées sur les limites de débit existantes.",
|
||||
"action_bar_reservation_add": "Réserver un sujet",
|
||||
"action_bar_reservation_edit": "Changer la réservation",
|
||||
"action_bar_reservation_delete": "Supprimer la réservation",
|
||||
"action_bar_reservation_limit_reached": "Limite atteinte",
|
||||
"nav_upgrade_banner_label": "Passer à ntfy Pro",
|
||||
"display_name_dialog_title": "Changer le nom affiché",
|
||||
"reserve_dialog_checkbox_label": "Réserver un sujet et en configurer l'accès",
|
||||
"display_name_dialog_placeholder": "Nom affiché",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Générer un nom",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Sujet déjà réservé",
|
||||
"account_basics_title": "Compte",
|
||||
"account_basics_username_title": "Nom d'utilisateur",
|
||||
"account_basics_username_description": "Hé, c'est toi ❤",
|
||||
"account_basics_username_admin_tooltip": "Vous êtes Administrateur",
|
||||
"account_basics_password_title": "Mot de passe",
|
||||
"account_basics_password_description": "Changer le mot de passe de votre compte",
|
||||
"account_basics_password_dialog_title": "Changer le mot de passe",
|
||||
"account_basics_password_dialog_current_password_label": "Mot de passe actuel",
|
||||
"account_basics_password_dialog_new_password_label": "Nouveau mot de passe",
|
||||
"account_basics_password_dialog_confirm_password_label": "Confirmer le mot de passe",
|
||||
"account_basics_password_dialog_button_submit": "Changer le mot de passe",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Mot de passe incorrect",
|
||||
"account_usage_title": "Utilisation",
|
||||
"account_usage_of_limit": "sur {{limit}}",
|
||||
"account_usage_unlimited": "Illimité",
|
||||
"account_usage_limits_reset_daily": "Les limites d'utilisation sont réinitialisées chaque jour à minuit (UTC)",
|
||||
"account_basics_tier_title": "Type de compte",
|
||||
"account_basics_tier_description": "Le niveau de puissance de votre compte",
|
||||
"account_basics_tier_admin": "Administrateur",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(avec le tarif {{tier}})",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(pas de tarif)",
|
||||
"account_basics_tier_free": "Gratuit",
|
||||
"account_basics_tier_upgrade_button": "Passer à Pro",
|
||||
"account_basics_tier_change_button": "Changer",
|
||||
"account_basics_tier_paid_until": "Abonnement payé jusqu'à {{date}}, et va être automatiquement renouvelé",
|
||||
"account_basics_tier_canceled_subscription": "Votre abonnement a été annulé et va être rétrogradé vers un compte gratuit le {{date}}.",
|
||||
"account_basics_tier_manage_billing_button": "Gérer la facturation",
|
||||
"account_usage_messages_title": "Messages publiés",
|
||||
"account_usage_emails_title": "Emails envoyés",
|
||||
"account_usage_reservations_title": "Sujets réservés",
|
||||
"account_usage_reservations_none": "Pas de sujet réservé pour ce compte",
|
||||
"account_usage_attachment_storage_title": "Stockage des pièces jointes",
|
||||
"account_usage_attachment_storage_description": "{{filesize}} par fichier, supprimé après {{expiry}}",
|
||||
"account_usage_cannot_create_portal_session": "Impossible d'ouvrir le portail de facturation",
|
||||
"account_delete_title": "Supprimer le compte",
|
||||
"account_delete_description": "Supprimer définitivement votre compte",
|
||||
"account_basics_tier_basic": "Basique",
|
||||
"account_delete_dialog_description": "Cela supprimera définitivement votre compte, ainsi que toutes les données qui sont stockées sur le serveur. Après suppression, votre nom d'utilisateur sera indisponible pendant 7 jours. Si vous voulez vraiment faire cela, veuillez le confirmer en mettant votre mot de passe dans le champ ci-dessous.",
|
||||
"account_delete_dialog_label": "Mot de passe",
|
||||
"account_delete_dialog_button_cancel": "Annuler",
|
||||
"account_delete_dialog_button_submit": "Supprimer définitivement le compte",
|
||||
"account_delete_dialog_billing_warning": "Supprimer votre compte annule aussi immédiatement votre facturation. Vous n'aurez plus accès à votre tableau de bord de facturation.",
|
||||
"account_upgrade_dialog_title": "Changer le tarif du compte",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Facturation</strong> : Lors d'un changement entre un plan payant et un autre, la différence de prix sera créditée ou remboursée sur la prochaine facture. Vous ne recevrez pas d'autre facture avant la fin de la prochaine période de facturation.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, <strong>veuillez supprimer au moins {{count}} sujets réservés</strong>. Vous pouvez supprimer des sujets réservés dans les <Link>Settings</Link>.",
|
||||
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} sujets réservés",
|
||||
"account_upgrade_dialog_tier_features_messages": "{{messages}} messages journaliers",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{emails}} emails journaliers",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} par fichier",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} stockage total",
|
||||
"account_upgrade_dialog_tier_selected_label": "Sélectionné",
|
||||
"account_upgrade_dialog_tier_current_label": "Actuel",
|
||||
"account_upgrade_dialog_button_cancel": "Annuler",
|
||||
"account_upgrade_dialog_button_redirect_signup": "S'inscrire maintenant",
|
||||
"account_upgrade_dialog_button_pay_now": "Payer maintenant et s'abonner",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "Annuler l'abonnement",
|
||||
"account_upgrade_dialog_button_update_subscription": "Mettre à jour l'abonnement",
|
||||
"account_tokens_title": "Jetons d'accès",
|
||||
"account_tokens_table_token_header": "Jeton",
|
||||
"account_tokens_table_label_header": "Étiquette",
|
||||
"account_tokens_table_last_access_header": "Dernier accès",
|
||||
"account_tokens_table_expires_header": "Expire",
|
||||
"account_tokens_table_never_expires": "N'expire jamais",
|
||||
"account_tokens_table_current_session": "Session de navigation actuelle",
|
||||
"account_tokens_table_copy_to_clipboard": "Copier dans le presse-papier",
|
||||
"account_tokens_table_copied_to_clipboard": "Jeton d'accès copié",
|
||||
"account_tokens_table_create_token_button": "Créer un jeton d'accès",
|
||||
"account_tokens_table_last_origin_tooltip": "Depuis l'adresse IP {{ip}}, cliquer pour rechercher",
|
||||
"account_tokens_dialog_title_create": "Créer un jeton d'accès",
|
||||
"account_tokens_dialog_title_edit": "Modifier le jeton d'accès",
|
||||
"account_tokens_dialog_title_delete": "Supprimer le jeton d'accès",
|
||||
"account_tokens_dialog_label": "Étiquette, par ex. Notifications Radarr",
|
||||
"account_tokens_dialog_button_create": "Créer un jeton",
|
||||
"account_tokens_dialog_expires_label": "Le jeton d'accès expire dans",
|
||||
"account_tokens_dialog_expires_unchanged": "Laisser la date d'expiration inchangée",
|
||||
"account_tokens_dialog_expires_x_hours": "Le jeton expire dans {{hours}} heures",
|
||||
"account_tokens_dialog_expires_x_days": "Le jeton expire dans {{days}} jours",
|
||||
"account_tokens_dialog_expires_never": "Le jeton n'expire jamais",
|
||||
"account_tokens_delete_dialog_title": "Supprimer le jeton d'accès",
|
||||
"account_tokens_delete_dialog_submit_button": "Supprimer définitivement le jeton",
|
||||
"prefs_reservations_title": "Sujets réservés",
|
||||
"prefs_reservations_limit_reached": "Vous avez atteint votre limite de réservation de sujets.",
|
||||
"prefs_reservations_add_button": "Ajouter un sujet réservé",
|
||||
"prefs_reservations_edit_button": "Modifier l'accès d'un sujet",
|
||||
"prefs_reservations_delete_button": "Réinitialiser l'accès d'un sujet",
|
||||
"prefs_reservations_table": "Tableau des sujets réservés",
|
||||
"prefs_reservations_table_topic_header": "Sujet",
|
||||
"prefs_reservations_table_access_header": "Accès",
|
||||
"prefs_reservations_table_everyone_deny_all": "Seulement moi peut publier et m'abonner",
|
||||
"prefs_reservations_table_everyone_read_only": "Je peux publier et m'abonner, tout le monde peut s'abonner",
|
||||
"prefs_reservations_table_everyone_write_only": "Je peux publier et m'abonner, tout le monde peut publier",
|
||||
"prefs_reservations_table_everyone_read_write": "Tout le monde peut publier et s'abonner",
|
||||
"prefs_reservations_table_not_subscribed": "Pas abonné",
|
||||
"prefs_reservations_dialog_title_add": "Réserver un sujet",
|
||||
"prefs_reservations_dialog_title_edit": "Modifier un sujet réservé",
|
||||
"prefs_reservations_dialog_title_delete": "Supprimé un sujet réservé",
|
||||
"prefs_reservations_dialog_description": "Réserver un sujet vous donne la propriété sur ce sujet et vous permet de définir les permissions d'accès à ce sujet pour d'autres utilisateurs.",
|
||||
"prefs_reservations_dialog_topic_label": "Sujet",
|
||||
"prefs_reservations_dialog_access_label": "Accès",
|
||||
"reservation_delete_dialog_description": "Supprimer un sujet réservé abandonne la propriété sur le sujet et permet aux autres de le réserver. Vous pouvez garder ou supprimer les messages et pièces jointes existantes.",
|
||||
"reservation_delete_dialog_action_keep_title": "Garder les messages et pièces jointes mises en cache",
|
||||
"reservation_delete_dialog_action_keep_description": "Les messages et pièces jointes qui sont dans le cache du serveur deviendront visibles publiquement pour les personnes ayant connaissance du nom du sujet.",
|
||||
"reservation_delete_dialog_action_delete_title": "Supprimer les messages et pièces jointes mises en cache",
|
||||
"reservation_delete_dialog_action_delete_description": "Les messages et pièces jointes mises en cache seront définitivement supprimées. Cette action ne peut pas être annulée.",
|
||||
"reservation_delete_dialog_submit_button": "Supprimer un sujet réservé",
|
||||
"alert_not_supported_context_description": "Les notifications ne sont supportées qu'en HTTPS. C'est une limitation de la <mdnLink>Notifications API</mdnLink>.",
|
||||
"account_basics_tier_payment_overdue": "Votre paiement est en retard. Veuillez mettre à jour votre méthode de paiement, ou votre compte va bientôt être rétrogradé.",
|
||||
"account_upgrade_dialog_cancel_warning": "Cela va <strong>annuler votre abonnement</strong> et rétrograder votre compte le {{date}}. Ce jour là, les sujets réservés ainsi que tous les messages dans le cache du serveur <strong>seront supprimés</strong>.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, <strong>veuillez supprimer au moins un sujet réservé</strong>. Vous pouvez supprimer des sujets réservés dans les <Link>Settings</Link>.",
|
||||
"account_tokens_description": "Utilisez des jetons d'accès lors de la publication ou de l'abonnement via l'API de ntfy, afin d'éviter d'envoyer vos identifiants de compte. Regardez la <Link>documentation</Link> pour en savoir plus.",
|
||||
"account_tokens_delete_dialog_description": "Avant de supprimer un jeton d'accès, assurez-vous qu'aucune application ou script ne soit en train de l'utiliser. <strong>Cette action ne peut pas être annulée</strong>.",
|
||||
"prefs_reservations_description": "Vous pouvez réserver les noms de sujet à usage personnel ici. Réserver un sujet vous donne la propriété sur ce sujet et vous permet de définir les permissions d'accès à ce sujet pour d'autres utilisateurs."
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
"prefs_users_dialog_title_edit": "Felhasználó szerkesztése",
|
||||
"prefs_users_dialog_username_label": "Felhasználónév, pl: jozsi",
|
||||
"prefs_users_dialog_password_label": "Jelszó",
|
||||
"prefs_users_dialog_button_add": "Hozzáadás",
|
||||
"common_add": "Hozzáadás",
|
||||
"prefs_users_dialog_base_url_label": "Szerver címe, pl: https://ntfy.sh",
|
||||
"notifications_loading": "Értesítések betöltése …",
|
||||
"publish_dialog_progress_uploading": "Feltöltés …",
|
||||
@@ -144,8 +144,8 @@
|
||||
"error_boundary_gathering_info": "Több információ…",
|
||||
"publish_dialog_attachment_limits_file_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}})",
|
||||
"prefs_users_title": "Felhasználók kezelése",
|
||||
"prefs_users_dialog_button_cancel": "Mégsem",
|
||||
"prefs_users_dialog_button_save": "Mentés",
|
||||
"common_cancel": "Mégsem",
|
||||
"common_save": "Mentés",
|
||||
"prefs_users_dialog_title_add": "Felhasználó hozzáadása",
|
||||
"prefs_appearance_language_title": "Nyelv",
|
||||
"priority_low": "alacsony",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user