Compare commits
164 Commits
new-homepa
...
http-respo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db1a1fec0c | ||
|
|
7fb6f794e5 | ||
|
|
df68b0cb43 | ||
|
|
ca49fd1161 | ||
|
|
bb3f17ada2 | ||
|
|
d18c61f0da | ||
|
|
92cfc04024 | ||
|
|
2d0ce79011 | ||
|
|
c6e091a754 | ||
|
|
c8c16eb8e6 | ||
|
|
c815b183d4 | ||
|
|
b8e976f4f6 | ||
|
|
6c51b7558a | ||
|
|
c4e4cc5aa7 | ||
|
|
5e90ff7db0 | ||
|
|
6451762508 | ||
|
|
fda90c217f | ||
|
|
94066c24dc | ||
|
|
76d46ec646 | ||
|
|
e90f52f375 | ||
|
|
ca68494203 | ||
|
|
396e61cdb3 | ||
|
|
dfaab8c386 | ||
|
|
0df3e3e4f5 | ||
|
|
f2f5a06be1 | ||
|
|
8d7ff4d7db | ||
|
|
9f052bdf8b | ||
|
|
5472c8513f | ||
|
|
c028ec9083 | ||
|
|
31a87935a5 | ||
|
|
80292f1f4d | ||
|
|
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]
|
github: [binwiederhier]
|
||||||
|
liberapay: ntfy
|
||||||
|
|||||||
2
.github/workflows/build.yaml
vendored
2
.github/workflows/build.yaml
vendored
@@ -8,7 +8,7 @@ jobs:
|
|||||||
name: Install Go
|
name: Install Go
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.18.x'
|
go-version: '1.19.x'
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
uses: actions/setup-node@v2
|
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
|
name: Install Go
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.18.x'
|
go-version: '1.19.x'
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
uses: actions/setup-node@v2
|
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
|
name: Install Go
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.18.x'
|
go-version: '1.19.x'
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
|
|||||||
9
Makefile
9
Makefile
@@ -88,7 +88,6 @@ build-deps-ubuntu:
|
|||||||
curl \
|
curl \
|
||||||
gcc-aarch64-linux-gnu \
|
gcc-aarch64-linux-gnu \
|
||||||
gcc-arm-linux-gnueabi \
|
gcc-arm-linux-gnueabi \
|
||||||
upx \
|
|
||||||
jq
|
jq
|
||||||
which pip3 || sudo apt install -y python3-pip
|
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
|
touch server/docs/index.html server/site/app.html
|
||||||
|
|
||||||
cli-deps-all:
|
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
|
go install github.com/goreleaser/goreleaser@latest
|
||||||
|
|
||||||
cli-deps-gcc-armv6-armv7:
|
cli-deps-gcc-armv6-armv7:
|
||||||
@@ -231,14 +229,17 @@ cli-build-results:
|
|||||||
check: test fmt-check vet lint staticcheck
|
check: test fmt-check vet lint staticcheck
|
||||||
|
|
||||||
test: .PHONY
|
test: .PHONY
|
||||||
|
go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||||
|
|
||||||
|
testv: .PHONY
|
||||||
go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
go test -v $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)')
|
||||||
|
|
||||||
race: .PHONY
|
race: .PHONY
|
||||||
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:
|
coverage:
|
||||||
mkdir -p build/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
|
go tool cover -func build/coverage/coverage.txt
|
||||||
|
|
||||||
coverage-html:
|
coverage-html:
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -61,9 +61,9 @@ for the server and the Android app. Or, if you'd like to help translate 🇩🇪
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Sponsors
|
## Sponsors
|
||||||
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier).
|
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier),
|
||||||
I would be humbled if you helped me carry the server and developer account costs. Even small donations are very much
|
and [Liberapay](https://liberapay.com/ntfy). I would be humbled if you helped me carry the server and developer
|
||||||
appreciated. A big fat **Thank You** to the folks already sponsoring ntfy:
|
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/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>
|
<a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a>
|
||||||
@@ -110,11 +110,18 @@ 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/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/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/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>
|
||||||
|
|
||||||
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
|
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
|
||||||
and [DigitalOcean](https://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
|
## 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.
|
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"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/client"
|
"heckel.io/ntfy/client"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/test"
|
"heckel.io/ntfy/test"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
log.SetLevel(log.ErrorLevel)
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
func TestClient_Publish_Subscribe(t *testing.T) {
|
func TestClient_Publish_Subscribe(t *testing.T) {
|
||||||
s, port := test.StartServer(t)
|
s, port := test.StartServer(t)
|
||||||
defer test.StopServer(t, s, port)
|
defer test.StopServer(t, s, port)
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ func WithBasicAuth(user, pass string) PublishOption {
|
|||||||
return WithHeader("Authorization", util.BasicAuth(user, pass))
|
return WithHeader("Authorization", util.BasicAuth(user, pass))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithBearerAuth adds the Authorization header for Bearer auth to the request
|
||||||
|
func WithBearerAuth(token string) PublishOption {
|
||||||
|
return WithHeader("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||||
|
}
|
||||||
|
|
||||||
// WithNoCache instructs the server not to cache the message server-side
|
// WithNoCache instructs the server not to cache the message server-side
|
||||||
func WithNoCache() PublishOption {
|
func WithNoCache() PublishOption {
|
||||||
return WithHeader("X-Cache", "no")
|
return WithHeader("X-Cache", "no")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var flagsAccess = append(
|
var flagsAccess = append(
|
||||||
flagsUser,
|
append([]cli.Flag{}, flagsUser...),
|
||||||
&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
|
&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -189,7 +189,11 @@ func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if u.Role == user.RoleAdmin {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
|
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
|
||||||
} else if len(grants) > 0 {
|
} else if len(grants) > 0 {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func TestCLI_Access_Show(t *testing.T) {
|
|||||||
|
|
||||||
app, _, _, stderr := newTestApp()
|
app, _, _, stderr := newTestApp()
|
||||||
require.Nil(t, runAccessCommand(app, conf))
|
require.Nil(t, runAccessCommand(app, conf))
|
||||||
require.Contains(t, stderr.String(), "user * (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) {
|
func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
||||||
@@ -32,12 +32,12 @@ func TestCLI_Access_Grant_And_Publish(t *testing.T) {
|
|||||||
|
|
||||||
app, _, _, stderr := newTestApp()
|
app, _, _, stderr := newTestApp()
|
||||||
require.Nil(t, runAccessCommand(app, conf))
|
require.Nil(t, runAccessCommand(app, conf))
|
||||||
expected := `user phil (admin)
|
expected := `user phil (role: admin, tier: none)
|
||||||
- read-write access to all topics (admin role)
|
- read-write access to all topics (admin role)
|
||||||
user ben (user)
|
user ben (role: user, tier: none)
|
||||||
- read-write access to topic announcements
|
- read-write access to topic announcements
|
||||||
- read-only access to topic sometopic
|
- read-only access to topic sometopic
|
||||||
user * (anonymous)
|
user * (role: anonymous, tier: none)
|
||||||
- read-only access to topic announcements
|
- read-only access to topic announcements
|
||||||
- no access to any (other) topics (server config)
|
- no access to any (other) topics (server config)
|
||||||
`
|
`
|
||||||
@@ -79,7 +79,9 @@ user * (anonymous)
|
|||||||
func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error {
|
func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||||
userArgs := []string{
|
userArgs := []string{
|
||||||
"ntfy",
|
"ntfy",
|
||||||
|
"--log-level=ERROR",
|
||||||
"access",
|
"access",
|
||||||
|
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
|
||||||
"--auth-file=" + conf.AuthFile,
|
"--auth-file=" + conf.AuthFile,
|
||||||
"--auth-default-access=" + conf.AuthDefault.String(),
|
"--auth-default-access=" + conf.AuthDefault.String(),
|
||||||
}
|
}
|
||||||
|
|||||||
40
cmd/app.go
40
cmd/app.go
@@ -2,10 +2,12 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"github.com/urfave/cli/v2/altsrc"
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
"heckel.io/ntfy/log"
|
"heckel.io/ntfy/log"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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: "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"},
|
&cli.BoolFlag{Name: "no-log-dates", Aliases: []string{"no_log_dates"}, EnvVars: []string{"NTFY_NO_LOG_DATES"}, Usage: "disable the date/time prefix"},
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "log-level", Aliases: []string{"log_level"}, Value: log.InfoLevel.String(), EnvVars: []string{"NTFY_LOG_LEVEL"}, Usage: "set log level"}),
|
||||||
|
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "log-level-overrides", Aliases: []string{"log_level_overrides"}, EnvVars: []string{"NTFY_LOG_LEVEL_OVERRIDES"}, Usage: "set log level overrides"}),
|
||||||
|
altsrc.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
|
// New creates a new CLI application
|
||||||
func New() *cli.App {
|
func New() *cli.App {
|
||||||
return &cli.App{
|
return &cli.App{
|
||||||
@@ -40,15 +49,42 @@ func New() *cli.App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initLogFunc(c *cli.Context) error {
|
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") {
|
if c.Bool("trace") {
|
||||||
log.SetLevel(log.TraceLevel)
|
log.SetLevel(log.TraceLevel)
|
||||||
} else if c.Bool("debug") {
|
} else if c.Bool("debug") {
|
||||||
log.SetLevel(log.DebugLevel)
|
log.SetLevel(log.DebugLevel)
|
||||||
} else {
|
|
||||||
log.SetLevel(log.ToLevel(c.String("log-level")))
|
|
||||||
}
|
}
|
||||||
if c.Bool("no-log-dates") {
|
if c.Bool("no-log-dates") {
|
||||||
log.DisableDates()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"heckel.io/ntfy/client"
|
"heckel.io/ntfy/client"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -13,7 +14,7 @@ import (
|
|||||||
// This only contains helpers so far
|
// This only contains helpers so far
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
// log.SetOutput(io.Discard)
|
log.SetLevel(log.ErrorLevel)
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var flagsPublish = append(
|
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: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
|
||||||
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
|
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
|
||||||
&cli.StringFlag{Name: "message", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MESSAGE"}, Usage: "message body"},
|
&cli.StringFlag{Name: "message", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MESSAGE"}, Usage: "message body"},
|
||||||
@@ -35,6 +35,7 @@ var flagsPublish = append(
|
|||||||
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
||||||
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
||||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
||||||
|
&cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"},
|
||||||
&cli.IntFlag{Name: "wait-pid", Aliases: []string{"wait_pid", "pid"}, EnvVars: []string{"NTFY_WAIT_PID"}, Usage: "wait until PID exits before publishing"},
|
&cli.IntFlag{Name: "wait-pid", Aliases: []string{"wait_pid", "pid"}, EnvVars: []string{"NTFY_WAIT_PID"}, Usage: "wait until PID exits before publishing"},
|
||||||
&cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"wait_cmd", "cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"},
|
&cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"wait_cmd", "cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"},
|
||||||
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"no_cache", "C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
|
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"no_cache", "C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
|
||||||
@@ -99,10 +100,18 @@ func execPublish(c *cli.Context) error {
|
|||||||
file := c.String("file")
|
file := c.String("file")
|
||||||
email := c.String("email")
|
email := c.String("email")
|
||||||
user := c.String("user")
|
user := c.String("user")
|
||||||
|
token := c.String("token")
|
||||||
noCache := c.Bool("no-cache")
|
noCache := c.Bool("no-cache")
|
||||||
noFirebase := c.Bool("no-firebase")
|
noFirebase := c.Bool("no-firebase")
|
||||||
quiet := c.Bool("quiet")
|
quiet := c.Bool("quiet")
|
||||||
pid := c.Int("wait-pid")
|
pid := c.Int("wait-pid")
|
||||||
|
|
||||||
|
// Checks
|
||||||
|
if user != "" && token != "" {
|
||||||
|
return errors.New("cannot set both --user and --token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do the things
|
||||||
topic, message, command, err := parseTopicMessageCommand(c)
|
topic, message, command, err := parseTopicMessageCommand(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -144,6 +153,9 @@ func execPublish(c *cli.Context) error {
|
|||||||
if noFirebase {
|
if noFirebase {
|
||||||
options = append(options, client.WithNoFirebase())
|
options = append(options, client.WithNoFirebase())
|
||||||
}
|
}
|
||||||
|
if token != "" {
|
||||||
|
options = append(options, client.WithBearerAuth(token))
|
||||||
|
}
|
||||||
if user != "" {
|
if user != "" {
|
||||||
var pass string
|
var pass string
|
||||||
parts := strings.SplitN(user, ":", 2)
|
parts := strings.SplitN(user, ":", 2)
|
||||||
|
|||||||
@@ -8,20 +8,27 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
||||||
testMessage := util.RandomString(10)
|
testMessage := util.RandomString(10)
|
||||||
|
|
||||||
app, _, _, _ := newTestApp()
|
app, _, _, _ := newTestApp()
|
||||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
|
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
|
||||||
time.Sleep(3 * time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s
|
|
||||||
|
|
||||||
app2, _, stdout, _ := newTestApp()
|
_, err := util.Retry(func() (*int, error) {
|
||||||
require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}))
|
app2, _, stdout, _ := newTestApp()
|
||||||
require.Contains(t, stdout.String(), testMessage)
|
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) {
|
func TestCLI_Publish_Subscribe_Poll(t *testing.T) {
|
||||||
|
|||||||
45
cmd/serve.go
45
cmd/serve.go
@@ -34,7 +34,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var flagsServe = append(
|
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"},
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
|
||||||
@@ -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: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
||||||
|
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "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-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
|
||||||
@@ -77,6 +78,7 @@ 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.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.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.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.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
||||||
@@ -131,6 +133,7 @@ func execServe(c *cli.Context) error {
|
|||||||
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
|
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
|
||||||
keepaliveInterval := c.Duration("keepalive-interval")
|
keepaliveInterval := c.Duration("keepalive-interval")
|
||||||
managerInterval := c.Duration("manager-interval")
|
managerInterval := c.Duration("manager-interval")
|
||||||
|
disallowedTopics := c.StringSlice("disallowed-topics")
|
||||||
webRoot := c.String("web-root")
|
webRoot := c.String("web-root")
|
||||||
enableSignup := c.Bool("enable-signup")
|
enableSignup := c.Bool("enable-signup")
|
||||||
enableLogin := c.Bool("enable-login")
|
enableLogin := c.Bool("enable-login")
|
||||||
@@ -150,6 +153,7 @@ func execServe(c *cli.Context) error {
|
|||||||
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
||||||
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
|
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
|
||||||
visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",")
|
visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",")
|
||||||
|
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
|
||||||
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
||||||
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
|
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
|
||||||
behindProxy := c.Bool("behind-proxy")
|
behindProxy := c.Bool("behind-proxy")
|
||||||
@@ -249,8 +253,12 @@ func execServe(c *cli.Context) error {
|
|||||||
stripe.Key = stripeSecretKey
|
stripe.Key = stripeSecretKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add default forbidden topics
|
||||||
|
disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...)
|
||||||
|
|
||||||
// Run server
|
// Run server
|
||||||
conf := server.NewConfig()
|
conf := server.NewConfig()
|
||||||
|
conf.File = config
|
||||||
conf.BaseURL = baseURL
|
conf.BaseURL = baseURL
|
||||||
conf.ListenHTTP = listenHTTP
|
conf.ListenHTTP = listenHTTP
|
||||||
conf.ListenHTTPS = listenHTTPS
|
conf.ListenHTTPS = listenHTTPS
|
||||||
@@ -273,6 +281,7 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
||||||
conf.KeepaliveInterval = keepaliveInterval
|
conf.KeepaliveInterval = keepaliveInterval
|
||||||
conf.ManagerInterval = managerInterval
|
conf.ManagerInterval = managerInterval
|
||||||
|
conf.DisallowedTopics = disallowedTopics
|
||||||
conf.WebRootIsApp = webRootIsApp
|
conf.WebRootIsApp = webRootIsApp
|
||||||
conf.UpstreamBaseURL = upstreamBaseURL
|
conf.UpstreamBaseURL = upstreamBaseURL
|
||||||
conf.SMTPSenderAddr = smtpSenderAddr
|
conf.SMTPSenderAddr = smtpSenderAddr
|
||||||
@@ -285,10 +294,11 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.TotalTopicLimit = totalTopicLimit
|
conf.TotalTopicLimit = totalTopicLimit
|
||||||
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||||
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
||||||
conf.VisitorAttachmentDailyBandwidthLimit = int(visitorAttachmentDailyBandwidthLimit)
|
conf.VisitorAttachmentDailyBandwidthLimit = visitorAttachmentDailyBandwidthLimit
|
||||||
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
|
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
|
||||||
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
|
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
|
||||||
conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs
|
conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs
|
||||||
|
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
|
||||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||||
conf.BehindProxy = behindProxy
|
conf.BehindProxy = behindProxy
|
||||||
@@ -306,9 +316,9 @@ func execServe(c *cli.Context) error {
|
|||||||
// Run server
|
// Run server
|
||||||
s, err := server.New(conf)
|
s, err := server.New(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err.Error())
|
||||||
} else if err := s.Run(); err != nil {
|
} else if err := s.Run(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err.Error())
|
||||||
}
|
}
|
||||||
log.Info("Exiting.")
|
log.Info("Exiting.")
|
||||||
return nil
|
return nil
|
||||||
@@ -335,7 +345,9 @@ func sigHandlerConfigReload(config string) {
|
|||||||
log.Warn("Hot reload failed: %s", err.Error())
|
log.Warn("Hot reload failed: %s", err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
reloadLogLevel(inputSource)
|
if err := reloadLogLevel(inputSource); err != nil {
|
||||||
|
log.Warn("Reloading log level failed: %s", err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,13 +376,24 @@ func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func reloadLogLevel(inputSource altsrc.InputSourceContext) {
|
func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
|
||||||
newLevelStr, err := inputSource.String("log-level")
|
newLevelStr, err := inputSource.String("log-level")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Cannot load log level: %s", err.Error())
|
return fmt.Errorf("cannot load log level: %s", err.Error())
|
||||||
return
|
|
||||||
}
|
}
|
||||||
newLevel := log.ToLevel(newLevelStr)
|
overrides, err := inputSource.StringSlice("log-level-overrides")
|
||||||
log.SetLevel(newLevel)
|
if err != nil {
|
||||||
log.Info("Log level is %s", newLevel.String())
|
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(
|
var flagsSubscribe = append(
|
||||||
flagsDefault,
|
append([]cli.Flag{}, flagsDefault...),
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
||||||
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
|
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
|
||||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
||||||
|
|||||||
337
cmd/tier.go
Normal file
337
cmd/tier.go
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
//go:build !noserver
|
||||||
|
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
commands = append(commands, cmdTier)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultMessageLimit = 5000
|
||||||
|
defaultMessageExpiryDuration = 12 * time.Hour
|
||||||
|
defaultEmailLimit = 20
|
||||||
|
defaultReservationLimit = 3
|
||||||
|
defaultAttachmentFileSizeLimit = "15M"
|
||||||
|
defaultAttachmentTotalSizeLimit = "100M"
|
||||||
|
defaultAttachmentExpiryDuration = 6 * time.Hour
|
||||||
|
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.DurationFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
|
||||||
|
&cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"},
|
||||||
|
&cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"},
|
||||||
|
&cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"},
|
||||||
|
&cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"},
|
||||||
|
&cli.DurationFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"},
|
||||||
|
&cli.StringFlag{Name: "attachment-bandwidth-limit", Value: defaultAttachmentBandwidthLimit, Usage: "daily bandwidth limit for attachment uploads/downloads"},
|
||||||
|
&cli.StringFlag{Name: "stripe-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||||
|
&cli.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.DurationFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
|
||||||
|
&cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"},
|
||||||
|
&cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"},
|
||||||
|
&cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"},
|
||||||
|
&cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"},
|
||||||
|
&cli.DurationFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"},
|
||||||
|
&cli.StringFlag{Name: "attachment-bandwidth-limit", Usage: "daily bandwidth limit for attachment uploads/downloads"},
|
||||||
|
&cli.StringFlag{Name: "stripe-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||||
|
},
|
||||||
|
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-price-id=price_1234 \
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
tier := &user.Tier{
|
||||||
|
ID: "", // Generated
|
||||||
|
Code: code,
|
||||||
|
Name: name,
|
||||||
|
MessageLimit: c.Int64("message-limit"),
|
||||||
|
MessageExpiryDuration: c.Duration("message-expiry-duration"),
|
||||||
|
EmailLimit: c.Int64("email-limit"),
|
||||||
|
ReservationLimit: c.Int64("reservation-limit"),
|
||||||
|
AttachmentFileSizeLimit: attachmentFileSizeLimit,
|
||||||
|
AttachmentTotalSizeLimit: attachmentTotalSizeLimit,
|
||||||
|
AttachmentExpiryDuration: c.Duration("attachment-expiry-duration"),
|
||||||
|
AttachmentBandwidthLimit: attachmentBandwidthLimit,
|
||||||
|
StripePriceID: c.String("stripe-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 = c.Duration("message-expiry-duration")
|
||||||
|
}
|
||||||
|
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 = c.Duration("attachment-expiry-duration")
|
||||||
|
}
|
||||||
|
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-price-id") {
|
||||||
|
tier.StripePriceID = c.String("stripe-price-id")
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
stripePriceID := tier.StripePriceID
|
||||||
|
if stripePriceID == "" {
|
||||||
|
stripePriceID = "(none)"
|
||||||
|
}
|
||||||
|
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 price: %s\n", stripePriceID)
|
||||||
|
}
|
||||||
66
cmd/tier_test.go
Normal file
66
cmd/tier_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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=99h",
|
||||||
|
"--email-limit=91",
|
||||||
|
"--reservation-limit=98",
|
||||||
|
"--attachment-file-size-limit=100m",
|
||||||
|
"--attachment-expiry-duration=7h",
|
||||||
|
"--attachment-total-size-limit=10G",
|
||||||
|
"--attachment-bandwidth-limit=100G",
|
||||||
|
"--stripe-price-id=price_991",
|
||||||
|
"pro",
|
||||||
|
))
|
||||||
|
require.Contains(t, stderr.String(), "- Message limit: 999")
|
||||||
|
require.Contains(t, stderr.String(), "- Message expiry duration: 99h")
|
||||||
|
require.Contains(t, stderr.String(), "- Email limit: 91")
|
||||||
|
require.Contains(t, stderr.String(), "- Reservation limit: 98")
|
||||||
|
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB")
|
||||||
|
require.Contains(t, stderr.String(), "- Attachment expiry duration: 7h")
|
||||||
|
require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB")
|
||||||
|
require.Contains(t, stderr.String(), "- Stripe price: price_991")
|
||||||
|
|
||||||
|
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 (
|
const (
|
||||||
tierReset = "-"
|
tierReset = "-"
|
||||||
createdByCLI = "cli"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -25,7 +24,7 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var flagsUser = append(
|
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"},
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||||
@@ -47,6 +46,7 @@ var cmdUser = &cli.Command{
|
|||||||
Action: execUserAdd,
|
Action: execUserAdd,
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"},
|
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"},
|
||||||
|
&cli.BoolFlag{Name: "ignore-exists", Usage: "if the user already exists, perform no action and exit"},
|
||||||
},
|
},
|
||||||
Description: `Add a new user to the ntfy user database.
|
Description: `Add a new user to the ntfy user database.
|
||||||
|
|
||||||
@@ -140,22 +140,22 @@ Example:
|
|||||||
Action: execUserList,
|
Action: execUserList,
|
||||||
Description: `Shows a list of all configured users, including the everyone ('*') user.
|
Description: `Shows a list of all configured users, including the everyone ('*') user.
|
||||||
|
|
||||||
This 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 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.
|
Description: `Manage users of the ntfy server.
|
||||||
|
|
||||||
|
The command allows you to add/remove/change users in the ntfy user database, as well as change
|
||||||
|
passwords or roles.
|
||||||
|
|
||||||
This is a server-only command. It directly manages the user.db as defined in the server config
|
This is a server-only command. It directly manages the user.db as defined in the server config
|
||||||
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
|
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
|
||||||
to the related command 'ntfy access'.
|
to the related command 'ntfy access'.
|
||||||
|
|
||||||
The command allows you to add/remove/change users in the ntfy user database, as well as change
|
|
||||||
passwords or roles.
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
ntfy user list # Shows list of users (alias: 'ntfy access')
|
ntfy user list # Shows list of users (alias: 'ntfy access')
|
||||||
ntfy user add phil # Add regular user phil
|
ntfy user add phil # Add regular user phil
|
||||||
@@ -177,7 +177,7 @@ func execUserAdd(c *cli.Context) error {
|
|||||||
password := os.Getenv("NTFY_PASSWORD")
|
password := os.Getenv("NTFY_PASSWORD")
|
||||||
if username == "" {
|
if username == "" {
|
||||||
return errors.New("username expected, type 'ntfy user add --help' for help")
|
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")
|
return errors.New("username not allowed")
|
||||||
} else if !user.AllowedRole(role) {
|
} else if !user.AllowedRole(role) {
|
||||||
return errors.New("role must be either 'user' or 'admin'")
|
return errors.New("role must be either 'user' or 'admin'")
|
||||||
@@ -187,6 +187,10 @@ func execUserAdd(c *cli.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if user, _ := manager.User(username); user != nil {
|
if user, _ := manager.User(username); user != nil {
|
||||||
|
if c.Bool("ignore-exists") {
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "user %s already exists (exited successfully)\n", username)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return fmt.Errorf("user %s already exists", username)
|
return fmt.Errorf("user %s already exists", username)
|
||||||
}
|
}
|
||||||
if password == "" {
|
if password == "" {
|
||||||
@@ -197,7 +201,7 @@ func execUserAdd(c *cli.Context) error {
|
|||||||
|
|
||||||
password = p
|
password = p
|
||||||
}
|
}
|
||||||
if err := manager.AddUser(username, password, role, createdByCLI); err != nil {
|
if err := manager.AddUser(username, password, role); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role)
|
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)
|
username := c.Args().Get(0)
|
||||||
if username == "" {
|
if username == "" {
|
||||||
return errors.New("username expected, type 'ntfy user del --help' for help")
|
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")
|
return errors.New("username not allowed")
|
||||||
}
|
}
|
||||||
manager, err := createUserManager(c)
|
manager, err := createUserManager(c)
|
||||||
@@ -230,7 +234,7 @@ func execUserChangePass(c *cli.Context) error {
|
|||||||
password := os.Getenv("NTFY_PASSWORD")
|
password := os.Getenv("NTFY_PASSWORD")
|
||||||
if username == "" {
|
if username == "" {
|
||||||
return errors.New("username expected, type 'ntfy user change-pass --help' for help")
|
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")
|
return errors.New("username not allowed")
|
||||||
}
|
}
|
||||||
manager, err := createUserManager(c)
|
manager, err := createUserManager(c)
|
||||||
@@ -258,7 +262,7 @@ func execUserChangeRole(c *cli.Context) error {
|
|||||||
role := user.Role(c.Args().Get(1))
|
role := user.Role(c.Args().Get(1))
|
||||||
if username == "" || !user.AllowedRole(role) {
|
if username == "" || !user.AllowedRole(role) {
|
||||||
return errors.New("username and new role expected, type 'ntfy user change-role --help' for help")
|
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")
|
return errors.New("username not allowed")
|
||||||
}
|
}
|
||||||
manager, err := createUserManager(c)
|
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")
|
return errors.New("username and new tier expected, type 'ntfy user change-tier --help' for help")
|
||||||
} else if !user.AllowedTier(tier) && tier != tierReset {
|
} else if !user.AllowedTier(tier) && tier != tierReset {
|
||||||
return errors.New("invalid tier, must be tier code, or - to reset")
|
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")
|
return errors.New("username not allowed")
|
||||||
}
|
}
|
||||||
manager, err := createUserManager(c)
|
manager, err := createUserManager(c)
|
||||||
@@ -331,7 +335,7 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
|
|||||||
if err != nil {
|
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 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) {
|
func readPasswordAndConfirm(c *cli.Context) (string, error) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"heckel.io/ntfy/server"
|
"heckel.io/ntfy/server"
|
||||||
"heckel.io/ntfy/test"
|
"heckel.io/ntfy/test"
|
||||||
"heckel.io/ntfy/user"
|
"heckel.io/ntfy/user"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -113,7 +114,10 @@ func TestCLI_User_Delete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
|
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
|
||||||
|
configFile := filepath.Join(t.TempDir(), "server-dummy.yml")
|
||||||
|
require.Nil(t, os.WriteFile(configFile, []byte(""), 0600)) // Dummy config file to avoid lookup of real server.yml
|
||||||
conf = server.NewConfig()
|
conf = server.NewConfig()
|
||||||
|
conf.File = configFile
|
||||||
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||||
conf.AuthDefault = user.PermissionDenyAll
|
conf.AuthDefault = user.PermissionDenyAll
|
||||||
s, port = test.StartServerWithConfig(t, conf)
|
s, port = test.StartServerWithConfig(t, conf)
|
||||||
@@ -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 {
|
func runUserCommand(app *cli.App, conf *server.Config, args ...string) error {
|
||||||
userArgs := []string{
|
userArgs := []string{
|
||||||
"ntfy",
|
"ntfy",
|
||||||
|
"--log-level=ERROR",
|
||||||
"user",
|
"user",
|
||||||
|
"--config=" + conf.File, // Dummy config file to avoid lookups of real file
|
||||||
"--auth-file=" + conf.AuthFile,
|
"--auth-file=" + conf.AuthFile,
|
||||||
"--auth-default-access=" + conf.AuthDefault.String(),
|
"--auth-default-access=" + conf.AuthDefault.String(),
|
||||||
}
|
}
|
||||||
|
|||||||
282
docs/config.md
282
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 del phil # Delete user phil
|
||||||
ntfy user change-pass phil # Change password for user phil
|
ntfy user change-pass phil # Change password for user phil
|
||||||
ntfy user change-role phil admin # Make user phil an admin
|
ntfy user change-role phil admin # Make user phil an admin
|
||||||
|
ntfy user change-tier phil pro # Change phil's tier to "pro"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Access control list (ACL)
|
### Access control list (ACL)
|
||||||
@@ -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
|
to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated
|
||||||
(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics.
|
(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics.
|
||||||
|
|
||||||
|
### Access tokens
|
||||||
|
In addition to username/password auth, ntfy also provides authentication via access tokens. Access tokens are useful
|
||||||
|
to avoid having to configure your password across multiple publishing/subscribing applications. For instance, you may
|
||||||
|
want to use a dedicated token to publish from your backup host, and one from your home automation system.
|
||||||
|
|
||||||
|
!!! info
|
||||||
|
As of today, access tokens grant users **full access to the user account**. Aside from changing the password,
|
||||||
|
and deleting the account, every action can be performed with a token. Granular access tokens are on the roadmap,
|
||||||
|
but not yet implemented.
|
||||||
|
|
||||||
|
The `ntfy token` command can be used to manage access tokens for users. Tokens can have labels, and they can expire
|
||||||
|
automatically (or never expire). Each user can have up to 20 tokens (hardcoded).
|
||||||
|
|
||||||
|
**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details):
|
||||||
|
```
|
||||||
|
ntfy token list # Shows list of tokens for all users
|
||||||
|
ntfy token list phil # Shows list of tokens for user phil
|
||||||
|
ntfy token add phil # Create token for user phil which never expires
|
||||||
|
ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days
|
||||||
|
ntfy token remove phil tk_th2sxr... # Delete token
|
||||||
|
```
|
||||||
|
|
||||||
|
**Creating an access token:**
|
||||||
|
```
|
||||||
|
$ ntfy token add --expires=30d --label="backups" phil
|
||||||
|
$ ntfy token list
|
||||||
|
user phil
|
||||||
|
- tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 (backups), expires 15 Mar 23 14:33 EDT, accessed from 0.0.0.0 at 13 Feb 23 13:33 EST
|
||||||
|
```
|
||||||
|
|
||||||
|
Once an access token is created, you can **use it to authenticate against the ntfy server, e.g. when you publish or
|
||||||
|
subscribe to topics**. To learn how, check out [authenticate via access tokens](publish.md#access-tokens).
|
||||||
|
|
||||||
### Example: Private instance
|
### Example: Private instance
|
||||||
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`:
|
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`:
|
||||||
|
|
||||||
@@ -504,7 +538,7 @@ or the root domain:
|
|||||||
proxy_send_timeout 3m;
|
proxy_send_timeout 3m;
|
||||||
proxy_read_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_send_timeout 3m;
|
||||||
proxy_read_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_send_timeout 3m;
|
||||||
proxy_read_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_send_timeout 3m;
|
||||||
proxy_read_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,69 @@ Note that the self-hosted server literally sends the message `New message` for e
|
|||||||
may be `Some other message`. This is so that if iOS cannot talk to the self-hosted server (in time, or at all),
|
may be `Some other message`. This is so that if iOS cannot talk to the self-hosted server (in time, or at all),
|
||||||
it'll show `New message` as a popup.
|
it'll show `New message` as a popup.
|
||||||
|
|
||||||
|
## Tiers
|
||||||
|
ntfy supports associating users to pre-defined tiers. Tiers can be used to grant users higher limits, such as
|
||||||
|
daily message limits, attachment size, or make it possible for users to reserve topics. If [payments are enabled](#payments),
|
||||||
|
tiers can be paid or unpaid, and users can upgrade/downgrade between them. If payments are disabled, then the only way
|
||||||
|
to switch between tiers is with the `ntfy user change-tier` command (see [users and roles](#users-and-roles)).
|
||||||
|
|
||||||
|
By default, **newly created users have no tier**, and all usage limits are read from the `server.yml` config file.
|
||||||
|
Once a user is associated with a tier, some limits are overridden based on the tier.
|
||||||
|
|
||||||
|
The `ntfy tier` command can be used to manage all available tiers. By default, there are no pre-defined tiers.
|
||||||
|
|
||||||
|
**Example commands** (type `ntfy token --help` or `ntfy token COMMAND --help` for more details):
|
||||||
|
```
|
||||||
|
ntfy tier add pro # Add tier with code "pro", using the defaults
|
||||||
|
ntfy tier change --name="Pro" pro # Update the name of an existing tier
|
||||||
|
ntfy tier del starter # Delete an existing tier
|
||||||
|
ntfy user change-tier phil pro # Switch user "phil" to tier "pro"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Creating a tier (full example):**
|
||||||
|
```
|
||||||
|
ntfy tier add \
|
||||||
|
--name="Pro" \
|
||||||
|
--message-limit=10000 \
|
||||||
|
--message-expiry-duration=24h \
|
||||||
|
--email-limit=50 \
|
||||||
|
--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).
|
||||||
|
|
||||||
|
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"
|
||||||
|
```
|
||||||
|
|
||||||
## Rate limiting
|
## Rate limiting
|
||||||
!!! info
|
!!! info
|
||||||
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
||||||
@@ -788,7 +885,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-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 5s.
|
||||||
* `visitor-request-limit-exempt-hosts` is a comma-separated list of hostnames and IPs to be exempt from request rate
|
* `visitor-request-limit-exempt-hosts` is a comma-separated list of hostnames and IPs to be exempt from request rate
|
||||||
limiting; hostnames are resolved at the time the server is started. Defaults to an empty list.
|
limiting; hostnames are resolved at the time the server is started. Defaults to an empty list.
|
||||||
|
|
||||||
|
### Message limits
|
||||||
|
By default, the number of messages a visitor can send is governed entirely by the [request limit](#request-limits).
|
||||||
|
For instance, if the request limit allows for 15,000 requests per day, and all of those requests are POST/PUT requests
|
||||||
|
to publish messages, then that is the daily message limit.
|
||||||
|
|
||||||
|
To limit the number of daily messages per visitor, you can set `visitor-message-daily-limit`. This defines the number
|
||||||
|
of messages a visitor can send in a day. This counter is reset every day at midnight (UTC).
|
||||||
|
|
||||||
### Attachment limits
|
### Attachment limits
|
||||||
Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant
|
Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant
|
||||||
per-visitor limits:
|
per-visitor limits:
|
||||||
@@ -962,18 +1067,57 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
|
|||||||
maxretry = 10
|
maxretry = 10
|
||||||
```
|
```
|
||||||
|
|
||||||
## Debugging/tracing
|
## Logging & debugging
|
||||||
|
By default, ntfy logs to the console (stderr), with an `info` log level, and in a human-readable text format.
|
||||||
|
|
||||||
|
ntfy supports five different log levels, can also write to a file, log as JSON, and even supports granular
|
||||||
|
log level overrides for easier debugging. Some options (`log-level` and `log-level-overrides`) can be hot reloaded
|
||||||
|
by calling `kill -HUP $pid` or `systemctl reload ntfy`.
|
||||||
|
|
||||||
|
The following config options define the logging behavior:
|
||||||
|
|
||||||
|
* `log-format` defines the output format, can be `text` (default) or `json`
|
||||||
|
* `log-file` is a filename to write logs to. If this is not set, ntfy logs to stderr.
|
||||||
|
* `log-level` defines the default log level, can be one of `trace`, `debug`, `info` (default), `warn` or `error`.
|
||||||
|
Be aware that `debug` (and particularly `trace`) can be **very verbose**. Only turn them on briefly for debugging purposes.
|
||||||
|
* `log-level-overrides` lets you override the log level if certain fields match. This is incredibly powerful
|
||||||
|
for debugging certain parts of the system (e.g. only the account management, or only a certain visitor).
|
||||||
|
This is an array of strings in the format:
|
||||||
|
- `field=value -> level` to match a value exactly, e.g. `tag=manager -> trace`
|
||||||
|
- `field -> level` to match any value, e.g. `time_taken_ms -> debug`
|
||||||
|
|
||||||
|
**Logging config (good for production use):**
|
||||||
|
``` yaml
|
||||||
|
log-level: info
|
||||||
|
log-format: json
|
||||||
|
log-file: /var/log/ntfy.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**Temporary debugging:**
|
||||||
If something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level`
|
If something's not working right, you can debug/trace through what the ntfy server is doing by setting the `log-level`
|
||||||
to `DEBUG` or `TRACE`. The `DEBUG` setting will output information about each published message, but not the message
|
to `debug` or `trace`. The `debug` setting will output information about each published message, but not the message
|
||||||
contents. The `TRACE` setting will also print the message contents.
|
contents. The `trace` setting will also print the message contents.
|
||||||
|
|
||||||
|
Alternatively, you can set `log-level-overrides` for only certain fields, such as a visitor's IP address (`visitor_ip`),
|
||||||
|
a username (`user_name`), or a tag (`tag`). There are dozens of fields you can use to override log levels. To learn what
|
||||||
|
they are, either turn the log-level to `trace` and observe, or reference the [source code](https://github.com/binwiederhier/ntfy).
|
||||||
|
|
||||||
|
Here's an example that will output only `info` log events, except when they match either of the defined overrides:
|
||||||
|
``` yaml
|
||||||
|
log-level: info
|
||||||
|
log-level-overrides:
|
||||||
|
- "tag=manager -> trace"
|
||||||
|
- "visitor_ip=1.2.3.4 -> debug"
|
||||||
|
- "time_taken_ms -> debug"
|
||||||
|
```
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
Both options are very verbose and should only be enabled in production for short periods of time. Otherwise,
|
The `debug` and `trace` log levels are very verbose, and using `log-level-overrides` has a
|
||||||
you're going to run out of disk space pretty quickly.
|
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 also hot-reload the `log-level` and `log-level-overrides` by sending the `SIGHUP` signal to the process after
|
||||||
You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd), or by calling `kill -HUP $(pidof ntfy)`.
|
editing the `server.yml` file. You can do so by calling `systemctl reload ntfy` (if ntfy is running inside systemd),
|
||||||
If successful, you'll see something like this:
|
or by calling `kill -HUP $(pidof ntfy)`. If successful, you'll see something like this:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ ntfy serve
|
$ ntfy serve
|
||||||
@@ -1029,14 +1173,15 @@ 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-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
|
||||||
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
|
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
|
||||||
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
|
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
|
||||||
|
| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. |
|
||||||
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
|
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
|
||||||
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
||||||
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
|
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
|
||||||
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
||||||
| `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) |
|
| `web-root` | `NTFY_WEB_ROOT` | `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-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
|
||||||
| `enable-login` | `NTFY_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
|
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
|
||||||
| `enable-reservations` | `NTFY_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
|
| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
|
||||||
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
|
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
|
||||||
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
|
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
|
||||||
|
|
||||||
@@ -1057,58 +1202,71 @@ CATEGORY:
|
|||||||
|
|
||||||
DESCRIPTION:
|
DESCRIPTION:
|
||||||
Run the ntfy server and listen for incoming requests
|
Run the ntfy server and listen for incoming requests
|
||||||
|
|
||||||
The command will load the configuration from /etc/ntfy/server.yml. Config options can
|
The command will load the configuration from /etc/ntfy/server.yml. Config options can
|
||||||
be overridden using the command line options.
|
be overridden using the command line options.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
ntfy serve # Starts server in the foreground (on port 80)
|
ntfy serve # Starts server in the foreground (on port 80)
|
||||||
ntfy serve --listen-http :8080 # Starts server with alternate port
|
ntfy serve --listen-http :8080 # Starts server with alternate port
|
||||||
|
|
||||||
OPTIONS:
|
OPTIONS:
|
||||||
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
--debug, -d enable debug logging (default: false) [$NTFY_DEBUG]
|
||||||
--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]
|
--trace enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE]
|
||||||
--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]
|
--no-log-dates, --no_log_dates disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES]
|
||||||
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
--log-level value, --log_level value set log level (default: "INFO") [$NTFY_LOG_LEVEL]
|
||||||
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
|
--log-level-overrides value, --log_level_overrides value [ --log-level-overrides value, --log_level_overrides value ] set log level overrides [$NTFY_LOG_LEVEL_OVERRIDES]
|
||||||
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
--log-format value, --log_format value set log format (default: "text") [$NTFY_LOG_FORMAT]
|
||||||
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
--log-file value, --log_file value set log file, default is STDOUT [$NTFY_LOG_FILE]
|
||||||
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
||||||
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
||||||
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
--listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
||||||
--cache-batch-size value, --cache_batch_size value max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE]
|
--listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
||||||
--cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT]
|
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
|
||||||
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
|
--listen-unix-mode value, --listen_unix_mode value file permissions of unix socket, e.g. 0700 (default: system default) [$NTFY_LISTEN_UNIX_MODE]
|
||||||
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
--key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
|
||||||
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_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]
|
||||||
--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]
|
||||||
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
||||||
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
--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]
|
||||||
--key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
|
--cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT]
|
||||||
--listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
|
||||||
--listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
||||||
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
|
--auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
|
||||||
--log-level value, --log_level value set log level (default: "INFO") [$NTFY_LOG_LEVEL]
|
--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]
|
||||||
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
||||||
--no-log-dates, --no_log_dates disable the date/time prefix (default: false) [$NTFY_NO_LOG_DATES]
|
--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]
|
||||||
--smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
|
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
||||||
--smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
|
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
||||||
--smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
|
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
||||||
--smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
|
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
||||||
--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]
|
--disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ] topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS]
|
||||||
--smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
|
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
|
||||||
--smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
|
--enable-signup, --enable_signup allows users to sign up via the web app, or API (default: false) [$NTFY_ENABLE_SIGNUP]
|
||||||
--trace enable tracing (very verbose, be careful) (default: false) [$NTFY_TRACE]
|
--enable-login, --enable_login allows users to log in via the web app, or API (default: false) [$NTFY_ENABLE_LOGIN]
|
||||||
--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]
|
--enable-reservations, --enable_reservations allows users to reserve topics (if their tier allows it) (default: false) [$NTFY_ENABLE_RESERVATIONS]
|
||||||
--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]
|
--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-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
--smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
|
||||||
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
--smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
|
||||||
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
--smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
|
||||||
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
--smtp-sender-from value, --smtp_sender_from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
|
||||||
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
|
--smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
|
||||||
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
--smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
|
||||||
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
--smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
|
||||||
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
|
--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]
|
||||||
|
--help, -h show help (default: false)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ sudo apt install \
|
|||||||
gcc-arm-linux-gnueabi \
|
gcc-arm-linux-gnueabi \
|
||||||
gcc-aarch64-linux-gnu \
|
gcc-aarch64-linux-gnu \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
upx \
|
|
||||||
git
|
git
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -328,7 +327,76 @@ To build your own version with Firebase, you must:
|
|||||||
```
|
```
|
||||||
|
|
||||||
## iOS app
|
## 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
|
!!! 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
|
## Jellyseerr/Overseerr webhook
|
||||||
Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook
|
Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook
|
||||||
JSON payload. Remember to change the `https://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
|
``` json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
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.
|
decent now.
|
||||||
|
|
||||||
|
## Paid plans? I thought it was open source?
|
||||||
|
All of ntfy will remain open source, with a free software license (Apache 2.0 and GPLv2). If you'd like to self-host, you
|
||||||
|
can (and should do that). The paid plans I am offering are for people that do not want to self-host, and/or need higher
|
||||||
|
limits.
|
||||||
|
|
||||||
## What is instant delivery?
|
## What is instant delivery?
|
||||||
[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the
|
[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the
|
||||||
server and listens for incoming notifications. This consumes additional battery (see above),
|
server and listens for incoming notifications. This consumes additional battery (see above),
|
||||||
|
|||||||
@@ -26,37 +26,37 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_x86_64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_x86_64.tar.gz
|
||||||
tar zxvf ntfy_1.30.1_linux_x86_64.tar.gz
|
tar zxvf ntfy_2.0.0_linux_x86_64.tar.gz
|
||||||
sudo cp -a ntfy_1.30.1_linux_x86_64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.0.0_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
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv6.tar.gz
|
||||||
tar zxvf ntfy_1.30.1_linux_armv6.tar.gz
|
tar zxvf ntfy_2.0.0_linux_armv6.tar.gz
|
||||||
sudo cp -a ntfy_1.30.1_linux_armv6/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.0.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_armv6/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_1.30.1_linux_armv7.tar.gz
|
tar zxvf ntfy_2.0.0_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_1.30.1_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.0.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_1.30.1_linux_arm64.tar.gz
|
tar zxvf ntfy_2.0.0_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_1.30.1_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_2.0.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_2.0.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```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.0.0/ntfy_2.0.0_linux_amd64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -114,7 +114,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```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.0.0/ntfy_2.0.0_linux_armv6.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -122,7 +122,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```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.0.0/ntfy_2.0.0_linux_armv7.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -130,7 +130,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```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.0.0/ntfy_2.0.0_linux_arm64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -140,28 +140,28 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_amd64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv6.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_armv7.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
@@ -189,18 +189,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
|
|||||||
|
|
||||||
## macOS
|
## macOS
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/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.0.0/ntfy_2.0.0_macOS_all.tar.gz),
|
||||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||||
|
|
||||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_macOS_all.tar.gz > ntfy_1.30.1_macOS_all.tar.gz
|
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.0.0/ntfy_2.0.0_macOS_all.tar.gz > ntfy_2.0.0_macOS_all.tar.gz
|
||||||
tar zxvf ntfy_1.30.1_macOS_all.tar.gz
|
tar zxvf ntfy_2.0.0_macOS_all.tar.gz
|
||||||
sudo cp -a ntfy_1.30.1_macOS_all/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_2.0.0_macOS_all/ntfy /usr/local/bin/ntfy
|
||||||
mkdir ~/Library/Application\ Support/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.0.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||||
ntfy --help
|
ntfy --help
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ ntfy --help
|
|||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
||||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/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.0.0/ntfy_2.0.0_windows_x86_64.zip),
|
||||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||||
|
|
||||||
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||||
@@ -287,7 +287,7 @@ services:
|
|||||||
restart: unless-stopped
|
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.
|
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,6 +32,7 @@ and uptime of third party servers, so use of each server is **at your own discre
|
|||||||
- [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard
|
- [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard
|
||||||
- [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool
|
- [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool
|
||||||
- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media
|
- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media
|
||||||
|
- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.7/services/ntfy/) ⭐ - Notification library for gophers and their furry friends.
|
||||||
- [Scrt.link](https://scrt.link/) - Share a secret
|
- [Scrt.link](https://scrt.link/) - Share a secret
|
||||||
- [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python
|
- [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ and uptime of third party servers, so use of each server is **at your own discre
|
|||||||
## Projects + scripts
|
## Projects + scripts
|
||||||
|
|
||||||
- [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust)
|
- [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust)
|
||||||
|
- [Grafana-ntfy-webhook-integration](https://github.com/academo/grafana-alerting-ntfy-webhook-integration) - Integrates Grafana alerts webhooks (Go)
|
||||||
- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh)
|
- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh)
|
||||||
- [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell)
|
- [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell)
|
||||||
- [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell)
|
- [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell)
|
||||||
@@ -107,9 +109,15 @@ 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)
|
- [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java)
|
||||||
- [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python)
|
- [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python)
|
||||||
- [ntfy-wrapper](https://github.com/vict0rsch/ntfy-wrapper) - Wrapper around ntfy (Python)
|
- [ntfy-wrapper](https://github.com/vict0rsch/ntfy-wrapper) - Wrapper around ntfy (Python)
|
||||||
|
- [nodebb-plugin-ntfy](https://github.com/NodeBB/nodebb-plugin-ntfy) - Push notifications for NodeBB forums
|
||||||
|
- [n8n-ntfy](https://github.com/raghavanand98/n8n-ntfy.sh) - n8n community node that lets you use ntfy in your workflows
|
||||||
|
|
||||||
## Blog + forum posts
|
## Blog + forum posts
|
||||||
|
|
||||||
|
- [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
|
- [Comment envoyer des notifications push sur votre téléphone facilement et gratuitement?](https://korben.info/notifications-push-telephone.html) - 1/2023
|
||||||
- [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022
|
- [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022
|
||||||
- [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022
|
- [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022
|
||||||
@@ -127,6 +135,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.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
|
- [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
|
- [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
|
- [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
|
- [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
|
- [Alarmanlage der dritten Art (YouTube video)](https://www.youtube.com/watch?v=altb5QLHbaU&feature=youtu.be) - youtube.com - 10/2022
|
||||||
|
|||||||
191
docs/publish.md
191
docs/publish.md
@@ -2591,23 +2591,22 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
|
|||||||
<figcaption>Publishing a message via e-mail</figcaption>
|
<figcaption>Publishing a message via e-mail</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
## Advanced features
|
## Authentication
|
||||||
|
|
||||||
### Authentication
|
|
||||||
Depending on whether the server is configured to support [access control](config.md#access-control), some topics
|
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.
|
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:
|
To publish/subscribe to protected topics, you can:
|
||||||
|
|
||||||
* Use [basic auth](#basic-auth), e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`
|
* Use [username & password](#username-password) via Basic auth, e.g. `Authorization: Basic dGVzdHVzZXI6ZmFrZXBhc3N3b3Jk`
|
||||||
* or use the [`auth` query parameter](#query-param), e.g. `?auth=QmFzaWMgZEdWemRIVnpaWEk2Wm1GclpYQmhjM04zYjNKaw`
|
* 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
|
!!! warning
|
||||||
Base64 only encodes username and password. It **is not encrypting it**. For your self-hosted server,
|
When using Basic auth, base64 only encodes username and password. It **is not encrypting it**. For your
|
||||||
**be sure to use HTTPS to avoid eavesdropping** and exposing your password.
|
self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing your password.
|
||||||
|
|
||||||
#### Basic auth
|
### Username & password
|
||||||
Here's an example using [Basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication), with a user `testuser`
|
The simplest way to authenticate against a ntfy server is to use [Basic auth](https://en.wikipedia.org/wiki/Basic_access_authentication).
|
||||||
and password `fakepassword`:
|
Here's an example with a user `testuser` and password `fakepassword`:
|
||||||
|
|
||||||
=== "Command line (curl)"
|
=== "Command line (curl)"
|
||||||
```
|
```
|
||||||
@@ -2701,7 +2700,172 @@ The following command will generate the appropriate value for you on *nix system
|
|||||||
echo "Basic $(echo -n 'testuser:fakepassword' | base64)"
|
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:
|
Here's an example using the `auth` query parameter:
|
||||||
|
|
||||||
=== "Command line (curl)"
|
=== "Command line (curl)"
|
||||||
@@ -2786,6 +2950,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 '='
|
echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '='
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Advanced features
|
||||||
|
|
||||||
### Message caching
|
### Message caching
|
||||||
!!! info
|
!!! info
|
||||||
If `Cache: no` is used, messages will only be delivered to connected subscribers, and won't be re-delivered if a
|
If `Cache: no` is used, messages will only be delivered to connected subscribers, and won't be re-delivered if a
|
||||||
@@ -2984,9 +3150,6 @@ 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 |
|
| [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 |
|
| [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
|
## Limitations
|
||||||
There are a few limitations to the API to prevent abuse and to keep the server healthy. Almost all of these settings
|
There are a few limitations to the API to prevent abuse and to keep the server healthy. Almost all of these settings
|
||||||
|
|||||||
@@ -2,7 +2,75 @@
|
|||||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||||
|
|
||||||
## ntfy server v1.31.0 (UNRELEASED)
|
## 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)
|
||||||
|
|
||||||
|
## ntfy server v1.31.0
|
||||||
|
Released February 14, 2023
|
||||||
|
|
||||||
|
This is a tiny release before the really big release, and also the last before the big v2.0.0. The most interesting
|
||||||
|
things in this release are the new preliminary health endpoint to allow monitoring in K8s (and others), and the removal
|
||||||
|
of `upx` binary packing (which was causing erroneous virus flagging). Aside from that, the `go-smtp` library did a
|
||||||
|
breaking-change upgrade, which required some work to get working again.
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
@@ -13,12 +81,24 @@ 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))
|
* Fix `chown` issues with RHEL-like based systems ([#566](https://github.com/binwiederhier/ntfy/issues/566)/[#565](https://github.com/binwiederhier/ntfy/pull/565), thanks to [@danieldemus](https://github.com/danieldemus))
|
||||||
* Removed `upx` (binary packing) for all builds due to false virus warnings ([#576](https://github.com/binwiederhier/ntfy/issues/576), thanks to [@shawnhwei](https://github.com/shawnhwei) for reporting)
|
* Removed `upx` (binary packing) for all builds due to false virus warnings ([#576](https://github.com/binwiederhier/ntfy/issues/576), thanks to [@shawnhwei](https://github.com/shawnhwei) for reporting)
|
||||||
|
* Upgraded `go-smtp` library and tests to v0.16.0 ([#569](https://github.com/binwiederhier/ntfy/issues/569))
|
||||||
|
|
||||||
**Documentation:**
|
**Documentation:**
|
||||||
|
|
||||||
* Add HTTP/2 and TLSv1.3 support to nginx docs ([#553](https://github.com/binwiederhier/ntfy/issues/553), thanks to [@bt90](https://github.com/bt90))
|
* Add HTTP/2 and TLSv1.3 support to nginx docs ([#553](https://github.com/binwiederhier/ntfy/issues/553), thanks to [@bt90](https://github.com/bt90))
|
||||||
* Small wording change for `client.yml` ([#562](https://github.com/binwiederhier/ntfy/pull/562), thanks to [@fleopaulD](https://github.com/fleopaulD))
|
* Small wording change for `client.yml` ([#562](https://github.com/binwiederhier/ntfy/pull/562), thanks to [@fleopaulD](https://github.com/fleopaulD))
|
||||||
* Fix K8s install docs ([#582](https://github.com/binwiederhier/ntfy/pull/582), thanks to [@Remedan](https://github.com/Remedan))
|
* Fix K8s install docs ([#582](https://github.com/binwiederhier/ntfy/pull/582), thanks to [@Remedan](https://github.com/Remedan))
|
||||||
|
* Updated Jellyseer docs ([#604](https://github.com/binwiederhier/ntfy/pull/604), thanks to [@Y0ngg4n](https://github.com/Y0ngg4n))
|
||||||
|
* Updated iOS developer docs ([#605](https://github.com/binwiederhier/ntfy/pull/605), thanks to [@SticksDev](https://github.com/SticksDev))
|
||||||
|
|
||||||
|
**Additional languages:**
|
||||||
|
|
||||||
|
* Portuguese (thanks to [@ssantos](https://hosted.weblate.org/user/ssantos/))
|
||||||
|
|
||||||
|
**Special thanks:**
|
||||||
|
|
||||||
|
A big Thank-you goes to everyone who tested the user account and payments work. I very much appreciate all the feedback,
|
||||||
|
suggestions, and bug reports. Thank you, @nwithan8, @deadcade, and @xenrox.
|
||||||
|
|
||||||
## ntfy server v1.30.1
|
## ntfy server v1.30.1
|
||||||
Released December 23, 2022 🎅
|
Released December 23, 2022 🎅
|
||||||
|
|||||||
27
docs/static/css/extra.css
vendored
27
docs/static/css/extra.css
vendored
@@ -8,9 +8,6 @@
|
|||||||
width: unset !important;
|
width: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
|
||||||
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%); filter: drop-shadow(0 5px 10px #ccc);
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-header__topic:first-child {
|
.md-header__topic:first-child {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -34,12 +31,30 @@ figure img, figure video {
|
|||||||
border-radius: 7px;
|
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);
|
filter: drop-shadow(3px 3px 3px #ccc);
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-md-color-scheme="slate"] figure img, body[data-md-color-scheme="slate"] figure video {
|
body[data-md-color-scheme="slate"] figure img,
|
||||||
filter: drop-shadow(3px 3px 3px #1a1313);
|
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 {
|
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 |
@@ -18,3 +18,10 @@ is to pin the tab so that it's always open, but sort of out of the way:
|
|||||||
{ width=500 }
|
{ width=500 }
|
||||||
<figcaption>Pin web app to move it out of the way</figcaption>
|
<figcaption>Pin web app to move it out of the way</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
|
If topic reservations are enabled, you can claim ownership over topics and define access to it:
|
||||||
|
|
||||||
|
<div id="reserve-screenshots" class="screenshots">
|
||||||
|
<a href="../../static/img/web-reserve-topic.png"><img src="../../static/img/web-reserve-topic.png"/></a>
|
||||||
|
<a href="../../static/img/web-reserve-topic-dialog.png"><img src="../../static/img/web-reserve-topic-dialog.png"/></a>
|
||||||
|
</div>
|
||||||
|
|||||||
36
go.mod
36
go.mod
@@ -4,22 +4,22 @@ go 1.18
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/firestore v1.9.0 // indirect
|
cloud.google.com/go/firestore v1.9.0 // indirect
|
||||||
cloud.google.com/go/storage v1.28.1 // indirect
|
cloud.google.com/go/storage v1.29.0 // indirect
|
||||||
github.com/BurntSushi/toml v1.2.1 // indirect
|
github.com/BurntSushi/toml v1.2.1 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||||
github.com/emersion/go-smtp v0.15.0
|
github.com/emersion/go-smtp v0.16.0
|
||||||
github.com/gabriel-vasile/mimetype v1.4.1
|
github.com/gabriel-vasile/mimetype v1.4.1
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.16
|
github.com/mattn/go-sqlite3 v1.14.16
|
||||||
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
|
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
|
||||||
github.com/stretchr/testify v1.8.1
|
github.com/stretchr/testify v1.8.1
|
||||||
github.com/urfave/cli/v2 v2.23.7
|
github.com/urfave/cli/v2 v2.24.4
|
||||||
golang.org/x/crypto v0.4.0
|
golang.org/x/crypto v0.6.0
|
||||||
golang.org/x/oauth2 v0.3.0 // indirect
|
golang.org/x/oauth2 v0.5.0 // indirect
|
||||||
golang.org/x/sync v0.1.0
|
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
|
golang.org/x/time v0.3.0
|
||||||
google.golang.org/api v0.105.0
|
google.golang.org/api v0.110.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,15 +27,15 @@ require github.com/pkg/errors v0.9.1 // indirect
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
firebase.google.com/go/v4 v4.10.0
|
firebase.google.com/go/v4 v4.10.0
|
||||||
github.com/stripe/stripe-go/v74 v74.5.0
|
github.com/stripe/stripe-go/v74 v74.7.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go v0.107.0 // indirect
|
cloud.google.com/go v0.110.0 // indirect
|
||||||
cloud.google.com/go/compute v1.14.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/compute/metadata v0.2.3 // indirect
|
||||||
cloud.google.com/go/iam v0.9.0 // indirect
|
cloud.google.com/go/iam v0.10.0 // indirect
|
||||||
cloud.google.com/go/longrunning v0.3.0 // indirect
|
cloud.google.com/go/longrunning v0.4.1 // indirect
|
||||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
@@ -45,21 +45,21 @@ require (
|
|||||||
github.com/golang/protobuf v1.5.2 // indirect
|
github.com/golang/protobuf v1.5.2 // indirect
|
||||||
github.com/google/go-cmp v0.5.9 // indirect
|
github.com/google/go-cmp v0.5.9 // indirect
|
||||||
github.com/google/uuid v1.3.0 // 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/googleapis/gax-go/v2 v2.7.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.0 // indirect
|
github.com/stretchr/objx v0.5.0 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
golang.org/x/net v0.4.0 // indirect
|
golang.org/x/net v0.7.0 // indirect
|
||||||
golang.org/x/sys v0.3.0 // indirect
|
golang.org/x/sys v0.5.0 // indirect
|
||||||
golang.org/x/text v0.5.0 // indirect
|
golang.org/x/text v0.7.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||||
google.golang.org/appengine v1.6.7 // indirect
|
google.golang.org/appengine v1.6.7 // indirect
|
||||||
google.golang.org/appengine/v2 v2.0.2 // indirect
|
google.golang.org/appengine/v2 v2.0.2 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef // indirect
|
google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc // indirect
|
||||||
google.golang.org/grpc v1.51.0 // indirect
|
google.golang.org/grpc v1.53.0 // indirect
|
||||||
google.golang.org/protobuf v1.28.1 // indirect
|
google.golang.org/protobuf v1.28.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
74
go.sum
74
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.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.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys=
|
||||||
cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I=
|
cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY=
|
||||||
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
|
cloud.google.com/go/compute v1.18.0 h1:FEigFqoDbys2cvFkZ9Fjq4gnHBP55anJ0yQyau2f9oY=
|
||||||
cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo=
|
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 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||||
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
|
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
|
||||||
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
|
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
|
||||||
cloud.google.com/go/iam v0.9.0 h1:bK6Or6mxhuL8lnj1i9j0yMo2wE/IeTO2cWlfUrf/TZs=
|
cloud.google.com/go/iam v0.10.0 h1:fpP/gByFs6US1ma53v7VxhvbJpO2Aapng6wabJ99MuI=
|
||||||
cloud.google.com/go/iam v0.9.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM=
|
cloud.google.com/go/iam v0.10.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.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
|
||||||
cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc=
|
cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo=
|
||||||
cloud.google.com/go/storage v1.28.1 h1:F5QDG5ChchaAVQhINh24U99OWHURqrW8OmQcGKXcbgI=
|
cloud.google.com/go/storage v1.29.0 h1:6weCgzRvMg7lzuUurI4697AqIRPU1SvzHhynwpW31jI=
|
||||||
cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y=
|
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 h1:dgK/8uwfJbzc5LZK/GyRRfIkZEDObN9q0kgEXsjlXN4=
|
||||||
firebase.google.com/go/v4 v4.10.0/go.mod h1:m0gLwPY9fxKggizzglgCNWOGnFnVPifLpqZzo5u3e/A=
|
firebase.google.com/go/v4 v4.10.0/go.mod h1:m0gLwPY9fxKggizzglgCNWOGnFnVPifLpqZzo5u3e/A=
|
||||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||||
@@ -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-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
|
||||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
|
github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8=
|
||||||
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
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.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
@@ -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.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/martian/v3 v3.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.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.2.1 h1:RY7tHKZcRlk788d5WSo/e83gOyyy742E8GSs771ySpg=
|
github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
|
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 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ=
|
||||||
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
|
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
|
||||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
@@ -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.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stripe/stripe-go/v74 v74.5.0 h1:YyqTvVQdS34KYGCfVB87EMn9eDV3FCFkSwfdOQhiVL4=
|
github.com/stripe/stripe-go/v74 v74.7.0 h1:KHlyslQj9YOv62b1sycQ31LFj7KlqR+seHsSowAWrjc=
|
||||||
github.com/stripe/stripe-go/v74 v74.5.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
|
github.com/stripe/stripe-go/v74 v74.7.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
|
||||||
github.com/urfave/cli/v2 v2.23.7 h1:YHDQ46s3VghFHFf1DdF+Sh7H4RqhcM+t0TmZRJx4oJY=
|
github.com/urfave/cli/v2 v2.24.4 h1:0gyJJEBYtCV87zI/x2nZCPyDxD51K6xM8SkwjHFCNEU=
|
||||||
github.com/urfave/cli/v2 v2.23.7/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
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 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
|
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
|
||||||
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
|
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/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
@@ -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-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-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.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.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||||
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
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.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.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s=
|
||||||
golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk=
|
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-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/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-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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
|
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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.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.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
|
||||||
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
|
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||||
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
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 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -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-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 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||||
google.golang.org/api v0.105.0 h1:t6P9Jj+6XTn4U9I2wycQai6Q/Kz7iOT+QzjJ3G2V4x8=
|
google.golang.org/api v0.110.0 h1:l+rh0KYUooe9JGbGVx71tbFo4SMbMTXK3I3ia2QSEeU=
|
||||||
google.golang.org/api v0.105.0/go.mod h1:qh7eD5FJks5+BcE+cjBIm6Gz8vioK7EHvnlniqXBnqI=
|
google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||||
@@ -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-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef h1:uQ2vjV/sHTsWSqdKeLqmwitzgvjMl7o4IdtHwUDXSJY=
|
google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc h1:ijGwO+0vL2hJt5gaygqP2j6PfflOBrRot0IczKbmtio=
|
||||||
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
|
google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
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.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=
|
||||||
google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww=
|
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-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
|||||||
231
log/event.go
Normal file
231
log/event.go
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tagField = "tag"
|
||||||
|
errorField = "error"
|
||||||
|
timeTakenField = "time_taken_ms"
|
||||||
|
exitCodeField = "exit_code"
|
||||||
|
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(exitCodeField, 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(tagField, 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(timeTakenField, 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(errorField, 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 With method
|
||||||
|
func (e *Event) With(contexts ...Contexter) *Event {
|
||||||
|
if e.contexters == nil {
|
||||||
|
e.contexters = contexts
|
||||||
|
} else {
|
||||||
|
e.contexters = append(e.contexters, contexts...)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeLog logs the event to the defined output. The event is only logged, if
|
||||||
|
// either the global log level is >= l, or if the log level in one of the overrides matches
|
||||||
|
// the level.
|
||||||
|
//
|
||||||
|
// If no overrides are defined (default), the Contexter array is not applied unless the event
|
||||||
|
// is actually logged. If overrides are defined, then Contexters have to be applied in any case
|
||||||
|
// to determine if they match. This is super complicated, but required for efficiency.
|
||||||
|
func (e *Event) maybeLog(l Level, message string, v ...any) {
|
||||||
|
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 {
|
||||||
|
log.Println(e.JSON())
|
||||||
|
} else {
|
||||||
|
log.Println(e.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, override := range ov {
|
||||||
|
value, exists := e.fields[field]
|
||||||
|
if exists {
|
||||||
|
if override.value == "" || override.value == value || override.value == fmt.Sprintf("%v", value) {
|
||||||
|
return override.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())
|
||||||
|
}
|
||||||
|
}
|
||||||
204
log/log.go
204
log/log.go
@@ -1,78 +1,92 @@
|
|||||||
package log
|
package log
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Level is a well-known log level, as defined below
|
// Defaults for package level variables
|
||||||
type Level int
|
var (
|
||||||
|
DefaultLevel = InfoLevel
|
||||||
// Well known log levels
|
DefaultFormat = TextFormat
|
||||||
const (
|
DefaultOutput = os.Stderr
|
||||||
TraceLevel Level = iota
|
|
||||||
DebugLevel
|
|
||||||
InfoLevel
|
|
||||||
WarnLevel
|
|
||||||
ErrorLevel
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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 (
|
var (
|
||||||
level = InfoLevel
|
level = DefaultLevel
|
||||||
mu = &sync.Mutex{}
|
format = DefaultFormat
|
||||||
|
overrides = make(map[string]*levelOverride)
|
||||||
|
output io.Writer = DefaultOutput
|
||||||
|
mu = &sync.RWMutex{}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Trace prints the given message, if the current log level is TRACE
|
// Fatal prints the given message, and exits the program
|
||||||
func Trace(message string, v ...any) {
|
func Fatal(message string, v ...any) {
|
||||||
logIf(TraceLevel, message, v...)
|
newEvent().Fatal(message, v...)
|
||||||
}
|
|
||||||
|
|
||||||
// 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...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error prints the given message, if the current log level is ERROR or lower
|
// Error prints the given message, if the current log level is ERROR or lower
|
||||||
func Error(message string, v ...any) {
|
func Error(message string, v ...any) {
|
||||||
logIf(ErrorLevel, message, v...)
|
newEvent().Error(message, v...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fatal prints the given message, and exits the program
|
// Warn prints the given message, if the current log level is WARN or lower
|
||||||
func Fatal(v ...any) {
|
func Warn(message string, v ...any) {
|
||||||
log.Fatalln(v...)
|
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
|
// CurrentLevel returns the current log level
|
||||||
func CurrentLevel() Level {
|
func CurrentLevel() Level {
|
||||||
mu.Lock()
|
mu.RLock()
|
||||||
defer mu.Unlock()
|
defer mu.RUnlock()
|
||||||
return level
|
return level
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,30 +97,70 @@ func SetLevel(newLevel Level) {
|
|||||||
level = newLevel
|
level = newLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLevelOverride adds a log override for the given field
|
||||||
|
func SetLevelOverride(field string, value string, level Level) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
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()
|
||||||
|
log.SetOutput(w)
|
||||||
|
output = w
|
||||||
|
}
|
||||||
|
|
||||||
|
// File returns the log file, if any, or an empty string otherwise
|
||||||
|
func File() string {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
if f, ok := output.(*os.File); ok {
|
||||||
|
return f.Name()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFile returns true if the output is a non-default file
|
||||||
|
func IsFile() bool {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
if _, ok := output.(*os.File); ok && output != DefaultOutput {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// DisableDates disables the date/time prefix
|
// DisableDates disables the date/time prefix
|
||||||
func DisableDates() {
|
func DisableDates() {
|
||||||
log.SetFlags(0)
|
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
|
// Loggable returns true if the given log level is lower or equal to the current log level
|
||||||
func Loggable(l Level) bool {
|
func Loggable(l Level) bool {
|
||||||
return CurrentLevel() <= l
|
return CurrentLevel() <= l
|
||||||
@@ -121,9 +175,3 @@ func IsTrace() bool {
|
|||||||
func IsDebug() bool {
|
func IsDebug() bool {
|
||||||
return Loggable(DebugLevel)
|
return Loggable(DebugLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func logIf(l Level, message string, v ...any) {
|
|
||||||
if CurrentLevel() <= l {
|
|
||||||
log.Printf(l.String()+" "+message, v...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
210
log/log_test.go
Normal file
210
log/log_test.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"os"
|
||||||
|
"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())
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
108
log/types.go
Normal file
108
log/types.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
type levelOverride struct {
|
||||||
|
value string
|
||||||
|
level Level
|
||||||
|
}
|
||||||
@@ -76,7 +76,7 @@ nav:
|
|||||||
- "Sending messages": publish.md
|
- "Sending messages": publish.md
|
||||||
- "Subscribing":
|
- "Subscribing":
|
||||||
- "From your phone": subscribe/phone.md
|
- "From your phone": subscribe/phone.md
|
||||||
- "From the Web UI": subscribe/web.md
|
- "From the Web app": subscribe/web.md
|
||||||
- "From the CLI": subscribe/cli.md
|
- "From the CLI": subscribe/cli.md
|
||||||
- "Using the API": subscribe/api.md
|
- "Using the API": subscribe/api.md
|
||||||
- "Self-hosting":
|
- "Self-hosting":
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const (
|
|||||||
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery
|
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery
|
||||||
DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
|
DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
|
||||||
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
|
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
|
||||||
DefaultStripePriceCacheDuration = 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
|
// Defines all global and per-visitor limits
|
||||||
@@ -44,10 +44,13 @@ const (
|
|||||||
DefaultVisitorSubscriptionLimit = 30
|
DefaultVisitorSubscriptionLimit = 30
|
||||||
DefaultVisitorRequestLimitBurst = 60
|
DefaultVisitorRequestLimitBurst = 60
|
||||||
DefaultVisitorRequestLimitReplenish = 5 * time.Second
|
DefaultVisitorRequestLimitReplenish = 5 * time.Second
|
||||||
|
DefaultVisitorMessageDailyLimit = 0
|
||||||
DefaultVisitorEmailLimitBurst = 16
|
DefaultVisitorEmailLimitBurst = 16
|
||||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||||
DefaultVisitorAccountCreateLimitBurst = 3
|
DefaultVisitorAccountCreationLimitBurst = 3
|
||||||
DefaultVisitorAccountCreateLimitReplenish = 24 * time.Hour
|
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
|
||||||
|
DefaultVisitorAuthFailureLimitBurst = 10
|
||||||
|
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
||||||
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
||||||
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
||||||
)
|
)
|
||||||
@@ -55,10 +58,15 @@ const (
|
|||||||
var (
|
var (
|
||||||
// DefaultVisitorStatsResetTime defines the time at which visitor stats are reset (wall clock only)
|
// DefaultVisitorStatsResetTime defines the time at which visitor stats are reset (wall clock only)
|
||||||
DefaultVisitorStatsResetTime = time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC)
|
DefaultVisitorStatsResetTime = time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// DefaultDisallowedTopics defines the topics that are forbidden, because they are used elsewhere. This array can be
|
||||||
|
// extended using the server.yml config. If updated, also update in Android and web app.
|
||||||
|
DefaultDisallowedTopics = []string{"docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
File string // Config file, only used for testing
|
||||||
BaseURL string
|
BaseURL string
|
||||||
ListenHTTP string
|
ListenHTTP string
|
||||||
ListenHTTPS string
|
ListenHTTPS string
|
||||||
@@ -75,12 +83,15 @@ type Config struct {
|
|||||||
AuthFile string
|
AuthFile string
|
||||||
AuthStartupQueries string
|
AuthStartupQueries string
|
||||||
AuthDefault user.Permission
|
AuthDefault user.Permission
|
||||||
|
AuthBcryptCost int
|
||||||
|
AuthStatsQueueWriterInterval time.Duration
|
||||||
AttachmentCacheDir string
|
AttachmentCacheDir string
|
||||||
AttachmentTotalSizeLimit int64
|
AttachmentTotalSizeLimit int64
|
||||||
AttachmentFileSizeLimit int64
|
AttachmentFileSizeLimit int64
|
||||||
AttachmentExpiryDuration time.Duration
|
AttachmentExpiryDuration time.Duration
|
||||||
KeepaliveInterval time.Duration
|
KeepaliveInterval time.Duration
|
||||||
ManagerInterval time.Duration
|
ManagerInterval time.Duration
|
||||||
|
DisallowedTopics []string
|
||||||
WebRootIsApp bool
|
WebRootIsApp bool
|
||||||
DelayedSenderInterval time.Duration
|
DelayedSenderInterval time.Duration
|
||||||
FirebaseKeepaliveInterval time.Duration
|
FirebaseKeepaliveInterval time.Duration
|
||||||
@@ -101,14 +112,17 @@ type Config struct {
|
|||||||
TotalAttachmentSizeLimit int64
|
TotalAttachmentSizeLimit int64
|
||||||
VisitorSubscriptionLimit int
|
VisitorSubscriptionLimit int
|
||||||
VisitorAttachmentTotalSizeLimit int64
|
VisitorAttachmentTotalSizeLimit int64
|
||||||
VisitorAttachmentDailyBandwidthLimit int
|
VisitorAttachmentDailyBandwidthLimit int64
|
||||||
VisitorRequestLimitBurst int
|
VisitorRequestLimitBurst int
|
||||||
VisitorRequestLimitReplenish time.Duration
|
VisitorRequestLimitReplenish time.Duration
|
||||||
VisitorRequestExemptIPAddrs []netip.Prefix
|
VisitorRequestExemptIPAddrs []netip.Prefix
|
||||||
|
VisitorMessageDailyLimit int
|
||||||
VisitorEmailLimitBurst int
|
VisitorEmailLimitBurst int
|
||||||
VisitorEmailLimitReplenish time.Duration
|
VisitorEmailLimitReplenish time.Duration
|
||||||
VisitorAccountCreateLimitBurst int
|
VisitorAccountCreationLimitBurst int
|
||||||
VisitorAccountCreateLimitReplenish time.Duration
|
VisitorAccountCreationLimitReplenish time.Duration
|
||||||
|
VisitorAuthFailureLimitBurst int
|
||||||
|
VisitorAuthFailureLimitReplenish time.Duration
|
||||||
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
|
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
|
||||||
BehindProxy bool
|
BehindProxy bool
|
||||||
StripeSecretKey string
|
StripeSecretKey string
|
||||||
@@ -125,6 +139,7 @@ type Config struct {
|
|||||||
// NewConfig instantiates a default new server config
|
// NewConfig instantiates a default new server config
|
||||||
func NewConfig() *Config {
|
func NewConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
|
File: "", // Only used for testing
|
||||||
BaseURL: "",
|
BaseURL: "",
|
||||||
ListenHTTP: DefaultListenHTTP,
|
ListenHTTP: DefaultListenHTTP,
|
||||||
ListenHTTPS: "",
|
ListenHTTPS: "",
|
||||||
@@ -140,13 +155,16 @@ func NewConfig() *Config {
|
|||||||
CacheBatchTimeout: 0,
|
CacheBatchTimeout: 0,
|
||||||
AuthFile: "",
|
AuthFile: "",
|
||||||
AuthStartupQueries: "",
|
AuthStartupQueries: "",
|
||||||
AuthDefault: user.NewPermission(true, true),
|
AuthDefault: user.PermissionReadWrite,
|
||||||
|
AuthBcryptCost: user.DefaultUserPasswordBcryptCost,
|
||||||
|
AuthStatsQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
|
||||||
AttachmentCacheDir: "",
|
AttachmentCacheDir: "",
|
||||||
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
||||||
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
||||||
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
||||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||||
ManagerInterval: DefaultManagerInterval,
|
ManagerInterval: DefaultManagerInterval,
|
||||||
|
DisallowedTopics: DefaultDisallowedTopics,
|
||||||
WebRootIsApp: false,
|
WebRootIsApp: false,
|
||||||
DelayedSenderInterval: DefaultDelayedSenderInterval,
|
DelayedSenderInterval: DefaultDelayedSenderInterval,
|
||||||
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
|
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
|
||||||
@@ -171,10 +189,13 @@ func NewConfig() *Config {
|
|||||||
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
||||||
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
||||||
VisitorRequestExemptIPAddrs: make([]netip.Prefix, 0),
|
VisitorRequestExemptIPAddrs: make([]netip.Prefix, 0),
|
||||||
|
VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit,
|
||||||
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
||||||
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||||
VisitorAccountCreateLimitBurst: DefaultVisitorAccountCreateLimitBurst,
|
VisitorAccountCreationLimitBurst: DefaultVisitorAccountCreationLimitBurst,
|
||||||
VisitorAccountCreateLimitReplenish: DefaultVisitorAccountCreateLimitReplenish,
|
VisitorAccountCreationLimitReplenish: DefaultVisitorAccountCreationLimitReplenish,
|
||||||
|
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
|
||||||
|
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
|
||||||
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
||||||
BehindProxy: false,
|
BehindProxy: false,
|
||||||
StripeSecretKey: "",
|
StripeSecretKey: "",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,6 +24,14 @@ func (e errHTTP) JSON() string {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e errHTTP) Context() log.Context {
|
||||||
|
return log.Context{
|
||||||
|
"error": e.Message,
|
||||||
|
"error_code": e.Code,
|
||||||
|
"http_status": e.HTTPCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func wrapErrHTTP(err *errHTTP, message string, args ...any) *errHTTP {
|
func wrapErrHTTP(err *errHTTP, message string, args ...any) *errHTTP {
|
||||||
return &errHTTP{
|
return &errHTTP{
|
||||||
Code: err.Code,
|
Code: err.Code,
|
||||||
@@ -33,6 +42,7 @@ func wrapErrHTTP(err *errHTTP, message string, args ...any) *errHTTP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", ""}
|
||||||
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"}
|
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", ""}
|
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""}
|
||||||
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""}
|
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""}
|
||||||
@@ -42,7 +52,7 @@ var (
|
|||||||
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"}
|
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"}
|
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", ""}
|
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", ""}
|
||||||
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is disallowed", ""}
|
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", ""}
|
||||||
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
|
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"}
|
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"}
|
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"}
|
||||||
@@ -57,7 +67,7 @@ var (
|
|||||||
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", ""}
|
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", ""}
|
||||||
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", ""}
|
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", ""}
|
||||||
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", ""}
|
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", ""}
|
||||||
errHTTPBadRequestMakesNoSenseForAdmin = &errHTTP{40026, http.StatusBadRequest, "invalid request: this makes no sense for admins", ""}
|
errHTTPBadRequestIncorrectPasswordConfirmation = &errHTTP{40026, http.StatusBadRequest, "invalid request: password confirmation is not correct", ""}
|
||||||
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""}
|
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""}
|
||||||
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""}
|
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""}
|
||||||
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""}
|
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""}
|
||||||
@@ -66,6 +76,7 @@ var (
|
|||||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "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", ""}
|
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", ""}
|
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", ""}
|
||||||
|
errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", ""}
|
||||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
|
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", ""}
|
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""}
|
||||||
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", ""}
|
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", ""}
|
||||||
@@ -73,10 +84,11 @@ var (
|
|||||||
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, 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"}
|
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"}
|
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"}
|
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
|
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", ""}
|
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"}
|
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
|
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
|
||||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
||||||
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", ""}
|
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/"}
|
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (in
|
|||||||
if !fileIDRegex.MatchString(id) {
|
if !fileIDRegex.MatchString(id) {
|
||||||
return 0, errInvalidFileID
|
return 0, errInvalidFileID
|
||||||
}
|
}
|
||||||
|
log.Tag(tagFileCache).Field("message_id", id).Debug("Writing attachment")
|
||||||
file := filepath.Join(c.dir, id)
|
file := filepath.Join(c.dir, id)
|
||||||
if _, err := os.Stat(file); err == nil {
|
if _, err := os.Stat(file); err == nil {
|
||||||
return 0, errFileExists
|
return 0, errFileExists
|
||||||
@@ -75,10 +76,10 @@ func (c *fileCache) Remove(ids ...string) error {
|
|||||||
if !fileIDRegex.MatchString(id) {
|
if !fileIDRegex.MatchString(id) {
|
||||||
return errInvalidFileID
|
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)
|
file := filepath.Join(c.dir, id)
|
||||||
if err := os.Remove(file); err != nil {
|
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)
|
size, err := dirSize(c.dir)
|
||||||
|
|||||||
118
server/log.go
Normal file
118
server/log.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 (
|
var (
|
||||||
errUnexpectedMessageType = errors.New("unexpected message type")
|
errUnexpectedMessageType = errors.New("unexpected message type")
|
||||||
|
errMessageNotFound = errors.New("message not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Messages cache
|
// Messages cache
|
||||||
@@ -50,6 +51,8 @@ const (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
|
||||||
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
@@ -60,7 +63,12 @@ const (
|
|||||||
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
||||||
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
||||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
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
|
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
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ? AND published = 1
|
WHERE topic = ? AND time >= ? AND published = 1
|
||||||
@@ -98,8 +106,8 @@ const (
|
|||||||
|
|
||||||
updateAttachmentDeleted = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?`
|
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`
|
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 >= ?`
|
selectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?`
|
||||||
selectAttachmentsSizeByUserQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`
|
selectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Schema management queries
|
// 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 attachment_deleted INT NOT NULL DEFAULT('0');
|
||||||
ALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0');
|
ALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0');
|
||||||
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sender ON messages (sender);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user ON messages (user);
|
||||||
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
||||||
`
|
`
|
||||||
migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`
|
migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`
|
||||||
@@ -363,10 +373,10 @@ func (c *messageCache) addMessages(ms []*message) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
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
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +458,18 @@ func (c *messageCache) MessagesExpired() ([]string, error) {
|
|||||||
return ids, nil
|
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 {
|
func (c *messageCache) MarkPublished(m *message) error {
|
||||||
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
||||||
return err
|
return err
|
||||||
@@ -563,8 +585,8 @@ func (c *messageCache) AttachmentBytesUsedBySender(sender string) (int64, error)
|
|||||||
return c.readAttachmentBytesUsed(rows)
|
return c.readAttachmentBytesUsed(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) AttachmentBytesUsedByUser(user string) (int64, error) {
|
func (c *messageCache) AttachmentBytesUsedByUser(userID string) (int64, error) {
|
||||||
rows, err := c.db.Query(selectAttachmentsSizeByUserQuery, user, time.Now().Unix())
|
rows, err := c.db.Query(selectAttachmentsSizeByUserIDQuery, userID, time.Now().Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -591,7 +613,7 @@ func (c *messageCache) processMessageBatches() {
|
|||||||
}
|
}
|
||||||
for messages := range c.queue.Dequeue() {
|
for messages := range c.queue.Dequeue() {
|
||||||
if err := c.addMessages(messages); err != nil {
|
if err := c.addMessages(messages); err != nil {
|
||||||
log.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()
|
defer rows.Close()
|
||||||
messages := make([]*message, 0)
|
messages := make([]*message, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var timestamp, expires, attachmentSize, attachmentExpires int64
|
m, err := readMessage(rows)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var tags []string
|
messages = append(messages, m)
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -676,6 +634,82 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
return messages, nil
|
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 {
|
func setupDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
|
||||||
// Run startup queries
|
// Run startup queries
|
||||||
if startupQueries != "" {
|
if startupQueries != "" {
|
||||||
@@ -736,7 +770,7 @@ func setupNewCacheDB(db *sql.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom0(db *sql.DB, _ time.Duration) 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 {
|
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -750,7 +784,7 @@ func migrateFrom0(db *sql.DB, _ time.Duration) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom1(db *sql.DB, _ time.Duration) error {
|
func migrateFrom1(db *sql.DB, _ time.Duration) error {
|
||||||
log.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 {
|
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -761,7 +795,7 @@ func migrateFrom1(db *sql.DB, _ time.Duration) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom2(db *sql.DB, _ time.Duration) error {
|
func migrateFrom2(db *sql.DB, _ time.Duration) error {
|
||||||
log.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 {
|
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -772,7 +806,7 @@ func migrateFrom2(db *sql.DB, _ time.Duration) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom3(db *sql.DB, _ time.Duration) error {
|
func migrateFrom3(db *sql.DB, _ time.Duration) error {
|
||||||
log.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 {
|
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -783,7 +817,7 @@ func migrateFrom3(db *sql.DB, _ time.Duration) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom4(db *sql.DB, _ time.Duration) error {
|
func migrateFrom4(db *sql.DB, _ time.Duration) error {
|
||||||
log.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 {
|
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -794,7 +828,7 @@ func migrateFrom4(db *sql.DB, _ time.Duration) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom5(db *sql.DB, _ time.Duration) error {
|
func migrateFrom5(db *sql.DB, _ time.Duration) error {
|
||||||
log.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 {
|
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -805,7 +839,7 @@ func migrateFrom5(db *sql.DB, _ time.Duration) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom6(db *sql.DB, _ time.Duration) error {
|
func migrateFrom6(db *sql.DB, _ time.Duration) error {
|
||||||
log.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 {
|
if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -816,7 +850,7 @@ func migrateFrom6(db *sql.DB, _ time.Duration) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom7(db *sql.DB, _ time.Duration) error {
|
func migrateFrom7(db *sql.DB, _ time.Duration) error {
|
||||||
log.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 {
|
if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -827,7 +861,7 @@ func migrateFrom7(db *sql.DB, _ time.Duration) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom8(db *sql.DB, _ time.Duration) error {
|
func migrateFrom8(db *sql.DB, _ time.Duration) error {
|
||||||
log.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 {
|
if _, err := db.Exec(migrate8To9AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -838,7 +872,7 @@ func migrateFrom8(db *sql.DB, _ time.Duration) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
|
func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
|
||||||
log.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()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -853,8 +887,5 @@ func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
|
|||||||
if _, err := tx.Exec(updateSchemaVersion, 10); err != nil {
|
if _, err := tx.Exec(updateSchemaVersion, 10); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
return tx.Commit()
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil // Update this when a new version is added
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
exampleIP1234 = netip.MustParseAddr("1.2.3.4")
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSqliteCache_Messages(t *testing.T) {
|
func TestSqliteCache_Messages(t *testing.T) {
|
||||||
testCacheMessages(t, newSqliteTestCache(t))
|
testCacheMessages(t, newSqliteTestCache(t))
|
||||||
}
|
}
|
||||||
@@ -294,10 +290,10 @@ func TestMemCache_Attachments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testCacheAttachments(t *testing.T, c *messageCache) {
|
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 := newDefaultMessage("mytopic", "flower for you")
|
||||||
m.ID = "m1"
|
m.ID = "m1"
|
||||||
m.Sender = exampleIP1234
|
m.Sender = netip.MustParseAddr("1.2.3.4")
|
||||||
m.Attachment = &attachment{
|
m.Attachment = &attachment{
|
||||||
Name: "flower.jpg",
|
Name: "flower.jpg",
|
||||||
Type: "image/jpeg",
|
Type: "image/jpeg",
|
||||||
@@ -310,7 +306,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
|||||||
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
|
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
|
||||||
m = newDefaultMessage("mytopic", "sending you a car")
|
m = newDefaultMessage("mytopic", "sending you a car")
|
||||||
m.ID = "m2"
|
m.ID = "m2"
|
||||||
m.Sender = exampleIP1234
|
m.Sender = netip.MustParseAddr("1.2.3.4")
|
||||||
m.Attachment = &attachment{
|
m.Attachment = &attachment{
|
||||||
Name: "car.jpg",
|
Name: "car.jpg",
|
||||||
Type: "image/jpeg",
|
Type: "image/jpeg",
|
||||||
@@ -323,7 +319,8 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
|||||||
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
|
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
|
||||||
m = newDefaultMessage("another-topic", "sending you another car")
|
m = newDefaultMessage("another-topic", "sending you another car")
|
||||||
m.ID = "m3"
|
m.ID = "m3"
|
||||||
m.Sender = exampleIP1234
|
m.User = "u_BAsbaAa"
|
||||||
|
m.Sender = netip.MustParseAddr("5.6.7.8")
|
||||||
m.Attachment = &attachment{
|
m.Attachment = &attachment{
|
||||||
Name: "another-car.jpg",
|
Name: "another-car.jpg",
|
||||||
Type: "image/jpeg",
|
Type: "image/jpeg",
|
||||||
@@ -355,11 +352,15 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
|||||||
|
|
||||||
size, err := c.AttachmentBytesUsedBySender("1.2.3.4")
|
size, err := c.AttachmentBytesUsedBySender("1.2.3.4")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(30000), size)
|
require.Equal(t, int64(10000), size)
|
||||||
|
|
||||||
size, err = c.AttachmentBytesUsedBySender("5.6.7.8")
|
size, err = c.AttachmentBytesUsedBySender("5.6.7.8")
|
||||||
require.Nil(t, err)
|
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) {
|
func TestSqliteCache_Attachments_Expired(t *testing.T) {
|
||||||
|
|||||||
598
server/server.go
598
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-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
|
# - 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".
|
# 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:
|
# Debian/RPM package users:
|
||||||
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
|
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
|
||||||
@@ -91,6 +93,7 @@
|
|||||||
#
|
#
|
||||||
# auth-file: <filename>
|
# auth-file: <filename>
|
||||||
# auth-default-access: "read-write"
|
# auth-default-access: "read-write"
|
||||||
|
# auth-startup-queries:
|
||||||
|
|
||||||
# If set, the X-Forwarded-For header is used to determine the visitor IP address
|
# If set, the X-Forwarded-For header is used to determine the visitor IP address
|
||||||
# instead of the remote address of the connection.
|
# instead of the remote address of the connection.
|
||||||
@@ -152,6 +155,17 @@
|
|||||||
#
|
#
|
||||||
# manager-interval: "1m"
|
# 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
|
# 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.
|
# 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.
|
# 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-replenish: "5s"
|
||||||
# visitor-request-limit-exempt-hosts: ""
|
# 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:
|
# Rate limiting: Allowed emails per visitor:
|
||||||
# - visitor-email-limit-burst is the initial bucket of emails each visitor has
|
# - 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
|
# - visitor-email-limit-replenish is the rate at which the bucket is refilled
|
||||||
@@ -224,10 +244,36 @@
|
|||||||
# stripe-secret-key:
|
# stripe-secret-key:
|
||||||
# stripe-webhook-key:
|
# stripe-webhook-key:
|
||||||
|
|
||||||
# Log level, can be TRACE, DEBUG, INFO, WARN or ERROR
|
# Logging options
|
||||||
# This option can be hot-reloaded by calling "kill -HUP $pid" or "systemctl reload ntfy".
|
|
||||||
#
|
#
|
||||||
# Be aware that DEBUG (and particularly TRACE) can be VERY CHATTY. Only turn them on for
|
# By default, ntfy logs to the console (stderr), with an "info" log level, and in a human-readable text format.
|
||||||
# debugging purposes, or your disk will fill up quickly.
|
# 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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"heckel.io/ntfy/log"
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/user"
|
"heckel.io/ntfy/user"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
subscriptionIDLength = 16
|
|
||||||
createdByAPI = "api"
|
|
||||||
syncTopicAccountSyncEvent = "sync"
|
syncTopicAccountSyncEvent = "sync"
|
||||||
|
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
admin := v.user != nil && v.user.Role == user.RoleAdmin
|
u := v.User()
|
||||||
if !admin {
|
if !u.IsAdmin() { // u may be nil, but that's fine
|
||||||
if !s.config.EnableSignup {
|
if !s.config.EnableSignup {
|
||||||
return errHTTPBadRequestSignupNotEnabled
|
return errHTTPBadRequestSignupNotEnabled
|
||||||
} else if v.user != nil {
|
} else if u != nil {
|
||||||
return errHTTPUnauthorized // Cannot create account from user context
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {
|
if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {
|
||||||
return errHTTPConflictUserExists
|
return errHTTPConflictUserExists
|
||||||
}
|
}
|
||||||
if v.accountLimiter != nil && !v.accountLimiter.Allow() {
|
logvr(v, r).Tag(tagAccount).Field("user_name", newAccount.Username).Info("Creating user %s", newAccount.Username)
|
||||||
return errHTTPTooManyRequestsLimitAccountCreation
|
if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser); err != nil {
|
||||||
}
|
|
||||||
if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser, createdByAPI); err != nil { // TODO this should return a User
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
v.AccountCreated()
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
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()
|
info, err := v.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
logvr(v, r).Tag(tagAccount).Fields(visitorExtendedInfoContext(info)).Debug("Retrieving account stats")
|
||||||
limits, stats := info.Limits, info.Stats
|
limits, stats := info.Limits, info.Stats
|
||||||
response := &apiAccountResponse{
|
response := &apiAccountResponse{
|
||||||
Limits: &apiAccountLimits{
|
Limits: &apiAccountLimits{
|
||||||
Basis: string(limits.Basis),
|
Basis: string(limits.Basis),
|
||||||
Messages: limits.MessagesLimit,
|
Messages: limits.MessageLimit,
|
||||||
MessagesExpiryDuration: int64(limits.MessagesExpiryDuration.Seconds()),
|
MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()),
|
||||||
Emails: limits.EmailsLimit,
|
Emails: limits.EmailLimit,
|
||||||
Reservations: limits.ReservationsLimit,
|
Reservations: limits.ReservationsLimit,
|
||||||
AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
|
AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
|
||||||
AttachmentFileSize: limits.AttachmentFileSizeLimit,
|
AttachmentFileSize: limits.AttachmentFileSizeLimit,
|
||||||
AttachmentExpiryDuration: int64(limits.AttachmentExpiryDuration.Seconds()),
|
AttachmentExpiryDuration: int64(limits.AttachmentExpiryDuration.Seconds()),
|
||||||
|
AttachmentBandwidth: limits.AttachmentBandwidthLimit,
|
||||||
},
|
},
|
||||||
Stats: &apiAccountStats{
|
Stats: &apiAccountStats{
|
||||||
Messages: stats.Messages,
|
Messages: stats.Messages,
|
||||||
@@ -68,37 +73,38 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
|||||||
AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
|
AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if v.user != nil {
|
u := v.User()
|
||||||
response.Username = v.user.Name
|
if u != nil {
|
||||||
response.Role = string(v.user.Role)
|
response.Username = u.Name
|
||||||
response.SyncTopic = v.user.SyncTopic
|
response.Role = string(u.Role)
|
||||||
if v.user.Prefs != nil {
|
response.SyncTopic = u.SyncTopic
|
||||||
if v.user.Prefs.Language != "" {
|
if u.Prefs != nil {
|
||||||
response.Language = v.user.Prefs.Language
|
if u.Prefs.Language != nil {
|
||||||
|
response.Language = *u.Prefs.Language
|
||||||
}
|
}
|
||||||
if v.user.Prefs.Notification != nil {
|
if u.Prefs.Notification != nil {
|
||||||
response.Notification = v.user.Prefs.Notification
|
response.Notification = u.Prefs.Notification
|
||||||
}
|
}
|
||||||
if v.user.Prefs.Subscriptions != nil {
|
if u.Prefs.Subscriptions != nil {
|
||||||
response.Subscriptions = v.user.Prefs.Subscriptions
|
response.Subscriptions = u.Prefs.Subscriptions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v.user.Tier != nil {
|
if u.Tier != nil {
|
||||||
response.Tier = &apiAccountTier{
|
response.Tier = &apiAccountTier{
|
||||||
Code: v.user.Tier.Code,
|
Code: u.Tier.Code,
|
||||||
Name: v.user.Tier.Name,
|
Name: u.Tier.Name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v.user.Billing.StripeCustomerID != "" {
|
if u.Billing.StripeCustomerID != "" {
|
||||||
response.Billing = &apiAccountBilling{
|
response.Billing = &apiAccountBilling{
|
||||||
Customer: true,
|
Customer: true,
|
||||||
Subscription: v.user.Billing.StripeSubscriptionID != "",
|
Subscription: u.Billing.StripeSubscriptionID != "",
|
||||||
Status: string(v.user.Billing.StripeSubscriptionStatus),
|
Status: string(u.Billing.StripeSubscriptionStatus),
|
||||||
PaidUntil: v.user.Billing.StripeSubscriptionPaidUntil.Unix(),
|
PaidUntil: u.Billing.StripeSubscriptionPaidUntil.Unix(),
|
||||||
CancelAt: v.user.Billing.StripeSubscriptionCancelAt.Unix(),
|
CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reservations, err := s.userManager.Reservations(v.user.Name)
|
reservations, err := s.userManager.Reservations(u.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -111,6 +117,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 {
|
} else {
|
||||||
response.Username = user.Everyone
|
response.Username = user.Everyone
|
||||||
response.Role = string(user.RoleAnonymous)
|
response.Role = string(user.RoleAnonymous)
|
||||||
@@ -118,149 +144,213 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
|||||||
return s.writeJSON(w, response)
|
return s.writeJSON(w, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountDelete(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
if v.user.Billing.StripeSubscriptionID != "" {
|
req, err := readJSONWithLimit[apiAccountDeleteRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
log.Info("Deleting user %s (billing customer: %s, billing subscription: %s)", v.user.Name, v.user.Billing.StripeCustomerID, v.user.Billing.StripeSubscriptionID)
|
if err != nil {
|
||||||
if v.user.Billing.StripeSubscriptionID != "" {
|
return err
|
||||||
if _, err := s.stripe.CancelSubscription(v.user.Billing.StripeSubscriptionID); err != nil {
|
} else if req.Password == "" {
|
||||||
return err
|
return errHTTPBadRequest
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Info("Deleting user %s", v.user.Name)
|
|
||||||
}
|
}
|
||||||
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 err
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
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 err
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountTokenIssue(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
// TODO rate limit
|
req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
|
||||||
token, err := s.userManager.CreateToken(v.user)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
response := &apiAccountTokenResponse{
|
response := &apiAccountTokenResponse{
|
||||||
Token: token.Value,
|
Token: token.Value,
|
||||||
Expires: token.Expires.Unix(),
|
Label: token.Label,
|
||||||
|
LastAccess: token.LastAccess.Unix(),
|
||||||
|
LastOrigin: token.LastOrigin.String(),
|
||||||
|
Expires: token.Expires.Unix(),
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, response)
|
return s.writeJSON(w, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountTokenExtend(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
// TODO rate limit
|
u := v.User()
|
||||||
if v.user == nil {
|
req, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
|
||||||
return errHTTPUnauthorized
|
if err != nil {
|
||||||
} else if v.user.Token == "" {
|
return err
|
||||||
return errHTTPBadRequestNoTokenProvided
|
} 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
response := &apiAccountTokenResponse{
|
response := &apiAccountTokenResponse{
|
||||||
Token: token.Value,
|
Token: token.Value,
|
||||||
Expires: token.Expires.Unix(),
|
Label: token.Label,
|
||||||
|
LastAccess: token.LastAccess.Unix(),
|
||||||
|
LastOrigin: token.LastOrigin.String(),
|
||||||
|
Expires: token.Expires.Unix(),
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, response)
|
return s.writeJSON(w, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
// TODO rate limit
|
u := v.User()
|
||||||
if v.user.Token == "" {
|
token := readParam(r, "X-Token", "Token") // DELETEs cannot have a body, and we don't want it in the path
|
||||||
return errHTTPBadRequestNoTokenProvided
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
logvr(v, r).
|
||||||
|
Tag(tagAccount).
|
||||||
|
Field("token", token).
|
||||||
|
Debug("Deleted token for user %s", u.Name)
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if v.user.Prefs == nil {
|
u := v.User()
|
||||||
v.user.Prefs = &user.Prefs{}
|
if u.Prefs == nil {
|
||||||
|
u.Prefs = &user.Prefs{}
|
||||||
}
|
}
|
||||||
prefs := v.user.Prefs
|
prefs := u.Prefs
|
||||||
if newPrefs.Language != "" {
|
if newPrefs.Language != nil {
|
||||||
prefs.Language = newPrefs.Language
|
prefs.Language = newPrefs.Language
|
||||||
}
|
}
|
||||||
if newPrefs.Notification != nil {
|
if newPrefs.Notification != nil {
|
||||||
if prefs.Notification == nil {
|
if prefs.Notification == nil {
|
||||||
prefs.Notification = &user.NotificationPrefs{}
|
prefs.Notification = &user.NotificationPrefs{}
|
||||||
}
|
}
|
||||||
if newPrefs.Notification.DeleteAfter > 0 {
|
if newPrefs.Notification.DeleteAfter != nil {
|
||||||
prefs.Notification.DeleteAfter = newPrefs.Notification.DeleteAfter
|
prefs.Notification.DeleteAfter = newPrefs.Notification.DeleteAfter
|
||||||
}
|
}
|
||||||
if newPrefs.Notification.Sound != "" {
|
if newPrefs.Notification.Sound != nil {
|
||||||
prefs.Notification.Sound = newPrefs.Notification.Sound
|
prefs.Notification.Sound = newPrefs.Notification.Sound
|
||||||
}
|
}
|
||||||
if newPrefs.Notification.MinPriority > 0 {
|
if newPrefs.Notification.MinPriority != nil {
|
||||||
prefs.Notification.MinPriority = newPrefs.Notification.MinPriority
|
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 err
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if v.user.Prefs == nil {
|
u := v.User()
|
||||||
v.user.Prefs = &user.Prefs{}
|
prefs := u.Prefs
|
||||||
|
if prefs == nil {
|
||||||
|
prefs = &user.Prefs{}
|
||||||
}
|
}
|
||||||
newSubscription.ID = "" // Client cannot set ID
|
for _, subscription := range prefs.Subscriptions {
|
||||||
for _, subscription := range v.user.Prefs.Subscriptions {
|
|
||||||
if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic {
|
if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic {
|
||||||
newSubscription = subscription
|
return errHTTPConflictSubscriptionExists
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if newSubscription.ID == "" {
|
prefs.Subscriptions = append(prefs.Subscriptions, newSubscription)
|
||||||
newSubscription.ID = util.RandomString(subscriptionIDLength)
|
logvr(v, r).Tag(tagAccount).With(newSubscription).Debug("Adding subscription for user %s", u.Name)
|
||||||
v.user.Prefs.Subscriptions = append(v.user.Prefs.Subscriptions, newSubscription)
|
if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
|
||||||
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, newSubscription)
|
return s.writeJSON(w, newSubscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
matches := apiAccountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
|
updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false)
|
||||||
if len(matches) != 2 {
|
|
||||||
return errHTTPInternalErrorInvalidPath
|
|
||||||
}
|
|
||||||
subscriptionID := matches[1]
|
|
||||||
updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
return errHTTPNotFound
|
||||||
}
|
}
|
||||||
var subscription *user.Subscription
|
var subscription *user.Subscription
|
||||||
for _, sub := range v.user.Prefs.Subscriptions {
|
for _, sub := range prefs.Subscriptions {
|
||||||
if sub.ID == subscriptionID {
|
if sub.BaseURL == updatedSubscription.BaseURL && sub.Topic == updatedSubscription.Topic {
|
||||||
sub.DisplayName = updatedSubscription.DisplayName
|
sub.DisplayName = updatedSubscription.DisplayName
|
||||||
subscription = sub
|
subscription = sub
|
||||||
break
|
break
|
||||||
@@ -269,41 +359,45 @@ func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.
|
|||||||
if subscription == nil {
|
if subscription == nil {
|
||||||
return errHTTPNotFound
|
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 err
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, subscription)
|
return s.writeJSON(w, subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
matches := apiAccountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
|
// DELETEs cannot have a body, and we don't want it in the path
|
||||||
if len(matches) != 2 {
|
deleteBaseURL := readParam(r, "X-BaseURL", "BaseURL")
|
||||||
return errHTTPInternalErrorInvalidPath
|
deleteTopic := readParam(r, "X-Topic", "Topic")
|
||||||
}
|
u := v.User()
|
||||||
subscriptionID := matches[1]
|
prefs := u.Prefs
|
||||||
if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil {
|
if prefs == nil || prefs.Subscriptions == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
newSubscriptions := make([]*user.Subscription, 0)
|
newSubscriptions := make([]*user.Subscription, 0)
|
||||||
for _, subscription := range v.user.Prefs.Subscriptions {
|
for _, sub := range u.Prefs.Subscriptions {
|
||||||
if subscription.ID != subscriptionID {
|
if sub.BaseURL == deleteBaseURL && sub.Topic == deleteTopic {
|
||||||
newSubscriptions = append(newSubscriptions, subscription)
|
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) {
|
if len(newSubscriptions) < len(prefs.Subscriptions) {
|
||||||
v.user.Prefs.Subscriptions = newSubscriptions
|
prefs.Subscriptions = newSubscriptions
|
||||||
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
if err := s.userManager.ChangeSettings(u.ID, prefs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
if v.user != nil && v.user.Role == user.RoleAdmin {
|
u := v.User()
|
||||||
return errHTTPBadRequestMakesNoSenseForAdmin
|
req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
}
|
|
||||||
req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -314,30 +408,46 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errHTTPBadRequestPermissionInvalid
|
return errHTTPBadRequestPermissionInvalid
|
||||||
}
|
}
|
||||||
if v.user.Tier == nil {
|
// Check if we are allowed to reserve this topic
|
||||||
|
if u.IsUser() && u.Tier == nil {
|
||||||
return errHTTPUnauthorized
|
return errHTTPUnauthorized
|
||||||
}
|
} else if err := s.userManager.AllowReservation(u.Name, req.Topic); err != nil {
|
||||||
if err := s.userManager.CheckAllowAccess(v.user.Name, req.Topic); err != nil {
|
|
||||||
return errHTTPConflictTopicReserved
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !hasReservation {
|
t.CancelSubscribers(u.ID)
|
||||||
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
|
|
||||||
}
|
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
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 {
|
func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
matches := apiAccountReservationSingleRegex.FindStringSubmatch(r.URL.Path)
|
matches := apiAccountReservationSingleRegex.FindStringSubmatch(r.URL.Path)
|
||||||
if len(matches) != 2 {
|
if len(matches) != 2 {
|
||||||
@@ -347,30 +457,78 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R
|
|||||||
if !topicRegex.MatchString(topic) {
|
if !topicRegex.MatchString(topic) {
|
||||||
return errHTTPBadRequestTopicInvalid
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if !authorized {
|
} else if !authorized {
|
||||||
return errHTTPUnauthorized
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
if deleteMessages {
|
||||||
|
if err := s.messageCache.ExpireMessages(topic); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.pruneMessages()
|
||||||
|
}
|
||||||
return s.writeJSON(w, newSuccessResponse())
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) publishSyncEvent(v *visitor) error {
|
// maybeRemoveMessagesAndExcessReservations deletes topic reservations for the given user (if too many for tier),
|
||||||
if v.user == nil || v.user.SyncTopic == "" {
|
// and marks associated messages for the topics as deleted. This also eventually deletes attachments.
|
||||||
return nil
|
// 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 {
|
||||||
log.Trace("Publishing sync event to user %s's sync topic %s", v.user.Name, v.user.SyncTopic)
|
reservations, err := s.userManager.Reservations(u.Name)
|
||||||
topics, err := s.topicsFromIDs(v.user.SyncTopic)
|
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
|
||||||
|
}
|
||||||
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if len(topics) == 0 {
|
|
||||||
return errors.New("cannot retrieve sync topic")
|
|
||||||
}
|
}
|
||||||
syncTopic := topics[0]
|
|
||||||
messageBytes, err := json.Marshal(&apiAccountSyncTopicResponse{Event: syncTopicAccountSyncEvent})
|
messageBytes, err := json.Marshal(&apiAccountSyncTopicResponse{Event: syncTopicAccountSyncEvent})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -381,14 +539,3 @@ func (s *Server) publishSyncEvent(v *visitor) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/user"
|
"heckel.io/ntfy/user"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -14,6 +18,7 @@ func TestAccount_Signup_Success(t *testing.T) {
|
|||||||
conf := newTestConfigWithAuthFile(t)
|
conf := newTestConfigWithAuthFile(t)
|
||||||
conf.EnableSignup = true
|
conf.EnableSignup = true
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
@@ -25,6 +30,10 @@ func TestAccount_Signup_Success(t *testing.T) {
|
|||||||
token, _ := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
token, _ := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
||||||
require.NotEmpty(t, token.Token)
|
require.NotEmpty(t, token.Token)
|
||||||
require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires)
|
require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires)
|
||||||
|
require.True(t, strings.HasPrefix(token.Token, "tk_"))
|
||||||
|
require.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{
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
"Authorization": util.BearerAuth(token.Token),
|
"Authorization": util.BearerAuth(token.Token),
|
||||||
@@ -33,12 +42,20 @@ func TestAccount_Signup_Success(t *testing.T) {
|
|||||||
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
require.Equal(t, "phil", account.Username)
|
require.Equal(t, "phil", account.Username)
|
||||||
require.Equal(t, "user", account.Role)
|
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) {
|
func TestAccount_Signup_UserExists(t *testing.T) {
|
||||||
conf := newTestConfigWithAuthFile(t)
|
conf := newTestConfigWithAuthFile(t)
|
||||||
conf.EnableSignup = true
|
conf.EnableSignup = true
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
@@ -52,6 +69,7 @@ func TestAccount_Signup_LimitReached(t *testing.T) {
|
|||||||
conf := newTestConfigWithAuthFile(t)
|
conf := newTestConfigWithAuthFile(t)
|
||||||
conf.EnableSignup = true
|
conf.EnableSignup = true
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
|
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 := newTestConfigWithAuthFile(t)
|
||||||
conf.EnableSignup = true
|
conf.EnableSignup = true
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, "unit-test"))
|
log.Info("1")
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, "unit-test"))
|
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{
|
rr := request(t, s, "POST", "/v1/account", `{"username":"emma", "password":"emma"}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
log.Info("4")
|
||||||
rr = request(t, s, "POST", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{
|
rr = request(t, s, "POST", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("ben", "ben"),
|
"Authorization": util.BasicAuth("ben", "ben"),
|
||||||
})
|
})
|
||||||
@@ -85,12 +106,27 @@ func TestAccount_Signup_Disabled(t *testing.T) {
|
|||||||
conf := newTestConfigWithAuthFile(t)
|
conf := newTestConfigWithAuthFile(t)
|
||||||
conf.EnableSignup = false
|
conf.EnableSignup = false
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
require.Equal(t, 400, rr.Code)
|
require.Equal(t, 400, rr.Code)
|
||||||
require.Equal(t, 40022, toHTTPError(t, rr.Body.String()).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) {
|
func TestAccount_Get_Anonymous(t *testing.T) {
|
||||||
conf := newTestConfigWithAuthFile(t)
|
conf := newTestConfigWithAuthFile(t)
|
||||||
conf.VisitorRequestLimitReplenish = 86 * time.Second
|
conf.VisitorRequestLimitReplenish = 86 * time.Second
|
||||||
@@ -99,6 +135,7 @@ func TestAccount_Get_Anonymous(t *testing.T) {
|
|||||||
conf.AttachmentFileSizeLimit = 512
|
conf.AttachmentFileSizeLimit = 512
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
s.smtpSender = &testMailer{}
|
s.smtpSender = &testMailer{}
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
rr := request(t, s, "GET", "/v1/account", "", nil)
|
rr := request(t, s, "GET", "/v1/account", "", nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
@@ -133,9 +170,11 @@ func TestAccount_Get_Anonymous(t *testing.T) {
|
|||||||
|
|
||||||
func TestAccount_ChangeSettings(t *testing.T) {
|
func TestAccount_ChangeSettings(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
defer s.closeDatabases()
|
||||||
user, _ := s.userManager.User("phil")
|
|
||||||
token, _ := s.userManager.CreateToken(user)
|
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{
|
rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
@@ -153,14 +192,16 @@ func TestAccount_ChangeSettings(t *testing.T) {
|
|||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
require.Equal(t, "de", account.Language)
|
require.Equal(t, "de", account.Language)
|
||||||
require.Equal(t, 86400, account.Notification.DeleteAfter)
|
require.Equal(t, util.Int(86400), account.Notification.DeleteAfter)
|
||||||
require.Equal(t, "juntos", account.Notification.Sound)
|
require.Equal(t, util.String("juntos"), account.Notification.Sound)
|
||||||
require.Equal(t, 0, account.Notification.MinPriority) // Not set
|
require.Nil(t, account.Notification.MinPriority) // Not set
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfigWithAuthFile(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{
|
rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def"}`, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
@@ -173,13 +214,11 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
|||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
require.Equal(t, 1, len(account.Subscriptions))
|
require.Equal(t, 1, len(account.Subscriptions))
|
||||||
require.NotEmpty(t, account.Subscriptions[0].ID)
|
|
||||||
require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
|
require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
|
||||||
require.Equal(t, "def", account.Subscriptions[0].Topic)
|
require.Equal(t, "def", account.Subscriptions[0].Topic)
|
||||||
require.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", `{"base_url": "http://abc.com", "topic": "def", "display_name": "ding dong"}`, map[string]string{
|
||||||
rr = request(t, s, "PATCH", "/v1/account/subscription/"+subscriptionID, `{"display_name": "ding dong"}`, map[string]string{
|
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
@@ -190,13 +229,14 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
|||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
require.Equal(t, 1, len(account.Subscriptions))
|
require.Equal(t, 1, len(account.Subscriptions))
|
||||||
require.Equal(t, subscriptionID, account.Subscriptions[0].ID)
|
|
||||||
require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
|
require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
|
||||||
require.Equal(t, "def", account.Subscriptions[0].Topic)
|
require.Equal(t, "def", account.Subscriptions[0].Topic)
|
||||||
require.Equal(t, "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"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
"X-BaseURL": "http://abc.com",
|
||||||
|
"X-Topic": "def",
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
@@ -210,9 +250,22 @@ func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
|||||||
|
|
||||||
func TestAccount_ChangePassword(t *testing.T) {
|
func TestAccount_ChangePassword(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfigWithAuthFile(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"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
@@ -230,6 +283,7 @@ func TestAccount_ChangePassword(t *testing.T) {
|
|||||||
|
|
||||||
func TestAccount_ChangePassword_NoAccount(t *testing.T) {
|
func TestAccount_ChangePassword_NoAccount(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
rr := request(t, s, "POST", "/v1/account/password", `{"password": "new password"}`, nil)
|
rr := request(t, s, "POST", "/v1/account/password", `{"password": "new password"}`, nil)
|
||||||
require.Equal(t, 401, rr.Code)
|
require.Equal(t, 401, rr.Code)
|
||||||
@@ -237,7 +291,9 @@ func TestAccount_ChangePassword_NoAccount(t *testing.T) {
|
|||||||
|
|
||||||
func TestAccount_ExtendToken(t *testing.T) {
|
func TestAccount_ExtendToken(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfigWithAuthFile(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{
|
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
@@ -256,11 +312,24 @@ func TestAccount_ExtendToken(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, token.Token, extendedToken.Token)
|
require.Equal(t, token.Token, extendedToken.Token)
|
||||||
require.True(t, token.Expires < extendedToken.Expires)
|
require.True(t, token.Expires < extendedToken.Expires)
|
||||||
|
|
||||||
|
expires := time.Now().Add(999 * time.Hour)
|
||||||
|
body := fmt.Sprintf(`{"token":"%s", "label":"some label", "expires": %d}`, token.Token, expires.Unix())
|
||||||
|
rr = request(t, s, "PATCH", "/v1/account/token", body, map[string]string{
|
||||||
|
"Authorization": util.BearerAuth(token.Token),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
token, err = util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "some label", token.Label)
|
||||||
|
require.Equal(t, expires.Unix(), token.Expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) {
|
func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfigWithAuthFile(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{
|
rr := request(t, s, "PATCH", "/v1/account/token", "", map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"), // Not Bearer!
|
"Authorization": util.BasicAuth("phil", "phil"), // Not Bearer!
|
||||||
@@ -271,7 +340,9 @@ func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) {
|
|||||||
|
|
||||||
func TestAccount_DeleteToken(t *testing.T) {
|
func TestAccount_DeleteToken(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfigWithAuthFile(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{
|
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
@@ -279,6 +350,7 @@ func TestAccount_DeleteToken(t *testing.T) {
|
|||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
require.True(t, token.Expires > time.Now().Add(71*time.Hour).Unix())
|
||||||
|
|
||||||
// Delete token failure (using basic auth)
|
// Delete token failure (using basic auth)
|
||||||
rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{
|
rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{
|
||||||
@@ -319,15 +391,20 @@ func TestAccount_Delete_Success(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
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"),
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Account was marked deleted
|
||||||
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 401, rr.Code)
|
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) {
|
func TestAccount_Delete_Not_Allowed(t *testing.T) {
|
||||||
@@ -340,6 +417,15 @@ func TestAccount_Delete_Not_Allowed(t *testing.T) {
|
|||||||
|
|
||||||
rr = request(t, s, "DELETE", "/v1/account", "", nil)
|
rr = request(t, s, "DELETE", "/v1/account", "", nil)
|
||||||
require.Equal(t, 401, rr.Code)
|
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) {
|
func TestAccount_Reservation_AddWithoutTierFails(t *testing.T) {
|
||||||
@@ -360,13 +446,52 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
|
|||||||
conf := newTestConfigWithAuthFile(t)
|
conf := newTestConfigWithAuthFile(t)
|
||||||
conf.EnableSignup = true
|
conf.EnableSignup = true
|
||||||
s := newTestServer(t, conf)
|
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"),
|
"Authorization": util.BasicAuth("phil", "adminpass"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 400, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).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) {
|
func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||||
@@ -379,16 +504,16 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
|||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
// Create a tier
|
// Create a tier
|
||||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
Code: "pro",
|
Code: "pro",
|
||||||
Paid: false,
|
MessageLimit: 123,
|
||||||
MessagesLimit: 123,
|
MessageExpiryDuration: 86400 * time.Second,
|
||||||
MessagesExpiryDuration: 86400 * time.Second,
|
EmailLimit: 32,
|
||||||
EmailsLimit: 32,
|
ReservationLimit: 2,
|
||||||
ReservationsLimit: 2,
|
|
||||||
AttachmentFileSizeLimit: 1231231,
|
AttachmentFileSizeLimit: 1231231,
|
||||||
AttachmentTotalSizeLimit: 123123,
|
AttachmentTotalSizeLimit: 123123,
|
||||||
AttachmentExpiryDuration: 10800 * time.Second,
|
AttachmentExpiryDuration: 10800 * time.Second,
|
||||||
|
AttachmentBandwidthLimit: 21474836480,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
|
|
||||||
@@ -429,6 +554,7 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
|||||||
require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
|
require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
|
||||||
require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)
|
require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)
|
||||||
require.Equal(t, int64(10800), account.Limits.AttachmentExpiryDuration)
|
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, 2, len(account.Reservations))
|
||||||
require.Equal(t, "another", account.Reservations[0].Topic)
|
require.Equal(t, "another", account.Reservations[0].Topic)
|
||||||
require.Equal(t, "write-only", account.Reservations[0].Everyone)
|
require.Equal(t, "write-only", account.Reservations[0].Everyone)
|
||||||
@@ -460,10 +586,10 @@ func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {
|
|||||||
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
Code: "pro",
|
Code: "pro",
|
||||||
MessagesLimit: 20,
|
MessageLimit: 20,
|
||||||
ReservationsLimit: 2,
|
ReservationLimit: 2,
|
||||||
}))
|
}))
|
||||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
|
|
||||||
@@ -483,3 +609,222 @@ func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {
|
|||||||
rr = request(t, s, "POST", "/mytopic", `Howdy`, nil)
|
rr = request(t, s, "POST", "/mytopic", `Howdy`, nil)
|
||||||
require.Equal(t, 403, rr.Code)
|
require.Equal(t, 403, rr.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||||
|
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))
|
||||||
|
|
||||||
|
// 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!
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
|
||||||
|
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_Reservation_Add_Kills_Other_Subscribers(t *testing.T) {
|
||||||
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
conf.AuthDefault = user.PermissionReadWrite
|
||||||
|
conf.EnableSignup = true
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// Create user with tier
|
||||||
|
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
|
Code: "pro",
|
||||||
|
MessageLimit: 20,
|
||||||
|
ReservationLimit: 2,
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
|
|
||||||
|
// Subscribe anonymously
|
||||||
|
anonCh, userCh := make(chan bool), make(chan bool)
|
||||||
|
go func() {
|
||||||
|
rr := request(t, s, "GET", "/mytopic/json", ``, nil) // This blocks until it's killed!
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
messages := toMessages(t, rr.Body.String())
|
||||||
|
require.Equal(t, 2, len(messages)) // This is the meat. We should NOT receive the second message!
|
||||||
|
require.Equal(t, "open", messages[0].Event)
|
||||||
|
require.Equal(t, "message before reservation", messages[1].Message)
|
||||||
|
anonCh <- true
|
||||||
|
log.Info("Anonymous subscription ended")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Subscribe with user
|
||||||
|
go func() {
|
||||||
|
rr := request(t, s, "GET", "/mytopic/json", ``, map[string]string{ // Blocks!
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
messages := toMessages(t, rr.Body.String())
|
||||||
|
require.Equal(t, 3, len(messages))
|
||||||
|
require.Equal(t, "open", messages[0].Event)
|
||||||
|
require.Equal(t, "message before reservation", messages[1].Message)
|
||||||
|
require.Equal(t, "message after reservation", messages[2].Message)
|
||||||
|
userCh <- true
|
||||||
|
log.Info("User subscription ended")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Publish message (before reservation)
|
||||||
|
time.Sleep(2 * time.Second) // Wait for subscribers
|
||||||
|
rr = request(t, s, "POST", "/mytopic", "message before reservation", nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
time.Sleep(2 * time.Second) // Wait for subscribers to receive message
|
||||||
|
|
||||||
|
// Reserve a topic
|
||||||
|
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Everyone but phil should be killed
|
||||||
|
select {
|
||||||
|
case <-anonCh:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Waiting for anonymous subscription to be killed failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish a message
|
||||||
|
rr = request(t, s, "POST", "/mytopic", "message after reservation", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Kill user Go routine
|
||||||
|
s.topics["mytopic"].CancelSubscribers("<invalid>")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-userCh:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Waiting for user subscription to be killed failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||||
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
conf.AuthDefault = user.PermissionReadWrite
|
||||||
|
conf.AuthStatsQueueWriterInterval = 200 * time.Millisecond
|
||||||
|
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
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify that message stats were persisted
|
||||||
|
u, err := s.userManager.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(1), u.Stats.Messages)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
u, err = s.userManager.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(2), u.Stats.Messages) // v.EnqueueUserStats had run!
|
||||||
|
|
||||||
|
// 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"
|
"firebase.google.com/go/v4/messaging"
|
||||||
"fmt"
|
"fmt"
|
||||||
"google.golang.org/api/option"
|
"google.golang.org/api/option"
|
||||||
"heckel.io/ntfy/log"
|
|
||||||
"heckel.io/ntfy/user"
|
"heckel.io/ntfy/user"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -39,19 +38,23 @@ func newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClien
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *firebaseClient) Send(v *visitor, m *message) error {
|
func (c *firebaseClient) Send(v *visitor, m *message) error {
|
||||||
if err := v.FirebaseAllowed(); err != nil {
|
if !v.FirebaseAllowed() {
|
||||||
return errFirebaseTemporarilyBanned
|
return errFirebaseTemporarilyBanned
|
||||||
}
|
}
|
||||||
fbm, err := toFirebaseMessage(m, c.auther)
|
fbm, err := toFirebaseMessage(m, c.auther)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if log.IsTrace() {
|
ev := logvm(v, m).Tag(tagFirebase)
|
||||||
log.Trace("%s Firebase message: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(fbm))
|
if ev.IsTrace() {
|
||||||
|
ev.Field("firebase_message", util.MaybeMarshalJSON(fbm)).Trace("Firebase message")
|
||||||
}
|
}
|
||||||
err = c.sender.Send(fbm)
|
err = c.sender.Send(fbm)
|
||||||
if err == errFirebaseQuotaExceeded {
|
if err == errFirebaseQuotaExceeded {
|
||||||
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()
|
v.FirebaseTemporarilyDeny()
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|||||||
163
server/server_manager.go
Normal file
163
server/server_manager.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
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).Trace("- topic %s: %d subscribers", t.ID, subs)
|
||||||
|
msgs, exists := messageCounts[t.ID]
|
||||||
|
if subs == 0 && (!exists || msgs == 0) {
|
||||||
|
log.Tag(tagManager).Trace("Deleting empty topic %s", t.ID)
|
||||||
|
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 {
|
||||||
|
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 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")
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/log"
|
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -147,7 +146,7 @@ func writeMatrixDiscoveryResponse(w http.ResponseWriter) error {
|
|||||||
|
|
||||||
// writeMatrixError logs and writes the errMatrix to the given http.ResponseWriter as a matrixResponse
|
// 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 {
|
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())
|
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Matrix gateway error")
|
||||||
return writeMatrixResponse(w, err.pushKey)
|
return writeMatrixResponse(w, err.pushKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
|
func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
if !s.config.EnableWeb {
|
if !s.config.EnableWeb {
|
||||||
@@ -24,7 +36,7 @@ func (s *Server) ensureUserManager(next handleFunc) handleFunc {
|
|||||||
|
|
||||||
func (s *Server) ensureUser(next handleFunc) handleFunc {
|
func (s *Server) ensureUser(next handleFunc) handleFunc {
|
||||||
return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
if v.user == nil {
|
if v.User() == nil {
|
||||||
return errHTTPUnauthorized
|
return errHTTPUnauthorized
|
||||||
}
|
}
|
||||||
return next(w, r, v)
|
return next(w, r, v)
|
||||||
@@ -42,7 +54,7 @@ func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
|
|||||||
|
|
||||||
func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc {
|
func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc {
|
||||||
return s.ensureUser(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
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 errHTTPBadRequestNotAPaidUser
|
||||||
}
|
}
|
||||||
return next(w, r, v)
|
return next(w, r, v)
|
||||||
@@ -51,9 +63,6 @@ func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc {
|
|||||||
|
|
||||||
func (s *Server) withAccountSync(next handleFunc) handleFunc {
|
func (s *Server) withAccountSync(next handleFunc) handleFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
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)
|
err := next(w, r, v)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.publishSyncEventAsync(v)
|
s.publishSyncEventAsync(v)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stripe/stripe-go/v74"
|
"github.com/stripe/stripe-go/v74"
|
||||||
@@ -21,12 +20,6 @@ import (
|
|||||||
"time"
|
"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.
|
// Payments in ntfy are done via Stripe.
|
||||||
//
|
//
|
||||||
// Pretty much all payments related things are in this file. The following processes
|
// 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.
|
// 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.
|
// 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
|
// 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 {
|
func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||||
tiers, err := s.userManager.Tiers()
|
tiers, err := s.userManager.Tiers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
freeTier := defaultVisitorLimits(s.config)
|
freeTier := configBasedVisitorLimits(s.config)
|
||||||
response := []*apiAccountBillingTier{
|
response := []*apiAccountBillingTier{
|
||||||
{
|
{
|
||||||
// This is a bit of a hack: This is the "Free" tier. It has no tier code, name or price.
|
// This is a bit of a hack: This is the "Free" tier. It has no tier code, name or price.
|
||||||
Limits: &apiAccountLimits{
|
Limits: &apiAccountLimits{
|
||||||
Messages: freeTier.MessagesLimit,
|
Basis: string(visitorLimitBasisIP),
|
||||||
MessagesExpiryDuration: int64(freeTier.MessagesExpiryDuration.Seconds()),
|
Messages: freeTier.MessageLimit,
|
||||||
Emails: freeTier.EmailsLimit,
|
MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()),
|
||||||
|
Emails: freeTier.EmailLimit,
|
||||||
Reservations: freeTier.ReservationsLimit,
|
Reservations: freeTier.ReservationsLimit,
|
||||||
AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit,
|
AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit,
|
||||||
AttachmentFileSize: freeTier.AttachmentFileSizeLimit,
|
AttachmentFileSize: freeTier.AttachmentFileSizeLimit,
|
||||||
@@ -85,10 +89,11 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
|
|||||||
Name: tier.Name,
|
Name: tier.Name,
|
||||||
Price: priceStr,
|
Price: priceStr,
|
||||||
Limits: &apiAccountLimits{
|
Limits: &apiAccountLimits{
|
||||||
Messages: tier.MessagesLimit,
|
Basis: string(visitorLimitBasisTier),
|
||||||
MessagesExpiryDuration: int64(tier.MessagesExpiryDuration.Seconds()),
|
Messages: tier.MessageLimit,
|
||||||
Emails: tier.EmailsLimit,
|
MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()),
|
||||||
Reservations: tier.ReservationsLimit,
|
Emails: tier.EmailLimit,
|
||||||
|
Reservations: tier.ReservationLimit,
|
||||||
AttachmentTotalSize: tier.AttachmentTotalSizeLimit,
|
AttachmentTotalSize: tier.AttachmentTotalSizeLimit,
|
||||||
AttachmentFileSize: tier.AttachmentFileSizeLimit,
|
AttachmentFileSize: tier.AttachmentFileSizeLimit,
|
||||||
AttachmentExpiryDuration: int64(tier.AttachmentExpiryDuration.Seconds()),
|
AttachmentExpiryDuration: int64(tier.AttachmentExpiryDuration.Seconds()),
|
||||||
@@ -101,10 +106,11 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
|
|||||||
// handleAccountBillingSubscriptionCreate creates a Stripe checkout flow to create a user subscription. The tier
|
// 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.
|
// 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 {
|
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
|
return errHTTPBadRequestBillingSubscriptionExists
|
||||||
}
|
}
|
||||||
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit)
|
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -114,11 +120,14 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
|
|||||||
} else if tier.StripePriceID == "" {
|
} else if tier.StripePriceID == "" {
|
||||||
return errNotAPaidTier
|
return errNotAPaidTier
|
||||||
}
|
}
|
||||||
log.Info("Stripe: No existing subscription, creating checkout flow")
|
logvr(v, r).
|
||||||
|
With(tier).
|
||||||
|
Tag(tagStripe).
|
||||||
|
Info("Creating Stripe checkout flow")
|
||||||
var stripeCustomerID *string
|
var stripeCustomerID *string
|
||||||
if v.user.Billing.StripeCustomerID != "" {
|
if u.Billing.StripeCustomerID != "" {
|
||||||
stripeCustomerID = &v.user.Billing.StripeCustomerID
|
stripeCustomerID = &u.Billing.StripeCustomerID
|
||||||
stripeCustomer, err := s.stripe.GetCustomer(v.user.Billing.StripeCustomerID)
|
stripeCustomer, err := s.stripe.GetCustomer(u.Billing.StripeCustomerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if stripeCustomer.Subscriptions != nil && len(stripeCustomer.Subscriptions.Data) > 0 {
|
} else if stripeCustomer.Subscriptions != nil && len(stripeCustomer.Subscriptions.Data) > 0 {
|
||||||
@@ -128,7 +137,7 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
|
|||||||
successURL := s.config.BaseURL + apiAccountBillingSubscriptionCheckoutSuccessTemplate
|
successURL := s.config.BaseURL + apiAccountBillingSubscriptionCheckoutSuccessTemplate
|
||||||
params := &stripe.CheckoutSessionParams{
|
params := &stripe.CheckoutSessionParams{
|
||||||
Customer: stripeCustomerID, // A user may have previously deleted their subscription
|
Customer: stripeCustomerID, // A user may have previously deleted their subscription
|
||||||
ClientReferenceID: &v.user.Name,
|
ClientReferenceID: &u.ID,
|
||||||
SuccessURL: &successURL,
|
SuccessURL: &successURL,
|
||||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||||
AllowPromotionCodes: stripe.Bool(true),
|
AllowPromotionCodes: stripe.Bool(true),
|
||||||
@@ -138,9 +147,9 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
|
|||||||
Quantity: stripe.Int64(1),
|
Quantity: stripe.Int64(1),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
/*AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{
|
AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{
|
||||||
Enabled: stripe.Bool(true),
|
Enabled: stripe.Bool(true),
|
||||||
},*/
|
},
|
||||||
}
|
}
|
||||||
sess, err := s.stripe.NewCheckoutSession(params)
|
sess, err := s.stripe.NewCheckoutSession(params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -155,8 +164,8 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
|
|||||||
// handleAccountBillingSubscriptionCreateSuccess is called after the Stripe checkout session has succeeded. We use
|
// 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
|
// 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.
|
// 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 {
|
func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
// We don't have a v.user in this endpoint, only a userManager!
|
// We don't have v.User() in this endpoint, only a userManager!
|
||||||
matches := apiAccountBillingSubscriptionCheckoutSuccessRegex.FindStringSubmatch(r.URL.Path)
|
matches := apiAccountBillingSubscriptionCheckoutSuccessRegex.FindStringSubmatch(r.URL.Path)
|
||||||
if len(matches) != 2 {
|
if len(matches) != 2 {
|
||||||
return errHTTPInternalErrorInvalidPath
|
return errHTTPInternalErrorInvalidPath
|
||||||
@@ -178,11 +187,33 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
u, err := s.userManager.User(sess.ClientReferenceID)
|
u, err := s.userManager.UserByID(sess.ClientReferenceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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_subscription_id": sub.ID,
|
||||||
|
"stripe_subscription_status": string(sub.Status),
|
||||||
|
"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), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
|
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
|
||||||
@@ -192,10 +223,11 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
|
|||||||
// handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates
|
// 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.
|
// 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 {
|
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
|
return errNoBillingSubscription
|
||||||
}
|
}
|
||||||
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit)
|
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -203,10 +235,20 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Info("Stripe: Changing tier and subscription to %s", tier.Code)
|
logvr(v, r).
|
||||||
sub, err := s.stripe.GetSubscription(v.user.Billing.StripeSubscriptionID)
|
Tag(tagStripe).
|
||||||
|
Fields(log.Context{
|
||||||
|
"new_tier_id": tier.ID,
|
||||||
|
"new_tier_name": tier.Name,
|
||||||
|
"new_tier_stripe_price_id": tier.StripePriceID,
|
||||||
|
// Other stripe_* fields filled by visitor context
|
||||||
|
}).
|
||||||
|
Info("Changing Stripe subscription and billing tier to %s/%s (price %s)", tier.ID, tier.Name, tier.StripePriceID)
|
||||||
|
sub, err := s.stripe.GetSubscription(u.Billing.StripeSubscriptionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
} else if sub.Items == nil || len(sub.Items.Data) != 1 {
|
||||||
|
return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "no items, or more than one item")
|
||||||
}
|
}
|
||||||
params := &stripe.SubscriptionParams{
|
params := &stripe.SubscriptionParams{
|
||||||
CancelAtPeriodEnd: stripe.Bool(false),
|
CancelAtPeriodEnd: stripe.Bool(false),
|
||||||
@@ -226,13 +268,16 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleAccountBillingSubscriptionDelete facilitates downgrading a paid user to a tier-less user,
|
// 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 {
|
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{
|
params := &stripe.SubscriptionParams{
|
||||||
CancelAtPeriodEnd: stripe.Bool(true),
|
CancelAtPeriodEnd: stripe.Bool(true),
|
||||||
}
|
}
|
||||||
_, err := s.stripe.UpdateSubscription(v.user.Billing.StripeSubscriptionID, params)
|
_, err := s.stripe.UpdateSubscription(u.Billing.StripeSubscriptionID, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -243,11 +288,13 @@ func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r
|
|||||||
// handleAccountBillingPortalSessionCreate creates a session to the customer billing portal, and returns the
|
// 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.
|
// 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 {
|
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
|
return errHTTPBadRequestNotAPaidUser
|
||||||
}
|
}
|
||||||
params := &stripe.BillingPortalSessionParams{
|
params := &stripe.BillingPortalSessionParams{
|
||||||
Customer: stripe.String(v.user.Billing.StripeCustomerID),
|
Customer: stripe.String(u.Billing.StripeCustomerID),
|
||||||
ReturnURL: stripe.String(s.config.BaseURL),
|
ReturnURL: stripe.String(s.config.BaseURL),
|
||||||
}
|
}
|
||||||
ps, err := s.stripe.NewPortalSession(params)
|
ps, err := s.stripe.NewPortalSession(params)
|
||||||
@@ -262,8 +309,8 @@ func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter,
|
|||||||
|
|
||||||
// handleAccountBillingWebhook handles incoming Stripe webhooks. It mainly keeps the local user database in sync
|
// 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
|
// 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.
|
// visitor (v) in this endpoint is the Stripe API, so we don't have u available.
|
||||||
func (s *Server) handleAccountBillingWebhook(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
func (s *Server) handleAccountBillingWebhook(_ http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
stripeSignature := r.Header.Get("Stripe-Signature")
|
stripeSignature := r.Header.Get("Stripe-Signature")
|
||||||
if stripeSignature == "" {
|
if stripeSignature == "" {
|
||||||
return errHTTPBadRequestBillingRequestInvalid
|
return errHTTPBadRequestBillingRequestInvalid
|
||||||
@@ -280,89 +327,107 @@ func (s *Server) handleAccountBillingWebhook(w http.ResponseWriter, r *http.Requ
|
|||||||
} else if event.Data == nil || event.Data.Raw == nil {
|
} else if event.Data == nil || event.Data.Raw == nil {
|
||||||
return errHTTPBadRequestBillingRequestInvalid
|
return errHTTPBadRequestBillingRequestInvalid
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Stripe: webhook event %s received", event.Type)
|
|
||||||
switch event.Type {
|
switch event.Type {
|
||||||
case "customer.subscription.updated":
|
case "customer.subscription.updated":
|
||||||
return s.handleAccountBillingWebhookSubscriptionUpdated(event.Data.Raw)
|
return s.handleAccountBillingWebhookSubscriptionUpdated(r, v, event)
|
||||||
case "customer.subscription.deleted":
|
case "customer.subscription.deleted":
|
||||||
return s.handleAccountBillingWebhookSubscriptionDeleted(event.Data.Raw)
|
return s.handleAccountBillingWebhookSubscriptionDeleted(r, v, event)
|
||||||
default:
|
default:
|
||||||
|
logvr(v, r).
|
||||||
|
Tag(tagStripe).
|
||||||
|
Field("stripe_webhook_type", event.Type).
|
||||||
|
Warn("Unhandled Stripe webhook event %s received", event.Type)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(event json.RawMessage) error {
|
func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(r *http.Request, v *visitor, event stripe.Event) error {
|
||||||
r, err := util.UnmarshalJSON[apiStripeSubscriptionUpdatedEvent](io.NopCloser(bytes.NewReader(event)))
|
ev, err := util.UnmarshalJSON[apiStripeSubscriptionUpdatedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 == "" {
|
||||||
return errHTTPBadRequestBillingRequestInvalid
|
return errHTTPBadRequestBillingRequestInvalid
|
||||||
}
|
}
|
||||||
subscriptionID, priceID := r.ID, r.Items.Data[0].Price.ID
|
subscriptionID, priceID := ev.ID, ev.Items.Data[0].Price.ID
|
||||||
log.Info("Stripe: customer %s: Updating subscription to status %s, with price %s", r.Customer, r.Status, priceID)
|
logvr(v, r).
|
||||||
u, err := s.userManager.UserByStripeCustomer(r.Customer)
|
Tag(tagStripe).
|
||||||
|
Fields(log.Context{
|
||||||
|
"stripe_webhook_type": event.Type,
|
||||||
|
"stripe_customer_id": ev.Customer,
|
||||||
|
"stripe_subscription_id": ev.ID,
|
||||||
|
"stripe_subscription_status": ev.Status,
|
||||||
|
"stripe_subscription_paid_until": ev.CurrentPeriodEnd,
|
||||||
|
"stripe_subscription_cancel_at": ev.CancelAt,
|
||||||
|
"stripe_price_id": priceID,
|
||||||
|
}).
|
||||||
|
Info("Updating subscription to status %s, with price %s", ev.Status, priceID)
|
||||||
|
userFn := func() (*user.User, error) {
|
||||||
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
v.SetUser(u)
|
||||||
tier, err := s.userManager.TierByStripePrice(priceID)
|
tier, err := s.userManager.TierByStripePrice(priceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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, ev.CurrentPeriodEnd, ev.CancelAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.publishSyncEventAsync(s.visitorFromUser(u, netip.IPv4Unspecified()))
|
s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(event json.RawMessage) error {
|
func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(r *http.Request, v *visitor, event stripe.Event) error {
|
||||||
r, err := util.UnmarshalJSON[apiStripeSubscriptionDeletedEvent](io.NopCloser(bytes.NewReader(event)))
|
ev, err := util.UnmarshalJSON[apiStripeSubscriptionDeletedEvent](io.NopCloser(bytes.NewReader(event.Data.Raw)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if r.Customer == "" {
|
} else if ev.Customer == "" {
|
||||||
return errHTTPBadRequestBillingRequestInvalid
|
return errHTTPBadRequestBillingRequestInvalid
|
||||||
}
|
}
|
||||||
log.Info("Stripe: customer %s: subscription deleted, downgrading to unpaid tier", r.Customer)
|
u, err := s.userManager.UserByStripeCustomer(ev.Customer)
|
||||||
u, err := s.userManager.UserByStripeCustomer(r.Customer)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
return err
|
||||||
}
|
}
|
||||||
s.publishSyncEventAsync(s.visitorFromUser(u, netip.IPv4Unspecified()))
|
s.publishSyncEventAsync(s.visitor(netip.IPv4Unspecified(), u))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) updateSubscriptionAndTier(u *user.User, tier *user.Tier, customerID, subscriptionID, status string, paidUntil, cancelAt int64) error {
|
func (s *Server) updateSubscriptionAndTier(r *http.Request, v *visitor, u *user.User, tier *user.Tier, customerID, subscriptionID, status 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
|
|
||||||
}
|
|
||||||
reservationsLimit := visitorDefaultReservationsLimit
|
reservationsLimit := visitorDefaultReservationsLimit
|
||||||
if tier != nil {
|
if tier != nil {
|
||||||
reservationsLimit = tier.ReservationsLimit
|
reservationsLimit = tier.ReservationLimit
|
||||||
}
|
}
|
||||||
if int64(len(reservations)) > reservationsLimit {
|
if err := s.maybeRemoveMessagesAndExcessReservations(r, v, u, reservationsLimit); err != nil {
|
||||||
topics := make([]string, 0)
|
return err
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Change or remove tier
|
if tier == nil && u.Tier != nil {
|
||||||
if tier == nil {
|
logvr(v, r).Tag(tagStripe).Info("Resetting tier for user %s", u.Name)
|
||||||
if err := s.userManager.ResetTier(u.Name); err != nil {
|
if err := s.userManager.ResetTier(u.Name); err != nil {
|
||||||
return err
|
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_name": tier.Name,
|
||||||
|
"new_tier_stripe_price_id": tier.StripePriceID,
|
||||||
|
}).
|
||||||
|
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 {
|
if err := s.userManager.ChangeTier(u.Name, tier.Code); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -410,6 +475,7 @@ type stripeAPI interface {
|
|||||||
GetCustomer(id string) (*stripe.Customer, error)
|
GetCustomer(id string) (*stripe.Customer, error)
|
||||||
GetSession(id string) (*stripe.CheckoutSession, error)
|
GetSession(id string) (*stripe.CheckoutSession, error)
|
||||||
GetSubscription(id string) (*stripe.Subscription, 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)
|
UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error)
|
||||||
CancelSubscription(id string) (*stripe.Subscription, error)
|
CancelSubscription(id string) (*stripe.Subscription, error)
|
||||||
ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error)
|
ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error)
|
||||||
@@ -456,6 +522,10 @@ func (s *realStripeAPI) GetSubscription(id string) (*stripe.Subscription, error)
|
|||||||
return subscription.Get(id, nil)
|
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) {
|
func (s *realStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) {
|
||||||
return subscription.Update(id, params)
|
return subscription.Update(id, params)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,120 @@ import (
|
|||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/stripe/stripe-go/v74"
|
"github.com/stripe/stripe-go/v74"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
"heckel.io/ntfy/user"
|
"heckel.io/ntfy/user"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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_456", UnitAmount: 1000},
|
||||||
|
{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,
|
||||||
|
StripePriceID: "price_123",
|
||||||
|
}))
|
||||||
|
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,
|
||||||
|
StripePriceID: "price_456",
|
||||||
|
}))
|
||||||
|
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(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, "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) {
|
func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) {
|
||||||
stripeMock := &testStripeAPI{}
|
stripeMock := &testStripeAPI{}
|
||||||
defer stripeMock.AssertExpectations(t)
|
defer stripeMock.AssertExpectations(t)
|
||||||
@@ -30,11 +135,12 @@ func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) {
|
|||||||
Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil)
|
Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil)
|
||||||
|
|
||||||
// Create tier and user
|
// Create tier and user
|
||||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
|
ID: "ti_123",
|
||||||
Code: "pro",
|
Code: "pro",
|
||||||
StripePriceID: "price_123",
|
StripePriceID: "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
|
// 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"}`, map[string]string{
|
||||||
@@ -65,11 +171,12 @@ func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) {
|
|||||||
Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil)
|
Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil)
|
||||||
|
|
||||||
// Create tier and user
|
// Create tier and user
|
||||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
|
ID: "ti_123",
|
||||||
Code: "pro",
|
Code: "pro",
|
||||||
StripePriceID: "price_123",
|
StripePriceID: "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")
|
u, err := s.userManager.User("phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -106,11 +213,12 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
|
|||||||
Return(&stripe.Subscription{}, nil)
|
Return(&stripe.Subscription{}, nil)
|
||||||
|
|
||||||
// Create tier and user
|
// Create tier and user
|
||||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
|
ID: "ti_123",
|
||||||
Code: "pro",
|
Code: "pro",
|
||||||
StripePriceID: "price_123",
|
StripePriceID: "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")
|
u, err := s.userManager.User("phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -122,7 +230,7 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
|
|||||||
require.Nil(t, s.userManager.ChangeBilling(u.Name, billing))
|
require.Nil(t, s.userManager.ChangeBilling(u.Name, billing))
|
||||||
|
|
||||||
// Delete account
|
// 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"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
@@ -133,6 +241,164 @@ func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
|
|||||||
require.Equal(t, 401, rr.Code)
|
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",
|
||||||
|
StripePriceID: "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"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, 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, 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, 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) {
|
func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(t *testing.T) {
|
||||||
// This tests incoming webhooks from Stripe to update a subscription:
|
// This tests incoming webhooks from Stripe to update a subscription:
|
||||||
// - All Stripe columns are updated in the user table
|
// - All Stripe columns are updated in the user table
|
||||||
@@ -154,30 +420,34 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
|||||||
Return(jsonToStripeEvent(t, subscriptionUpdatedEventJSON), nil)
|
Return(jsonToStripeEvent(t, subscriptionUpdatedEventJSON), nil)
|
||||||
|
|
||||||
// Create a user with a Stripe subscription and 3 reservations
|
// 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",
|
Code: "starter",
|
||||||
StripePriceID: "price_1234", // !
|
StripePriceID: "price_1234", // !
|
||||||
ReservationsLimit: 1, // !
|
ReservationLimit: 1, // !
|
||||||
MessagesLimit: 100,
|
MessageLimit: 100,
|
||||||
MessagesExpiryDuration: time.Hour,
|
MessageExpiryDuration: time.Hour,
|
||||||
AttachmentExpiryDuration: time.Hour,
|
AttachmentExpiryDuration: time.Hour,
|
||||||
AttachmentFileSizeLimit: 1000000,
|
AttachmentFileSizeLimit: 1000000,
|
||||||
AttachmentTotalSizeLimit: 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",
|
Code: "pro",
|
||||||
StripePriceID: "price_1111", // !
|
StripePriceID: "price_1111", // !
|
||||||
ReservationsLimit: 3, // !
|
ReservationLimit: 3, // !
|
||||||
MessagesLimit: 200,
|
MessageLimit: 200,
|
||||||
MessagesExpiryDuration: time.Hour,
|
MessageExpiryDuration: time.Hour,
|
||||||
AttachmentExpiryDuration: time.Hour,
|
AttachmentExpiryDuration: time.Hour,
|
||||||
AttachmentFileSizeLimit: 1000000,
|
AttachmentFileSizeLimit: 1000000,
|
||||||
AttachmentTotalSizeLimit: 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.ChangeTier("phil", "pro"))
|
||||||
require.Nil(t, s.userManager.ReserveAccess("phil", "atopic", user.PermissionDenyAll))
|
require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll))
|
||||||
require.Nil(t, s.userManager.ReserveAccess("phil", "ztopic", user.PermissionDenyAll))
|
require.Nil(t, s.userManager.AddReservation("phil", "ztopic", user.PermissionDenyAll))
|
||||||
|
|
||||||
// Add billing details
|
// Add billing details
|
||||||
u, err := s.userManager.User("phil")
|
u, err := s.userManager.User("phil")
|
||||||
@@ -254,10 +524,205 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
|
|||||||
require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, z2.ID))
|
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",
|
||||||
|
StripePriceID: "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,
|
||||||
|
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.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
|
||||||
|
Items: []*stripe.SubscriptionItemsParams{
|
||||||
|
{
|
||||||
|
ID: stripe.String("someid_123"),
|
||||||
|
Price: stripe.String("price_456"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
Return(&stripe.Subscription{}, nil)
|
||||||
|
|
||||||
|
// Create tier and user
|
||||||
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
|
ID: "ti_123",
|
||||||
|
Code: "pro",
|
||||||
|
StripePriceID: "price_123",
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
|
ID: "ti_456",
|
||||||
|
Code: "business",
|
||||||
|
StripePriceID: "price_456",
|
||||||
|
}))
|
||||||
|
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"}`, 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 {
|
type testStripeAPI struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ stripeAPI = (*testStripeAPI)(nil)
|
||||||
|
|
||||||
func (s *testStripeAPI) NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
|
func (s *testStripeAPI) NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
|
||||||
args := s.Called(params)
|
args := s.Called(params)
|
||||||
return args.Get(0).(*stripe.CheckoutSession), args.Error(1)
|
return args.Get(0).(*stripe.CheckoutSession), args.Error(1)
|
||||||
@@ -288,8 +753,13 @@ func (s *testStripeAPI) GetSubscription(id string) (*stripe.Subscription, error)
|
|||||||
return args.Get(0).(*stripe.Subscription), args.Error(1)
|
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) {
|
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)
|
return args.Get(0).(*stripe.Subscription), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,8 +773,6 @@ func (s *testStripeAPI) ConstructWebhookEvent(payload []byte, header string, sec
|
|||||||
return args.Get(0).(stripe.Event), args.Error(1)
|
return args.Get(0).(stripe.Event), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ stripeAPI = (*testStripeAPI)(nil)
|
|
||||||
|
|
||||||
func jsonToStripeEvent(t *testing.T, v string) stripe.Event {
|
func jsonToStripeEvent(t *testing.T, v string) stripe.Event {
|
||||||
var e stripe.Event
|
var e stripe.Event
|
||||||
if err := json.Unmarshal([]byte(v), &e); err != nil {
|
if err := json.Unmarshal([]byte(v), &e); err != nil {
|
||||||
@@ -335,3 +803,26 @@ const subscriptionUpdatedEventJSON = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
"heckel.io/ntfy/user"
|
"heckel.io/ntfy/user"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -22,9 +23,15 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
log.SetLevel(log.ErrorLevel)
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishAndPoll(t *testing.T) {
|
func TestServer_PublishAndPoll(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
@@ -122,6 +129,7 @@ func TestServer_PublishAndSubscribe(t *testing.T) {
|
|||||||
|
|
||||||
publishFirstRR := request(t, s, "PUT", "/mytopic", "my first message", nil)
|
publishFirstRR := request(t, s, "PUT", "/mytopic", "my first message", nil)
|
||||||
require.Equal(t, 200, publishFirstRR.Code)
|
require.Equal(t, 200, publishFirstRR.Code)
|
||||||
|
time.Sleep(500 * time.Millisecond) // Publishing is done asynchronously, this avoids races
|
||||||
|
|
||||||
publishSecondRR := request(t, s, "PUT", "/mytopic", "my other message", map[string]string{
|
publishSecondRR := request(t, s, "PUT", "/mytopic", "my other message", map[string]string{
|
||||||
"Title": " This is a title ",
|
"Title": " This is a title ",
|
||||||
@@ -150,6 +158,19 @@ func TestServer_PublishAndSubscribe(t *testing.T) {
|
|||||||
require.Equal(t, []string{"tag1", "tag 2", "tag3"}, messages[2].Tags)
|
require.Equal(t, []string{"tag1", "tag 2", "tag3"}, messages[2].Tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_Publish_Disallowed_Topic(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.DisallowedTopics = []string{"about", "time", "this", "got", "added"}
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
rr := request(t, s, "PUT", "/mytopic", "my first message", nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "PUT", "/about", "another message", nil)
|
||||||
|
require.Equal(t, 400, rr.Code)
|
||||||
|
require.Equal(t, 40010, toHTTPError(t, rr.Body.String()).Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_StaticSites(t *testing.T) {
|
func TestServer_StaticSites(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
@@ -303,6 +324,18 @@ func TestServer_PublishAt(t *testing.T) {
|
|||||||
require.Equal(t, "9.9.9.9", messages[0].Sender.String()) // It's stored in the DB though!
|
require.Equal(t, "9.9.9.9", messages[0].Sender.String()) // It's stored in the DB though!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAt_Expires(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
|
||||||
|
"In": "2 days",
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
m := toMessage(t, response.Body.String())
|
||||||
|
require.True(t, m.Expires > time.Now().Add(12*time.Hour+48*time.Hour-time.Minute).Unix())
|
||||||
|
require.True(t, m.Expires < time.Now().Add(12*time.Hour+48*time.Hour+time.Minute).Unix())
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishAtWithCacheError(t *testing.T) {
|
func TestServer_PublishAtWithCacheError(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
@@ -625,7 +658,7 @@ func TestServer_Auth_Success_Admin(t *testing.T) {
|
|||||||
c := newTestConfigWithAuthFile(t)
|
c := newTestConfigWithAuthFile(t)
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, "unit-test"))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
|
||||||
|
|
||||||
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
@@ -639,7 +672,7 @@ func TestServer_Auth_Success_User(t *testing.T) {
|
|||||||
c.AuthDefault = user.PermissionDenyAll
|
c.AuthDefault = user.PermissionDenyAll
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, "unit-test"))
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
||||||
require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite))
|
require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite))
|
||||||
|
|
||||||
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
||||||
@@ -653,7 +686,7 @@ func TestServer_Auth_Success_User_MultipleTopics(t *testing.T) {
|
|||||||
c.AuthDefault = user.PermissionDenyAll
|
c.AuthDefault = user.PermissionDenyAll
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, "unit-test"))
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
||||||
require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite))
|
require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite))
|
||||||
require.Nil(t, s.userManager.AllowAccess("ben", "anothertopic", user.PermissionReadWrite))
|
require.Nil(t, s.userManager.AllowAccess("ben", "anothertopic", user.PermissionReadWrite))
|
||||||
|
|
||||||
@@ -674,7 +707,7 @@ func TestServer_Auth_Fail_InvalidPass(t *testing.T) {
|
|||||||
c.AuthDefault = user.PermissionDenyAll
|
c.AuthDefault = user.PermissionDenyAll
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, "unit-test"))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
|
||||||
|
|
||||||
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "INVALID"),
|
"Authorization": util.BasicAuth("phil", "INVALID"),
|
||||||
@@ -687,7 +720,7 @@ func TestServer_Auth_Fail_Unauthorized(t *testing.T) {
|
|||||||
c.AuthDefault = user.PermissionDenyAll
|
c.AuthDefault = user.PermissionDenyAll
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, "unit-test"))
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
|
||||||
require.Nil(t, s.userManager.AllowAccess("ben", "sometopic", user.PermissionReadWrite)) // Not mytopic!
|
require.Nil(t, s.userManager.AllowAccess("ben", "sometopic", user.PermissionReadWrite)) // Not mytopic!
|
||||||
|
|
||||||
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
||||||
@@ -701,7 +734,7 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
|
|||||||
c.AuthDefault = user.PermissionReadWrite // Open by default
|
c.AuthDefault = user.PermissionReadWrite // Open by default
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, "unit-test"))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
|
||||||
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "private", user.PermissionDenyAll))
|
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "private", user.PermissionDenyAll))
|
||||||
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead))
|
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead))
|
||||||
|
|
||||||
@@ -726,12 +759,30 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
|
|||||||
require.Equal(t, 403, response.Code) // Anonymous read not allowed
|
require.Equal(t, 403, response.Code) // Anonymous read not allowed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_Auth_Fail_Rate_Limiting(t *testing.T) {
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
response := request(t, s, "PUT", "/announcements", "test", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 401, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := request(t, s, "PUT", "/announcements", "test", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 429, response.Code)
|
||||||
|
require.Equal(t, 42909, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_Auth_ViaQuery(t *testing.T) {
|
func TestServer_Auth_ViaQuery(t *testing.T) {
|
||||||
c := newTestConfigWithAuthFile(t)
|
c := newTestConfigWithAuthFile(t)
|
||||||
c.AuthDefault = user.PermissionDenyAll
|
c.AuthDefault = user.PermissionDenyAll
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("ben", "some pass", user.RoleAdmin, "unit-test"))
|
require.Nil(t, s.userManager.AddUser("ben", "some pass", user.RoleAdmin))
|
||||||
|
|
||||||
u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(util.BasicAuth("ben", "some pass"))))
|
u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(util.BasicAuth("ben", "some pass"))))
|
||||||
response := request(t, s, "GET", u, "", nil)
|
response := request(t, s, "GET", u, "", nil)
|
||||||
@@ -743,15 +794,31 @@ func TestServer_Auth_ViaQuery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_StatsResetter(t *testing.T) {
|
func TestServer_StatsResetter(t *testing.T) {
|
||||||
|
// This tests the stats resetter for
|
||||||
|
// - an anonymous user
|
||||||
|
// - a user without a tier (treated like the same as the anonymous user)
|
||||||
|
// - a user with a tier
|
||||||
|
|
||||||
c := newTestConfigWithAuthFile(t)
|
c := newTestConfigWithAuthFile(t)
|
||||||
c.AuthDefault = user.PermissionDenyAll
|
|
||||||
c.VisitorStatsResetTime = time.Now().Add(2 * time.Second)
|
c.VisitorStatsResetTime = time.Now().Add(2 * time.Second)
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
go s.runStatsResetter()
|
go s.runStatsResetter()
|
||||||
|
|
||||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
// Create user with tier (tieruser) and user without tier (phil)
|
||||||
require.Nil(t, s.userManager.AllowAccess("phil", "mytopic", user.PermissionReadWrite))
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
|
Code: "test",
|
||||||
|
MessageLimit: 5,
|
||||||
|
MessageExpiryDuration: -5 * time.Second, // Second, what a hack!
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||||
|
require.Nil(t, s.userManager.AddUser("tieruser", "tieruser", user.RoleUser))
|
||||||
|
require.Nil(t, s.userManager.ChangeTier("tieruser", "test"))
|
||||||
|
|
||||||
|
// Send an anonymous message
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "test", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
// Send messages from user without tier (phil)
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
response := request(t, s, "PUT", "/mytopic", "test", map[string]string{
|
response := request(t, s, "PUT", "/mytopic", "test", map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
@@ -759,30 +826,150 @@ func TestServer_StatsResetter(t *testing.T) {
|
|||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
response := request(t, s, "GET", "/v1/account", "", map[string]string{
|
// Send messages from user with tier
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
for i := 0; i < 2; i++ {
|
||||||
})
|
response := request(t, s, "PUT", "/mytopic", "test", map[string]string{
|
||||||
require.Equal(t, 200, response.Code)
|
"Authorization": util.BasicAuth("tieruser", "tieruser"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
// User stats show 10 messages
|
// User stats show 6 messages (for user without tier)
|
||||||
response = request(t, s, "GET", "/v1/account", "", map[string]string{
|
response = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
|
account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(5), account.Stats.Messages)
|
require.Equal(t, int64(6), account.Stats.Messages)
|
||||||
|
|
||||||
|
// User stats show 6 messages (for anonymous visitor)
|
||||||
|
response = request(t, s, "GET", "/v1/account", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(6), account.Stats.Messages)
|
||||||
|
|
||||||
|
// User stats show 2 messages (for user with tier)
|
||||||
|
response = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("tieruser", "tieruser"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(2), account.Stats.Messages)
|
||||||
|
|
||||||
// Wait for stats resetter to run
|
// Wait for stats resetter to run
|
||||||
time.Sleep(2200 * time.Millisecond)
|
time.Sleep(2200 * time.Millisecond)
|
||||||
|
|
||||||
// User stats show 0 messages now!
|
// User stats show 0 messages now!
|
||||||
|
response = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(0), account.Stats.Messages)
|
||||||
|
|
||||||
|
// Since this is a user without a tier, the anonymous user should have the same stats
|
||||||
response = request(t, s, "GET", "/v1/account", "", nil)
|
response = request(t, s, "GET", "/v1/account", "", nil)
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
|
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(0), account.Stats.Messages)
|
require.Equal(t, int64(0), account.Stats.Messages)
|
||||||
|
|
||||||
|
// User stats show 0 messages (for user with tier)
|
||||||
|
response = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("tieruser", "tieruser"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(0), account.Stats.Messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_StatsResetter_MessageLimiter_EmailsLimiter(t *testing.T) {
|
||||||
|
// This tests that the messageLimiter (the only fixed limiter) and the emailsLimiter (token bucket)
|
||||||
|
// is reset by the stats resetter
|
||||||
|
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
s.smtpSender = &testMailer{}
|
||||||
|
|
||||||
|
// Publish some messages, and check stats
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "test", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "test", map[string]string{
|
||||||
|
"Email": "test@email.com",
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
rr := request(t, s, "GET", "/v1/account", "", nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(4), account.Stats.Messages)
|
||||||
|
require.Equal(t, int64(1), account.Stats.Emails)
|
||||||
|
v := s.visitor(netip.MustParseAddr("9.9.9.9"), nil)
|
||||||
|
require.Equal(t, int64(4), v.Stats().Messages)
|
||||||
|
require.Equal(t, int64(4), v.messagesLimiter.Value())
|
||||||
|
require.Equal(t, int64(1), v.Stats().Emails)
|
||||||
|
require.Equal(t, int64(1), v.emailsLimiter.Value())
|
||||||
|
|
||||||
|
// Reset stats and check again
|
||||||
|
s.resetStats()
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(0), account.Stats.Messages)
|
||||||
|
require.Equal(t, int64(0), account.Stats.Emails)
|
||||||
|
v = s.visitor(netip.MustParseAddr("9.9.9.9"), nil)
|
||||||
|
require.Equal(t, int64(0), v.Stats().Messages)
|
||||||
|
require.Equal(t, int64(0), v.messagesLimiter.Value())
|
||||||
|
require.Equal(t, int64(0), v.Stats().Emails)
|
||||||
|
require.Equal(t, int64(0), v.emailsLimiter.Value())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_DailyMessageQuotaFromDatabase(t *testing.T) {
|
||||||
|
// This tests that the daily message quota is prefilled originally from the database,
|
||||||
|
// if the visitor is unknown
|
||||||
|
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.AuthStatsQueueWriterInterval = 100 * time.Millisecond
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
// Create user, and update it with some message and email stats
|
||||||
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
|
Code: "test",
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||||
|
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||||
|
|
||||||
|
u, err := s.userManager.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
s.userManager.EnqueueUserStats(u.ID, &user.Stats{
|
||||||
|
Messages: 123456,
|
||||||
|
Emails: 999,
|
||||||
|
})
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
|
||||||
|
// Get account and verify stats are read from the DB, and that the visitor also has these stats
|
||||||
|
rr := request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(123456), account.Stats.Messages)
|
||||||
|
require.Equal(t, int64(999), account.Stats.Emails)
|
||||||
|
v := s.visitor(netip.MustParseAddr("9.9.9.9"), u)
|
||||||
|
require.Equal(t, int64(123456), v.Stats().Messages)
|
||||||
|
require.Equal(t, int64(123456), v.messagesLimiter.Value())
|
||||||
|
require.Equal(t, int64(999), v.Stats().Emails)
|
||||||
|
require.Equal(t, int64(999), v.emailsLimiter.Value())
|
||||||
}
|
}
|
||||||
|
|
||||||
type testMailer struct {
|
type testMailer struct {
|
||||||
@@ -830,7 +1017,7 @@ func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
|
|||||||
func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
|
func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.VisitorRequestLimitBurst = 60
|
c.VisitorRequestLimitBurst = 60
|
||||||
c.VisitorRequestLimitReplenish = 500 * time.Millisecond
|
c.VisitorRequestLimitReplenish = time.Second
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
for i := 0; i < 60; i++ {
|
for i := 0; i < 60; i++ {
|
||||||
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
||||||
@@ -839,7 +1026,7 @@ func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
|
|||||||
response := request(t, s, "PUT", "/mytopic", "message", nil)
|
response := request(t, s, "PUT", "/mytopic", "message", nil)
|
||||||
require.Equal(t, 429, response.Code)
|
require.Equal(t, 429, response.Code)
|
||||||
|
|
||||||
time.Sleep(520 * time.Millisecond)
|
time.Sleep(1020 * time.Millisecond)
|
||||||
response = request(t, s, "PUT", "/mytopic", "message", nil)
|
response = request(t, s, "PUT", "/mytopic", "message", nil)
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
}
|
}
|
||||||
@@ -1132,12 +1319,12 @@ func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) {
|
|||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
// Create tier with certain limits
|
// Create tier with certain limits
|
||||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
Code: "test",
|
Code: "test",
|
||||||
MessagesLimit: 5,
|
MessageLimit: 5,
|
||||||
MessagesExpiryDuration: -5 * time.Second, // Second, what a hack!
|
MessageExpiryDuration: -5 * time.Second, // Second, what a hack!
|
||||||
}))
|
}))
|
||||||
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", "test"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||||
|
|
||||||
// Publish to reach message limit
|
// Publish to reach message limit
|
||||||
@@ -1164,7 +1351,7 @@ func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachment(t *testing.T) {
|
func TestServer_PublishAttachment(t *testing.T) {
|
||||||
content := util.RandomString(5000) // > 4096
|
content := "text file!" + util.RandomString(4990) // > 4096
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
response := request(t, s, "PUT", "/mytopic", content, nil)
|
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
msg := toMessage(t, response.Body.String())
|
msg := toMessage(t, response.Body.String())
|
||||||
@@ -1311,7 +1498,7 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *
|
|||||||
c.VisitorAttachmentTotalSizeLimit = 10000
|
c.VisitorAttachmentTotalSizeLimit = 10000
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
response := request(t, s, "PUT", "/mytopic", util.RandomString(5000), nil)
|
response := request(t, s, "PUT", "/mytopic", "text file!"+util.RandomString(4990), nil)
|
||||||
msg := toMessage(t, response.Body.String())
|
msg := toMessage(t, response.Body.String())
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
require.Equal(t, "You received a file: attachment.txt", msg.Message)
|
require.Equal(t, "You received a file: attachment.txt", msg.Message)
|
||||||
@@ -1361,21 +1548,23 @@ func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
|
|||||||
|
|
||||||
// Create tier with certain limits
|
// Create tier with certain limits
|
||||||
sevenDays := time.Duration(604800) * time.Second
|
sevenDays := time.Duration(604800) * time.Second
|
||||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
Code: "test",
|
Code: "test",
|
||||||
MessagesLimit: 10,
|
MessageLimit: 10,
|
||||||
MessagesExpiryDuration: sevenDays,
|
MessageExpiryDuration: sevenDays,
|
||||||
AttachmentFileSizeLimit: 50_000,
|
AttachmentFileSizeLimit: 50_000,
|
||||||
AttachmentTotalSizeLimit: 200_000,
|
AttachmentTotalSizeLimit: 200_000,
|
||||||
AttachmentExpiryDuration: sevenDays, // 7 days
|
AttachmentExpiryDuration: sevenDays, // 7 days
|
||||||
|
AttachmentBandwidthLimit: 100000,
|
||||||
}))
|
}))
|
||||||
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", "test"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||||
|
|
||||||
// Publish and make sure we can retrieve it
|
// Publish and make sure we can retrieve it
|
||||||
response := request(t, s, "PUT", "/mytopic", content, map[string]string{
|
response := request(t, s, "PUT", "/mytopic", content, map[string]string{
|
||||||
"Authorization": util.BasicAuth("phil", "phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
msg := toMessage(t, response.Body.String())
|
msg := toMessage(t, response.Body.String())
|
||||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
require.True(t, msg.Attachment.Expires > time.Now().Add(sevenDays-30*time.Second).Unix())
|
require.True(t, msg.Attachment.Expires > time.Now().Add(sevenDays-30*time.Second).Unix())
|
||||||
@@ -1396,6 +1585,43 @@ func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
|
|||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentWithTierBasedBandwidthLimit(t *testing.T) {
|
||||||
|
content := util.RandomString(5000) // > 4096
|
||||||
|
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.VisitorAttachmentDailyBandwidthLimit = 1000 // Much lower than tier bandwidth!
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
// Create tier with certain limits
|
||||||
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
|
Code: "test",
|
||||||
|
MessageLimit: 10,
|
||||||
|
MessageExpiryDuration: time.Hour,
|
||||||
|
AttachmentFileSizeLimit: 50_000,
|
||||||
|
AttachmentTotalSizeLimit: 200_000,
|
||||||
|
AttachmentExpiryDuration: time.Hour,
|
||||||
|
AttachmentBandwidthLimit: 14000, // < 3x5000 bytes -> enough for one upload, one download
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||||
|
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||||
|
|
||||||
|
// Publish and make sure we can retrieve it
|
||||||
|
rr := request(t, s, "PUT", "/mytopic", content, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
msg := toMessage(t, rr.Body.String())
|
||||||
|
|
||||||
|
// Retrieve it (first time succeeds)
|
||||||
|
rr = request(t, s, "GET", "/file/"+msg.ID, content, nil) // File downloads do not send auth headers!!
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
require.Equal(t, content, rr.Body.String())
|
||||||
|
|
||||||
|
// Retrieve it AGAIN (fails, due to bandwidth limit)
|
||||||
|
rr = request(t, s, "GET", "/file/"+msg.ID, content, nil)
|
||||||
|
require.Equal(t, 429, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {
|
func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {
|
||||||
smallFile := util.RandomString(20_000)
|
smallFile := util.RandomString(20_000)
|
||||||
largeFile := util.RandomString(50_000)
|
largeFile := util.RandomString(50_000)
|
||||||
@@ -1406,14 +1632,15 @@ func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {
|
|||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
// Create tier with certain limits
|
// Create tier with certain limits
|
||||||
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||||
Code: "test",
|
Code: "test",
|
||||||
MessagesLimit: 100,
|
MessageLimit: 100,
|
||||||
AttachmentFileSizeLimit: 50_000,
|
AttachmentFileSizeLimit: 50_000,
|
||||||
AttachmentTotalSizeLimit: 200_000,
|
AttachmentTotalSizeLimit: 200_000,
|
||||||
AttachmentExpiryDuration: 30 * time.Second,
|
AttachmentExpiryDuration: 30 * time.Second,
|
||||||
|
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", "test"))
|
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||||
|
|
||||||
// Publish small file as anonymous
|
// Publish small file as anonymous
|
||||||
@@ -1499,6 +1726,25 @@ func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) {
|
|||||||
require.Equal(t, 41301, err.Code)
|
require.Equal(t, 41301, err.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentAndImmediatelyGetItWithCacheTimeout(t *testing.T) {
|
||||||
|
// This tests the awkward util.Retry in handleFile: Due to the async persisting of messages,
|
||||||
|
// the message is not immediately available when attempting to download it.
|
||||||
|
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.CacheBatchTimeout = 500 * time.Millisecond
|
||||||
|
c.CacheBatchSize = 10
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
content := "this is an ATTACHMENT"
|
||||||
|
rr := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, nil)
|
||||||
|
m := toMessage(t, rr.Body.String())
|
||||||
|
require.Equal(t, "myfile.txt", m.Attachment.Name)
|
||||||
|
|
||||||
|
path := strings.TrimPrefix(m.Attachment.URL, "http://127.0.0.1:12345")
|
||||||
|
rr = request(t, s, "GET", path, "", nil)
|
||||||
|
require.Equal(t, 200, rr.Code) // Not 404!
|
||||||
|
require.Equal(t, content, rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachmentAccountStats(t *testing.T) {
|
func TestServer_PublishAttachmentAccountStats(t *testing.T) {
|
||||||
content := util.RandomString(4999) // > 4096
|
content := util.RandomString(4999) // > 4096
|
||||||
|
|
||||||
@@ -1531,7 +1777,7 @@ func TestServer_Visitor_XForwardedFor_None(t *testing.T) {
|
|||||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
r.RemoteAddr = "8.9.10.11"
|
r.RemoteAddr = "8.9.10.11"
|
||||||
r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty!
|
r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty!
|
||||||
v, err := s.visitor(r)
|
v, err := s.maybeAuthenticate(r)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "8.9.10.11", v.ip.String())
|
require.Equal(t, "8.9.10.11", v.ip.String())
|
||||||
}
|
}
|
||||||
@@ -1543,7 +1789,7 @@ func TestServer_Visitor_XForwardedFor_Single(t *testing.T) {
|
|||||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
r.RemoteAddr = "8.9.10.11"
|
r.RemoteAddr = "8.9.10.11"
|
||||||
r.Header.Set("X-Forwarded-For", "1.1.1.1")
|
r.Header.Set("X-Forwarded-For", "1.1.1.1")
|
||||||
v, err := s.visitor(r)
|
v, err := s.maybeAuthenticate(r)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "1.1.1.1", v.ip.String())
|
require.Equal(t, "1.1.1.1", v.ip.String())
|
||||||
}
|
}
|
||||||
@@ -1555,7 +1801,7 @@ func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {
|
|||||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
r.RemoteAddr = "8.9.10.11"
|
r.RemoteAddr = "8.9.10.11"
|
||||||
r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ")
|
r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ")
|
||||||
v, err := s.visitor(r)
|
v, err := s.maybeAuthenticate(r)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "234.5.2.1", v.ip.String())
|
require.Equal(t, "234.5.2.1", v.ip.String())
|
||||||
}
|
}
|
||||||
@@ -1568,7 +1814,7 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
|
|||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
// Add lots of messages
|
// Add lots of messages
|
||||||
log.Printf("Adding %d messages", count)
|
log.Info("Adding %d messages", count)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
messages := make([]*message, 0)
|
messages := make([]*message, 0)
|
||||||
for i := 0; i < count; i++ {
|
for i := 0; i < count; i++ {
|
||||||
@@ -1578,37 +1824,73 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
|
|||||||
messages = append(messages, newDefaultMessage(topicID, "some message"))
|
messages = append(messages, newDefaultMessage(topicID, "some message"))
|
||||||
}
|
}
|
||||||
require.Nil(t, s.messageCache.addMessages(messages))
|
require.Nil(t, s.messageCache.addMessages(messages))
|
||||||
log.Printf("Done: Adding %d messages; took %s", count, time.Since(start).Round(time.Millisecond))
|
log.Info("Done: Adding %d messages; took %s", count, time.Since(start).Round(time.Millisecond))
|
||||||
|
|
||||||
// Update stats
|
// Update stats
|
||||||
statsChan := make(chan bool)
|
statsChan := make(chan bool)
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("Updating stats")
|
log.Info("Updating stats")
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
s.execManager()
|
s.execManager()
|
||||||
log.Printf("Done: Updating stats; took %s", time.Since(start).Round(time.Millisecond))
|
log.Info("Done: Updating stats; took %s", time.Since(start).Round(time.Millisecond))
|
||||||
statsChan <- true
|
statsChan <- true
|
||||||
}()
|
}()
|
||||||
time.Sleep(50 * time.Millisecond) // Make sure it starts first
|
time.Sleep(50 * time.Millisecond) // Make sure it starts first
|
||||||
|
|
||||||
// Publish message (during stats update)
|
// Publish message (during stats update)
|
||||||
log.Printf("Publishing message")
|
log.Info("Publishing message")
|
||||||
start = time.Now()
|
start = time.Now()
|
||||||
response := request(t, s, "PUT", "/mytopic", "some body", nil)
|
response := request(t, s, "PUT", "/mytopic", "some body", nil)
|
||||||
m := toMessage(t, response.Body.String())
|
m := toMessage(t, response.Body.String())
|
||||||
assert.Equal(t, "some body", m.Message)
|
assert.Equal(t, "some body", m.Message)
|
||||||
assert.True(t, time.Since(start) < 100*time.Millisecond)
|
assert.True(t, time.Since(start) < 100*time.Millisecond)
|
||||||
log.Printf("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond))
|
log.Info("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond))
|
||||||
|
|
||||||
// Wait for all goroutines
|
// Wait for all goroutines
|
||||||
<-statsChan
|
select {
|
||||||
log.Printf("Done: Waiting for all locks")
|
case <-statsChan:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("Timed out waiting for Go routines")
|
||||||
|
}
|
||||||
|
log.Info("Done: Waiting for all locks")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_AnonymousUser_And_NonTierUser_Are_Same_Visitor(t *testing.T) {
|
||||||
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
defer s.closeDatabases()
|
||||||
|
|
||||||
|
// Create user without tier
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||||
|
|
||||||
|
// Publish a message (anonymous user)
|
||||||
|
rr := request(t, s, "POST", "/mytopic", "hi", nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Publish a message (non-tier user)
|
||||||
|
rr = request(t, s, "POST", "/mytopic", "hi", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// User stats (anonymous user)
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", nil)
|
||||||
|
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Equal(t, int64(2), account.Stats.Messages)
|
||||||
|
|
||||||
|
// User stats (non-tier user)
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Equal(t, int64(2), account.Stats.Messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestConfig(t *testing.T) *Config {
|
func newTestConfig(t *testing.T) *Config {
|
||||||
conf := NewConfig()
|
conf := NewConfig()
|
||||||
conf.BaseURL = "http://127.0.0.1:12345"
|
conf.BaseURL = "http://127.0.0.1:12345"
|
||||||
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
||||||
|
conf.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
|
||||||
conf.AttachmentCacheDir = t.TempDir()
|
conf.AttachmentCacheDir = t.TempDir()
|
||||||
return conf
|
return conf
|
||||||
}
|
}
|
||||||
@@ -1616,6 +1898,8 @@ func newTestConfig(t *testing.T) *Config {
|
|||||||
func newTestConfigWithAuthFile(t *testing.T) *Config {
|
func newTestConfigWithAuthFile(t *testing.T) *Config {
|
||||||
conf := newTestConfig(t)
|
conf := newTestConfig(t)
|
||||||
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||||
|
conf.AuthStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
|
||||||
|
conf.AuthBcryptCost = bcrypt.MinCost // This speeds up tests a lot
|
||||||
return conf
|
return conf
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1653,11 +1937,11 @@ func subscribe(t *testing.T, s *Server, url string, rr *httptest.ResponseRecorde
|
|||||||
done <- true
|
done <- true
|
||||||
}()
|
}()
|
||||||
cancelAndWaitForDone := func() {
|
cancelAndWaitForDone := func() {
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(200 * time.Millisecond)
|
||||||
cancel()
|
cancel()
|
||||||
<-done
|
<-done
|
||||||
}
|
}
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(200 * time.Millisecond)
|
||||||
return cancelAndWaitForDone
|
return cancelAndWaitForDone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,18 @@ func (s *smtpSender) Send(v *visitor, m *message, to string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
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)
|
ev := logvm(v, m).
|
||||||
log.Trace("%s Mail body: %s", logMessagePrefix(v, m), message)
|
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))
|
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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if err != nil {
|
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++
|
s.failure++
|
||||||
} else {
|
} else {
|
||||||
s.success++
|
s.success++
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/emersion/go-smtp"
|
"github.com/emersion/go-smtp"
|
||||||
"heckel.io/ntfy/log"
|
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
@@ -34,6 +33,9 @@ type smtpBackend struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ smtp.Backend = (*smtpBackend)(nil)
|
||||||
|
var _ smtp.Session = (*smtpSession)(nil)
|
||||||
|
|
||||||
func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend {
|
func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend {
|
||||||
return &smtpBackend{
|
return &smtpBackend{
|
||||||
config: conf,
|
config: conf,
|
||||||
@@ -41,14 +43,9 @@ func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Reques
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
|
func (b *smtpBackend) NewSession(conn *smtp.Conn) (smtp.Session, error) {
|
||||||
log.Debug("%s Incoming mail, login with user %s", logSMTPPrefix(state), username)
|
logem(conn).Debug("Incoming mail")
|
||||||
return &smtpSession{backend: b, state: state}, nil
|
return &smtpSession{backend: b, conn: conn}, 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) Counts() (total int64, success int64, failure int64) {
|
func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
|
||||||
@@ -60,23 +57,23 @@ func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
|
|||||||
// smtpSession is returned after EHLO.
|
// smtpSession is returned after EHLO.
|
||||||
type smtpSession struct {
|
type smtpSession struct {
|
||||||
backend *smtpBackend
|
backend *smtpBackend
|
||||||
state *smtp.ConnectionState
|
conn *smtp.Conn
|
||||||
topic string
|
topic string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSession) AuthPlain(username, password string) error {
|
func (s *smtpSession) AuthPlain(username, _ string) error {
|
||||||
log.Debug("%s AUTH PLAIN (with username %s)", logSMTPPrefix(s.state), username)
|
logem(s.conn).Field("smtp_username", username).Debug("AUTH PLAIN (with username %s)", username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error {
|
func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
|
||||||
log.Debug("%s MAIL FROM: %s (with options: %#v)", logSMTPPrefix(s.state), from, opts)
|
logem(s.conn).Field("smtp_mail_from", from).Debug("MAIL FROM: %s", from)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSession) Rcpt(to string) error {
|
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 {
|
return s.withFailCount(func() error {
|
||||||
conf := s.backend.config
|
conf := s.backend.config
|
||||||
addressList, err := mail.ParseAddressList(to)
|
addressList, err := mail.ParseAddressList(to)
|
||||||
@@ -113,10 +110,11 @@ func (s *smtpSession) Data(r io.Reader) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if log.IsTrace() {
|
ev := logem(s.conn)
|
||||||
log.Trace("%s DATA: %s", logSMTPPrefix(s.state), string(b))
|
if ev.IsTrace() {
|
||||||
} else if log.IsDebug() {
|
ev.Field("smtp_data", string(b)).Trace("DATA")
|
||||||
log.Debug("%s DATA: %d byte(s)", logSMTPPrefix(s.state), len(b))
|
} else if ev.IsDebug() {
|
||||||
|
ev.Field("smtp_data_len", len(b)).Debug("DATA")
|
||||||
}
|
}
|
||||||
msg, err := mail.ReadMessage(bytes.NewReader(b))
|
msg, err := mail.ReadMessage(bytes.NewReader(b))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -156,9 +154,9 @@ func (s *smtpSession) Data(r io.Reader) error {
|
|||||||
|
|
||||||
func (s *smtpSession) publishMessage(m *message) error {
|
func (s *smtpSession) publishMessage(m *message) error {
|
||||||
// Extract remote address (for rate limiting)
|
// 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 {
|
if err != nil {
|
||||||
remoteAddr = s.state.RemoteAddr.String()
|
remoteAddr = s.conn.Conn().RemoteAddr().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call HTTP handler with fake HTTP request
|
// Call HTTP handler with fake HTTP request
|
||||||
@@ -198,7 +196,7 @@ func (s *smtpSession) withFailCount(fn func() error) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// Almost all of these errors are parse errors, and user input errors.
|
// Almost all of these errors are parse errors, and user input errors.
|
||||||
// We do not want to spam the log with WARN messages.
|
// 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++
|
s.backend.failure++
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"github.com/emersion/go-smtp"
|
"github.com/emersion/go-smtp"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSmtpBackend_Multipart(t *testing.T) {
|
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
|
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
Subject: and one more
|
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>
|
<div dir="ltr">what's up<br clear="all"><div><br></div></div>
|
||||||
|
|
||||||
--000000000000f3320b05d42915c9--`
|
--000000000000f3320b05d42915c9--
|
||||||
_, 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, "/mytopic", r.URL.Path)
|
require.Equal(t, "/mytopic", r.URL.Path)
|
||||||
require.Equal(t, "and one more", r.Header.Get("Title"))
|
require.Equal(t, "and one more", r.Header.Get("Title"))
|
||||||
require.Equal(t, "what's up", readAll(t, r.Body))
|
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||||
})
|
})
|
||||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
defer s.Close()
|
||||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
defer c.Close()
|
||||||
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSmtpBackend_MultipartNoBody(t *testing.T) {
|
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
|
Date: Tue, 28 Dec 2021 01:33:34 +0100
|
||||||
Message-ID: <CAAvm7ABCDsi9vsuu0WTRXzZQBC8dXrDOLT8iCWdqrsmg@mail.gmail.com>
|
Message-ID: <CAAvm7ABCDsi9vsuu0WTRXzZQBC8dXrDOLT8iCWdqrsmg@mail.gmail.com>
|
||||||
Subject: This email has a subject but no body
|
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>
|
<div dir="ltr"><br></div>
|
||||||
|
|
||||||
--000000000000bcf4a405d429f8d4--`
|
--000000000000bcf4a405d429f8d4--
|
||||||
_, 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, "/emailtest", r.URL.Path)
|
require.Equal(t, "/emailtest", r.URL.Path)
|
||||||
require.Equal(t, "", r.Header.Get("Title")) // We flipped message and body
|
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))
|
require.Equal(t, "This email has a subject but no body", readAll(t, r.Body))
|
||||||
})
|
})
|
||||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
defer s.Close()
|
||||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
defer c.Close()
|
||||||
require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh"))
|
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSmtpBackend_Plaintext(t *testing.T) {
|
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>
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
Subject: and one more
|
Subject: and one more
|
||||||
From: Phil <phil@example.com>
|
From: Phil <phil@example.com>
|
||||||
@@ -80,56 +97,68 @@ To: mytopic@ntfy.sh
|
|||||||
Content-Type: text/plain; charset="UTF-8"
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
what's up
|
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, "/mytopic", r.URL.Path)
|
||||||
require.Equal(t, "and one more", r.Header.Get("Title"))
|
require.Equal(t, "and one more", r.Header.Get("Title"))
|
||||||
require.Equal(t, "what's up", readAll(t, r.Body))
|
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||||
})
|
})
|
||||||
conf.SMTPServerAddrPrefix = ""
|
conf.SMTPServerAddrPrefix = ""
|
||||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
defer s.Close()
|
||||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
defer c.Close()
|
||||||
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSmtpBackend_Plaintext_No_ContentType(t *testing.T) {
|
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
|
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, "/mytopic", r.URL.Path)
|
||||||
require.Equal(t, "Very short mail", r.Header.Get("Title"))
|
require.Equal(t, "Very short mail", r.Header.Get("Title"))
|
||||||
require.Equal(t, "what's up", readAll(t, r.Body))
|
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||||
})
|
})
|
||||||
conf.SMTPServerAddrPrefix = ""
|
conf.SMTPServerAddrPrefix = ""
|
||||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
defer s.Close()
|
||||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
defer c.Close()
|
||||||
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSmtpBackend_Plaintext_EncodedSubject(t *testing.T) {
|
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==?=
|
Subject: =?UTF-8?B?VGhyZWUgc2FudGFzIPCfjoXwn46F8J+OhQ==?=
|
||||||
From: Phil <phil@example.com>
|
From: Phil <phil@example.com>
|
||||||
To: ntfy-mytopic@ntfy.sh
|
To: ntfy-mytopic@ntfy.sh
|
||||||
Content-Type: text/plain; charset="UTF-8"
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
what's up
|
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"))
|
require.Equal(t, "Three santas 🎅🎅🎅", r.Header.Get("Title"))
|
||||||
})
|
})
|
||||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
defer s.Close()
|
||||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
defer c.Close()
|
||||||
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||||
require.Nil(t, session.Data(strings.NewReader(email)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSmtpBackend_Plaintext_TooLongTruncate(t *testing.T) {
|
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>
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
Subject: and one more
|
Subject: and one more
|
||||||
From: Phil <phil@example.com>
|
From: Phil <phil@example.com>
|
||||||
@@ -148,60 +177,61 @@ so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA
|
|||||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
|
||||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
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
|
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
that should do it
|
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.
|
expected := `you know this is a string.
|
||||||
it's a long string.
|
it's a long string.
|
||||||
it's supposed to be longer than the max message length
|
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
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
|
||||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
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
|
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
BBBBBBBBBBBBBBBBBBBBBBBBB`
|
BBBBBBBBBBBBBBBBBBBBBBBBB`
|
||||||
require.Equal(t, 4096, len(expected)) // Sanity check
|
require.Equal(t, 4096, len(expected)) // Sanity check
|
||||||
require.Equal(t, expected, readAll(t, r.Body))
|
require.Equal(t, expected, readAll(t, r.Body))
|
||||||
})
|
})
|
||||||
|
defer s.Close()
|
||||||
|
defer c.Close()
|
||||||
conf.SMTPServerAddrPrefix = ""
|
conf.SMTPServerAddrPrefix = ""
|
||||||
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||||
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)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSmtpBackend_Unsupported(t *testing.T) {
|
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>
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
Subject: and one more
|
Subject: and one more
|
||||||
From: Phil <phil@example.com>
|
From: Phil <phil@example.com>
|
||||||
@@ -283,34 +316,89 @@ To: mytopic@ntfy.sh
|
|||||||
Content-Type: text/SOMETHINGELSE
|
Content-Type: text/SOMETHINGELSE
|
||||||
|
|
||||||
what's up
|
what's up
|
||||||
|
.
|
||||||
`
|
`
|
||||||
conf, backend := newTestBackend(t, func(http.ResponseWriter, *http.Request) {
|
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Nothing.
|
t.Fatal("This should not be called")
|
||||||
})
|
})
|
||||||
conf.SMTPServerAddrPrefix = ""
|
defer s.Close()
|
||||||
session, _ := backend.Login(fakeConnState(t, "1.2.3.4"), "user", "pass")
|
defer c.Close()
|
||||||
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: unsupported content type")
|
||||||
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
|
||||||
require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestBackend(t *testing.T, handler func(http.ResponseWriter, *http.Request)) (*Config, *smtpBackend) {
|
func TestSmtpBackend_InvalidAddress(t *testing.T) {
|
||||||
conf := newTestConfig(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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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.SMTPServerListen = ":25"
|
||||||
conf.SMTPServerDomain = "ntfy.sh"
|
conf.SMTPServerDomain = "ntfy.sh"
|
||||||
conf.SMTPServerAddrPrefix = "ntfy-"
|
conf.SMTPServerAddrPrefix = "ntfy-"
|
||||||
backend := newMailBackend(conf, handler)
|
backend := newMailBackend(conf, handler)
|
||||||
return conf, backend
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
}
|
|
||||||
|
|
||||||
func fakeConnState(t *testing.T, remoteAddr string) *smtp.ConnectionState {
|
|
||||||
ip, err := net.ResolveIPAddr("ip", remoteAddr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return &smtp.ConnectionState{
|
s = smtp.NewServer(backend)
|
||||||
Hostname: "myhostname",
|
s.Domain = conf.SMTPServerDomain
|
||||||
LocalAddr: ip,
|
s.AllowInsecureAuth = true
|
||||||
RemoteAddr: ip,
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ import (
|
|||||||
// can publish a message
|
// can publish a message
|
||||||
type topic struct {
|
type topic struct {
|
||||||
ID string
|
ID string
|
||||||
subscribers map[int]subscriber
|
subscribers map[int]*topicSubscriber
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// subscriber is a function that is called for every new message on a topic
|
||||||
type subscriber func(v *visitor, msg *message) error
|
type subscriber func(v *visitor, msg *message) error
|
||||||
|
|
||||||
@@ -21,16 +27,20 @@ type subscriber func(v *visitor, msg *message) error
|
|||||||
func newTopic(id string) *topic {
|
func newTopic(id string) *topic {
|
||||||
return &topic{
|
return &topic{
|
||||||
ID: id,
|
ID: id,
|
||||||
subscribers: make(map[int]subscriber),
|
subscribers: make(map[int]*topicSubscriber),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe subscribes to this topic
|
// 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()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
subscriberID := rand.Int()
|
subscriberID := rand.Int()
|
||||||
t.subscribers[subscriberID] = s
|
t.subscribers[subscriberID] = &topicSubscriber{
|
||||||
|
userID: userID, // May be empty
|
||||||
|
subscriber: s,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
return subscriberID
|
return subscriberID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,18 +58,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 map here. Actually sending out the messages then doesn't have to lock.
|
||||||
subscribers := t.subscribersCopy()
|
subscribers := t.subscribersCopy()
|
||||||
if len(subscribers) > 0 {
|
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 {
|
for _, s := range subscribers {
|
||||||
// We call the subscriber functions in their own Go routines because they are blocking, and
|
// 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.
|
// we don't want individual slow subscribers to be able to block others.
|
||||||
go func(s subscriber) {
|
go func(s subscriber) {
|
||||||
if err := s(v, m); err != nil {
|
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 {
|
} 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
|
return nil
|
||||||
@@ -72,13 +82,29 @@ func (t *topic) SubscribersCount() int {
|
|||||||
return len(t.subscribers)
|
return len(t.subscribers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// subscribersCopy returns a shallow copy of the subscribers map
|
// CancelSubscribers calls the cancel function for all subscribers, forcing
|
||||||
func (t *topic) subscribersCopy() map[int]subscriber {
|
func (t *topic) CancelSubscribers(exceptUserID string) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
subscribers := make(map[int]subscriber)
|
for _, s := range t.subscribers {
|
||||||
for k, v := range t.subscribers {
|
if s.userID != exceptUserID {
|
||||||
subscribers[k] = v
|
log.Tag(tagSubscribe).Field("topic", t.ID).Debug("Canceling subscriber %s", s.userID)
|
||||||
|
s.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
return subscribers
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/user"
|
"heckel.io/ntfy/user"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -42,6 +43,23 @@ type message struct {
|
|||||||
User string `json:"-"` // Username of the uploader, used to associated attachments
|
User string `json:"-"` // Username of the uploader, used to associated attachments
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *message) Context() log.Context {
|
||||||
|
fields := map[string]any{
|
||||||
|
"message_id": m.ID,
|
||||||
|
"message_time": m.Time,
|
||||||
|
"message_event": m.Event,
|
||||||
|
"message_topic": m.Topic,
|
||||||
|
"message_body_size": len(m.Message),
|
||||||
|
}
|
||||||
|
if m.Sender.IsValid() {
|
||||||
|
fields["message_sender"] = m.Sender.String()
|
||||||
|
}
|
||||||
|
if m.User != "" {
|
||||||
|
fields["message_user"] = m.User
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
type attachment struct {
|
type attachment struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
@@ -227,12 +245,31 @@ type apiAccountCreateRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountPasswordChangeRequest struct {
|
type apiAccountPasswordChangeRequest struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountDeleteRequest struct {
|
||||||
Password string `json:"password"`
|
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 {
|
type apiAccountTokenResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Expires int64 `json:"expires"`
|
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 {
|
type apiAccountTier struct {
|
||||||
@@ -241,7 +278,7 @@ type apiAccountTier struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountLimits 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"`
|
Messages int64 `json:"messages"`
|
||||||
MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
|
MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
|
||||||
Emails int64 `json:"emails"`
|
Emails int64 `json:"emails"`
|
||||||
@@ -249,6 +286,7 @@ type apiAccountLimits struct {
|
|||||||
AttachmentTotalSize int64 `json:"attachment_total_size"`
|
AttachmentTotalSize int64 `json:"attachment_total_size"`
|
||||||
AttachmentFileSize int64 `json:"attachment_file_size"`
|
AttachmentFileSize int64 `json:"attachment_file_size"`
|
||||||
AttachmentExpiryDuration int64 `json:"attachment_expiry_duration"`
|
AttachmentExpiryDuration int64 `json:"attachment_expiry_duration"`
|
||||||
|
AttachmentBandwidth int64 `json:"attachment_bandwidth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountStats struct {
|
type apiAccountStats struct {
|
||||||
@@ -276,17 +314,18 @@ type apiAccountBilling struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountResponse struct {
|
type apiAccountResponse struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Role string `json:"role,omitempty"`
|
Role string `json:"role,omitempty"`
|
||||||
SyncTopic string `json:"sync_topic,omitempty"`
|
SyncTopic string `json:"sync_topic,omitempty"`
|
||||||
Language string `json:"language,omitempty"`
|
Language string `json:"language,omitempty"`
|
||||||
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
||||||
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
||||||
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
||||||
Tier *apiAccountTier `json:"tier,omitempty"`
|
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
|
||||||
Limits *apiAccountLimits `json:"limits,omitempty"`
|
Tier *apiAccountTier `json:"tier,omitempty"`
|
||||||
Stats *apiAccountStats `json:"stats,omitempty"`
|
Limits *apiAccountLimits `json:"limits,omitempty"`
|
||||||
Billing *apiAccountBilling `json:"billing,omitempty"`
|
Stats *apiAccountStats `json:"stats,omitempty"`
|
||||||
|
Billing *apiAccountBilling `json:"billing,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type apiAccountReservationRequest struct {
|
type apiAccountReservationRequest struct {
|
||||||
@@ -353,5 +392,6 @@ type apiStripeSubscriptionUpdatedEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type apiStripeSubscriptionDeletedEvent struct {
|
type apiStripeSubscriptionDeletedEvent struct {
|
||||||
|
ID string `json:"id"`
|
||||||
Customer string `json:"customer"`
|
Customer string `json:"customer"`
|
||||||
}
|
}
|
||||||
|
|||||||
114
server/util.go
114
server/util.go
@@ -1,15 +1,14 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"bufio"
|
||||||
"github.com/emersion/go-smtp"
|
|
||||||
"heckel.io/ntfy/log"
|
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||||
@@ -48,51 +47,6 @@ func readQueryParam(r *http.Request, names ...string) string {
|
|||||||
return ""
|
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 {
|
func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
|
||||||
remoteAddr := r.RemoteAddr
|
remoteAddr := r.RemoteAddr
|
||||||
addrPort, err := netip.ParseAddrPort(remoteAddr)
|
addrPort, err := netip.ParseAddrPort(remoteAddr)
|
||||||
@@ -103,7 +57,7 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
ip = netip.IPv4Unspecified()
|
ip = netip.IPv4Unspecified()
|
||||||
if remoteAddr != "@" || !behindProxy { // RemoteAddr is @ when unix socket is used
|
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 +68,7 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
|
|||||||
ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",")
|
ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",")
|
||||||
realIP, err := netip.ParseAddr(strings.TrimSpace(util.LastString(ips, remoteAddr)))
|
realIP, err := netip.ParseAddr(strings.TrimSpace(util.LastString(ips, remoteAddr)))
|
||||||
if err != nil {
|
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
|
// Fall back to regular remote address if X-Forwarded-For is damaged
|
||||||
} else {
|
} else {
|
||||||
ip = realIP
|
ip = realIP
|
||||||
@@ -123,8 +77,8 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
|
|||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|
||||||
func readJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
|
func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
|
||||||
obj, err := util.UnmarshalJSONWithLimit[T](r, limit)
|
obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty)
|
||||||
if err == util.ErrUnmarshalJSON {
|
if err == util.ErrUnmarshalJSON {
|
||||||
return nil, errHTTPBadRequestJSONInvalid
|
return nil, errHTTPBadRequestJSONInvalid
|
||||||
} else if err == util.ErrTooLargeJSON {
|
} else if err == util.ErrTooLargeJSON {
|
||||||
@@ -134,3 +88,57 @@ func readJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
|
|||||||
}
|
}
|
||||||
return obj, nil
|
return obj, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type httpResponseWriter struct {
|
||||||
|
w http.ResponseWriter
|
||||||
|
headerWritten bool
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpResponseWriterWithHijacker struct {
|
||||||
|
httpResponseWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ http.ResponseWriter = (*httpResponseWriter)(nil)
|
||||||
|
var _ http.Flusher = (*httpResponseWriter)(nil)
|
||||||
|
var _ http.Hijacker = (*httpResponseWriterWithHijacker)(nil)
|
||||||
|
|
||||||
|
func newHTTPResponseWriter(w http.ResponseWriter) http.ResponseWriter {
|
||||||
|
if _, ok := w.(http.Hijacker); ok {
|
||||||
|
return &httpResponseWriterWithHijacker{httpResponseWriter: httpResponseWriter{w: w}}
|
||||||
|
}
|
||||||
|
return &httpResponseWriter{w: w}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *httpResponseWriter) Header() http.Header {
|
||||||
|
return w.w.Header()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *httpResponseWriter) Write(bytes []byte) (int, error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
w.headerWritten = true
|
||||||
|
w.mu.Unlock()
|
||||||
|
return w.w.Write(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *httpResponseWriter) WriteHeader(statusCode int) {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.headerWritten {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.headerWritten = true
|
||||||
|
w.mu.Unlock()
|
||||||
|
w.w.WriteHeader(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *httpResponseWriter) Flush() {
|
||||||
|
if f, ok := w.w.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *httpResponseWriterWithHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
h, _ := w.w.(http.Hijacker)
|
||||||
|
return h.Hijack()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/user"
|
"heckel.io/ntfy/user"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -12,38 +13,56 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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
|
// 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
|
// 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).
|
// 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.
|
// 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
|
// This number is zero, and changing it may have unintended consequences in the web app, or otherwise
|
||||||
visitorDefaultReservationsLimit = int64(0)
|
visitorDefaultReservationsLimit = int64(0)
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter
|
||||||
errVisitorLimitReached = errors.New("limit reached")
|
// 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
|
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
||||||
type visitor struct {
|
type visitor struct {
|
||||||
config *Config
|
config *Config
|
||||||
messageCache *messageCache
|
messageCache *messageCache
|
||||||
userManager *user.Manager // May be nil!
|
userManager *user.Manager // May be nil
|
||||||
ip netip.Addr
|
ip netip.Addr // Visitor IP address
|
||||||
user *user.User
|
user *user.User // Only set if authenticated user, otherwise nil
|
||||||
messages int64 // Number of messages sent, reset every day
|
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
|
||||||
emails int64 // Number of emails sent, reset every day
|
messagesLimiter *util.FixedLimiter // Rate limiter for messages
|
||||||
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
|
emailsLimiter *util.RateLimiter // Rate limiter for emails
|
||||||
messagesLimiter util.Limiter // Rate limiter for messages, may be nil
|
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
|
||||||
emailsLimiter *rate.Limiter // Rate limiter for emails
|
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
|
||||||
subscriptionLimiter util.Limiter // Fixed limiter for active subscriptions (ongoing connections)
|
accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil
|
||||||
bandwidthLimiter util.Limiter // Limiter for attachment bandwidth downloads
|
authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
|
||||||
accountLimiter *rate.Limiter // Rate limiter for account creation
|
firebase time.Time // Next allowed Firebase message
|
||||||
firebase time.Time // Next allowed Firebase message
|
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
|
||||||
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
|
mu sync.RWMutex
|
||||||
mu sync.Mutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type visitorInfo struct {
|
type visitorInfo struct {
|
||||||
@@ -53,13 +72,18 @@ type visitorInfo struct {
|
|||||||
|
|
||||||
type visitorLimits struct {
|
type visitorLimits struct {
|
||||||
Basis visitorLimitBasis
|
Basis visitorLimitBasis
|
||||||
MessagesLimit int64
|
RequestLimitBurst int
|
||||||
MessagesExpiryDuration time.Duration
|
RequestLimitReplenish rate.Limit
|
||||||
EmailsLimit int64
|
MessageLimit int64
|
||||||
|
MessageExpiryDuration time.Duration
|
||||||
|
EmailLimit int64
|
||||||
|
EmailLimitBurst int
|
||||||
|
EmailLimitReplenish rate.Limit
|
||||||
ReservationsLimit int64
|
ReservationsLimit int64
|
||||||
AttachmentTotalSizeLimit int64
|
AttachmentTotalSizeLimit int64
|
||||||
AttachmentFileSizeLimit int64
|
AttachmentFileSizeLimit int64
|
||||||
AttachmentExpiryDuration time.Duration
|
AttachmentExpiryDuration time.Duration
|
||||||
|
AttachmentBandwidthLimit int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type visitorStats struct {
|
type visitorStats struct {
|
||||||
@@ -83,56 +107,93 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
|
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
|
var messages, emails int64
|
||||||
if user != nil {
|
if user != nil {
|
||||||
messages = user.Stats.Messages
|
messages = user.Stats.Messages
|
||||||
emails = user.Stats.Emails
|
emails = user.Stats.Emails
|
||||||
} else {
|
|
||||||
accountLimiter = rate.NewLimiter(rate.Every(conf.VisitorAccountCreateLimitReplenish), conf.VisitorAccountCreateLimitBurst)
|
|
||||||
}
|
}
|
||||||
if user != nil && user.Tier != nil {
|
v := &visitor{
|
||||||
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{
|
|
||||||
config: conf,
|
config: conf,
|
||||||
messageCache: messageCache,
|
messageCache: messageCache,
|
||||||
userManager: userManager, // May be nil
|
userManager: userManager, // May be nil
|
||||||
ip: ip,
|
ip: ip,
|
||||||
user: user,
|
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),
|
firebase: time.Unix(0, 0),
|
||||||
seen: time.Now(),
|
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 {
|
func (v *visitor) Context() log.Context {
|
||||||
if !v.requestLimiter.Allow() {
|
v.mu.RLock()
|
||||||
return errVisitorLimitReached
|
defer v.mu.RUnlock()
|
||||||
}
|
return v.contextNoLock()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) FirebaseAllowed() error {
|
func (v *visitor) contextNoLock() log.Context {
|
||||||
v.mu.Lock()
|
info := v.infoLightNoLock()
|
||||||
defer v.mu.Unlock()
|
fields := log.Context{
|
||||||
if time.Now().Before(v.firebase) {
|
"visitor_ip": v.ip.String(),
|
||||||
return errVisitorLimitReached
|
"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() {
|
func (v *visitor) FirebaseTemporarilyDeny() {
|
||||||
@@ -141,33 +202,72 @@ func (v *visitor) FirebaseTemporarilyDeny() {
|
|||||||
v.firebase = time.Now().Add(v.config.FirebaseQuotaExceededPenaltyDuration)
|
v.firebase = time.Now().Add(v.config.FirebaseQuotaExceededPenaltyDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) MessageAllowed() error {
|
func (v *visitor) MessageAllowed() bool {
|
||||||
if v.messagesLimiter != nil && v.messagesLimiter.Allow(1) != nil {
|
v.mu.RLock() // limiters could be replaced!
|
||||||
return errVisitorLimitReached
|
defer v.mu.RUnlock()
|
||||||
}
|
return v.messagesLimiter.Allow()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) EmailAllowed() error {
|
func (v *visitor) EmailAllowed() bool {
|
||||||
if !v.emailsLimiter.Allow() {
|
v.mu.RLock() // limiters could be replaced!
|
||||||
return errVisitorLimitReached
|
defer v.mu.RUnlock()
|
||||||
}
|
return v.emailsLimiter.Allow()
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) SubscriptionAllowed() error {
|
func (v *visitor) SubscriptionAllowed() bool {
|
||||||
v.mu.Lock()
|
v.mu.RLock() // limiters could be replaced!
|
||||||
defer v.mu.Unlock()
|
defer v.mu.RUnlock()
|
||||||
if err := v.subscriptionLimiter.Allow(1); err != nil {
|
return v.subscriptionLimiter.Allow()
|
||||||
return errVisitorLimitReached
|
}
|
||||||
|
|
||||||
|
// 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() {
|
func (v *visitor) RemoveSubscription() {
|
||||||
v.mu.Lock()
|
v.mu.RLock()
|
||||||
defer v.mu.Unlock()
|
defer v.mu.RUnlock()
|
||||||
v.subscriptionLimiter.Allow(-1)
|
v.subscriptionLimiter.AllowN(-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) Keepalive() {
|
func (v *visitor) Keepalive() {
|
||||||
@@ -177,101 +277,200 @@ func (v *visitor) Keepalive() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) BandwidthLimiter() util.Limiter {
|
func (v *visitor) BandwidthLimiter() util.Limiter {
|
||||||
|
v.mu.RLock() // limiters could be replaced!
|
||||||
|
defer v.mu.RUnlock()
|
||||||
return v.bandwidthLimiter
|
return v.bandwidthLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) Stale() bool {
|
func (v *visitor) Stale() bool {
|
||||||
v.mu.Lock()
|
v.mu.RLock()
|
||||||
defer v.mu.Unlock()
|
defer v.mu.RUnlock()
|
||||||
return time.Since(v.seen) > visitorExpungeAfter
|
return time.Since(v.seen) > visitorExpungeAfter
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) IncrementMessages() {
|
func (v *visitor) Stats() *user.Stats {
|
||||||
v.mu.Lock()
|
v.mu.RLock() // limiters could be replaced!
|
||||||
defer v.mu.Unlock()
|
defer v.mu.RUnlock()
|
||||||
v.messages++
|
return &user.Stats{
|
||||||
if v.user != nil {
|
Messages: v.messagesLimiter.Value(),
|
||||||
v.user.Stats.Messages = v.messages
|
Emails: v.emailsLimiter.Value(),
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) ResetStats() {
|
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()
|
v.mu.Lock()
|
||||||
defer v.mu.Unlock()
|
defer v.mu.Unlock()
|
||||||
v.messages = 0
|
shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver
|
||||||
v.emails = 0
|
v.user = u
|
||||||
if v.user != nil {
|
if shouldResetLimiters {
|
||||||
v.user.Stats.Messages = 0
|
v.resetLimitersNoLock(u.Stats.Messages, u.Stats.Emails, true)
|
||||||
v.user.Stats.Emails = 0
|
|
||||||
// v.messagesLimiter = ... // FIXME
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
func (v *visitor) Limits() *visitorLimits {
|
||||||
v.mu.Lock()
|
v.mu.RLock()
|
||||||
defer v.mu.Unlock()
|
defer v.mu.RUnlock()
|
||||||
limits := defaultVisitorLimits(v.config)
|
return v.limitsNoLock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *visitor) limitsNoLock() *visitorLimits {
|
||||||
if v.user != nil && v.user.Tier != nil {
|
if v.user != nil && v.user.Tier != nil {
|
||||||
limits.Basis = visitorLimitBasisTier
|
return tierBasedVisitorLimits(v.config, v.user.Tier)
|
||||||
limits.MessagesLimit = v.user.Tier.MessagesLimit
|
}
|
||||||
limits.MessagesExpiryDuration = v.user.Tier.MessagesExpiryDuration
|
return configBasedVisitorLimits(v.config)
|
||||||
limits.EmailsLimit = v.user.Tier.EmailsLimit
|
}
|
||||||
limits.ReservationsLimit = v.user.Tier.ReservationsLimit
|
|
||||||
limits.AttachmentTotalSizeLimit = v.user.Tier.AttachmentTotalSizeLimit
|
func tierBasedVisitorLimits(conf *Config, tier *user.Tier) *visitorLimits {
|
||||||
limits.AttachmentFileSizeLimit = v.user.Tier.AttachmentFileSizeLimit
|
return &visitorLimits{
|
||||||
limits.AttachmentExpiryDuration = v.user.Tier.AttachmentExpiryDuration
|
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) {
|
func (v *visitor) Info() (*visitorInfo, error) {
|
||||||
v.mu.Lock()
|
v.mu.RLock()
|
||||||
messages := v.messages
|
info := v.infoLightNoLock()
|
||||||
emails := v.emails
|
v.mu.RUnlock()
|
||||||
v.mu.Unlock()
|
|
||||||
|
// Attachment stats from database
|
||||||
var attachmentsBytesUsed int64
|
var attachmentsBytesUsed int64
|
||||||
var err error
|
var err error
|
||||||
if v.user != nil {
|
u := v.User()
|
||||||
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(v.user.Name)
|
if u != nil {
|
||||||
|
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(u.ID)
|
||||||
} else {
|
} else {
|
||||||
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedBySender(v.ip.String())
|
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedBySender(v.IP().String())
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
info.Stats.AttachmentTotalSize = attachmentsBytesUsed
|
||||||
|
info.Stats.AttachmentTotalSizeRemaining = zeroIfNegative(info.Limits.AttachmentTotalSizeLimit - attachmentsBytesUsed)
|
||||||
|
|
||||||
|
// Reservation stats from database
|
||||||
var reservations int64
|
var reservations int64
|
||||||
if v.user != nil && v.userManager != nil {
|
if v.userManager != nil && u != nil {
|
||||||
reservations, err = v.userManager.ReservationsCount(v.user.Name)
|
reservations, err = v.userManager.ReservationsCount(u.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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{
|
stats := &visitorStats{
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
MessagesRemaining: zeroIfNegative(limits.MessagesLimit - messages),
|
MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages),
|
||||||
Emails: emails,
|
Emails: emails,
|
||||||
EmailsRemaining: zeroIfNegative(limits.EmailsLimit - emails),
|
EmailsRemaining: zeroIfNegative(limits.EmailLimit - emails),
|
||||||
Reservations: reservations,
|
|
||||||
ReservationsRemaining: zeroIfNegative(limits.ReservationsLimit - reservations),
|
|
||||||
AttachmentTotalSize: attachmentsBytesUsed,
|
|
||||||
AttachmentTotalSizeRemaining: zeroIfNegative(limits.AttachmentTotalSizeLimit - attachmentsBytesUsed),
|
|
||||||
}
|
}
|
||||||
return &visitorInfo{
|
return &visitorInfo{
|
||||||
Limits: limits,
|
Limits: limits,
|
||||||
Stats: stats,
|
Stats: stats,
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func zeroIfNegative(value int64) int64 {
|
func zeroIfNegative(value int64) int64 {
|
||||||
if value < 0 {
|
if value < 0 {
|
||||||
return 0
|
return 0
|
||||||
@@ -280,22 +479,16 @@ func zeroIfNegative(value int64) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func replenishDurationToDailyLimit(duration time.Duration) int64 {
|
func replenishDurationToDailyLimit(duration time.Duration) int64 {
|
||||||
return int64(24 * time.Hour / duration)
|
return int64(oneDay / duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dailyLimitToRate(limit int64) rate.Limit {
|
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 {
|
func visitorID(ip netip.Addr, u *user.User) string {
|
||||||
return &visitorLimits{
|
if u != nil && u.Tier != nil {
|
||||||
Basis: visitorLimitBasisIP,
|
return fmt.Sprintf("user:%s", u.ID)
|
||||||
MessagesLimit: replenishDurationToDailyLimit(conf.VisitorRequestLimitReplenish),
|
|
||||||
MessagesExpiryDuration: conf.CacheDuration,
|
|
||||||
EmailsLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish),
|
|
||||||
ReservationsLimit: visitorDefaultReservationsLimit,
|
|
||||||
AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit,
|
|
||||||
AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit,
|
|
||||||
AttachmentExpiryDuration: conf.AttachmentExpiryDuration,
|
|
||||||
}
|
}
|
||||||
|
return fmt.Sprintf("ip:%s", ip.String())
|
||||||
}
|
}
|
||||||
|
|||||||
596
user/manager.go
596
user/manager.go
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,11 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -12,9 +16,9 @@ import (
|
|||||||
const minBcryptTimingMillis = int64(50) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources
|
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) {
|
func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
|
||||||
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, "unit-test"))
|
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))
|
||||||
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", "mytopic", PermissionReadWrite))
|
||||||
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
||||||
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
|
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
|
||||||
@@ -92,20 +96,44 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) {
|
|||||||
|
|
||||||
func TestManager_AddUser_Invalid(t *testing.T) {
|
func TestManager_AddUser_Invalid(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin, "unit-test"))
|
require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin))
|
||||||
require.Equal(t, ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role", "unit-test"))
|
require.Equal(t, ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_AddUser_Timing(t *testing.T) {
|
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()
|
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)
|
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: "active",
|
||||||
|
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) {
|
func TestManager_Authenticate_Timing(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
|
||||||
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, "unit-test"))
|
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
|
||||||
|
|
||||||
// Timing a correct attempt
|
// Timing a correct attempt
|
||||||
start := time.Now().UnixMilli()
|
start := time.Now().UnixMilli()
|
||||||
@@ -126,10 +154,60 @@ func TestManager_Authenticate_Timing(t *testing.T) {
|
|||||||
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
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) {
|
func TestManager_UserManagement(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, "unit-test"))
|
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))
|
||||||
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", "mytopic", PermissionReadWrite))
|
||||||
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
||||||
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
|
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
|
||||||
@@ -141,7 +219,7 @@ func TestManager_UserManagement(t *testing.T) {
|
|||||||
phil, err := a.User("phil")
|
phil, err := a.User("phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "phil", phil.Name)
|
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)
|
require.Equal(t, RoleAdmin, phil.Role)
|
||||||
|
|
||||||
philGrants, err := a.Grants("phil")
|
philGrants, err := a.Grants("phil")
|
||||||
@@ -151,7 +229,7 @@ func TestManager_UserManagement(t *testing.T) {
|
|||||||
ben, err := a.User("ben")
|
ben, err := a.User("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "ben", ben.Name)
|
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)
|
require.Equal(t, RoleUser, ben.Role)
|
||||||
|
|
||||||
benGrants, err := a.Grants("ben")
|
benGrants, err := a.Grants("ben")
|
||||||
@@ -219,7 +297,7 @@ func TestManager_UserManagement(t *testing.T) {
|
|||||||
|
|
||||||
func TestManager_ChangePassword(t *testing.T) {
|
func TestManager_ChangePassword(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
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")
|
_, err := a.Authenticate("phil", "phil")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
@@ -233,7 +311,7 @@ func TestManager_ChangePassword(t *testing.T) {
|
|||||||
|
|
||||||
func TestManager_ChangeRole(t *testing.T) {
|
func TestManager_ChangeRole(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
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", "mytopic", PermissionReadWrite))
|
||||||
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
||||||
|
|
||||||
@@ -258,9 +336,10 @@ func TestManager_ChangeRole(t *testing.T) {
|
|||||||
|
|
||||||
func TestManager_Reservations(t *testing.T) {
|
func TestManager_Reservations(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
require.Nil(t, a.AddUser("phil", "phil", RoleUser))
|
||||||
require.Nil(t, a.ReserveAccess("ben", "ztopic", PermissionDenyAll))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||||
require.Nil(t, a.ReserveAccess("ben", "readme", PermissionRead))
|
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))
|
require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead))
|
||||||
|
|
||||||
reservations, err := a.Reservations("ben")
|
reservations, err := a.Reservations("ben")
|
||||||
@@ -276,35 +355,67 @@ func TestManager_Reservations(t *testing.T) {
|
|||||||
Owner: PermissionReadWrite,
|
Owner: PermissionReadWrite,
|
||||||
Everyone: PermissionDenyAll,
|
Everyone: PermissionDenyAll,
|
||||||
}, reservations[1])
|
}, 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) {
|
func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
require.Nil(t, a.CreateTier(&Tier{
|
require.Nil(t, a.AddTier(&Tier{
|
||||||
Code: "pro",
|
Code: "pro",
|
||||||
Name: "ntfy Pro",
|
Name: "ntfy Pro",
|
||||||
StripePriceID: "price123",
|
StripePriceID: "price123",
|
||||||
MessagesLimit: 5_000,
|
MessageLimit: 5_000,
|
||||||
MessagesExpiryDuration: 3 * 24 * time.Hour,
|
MessageExpiryDuration: 3 * 24 * time.Hour,
|
||||||
EmailsLimit: 50,
|
EmailLimit: 50,
|
||||||
ReservationsLimit: 5,
|
ReservationLimit: 5,
|
||||||
AttachmentFileSizeLimit: 52428800,
|
AttachmentFileSizeLimit: 52428800,
|
||||||
AttachmentTotalSizeLimit: 524288000,
|
AttachmentTotalSizeLimit: 524288000,
|
||||||
AttachmentExpiryDuration: 24 * time.Hour,
|
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.ChangeTier("ben", "pro"))
|
||||||
require.Nil(t, a.ReserveAccess("ben", "mytopic", PermissionDenyAll))
|
require.Nil(t, a.AddReservation("ben", "mytopic", PermissionDenyAll))
|
||||||
|
|
||||||
ben, err := a.User("ben")
|
ben, err := a.User("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, RoleUser, ben.Role)
|
require.Equal(t, RoleUser, ben.Role)
|
||||||
require.Equal(t, "pro", ben.Tier.Code)
|
require.Equal(t, "pro", ben.Tier.Code)
|
||||||
require.Equal(t, true, ben.Tier.Paid)
|
require.Equal(t, int64(5000), ben.Tier.MessageLimit)
|
||||||
require.Equal(t, int64(5000), ben.Tier.MessagesLimit)
|
require.Equal(t, 3*24*time.Hour, ben.Tier.MessageExpiryDuration)
|
||||||
require.Equal(t, 3*24*time.Hour, ben.Tier.MessagesExpiryDuration)
|
require.Equal(t, int64(50), ben.Tier.EmailLimit)
|
||||||
require.Equal(t, int64(50), ben.Tier.EmailsLimit)
|
require.Equal(t, int64(5), ben.Tier.ReservationLimit)
|
||||||
require.Equal(t, int64(5), ben.Tier.ReservationsLimit)
|
|
||||||
require.Equal(t, int64(52428800), ben.Tier.AttachmentFileSizeLimit)
|
require.Equal(t, int64(52428800), ben.Tier.AttachmentFileSizeLimit)
|
||||||
require.Equal(t, int64(524288000), ben.Tier.AttachmentTotalSizeLimit)
|
require.Equal(t, int64(524288000), ben.Tier.AttachmentTotalSizeLimit)
|
||||||
require.Equal(t, 24*time.Hour, ben.Tier.AttachmentExpiryDuration)
|
require.Equal(t, 24*time.Hour, ben.Tier.AttachmentExpiryDuration)
|
||||||
@@ -340,15 +451,16 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
|
|||||||
|
|
||||||
func TestManager_Token_Valid(t *testing.T) {
|
func TestManager_Token_Valid(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
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")
|
u, err := a.User("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
// Create token for user
|
// 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.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
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())
|
require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires.Unix())
|
||||||
|
|
||||||
u2, err := a.AuthenticateToken(token.Value)
|
u2, err := a.AuthenticateToken(token.Value)
|
||||||
@@ -356,16 +468,34 @@ func TestManager_Token_Valid(t *testing.T) {
|
|||||||
require.Equal(t, u.Name, u2.Name)
|
require.Equal(t, u.Name, u2.Name)
|
||||||
require.Equal(t, token.Value, u2.Token)
|
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
|
// 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)
|
u3, err := a.AuthenticateToken(token.Value)
|
||||||
require.Equal(t, ErrUnauthenticated, err)
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
require.Nil(t, u3)
|
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) {
|
func TestManager_Token_Invalid(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
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
|
u, err := a.AuthenticateToken(strings.Repeat("x", 32)) // 32 == token length
|
||||||
require.Nil(t, u)
|
require.Nil(t, u)
|
||||||
@@ -376,20 +506,26 @@ func TestManager_Token_Invalid(t *testing.T) {
|
|||||||
require.Equal(t, ErrUnauthenticated, err)
|
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) {
|
func TestManager_Token_Expire(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
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")
|
u, err := a.User("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
// Create tokens for user
|
// 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.Nil(t, err)
|
||||||
require.NotEmpty(t, token1.Value)
|
require.NotEmpty(t, token1.Value)
|
||||||
require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix())
|
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.Nil(t, err)
|
||||||
require.NotEmpty(t, token2.Value)
|
require.NotEmpty(t, token2.Value)
|
||||||
require.NotEqual(t, token1.Value, token2.Value)
|
require.NotEqual(t, token1.Value, token2.Value)
|
||||||
@@ -426,34 +562,34 @@ func TestManager_Token_Expire(t *testing.T) {
|
|||||||
|
|
||||||
func TestManager_Token_Extend(t *testing.T) {
|
func TestManager_Token_Extend(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
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
|
// Try to extend token for user without token
|
||||||
u, err := a.User("ben")
|
u, err := a.User("ben")
|
||||||
require.Nil(t, err)
|
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)
|
require.Equal(t, errNoTokenProvided, err)
|
||||||
|
|
||||||
// Create token for user
|
// 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.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
|
|
||||||
userWithToken, err := a.AuthenticateToken(token.Value)
|
userWithToken, err := a.AuthenticateToken(token.Value)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
time.Sleep(1100 * time.Millisecond)
|
extendedToken, err := a.ChangeToken(userWithToken.ID, userWithToken.Token, util.String("changed label"), util.Time(time.Now().Add(100*time.Hour)))
|
||||||
|
|
||||||
extendedToken, err := a.ExtendToken(userWithToken)
|
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, token.Value, extendedToken.Value)
|
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, 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) {
|
func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
||||||
a := newTestManager(t, PermissionDenyAll)
|
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
|
// Try to extend token for user without token
|
||||||
u, err := a.User("ben")
|
u, err := a.User("ben")
|
||||||
@@ -462,8 +598,8 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
|||||||
// Tokens
|
// Tokens
|
||||||
baseTime := time.Now().Add(24 * time.Hour)
|
baseTime := time.Now().Add(24 * time.Hour)
|
||||||
tokens := make([]string, 0)
|
tokens := make([]string, 0)
|
||||||
for i := 0; i < 12; i++ {
|
for i := 0; i < 22; i++ {
|
||||||
token, err := a.CreateToken(u)
|
token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour), netip.IPv4Unspecified())
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.NotEmpty(t, token.Value)
|
require.NotEmpty(t, token.Value)
|
||||||
tokens = append(tokens, token.Value)
|
tokens = append(tokens, token.Value)
|
||||||
@@ -479,7 +615,7 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
|||||||
_, err = a.AuthenticateToken(tokens[1])
|
_, err = a.AuthenticateToken(tokens[1])
|
||||||
require.Equal(t, ErrUnauthenticated, err)
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
|
|
||||||
for i := 2; i < 12; i++ {
|
for i := 2; i < 22; i++ {
|
||||||
userWithToken, err := a.AuthenticateToken(tokens[i])
|
userWithToken, err := a.AuthenticateToken(tokens[i])
|
||||||
require.Nil(t, err, "token[%d]=%s failed", i, tokens[i])
|
require.Nil(t, err, "token[%d]=%s failed", i, tokens[i])
|
||||||
require.Equal(t, "ben", userWithToken.Name)
|
require.Equal(t, "ben", userWithToken.Name)
|
||||||
@@ -491,23 +627,23 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.True(t, rows.Next())
|
require.True(t, rows.Next())
|
||||||
require.Nil(t, rows.Scan(&count))
|
require.Nil(t, rows.Scan(&count))
|
||||||
require.Equal(t, 10, count)
|
require.Equal(t, 20, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestManager_EnqueueStats(t *testing.T) {
|
func TestManager_EnqueueStats_ResetStats(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, err)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||||
|
|
||||||
// Baseline: No messages or emails
|
// Baseline: No messages or emails
|
||||||
u, err := a.User("ben")
|
u, err := a.User("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(0), u.Stats.Messages)
|
require.Equal(t, int64(0), u.Stats.Messages)
|
||||||
require.Equal(t, int64(0), u.Stats.Emails)
|
require.Equal(t, int64(0), u.Stats.Emails)
|
||||||
|
a.EnqueueUserStats(u.ID, &Stats{
|
||||||
u.Stats.Messages = 11
|
Messages: 11,
|
||||||
u.Stats.Emails = 2
|
Emails: 2,
|
||||||
a.EnqueueStats(u)
|
})
|
||||||
|
|
||||||
// Still no change, because it's queued asynchronously
|
// Still no change, because it's queued asynchronously
|
||||||
u, err = a.User("ben")
|
u, err = a.User("ben")
|
||||||
@@ -522,49 +658,260 @@ func TestManager_EnqueueStats(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(11), u.Stats.Messages)
|
require.Equal(t, int64(11), u.Stats.Messages)
|
||||||
require.Equal(t, int64(2), u.Stats.Emails)
|
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) {
|
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, err)
|
||||||
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser))
|
||||||
|
|
||||||
// No settings
|
// No settings
|
||||||
u, err := a.User("ben")
|
u, err := a.User("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Nil(t, u.Prefs.Subscriptions)
|
require.Nil(t, u.Prefs.Subscriptions)
|
||||||
require.Nil(t, u.Prefs.Notification)
|
require.Nil(t, u.Prefs.Notification)
|
||||||
require.Equal(t, "", u.Prefs.Language)
|
require.Nil(t, u.Prefs.Language)
|
||||||
|
|
||||||
// Save with new settings
|
// Save with new settings
|
||||||
u.Prefs = &Prefs{
|
prefs := &Prefs{
|
||||||
Language: "de",
|
Language: util.String("de"),
|
||||||
Notification: &NotificationPrefs{
|
Notification: &NotificationPrefs{
|
||||||
Sound: "ding",
|
Sound: util.String("ding"),
|
||||||
MinPriority: 2,
|
MinPriority: util.Int(2),
|
||||||
},
|
},
|
||||||
Subscriptions: []*Subscription{
|
Subscriptions: []*Subscription{
|
||||||
{
|
{
|
||||||
ID: "someID",
|
|
||||||
BaseURL: "https://ntfy.sh",
|
BaseURL: "https://ntfy.sh",
|
||||||
Topic: "mytopic",
|
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
|
// Read again
|
||||||
u, err = a.User("ben")
|
u, err = a.User("ben")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, "de", u.Prefs.Language)
|
require.Equal(t, util.String("de"), u.Prefs.Language)
|
||||||
require.Equal(t, "ding", u.Prefs.Notification.Sound)
|
require.Equal(t, util.String("ding"), u.Prefs.Notification.Sound)
|
||||||
require.Equal(t, 2, u.Prefs.Notification.MinPriority)
|
require.Equal(t, util.Int(2), u.Prefs.Notification.MinPriority)
|
||||||
require.Equal(t, 0, u.Prefs.Notification.DeleteAfter)
|
require.Nil(t, u.Prefs.Notification.DeleteAfter)
|
||||||
require.Equal(t, "someID", u.Prefs.Subscriptions[0].ID)
|
|
||||||
require.Equal(t, "https://ntfy.sh", u.Prefs.Subscriptions[0].BaseURL)
|
require.Equal(t, "https://ntfy.sh", u.Prefs.Subscriptions[0].BaseURL)
|
||||||
require.Equal(t, "mytopic", u.Prefs.Subscriptions[0].Topic)
|
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,
|
||||||
|
StripePriceID: "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,
|
||||||
|
StripePriceID: "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.StripePriceID)
|
||||||
|
|
||||||
|
// 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.StripePriceID)
|
||||||
|
|
||||||
|
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.StripePriceID)
|
||||||
|
|
||||||
|
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.StripePriceID)
|
||||||
|
|
||||||
|
// 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) {
|
func TestSqliteCache_Migration_From1(t *testing.T) {
|
||||||
@@ -609,7 +956,7 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
|
|||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
// Create manager to trigger migration
|
// Create manager to trigger migration
|
||||||
a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, userStatsQueueWriterInterval)
|
a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval)
|
||||||
checkSchemaVersion(t, a.db)
|
checkSchemaVersion(t, a.db)
|
||||||
|
|
||||||
users, err := a.Users()
|
users, err := a.Users()
|
||||||
@@ -626,11 +973,14 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
|
|||||||
everyoneGrants, err := a.Grants(Everyone)
|
everyoneGrants, err := a.Grants(Everyone)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
require.True(t, strings.HasPrefix(phil.ID, "u_"))
|
||||||
require.Equal(t, "phil", phil.Name)
|
require.Equal(t, "phil", phil.Name)
|
||||||
require.Equal(t, RoleAdmin, phil.Role)
|
require.Equal(t, RoleAdmin, phil.Role)
|
||||||
require.Equal(t, syncTopicLength, len(phil.SyncTopic))
|
require.Equal(t, syncTopicLength, len(phil.SyncTopic))
|
||||||
require.Equal(t, 0, len(philGrants))
|
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, "ben", ben.Name)
|
||||||
require.Equal(t, RoleUser, ben.Role)
|
require.Equal(t, RoleUser, ben.Role)
|
||||||
require.Equal(t, syncTopicLength, len(ben.SyncTopic))
|
require.Equal(t, syncTopicLength, len(ben.SyncTopic))
|
||||||
@@ -641,6 +991,7 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
|
|||||||
require.Equal(t, "secret", benGrants[1].TopicPattern)
|
require.Equal(t, "secret", benGrants[1].TopicPattern)
|
||||||
require.Equal(t, PermissionRead, benGrants[1].Allow)
|
require.Equal(t, PermissionRead, benGrants[1].Allow)
|
||||||
|
|
||||||
|
require.Equal(t, "u_everyone", everyone.ID)
|
||||||
require.Equal(t, Everyone, everyone.Name)
|
require.Equal(t, Everyone, everyone.Name)
|
||||||
require.Equal(t, RoleAnonymous, everyone.Role)
|
require.Equal(t, RoleAnonymous, everyone.Role)
|
||||||
require.Equal(t, 1, len(everyoneGrants))
|
require.Equal(t, 1, len(everyoneGrants))
|
||||||
@@ -660,11 +1011,11 @@ func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newTestManager(t *testing.T, defaultAccess Permission) *Manager {
|
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 {
|
func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, bcryptCost int, statsWriterInterval time.Duration) *Manager {
|
||||||
a, err := newManager(filename, startupQueries, defaultAccess, statsWriterInterval)
|
a, err := NewManager(filename, startupQueries, defaultAccess, bcryptCost, statsWriterInterval)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|||||||
101
user/types.go
101
user/types.go
@@ -1,15 +1,18 @@
|
|||||||
// Package user deals with authentication and authorization against topics
|
|
||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/stripe/stripe-go/v74"
|
"github.com/stripe/stripe-go/v74"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
|
"net/netip"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// User is a struct that represents a user
|
// User is a struct that represents a user
|
||||||
type User struct {
|
type User struct {
|
||||||
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Hash string // password hash (bcrypt)
|
Hash string // password hash (bcrypt)
|
||||||
Token string // Only set if token was used to log in
|
Token string // Only set if token was used to log in
|
||||||
@@ -19,8 +22,26 @@ type User struct {
|
|||||||
Stats *Stats
|
Stats *Stats
|
||||||
Billing *Billing
|
Billing *Billing
|
||||||
SyncTopic string
|
SyncTopic string
|
||||||
Created time.Time
|
Deleted bool
|
||||||
LastSeen time.Time
|
}
|
||||||
|
|
||||||
|
// 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
|
// Auther is an interface for authentication and authorization
|
||||||
@@ -37,45 +58,71 @@ type Auther interface {
|
|||||||
|
|
||||||
// Token represents a user token, including expiry date
|
// Token represents a user token, including expiry date
|
||||||
type Token struct {
|
type Token struct {
|
||||||
Value string
|
Value string
|
||||||
Expires time.Time
|
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
|
// Prefs represents a user's configuration settings
|
||||||
type Prefs struct {
|
type Prefs struct {
|
||||||
Language string `json:"language,omitempty"`
|
Language *string `json:"language,omitempty"`
|
||||||
Notification *NotificationPrefs `json:"notification,omitempty"`
|
Notification *NotificationPrefs `json:"notification,omitempty"`
|
||||||
Subscriptions []*Subscription `json:"subscriptions,omitempty"`
|
Subscriptions []*Subscription `json:"subscriptions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier represents a user's account type, including its account limits
|
// Tier represents a user's account type, including its account limits
|
||||||
type Tier struct {
|
type Tier struct {
|
||||||
Code string
|
ID string // Tier identifier (ti_...)
|
||||||
Name string
|
Code string // Code of the tier
|
||||||
Paid bool
|
Name string // Name of the tier
|
||||||
MessagesLimit int64
|
MessageLimit int64 // Daily message limit
|
||||||
MessagesExpiryDuration time.Duration
|
MessageExpiryDuration time.Duration // Cache duration for messages
|
||||||
EmailsLimit int64
|
EmailLimit int64 // Daily email limit
|
||||||
ReservationsLimit int64
|
ReservationLimit int64 // Number of topic reservations allowed by user
|
||||||
AttachmentFileSizeLimit int64
|
AttachmentFileSizeLimit int64 // Max file size per file (bytes)
|
||||||
AttachmentTotalSizeLimit int64
|
AttachmentTotalSizeLimit int64 // Total file size for all files of this user (bytes)
|
||||||
AttachmentExpiryDuration time.Duration
|
AttachmentExpiryDuration time.Duration // Duration after which attachments will be deleted
|
||||||
StripePriceID string
|
AttachmentBandwidthLimit int64 // Daily bandwidth limit for the user
|
||||||
|
StripePriceID string // Price ID for paid tiers (price_...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context returns fields for the log
|
||||||
|
func (t *Tier) Context() log.Context {
|
||||||
|
return log.Context{
|
||||||
|
"tier_id": t.ID,
|
||||||
|
"tier_code": t.Code,
|
||||||
|
"stripe_price_id": t.StripePriceID,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscription represents a user's topic subscription
|
// Subscription represents a user's topic subscription
|
||||||
type Subscription struct {
|
type Subscription struct {
|
||||||
ID string `json:"id"`
|
BaseURL string `json:"base_url"`
|
||||||
BaseURL string `json:"base_url"`
|
Topic string `json:"topic"`
|
||||||
Topic string `json:"topic"`
|
DisplayName *string `json:"display_name"`
|
||||||
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
|
// NotificationPrefs represents the user's notification settings
|
||||||
type NotificationPrefs struct {
|
type NotificationPrefs struct {
|
||||||
Sound string `json:"sound,omitempty"`
|
Sound *string `json:"sound,omitempty"`
|
||||||
MinPriority int `json:"min_priority,omitempty"`
|
MinPriority *int `json:"min_priority,omitempty"`
|
||||||
DeleteAfter int `json:"delete_after,omitempty"`
|
DeleteAfter *int `json:"delete_after,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats is a struct holding daily user statistics
|
// Stats is a struct holding daily user statistics
|
||||||
@@ -131,7 +178,7 @@ func NewPermission(read, write bool) Permission {
|
|||||||
|
|
||||||
// ParsePermission parses the string representation and returns a Permission
|
// ParsePermission parses the string representation and returns a Permission
|
||||||
func ParsePermission(s string) (Permission, error) {
|
func ParsePermission(s string) (Permission, error) {
|
||||||
switch s {
|
switch strings.ToLower(s) {
|
||||||
case "read-write", "rw":
|
case "read-write", "rw":
|
||||||
return NewPermission(true, true), nil
|
return NewPermission(true, true), nil
|
||||||
case "read-only", "read", "ro":
|
case "read-only", "read", "ro":
|
||||||
@@ -184,7 +231,8 @@ const (
|
|||||||
|
|
||||||
// Everyone is a special username representing anonymous users
|
// Everyone is a special username representing anonymous users
|
||||||
const (
|
const (
|
||||||
Everyone = "*"
|
Everyone = "*"
|
||||||
|
everyoneID = "u_everyone"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -226,5 +274,6 @@ var (
|
|||||||
ErrInvalidArgument = errors.New("invalid argument")
|
ErrInvalidArgument = errors.New("invalid argument")
|
||||||
ErrUserNotFound = errors.New("user not found")
|
ErrUserNotFound = errors.New("user not found")
|
||||||
ErrTierNotFound = errors.New("tier not found")
|
ErrTierNotFound = errors.New("tier not found")
|
||||||
|
ErrTokenNotFound = errors.New("token not found")
|
||||||
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
||||||
)
|
)
|
||||||
|
|||||||
60
user/types_test.go
Normal file
60
user/types_test.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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",
|
||||||
|
StripePriceID: "price_123",
|
||||||
|
}
|
||||||
|
context := tier.Context()
|
||||||
|
require.Equal(t, "ti_abc", context["tier_id"])
|
||||||
|
require.Equal(t, "pro", context["tier_code"])
|
||||||
|
require.Equal(t, "price_123", context["stripe_price_id"])
|
||||||
|
}
|
||||||
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
|
// Limiter is an interface that implements a rate limiting mechanism, e.g. based on time or a fixed value
|
||||||
type Limiter interface {
|
type Limiter interface {
|
||||||
// Allow adds n to the limiters internal value, or returns ErrLimitReached if the limit has been reached
|
// Allow adds one to the limiters value, or returns false if the limit has been reached
|
||||||
Allow(n int64) error
|
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
|
// 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
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ Limiter = (*FixedLimiter)(nil)
|
||||||
|
|
||||||
// NewFixedLimiter creates a new Limiter
|
// NewFixedLimiter creates a new Limiter
|
||||||
func NewFixedLimiter(limit int64) *FixedLimiter {
|
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{
|
return &FixedLimiter{
|
||||||
limit: limit,
|
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
|
// Allow adds one 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.
|
// exceeded, false is returned.
|
||||||
func (l *FixedLimiter) Allow(n int64) error {
|
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()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
if l.value+n > l.limit {
|
if l.value+n > l.limit {
|
||||||
return ErrLimitReached
|
return false
|
||||||
}
|
}
|
||||||
l.value += n
|
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.
|
// RateLimiter is a Limiter that wraps a rate.Limiter, allowing a floating time-based limit.
|
||||||
type RateLimiter struct {
|
type RateLimiter struct {
|
||||||
|
r rate.Limit
|
||||||
|
b int
|
||||||
|
value int64
|
||||||
limiter *rate.Limiter
|
limiter *rate.Limiter
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ Limiter = (*RateLimiter)(nil)
|
||||||
|
|
||||||
// NewRateLimiter creates a new RateLimiter
|
// NewRateLimiter creates a new RateLimiter
|
||||||
func NewRateLimiter(r rate.Limit, b int) *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{
|
return &RateLimiter{
|
||||||
|
r: r,
|
||||||
|
b: b,
|
||||||
|
value: value,
|
||||||
limiter: rate.NewLimiter(r, b),
|
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)
|
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
|
// Allow adds one 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.
|
// exceeded, false is returned.
|
||||||
func (l *RateLimiter) Allow(n int64) error {
|
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 {
|
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)) {
|
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
|
// 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()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
for i := 0; i < len(w.limiters); i++ {
|
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-- {
|
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
|
return 0, ErrLimitReached
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,26 +7,31 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFixedLimiter_Add(t *testing.T) {
|
func TestFixedLimiter_AllowValueReset(t *testing.T) {
|
||||||
l := NewFixedLimiter(10)
|
l := NewFixedLimiter(10)
|
||||||
if err := l.Allow(5); err != nil {
|
require.True(t, l.AllowN(5))
|
||||||
t.Fatal(err)
|
require.Equal(t, int64(5), l.Value())
|
||||||
}
|
|
||||||
if err := l.Allow(5); err != nil {
|
require.True(t, l.AllowN(5))
|
||||||
t.Fatal(err)
|
require.Equal(t, int64(10), l.Value())
|
||||||
}
|
|
||||||
if err := l.Allow(5); err != ErrLimitReached {
|
require.False(t, l.Allow())
|
||||||
t.Fatalf("expected ErrLimitReached, got %#v", err)
|
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) {
|
func TestFixedLimiter_AddSub(t *testing.T) {
|
||||||
l := NewFixedLimiter(10)
|
l := NewFixedLimiter(10)
|
||||||
l.Allow(5)
|
l.AllowN(5)
|
||||||
if l.value != 5 {
|
if l.value != 5 {
|
||||||
t.Fatalf("expected value to be %d, got %d", 5, l.value)
|
t.Fatalf("expected value to be %d, got %d", 5, l.value)
|
||||||
}
|
}
|
||||||
l.Allow(-2)
|
l.AllowN(-2)
|
||||||
if l.value != 3 {
|
if l.value != 3 {
|
||||||
t.Fatalf("expected value to be %d, got %d", 7, l.value)
|
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) {
|
func TestBytesLimiter_Add_Simple(t *testing.T) {
|
||||||
l := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h
|
l := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h
|
||||||
require.Nil(t, l.Allow(100*1024*1024))
|
require.True(t, l.AllowN(100*1024*1024))
|
||||||
require.Nil(t, l.Allow(100*1024*1024))
|
require.Equal(t, int64(100*1024*1024), l.Value())
|
||||||
require.Equal(t, ErrLimitReached, l.Allow(300*1024*1024))
|
|
||||||
|
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) {
|
func TestBytesLimiter_Add_Wait(t *testing.T) {
|
||||||
l := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h (~ 303 bytes per 100ms)
|
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.True(t, l.AllowN(250*1024*1024))
|
||||||
require.Equal(t, ErrLimitReached, l.Allow(400))
|
require.False(t, l.AllowN(400))
|
||||||
time.Sleep(200 * time.Millisecond)
|
time.Sleep(200 * time.Millisecond)
|
||||||
require.Nil(t, l.Allow(400))
|
require.True(t, l.AllowN(400))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLimitWriter_WriteNoLimiter(t *testing.T) {
|
func TestLimitWriter_WriteNoLimiter(t *testing.T) {
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import (
|
|||||||
//
|
//
|
||||||
// Example:
|
// Example:
|
||||||
//
|
//
|
||||||
// lookup := func() (string, error) {
|
// lookup := func() (string, error) {
|
||||||
// r, _ := http.Get("...")
|
// r, _ := http.Get("...")
|
||||||
// s, _ := io.ReadAll(r.Body)
|
// s, _ := io.ReadAll(r.Body)
|
||||||
// return string(s), nil
|
// return string(s), nil
|
||||||
// }
|
// }
|
||||||
// c := NewLookupCache[string](lookup, time.Hour)
|
// c := NewLookupCache[string](lookup, time.Hour)
|
||||||
// fmt.Println(c.Get()) // Fetches the string via HTTP
|
// fmt.Println(c.Get()) // Fetches the string via HTTP
|
||||||
// fmt.Println(c.Get()) // Uses cached value
|
// fmt.Println(c.Get()) // Uses cached value
|
||||||
type LookupCache[T any] struct {
|
type LookupCache[T any] struct {
|
||||||
value *T
|
value *T
|
||||||
lookup func() (T, error)
|
lookup func() (T, error)
|
||||||
@@ -26,8 +26,12 @@ type LookupCache[T any] struct {
|
|||||||
mu sync.Mutex
|
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)
|
// 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]{
|
return &LookupCache[T]{
|
||||||
value: nil,
|
value: nil,
|
||||||
lookup: lookup,
|
lookup: lookup,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ var (
|
|||||||
// NextOccurrenceUTC takes a time of day (e.g. 9:00am), and returns the next occurrence
|
// NextOccurrenceUTC takes a time of day (e.g. 9:00am), and returns the next occurrence
|
||||||
// of that time from the current time (in UTC).
|
// of that time from the current time (in UTC).
|
||||||
func NextOccurrenceUTC(timeOfDay, base time.Time) time.Time {
|
func NextOccurrenceUTC(timeOfDay, base time.Time) time.Time {
|
||||||
hour, minute, seconds := timeOfDay.Clock()
|
hour, minute, seconds := timeOfDay.UTC().Clock()
|
||||||
now := base.UTC()
|
now := base.UTC()
|
||||||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, seconds, 0, time.UTC)
|
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, seconds, 0, time.UTC)
|
||||||
if next.Before(now) {
|
if next.Before(now) {
|
||||||
|
|||||||
77
util/util.go
77
util/util.go
@@ -1,10 +1,12 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -107,13 +109,18 @@ func LastString(s []string, def string) string {
|
|||||||
|
|
||||||
// RandomString returns a random string with a given length
|
// RandomString returns a random string with a given length
|
||||||
func RandomString(length int) string {
|
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?!
|
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
|
||||||
defer randomMutex.Unlock()
|
defer randomMutex.Unlock()
|
||||||
b := make([]byte, length)
|
b := make([]byte, length-len(prefix))
|
||||||
for i := range b {
|
for i := range b {
|
||||||
b[i] = randomStringCharset[random.Intn(len(randomStringCharset))]
|
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
|
// 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
|
// 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).
|
// 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) {
|
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
|
// 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()
|
defer r.Close()
|
||||||
p, err := Peek(r, limit)
|
p, err := Peek(r, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -314,8 +335,56 @@ func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
|
|||||||
return nil, ErrTooLargeJSON
|
return nil, ErrTooLargeJSON
|
||||||
}
|
}
|
||||||
var obj T
|
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 nil, ErrUnmarshalJSON
|
||||||
}
|
}
|
||||||
return &obj, nil
|
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
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
"io"
|
"io"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -190,13 +193,79 @@ func TestReadJSON_Failure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReadJSONWithLimit_Success(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.Nil(t, err)
|
||||||
require.Equal(t, "some name", v.Name)
|
require.Equal(t, "some name", v.Name)
|
||||||
require.Equal(t, 99, v.Something)
|
require.Equal(t, 99, v.Something)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReadJSONWithLimit_FailureTooLong(t *testing.T) {
|
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)
|
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)))
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
13231
web/package-lock.json
generated
13231
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,11 @@
|
|||||||
// During web development, you may change values here for rapid testing.
|
// During web development, you may change values here for rapid testing.
|
||||||
|
|
||||||
var config = {
|
var config = {
|
||||||
base_url: "http://localhost:2586", // window.location.origin FIXME update before merging
|
base_url: window.location.origin, // Set this to "https://127.0.0.1" to test against a different server
|
||||||
app_root: "/app",
|
app_root: "/app",
|
||||||
enable_login: true,
|
enable_login: true,
|
||||||
enable_signup: true,
|
enable_signup: true,
|
||||||
enable_payments: true,
|
enable_payments: true,
|
||||||
enable_reservations: true,
|
enable_reservations: true,
|
||||||
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "pricing", "signup", "login", "reset-password"]
|
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,8 +4,6 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>ntfy web</title>
|
<title>ntfy web</title>
|
||||||
|
|
||||||
<link rel="stylesheet" href="static/css/home.css" type="text/css">
|
|
||||||
|
|
||||||
<!-- Mobile view -->
|
<!-- Mobile view -->
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
<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">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||||
@@ -31,7 +29,8 @@
|
|||||||
<!-- Never index -->
|
<!-- Never index -->
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<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">
|
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/fonts.css" type="text/css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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 */
|
/* general styling */
|
||||||
|
|
||||||
#site {
|
html, body {
|
||||||
font-family: 'Roboto', sans-serif;
|
font-family: 'Roboto', sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
@@ -9,16 +9,22 @@
|
|||||||
padding: 0;
|
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;
|
color: #338574;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site a:hover {
|
a:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #317f6f;
|
color: #317f6f;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site h1 {
|
h1 {
|
||||||
margin-top: 35px;
|
margin-top: 35px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
font-size: 2.5em;
|
font-size: 2.5em;
|
||||||
@@ -28,7 +34,7 @@
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site h2 {
|
h2 {
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
font-size: 1.8em;
|
font-size: 1.8em;
|
||||||
@@ -36,7 +42,7 @@
|
|||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site h3 {
|
h3 {
|
||||||
margin-top: 25px;
|
margin-top: 25px;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
@@ -44,28 +50,28 @@
|
|||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site p {
|
p {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
line-height: 160%;
|
line-height: 160%;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site p.smallMarginBottom {
|
p.smallMarginBottom {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site b {
|
b {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site tt {
|
tt {
|
||||||
background: #eee;
|
background: #eee;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site code {
|
code {
|
||||||
display: block;
|
display: block;
|
||||||
background: #eee;
|
background: #eee;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
@@ -79,18 +85,18 @@
|
|||||||
|
|
||||||
/* Main page */
|
/* Main page */
|
||||||
|
|
||||||
#site #main {
|
#main {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto 50px auto;
|
margin: 0 auto 50px auto;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site #error {
|
#error {
|
||||||
color: darkred;
|
color: darkred;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site #ironicCenterTagDontFreakOut {
|
#ironicCenterTagDontFreakOut {
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,22 +120,22 @@
|
|||||||
|
|
||||||
/* Figures */
|
/* Figures */
|
||||||
|
|
||||||
#site figure {
|
figure {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site figure img, figure video {
|
figure img, figure video {
|
||||||
filter: drop-shadow(3px 3px 3px #ccc);
|
filter: drop-shadow(3px 3px 3px #ccc);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site figure video {
|
figure video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 450px;
|
max-height: 450px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site figcaption {
|
figcaption {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
@@ -137,18 +143,18 @@
|
|||||||
|
|
||||||
/* Screenshots */
|
/* Screenshots */
|
||||||
|
|
||||||
#site #screenshots {
|
#screenshots {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site #screenshots img {
|
#screenshots img {
|
||||||
height: 190px;
|
height: 190px;
|
||||||
margin: 3px;
|
margin: 3px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
filter: drop-shadow(2px 2px 2px #ddd);
|
filter: drop-shadow(2px 2px 2px #ddd);
|
||||||
}
|
}
|
||||||
|
|
||||||
#site #screenshots .nowrap {
|
#screenshots .nowrap {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,60 +220,52 @@
|
|||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
|
|
||||||
#site #header {
|
#header {
|
||||||
background: #338574;
|
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: 130px;
|
||||||
height: 70px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#site #header #headerBox {
|
#header #headerBox {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site #header #logo {
|
#header #logo {
|
||||||
margin-top: 14px;
|
margin-top: 23px;
|
||||||
width: 48px;
|
|
||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site #header #name {
|
#header #name {
|
||||||
float: left;
|
float: left;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 1.7em;
|
font-size: 2.6em;
|
||||||
font-weight: 400;
|
font-weight: 300;
|
||||||
margin: 12px 0 0 10px;
|
margin: 35px 0 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site #header #menu {
|
#header ol {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
float: right;
|
float: right;
|
||||||
margin-top: 16px;
|
margin-top: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site #header #menu li {
|
#header ol li {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 3px 10px;
|
margin: 0 10px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
border-radius: 5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#site #header #menu li {
|
#header ol li a, nav ol li a:visited {
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#site #header #menu li a,
|
|
||||||
#site #header #menu li a:visited {
|
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site #header #menu li:hover {
|
#header ol li a:hover {
|
||||||
background: #3f9a86;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
#site li {
|
li {
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
@@ -276,7 +274,7 @@
|
|||||||
|
|
||||||
/* Hide top menu SMALL SCREEN */
|
/* Hide top menu SMALL SCREEN */
|
||||||
@media only screen and (max-width: 780px) {
|
@media only screen and (max-width: 780px) {
|
||||||
#header #menu {
|
#header ol {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
web/public/static/langs/ar.json
Normal file
45
web/public/static/langs/ar.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"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": "تم نسخه إلى الحافظة"
|
||||||
|
}
|
||||||
@@ -114,8 +114,8 @@
|
|||||||
"prefs_users_table_user_header": "Потребител",
|
"prefs_users_table_user_header": "Потребител",
|
||||||
"prefs_users_dialog_title_edit": "Промяна на потребител",
|
"prefs_users_dialog_title_edit": "Промяна на потребител",
|
||||||
"prefs_users_dialog_base_url_label": "Адрес на услугата, e.g. https://ntfy.sh",
|
"prefs_users_dialog_base_url_label": "Адрес на услугата, e.g. https://ntfy.sh",
|
||||||
"prefs_users_dialog_button_cancel": "Отказ",
|
"common_cancel": "Отказ",
|
||||||
"prefs_users_dialog_button_save": "Запазване",
|
"common_save": "Запазване",
|
||||||
"prefs_appearance_language_title": "Език",
|
"prefs_appearance_language_title": "Език",
|
||||||
"subscribe_dialog_login_password_label": "Парола",
|
"subscribe_dialog_login_password_label": "Парола",
|
||||||
"subscribe_dialog_login_button_login": "Вход",
|
"subscribe_dialog_login_button_login": "Вход",
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
"prefs_users_dialog_title_add": "Добавяне на потребител",
|
"prefs_users_dialog_title_add": "Добавяне на потребител",
|
||||||
"prefs_notifications_delete_after_one_month": "След един месец",
|
"prefs_notifications_delete_after_one_month": "След един месец",
|
||||||
"prefs_users_dialog_username_label": "Потребител, напр. phil",
|
"prefs_users_dialog_username_label": "Потребител, напр. phil",
|
||||||
"prefs_users_dialog_button_add": "Добавяне",
|
"common_add": "Добавяне",
|
||||||
"error_boundary_title": "О, не, ntfy се срина",
|
"error_boundary_title": "О, не, ntfy се срина",
|
||||||
"error_boundary_description": "Това очевидно не трябва да се случва. Много съжаляваме!<br/>Ако имате минута, <githubLink>докладвайте в GitHub</githubLink> или ни уведомете в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
|
"error_boundary_description": "Това очевидно не трябва да се случва. Много съжаляваме!<br/>Ако имате минута, <githubLink>докладвайте в GitHub</githubLink> или ни уведомете в <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
|
||||||
"error_boundary_stack_trace": "Следа от стека",
|
"error_boundary_stack_trace": "Следа от стека",
|
||||||
|
|||||||
@@ -116,9 +116,9 @@
|
|||||||
"prefs_users_add_button": "Přidat uživatele",
|
"prefs_users_add_button": "Přidat uživatele",
|
||||||
"prefs_users_table_user_header": "Uživatel",
|
"prefs_users_table_user_header": "Uživatel",
|
||||||
"prefs_users_table_base_url_header": "URL služby",
|
"prefs_users_table_base_url_header": "URL služby",
|
||||||
"prefs_users_dialog_button_cancel": "Zrušit",
|
"common_cancel": "Zrušit",
|
||||||
"prefs_users_dialog_button_add": "Přidat",
|
"common_add": "Přidat",
|
||||||
"prefs_users_dialog_button_save": "Uložit",
|
"common_save": "Uložit",
|
||||||
"priority_min": "nejnižší",
|
"priority_min": "nejnižší",
|
||||||
"priority_low": "nízká",
|
"priority_low": "nízká",
|
||||||
"priority_default": "výchozí",
|
"priority_default": "výchozí",
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
"prefs_notifications_min_priority_max_only": "Nur höchste Priorität",
|
"prefs_notifications_min_priority_max_only": "Nur höchste Priorität",
|
||||||
"prefs_notifications_delete_after_never": "Nie",
|
"prefs_notifications_delete_after_never": "Nie",
|
||||||
"prefs_users_dialog_password_label": "Kennwort",
|
"prefs_users_dialog_password_label": "Kennwort",
|
||||||
"prefs_users_dialog_button_cancel": "Abbrechen",
|
"common_cancel": "Abbrechen",
|
||||||
"prefs_users_dialog_button_add": "Hinzufügen",
|
"common_add": "Hinzufügen",
|
||||||
"prefs_users_dialog_button_save": "Speichern",
|
"common_save": "Speichern",
|
||||||
"prefs_appearance_language_title": "Sprache",
|
"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.",
|
"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",
|
"publish_dialog_message_placeholder": "Gib hier eine Nachricht ein",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"common_cancel": "Cancel",
|
||||||
|
"common_save": "Save",
|
||||||
|
"common_add": "Add",
|
||||||
"signup_title": "Create a ntfy account",
|
"signup_title": "Create a ntfy account",
|
||||||
"signup_form_username": "Username",
|
"signup_form_username": "Username",
|
||||||
"signup_form_password": "Password",
|
"signup_form_password": "Password",
|
||||||
@@ -9,15 +12,19 @@
|
|||||||
"signup_disabled": "Signup is disabled",
|
"signup_disabled": "Signup is disabled",
|
||||||
"signup_error_username_taken": "Username {{username}} is already taken",
|
"signup_error_username_taken": "Username {{username}} is already taken",
|
||||||
"signup_error_creation_limit_reached": "Account creation limit reached",
|
"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_title": "Sign in to your ntfy account",
|
||||||
"login_form_button_submit": "Sign in",
|
"login_form_button_submit": "Sign in",
|
||||||
"login_link_signup": "Sign up",
|
"login_link_signup": "Sign up",
|
||||||
|
"login_disabled": "Login is disabled",
|
||||||
"action_bar_show_menu": "Show menu",
|
"action_bar_show_menu": "Show menu",
|
||||||
"action_bar_logo_alt": "ntfy logo",
|
"action_bar_logo_alt": "ntfy logo",
|
||||||
"action_bar_settings": "Settings",
|
"action_bar_settings": "Settings",
|
||||||
"action_bar_account": "Account",
|
"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_send_test_notification": "Send test notification",
|
||||||
"action_bar_clear_notifications": "Clear all notifications",
|
"action_bar_clear_notifications": "Clear all notifications",
|
||||||
"action_bar_unsubscribe": "Unsubscribe",
|
"action_bar_unsubscribe": "Unsubscribe",
|
||||||
@@ -41,6 +48,8 @@
|
|||||||
"nav_button_subscribe": "Subscribe to topic",
|
"nav_button_subscribe": "Subscribe to topic",
|
||||||
"nav_button_muted": "Notifications muted",
|
"nav_button_muted": "Notifications muted",
|
||||||
"nav_button_connecting": "connecting",
|
"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_title": "Notifications are disabled",
|
||||||
"alert_grant_description": "Grant your browser permission to display desktop notifications.",
|
"alert_grant_description": "Grant your browser permission to display desktop notifications.",
|
||||||
"alert_grant_button": "Grant now",
|
"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_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_example": "Example",
|
||||||
"notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
|
"notifications_more_details": "For more information, check out the <websiteLink>website</websiteLink> or <docsLink>documentation</docsLink>.",
|
||||||
"subscription_settings_dialog_title": "Subscription settings",
|
"display_name_dialog_title": "Change display name",
|
||||||
"subscription_settings_dialog_description": "Configure settings specifically for this topic subscription. Settings are currently only applied locally.",
|
"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.",
|
||||||
"subscription_settings_dialog_display_name_placeholder": "Display name",
|
"display_name_dialog_placeholder": "Display name",
|
||||||
"subscription_settings_dialog_reserve_topic_label": "Reserve topic and configure access",
|
"reserve_dialog_checkbox_label": "Reserve topic and configure access",
|
||||||
"subscription_settings_button_cancel": "Cancel",
|
|
||||||
"subscription_settings_button_save": "Save",
|
|
||||||
"notifications_loading": "Loading notifications …",
|
"notifications_loading": "Loading notifications …",
|
||||||
"publish_dialog_title_topic": "Publish to {{topic}}",
|
"publish_dialog_title_topic": "Publish to {{topic}}",
|
||||||
"publish_dialog_title_no_topic": "Publish notification",
|
"publish_dialog_title_no_topic": "Publish notification",
|
||||||
@@ -170,35 +177,40 @@
|
|||||||
"account_basics_password_title": "Password",
|
"account_basics_password_title": "Password",
|
||||||
"account_basics_password_description": "Change your account password",
|
"account_basics_password_description": "Change your account password",
|
||||||
"account_basics_password_dialog_title": "Change 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_new_password_label": "New password",
|
||||||
"account_basics_password_dialog_confirm_password_label": "Confirm 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_button_submit": "Change password",
|
||||||
|
"account_basics_password_dialog_current_password_incorrect": "Password incorrect",
|
||||||
"account_usage_title": "Usage",
|
"account_usage_title": "Usage",
|
||||||
"account_usage_of_limit": "of {{limit}}",
|
"account_usage_of_limit": "of {{limit}}",
|
||||||
"account_usage_unlimited": "Unlimited",
|
"account_usage_unlimited": "Unlimited",
|
||||||
"account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)",
|
"account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)",
|
||||||
"account_usage_tier_title": "Account type",
|
"account_basics_tier_title": "Account type",
|
||||||
"account_usage_tier_description": "Your account's power level",
|
"account_basics_tier_description": "Your account's power level",
|
||||||
"account_usage_tier_admin": "Admin",
|
"account_basics_tier_admin": "Admin",
|
||||||
"account_usage_tier_basic": "Basic",
|
"account_basics_tier_admin_suffix_with_tier": "(with {{tier}} tier)",
|
||||||
"account_usage_tier_free": "Free",
|
"account_basics_tier_admin_suffix_no_tier": "(no tier)",
|
||||||
"account_usage_tier_upgrade_button": "Upgrade to Pro",
|
"account_basics_tier_basic": "Basic",
|
||||||
"account_usage_tier_change_button": "Change",
|
"account_basics_tier_free": "Free",
|
||||||
"account_usage_tier_paid_until": "Subscription paid until {{date}}, and will auto-renew",
|
"account_basics_tier_upgrade_button": "Upgrade to Pro",
|
||||||
"account_usage_tier_payment_overdue": "Your payment is overdue. Please update your payment method, or your account will be downgraded soon.",
|
"account_basics_tier_change_button": "Change",
|
||||||
"account_usage_tier_canceled_subscription": "Your subscription was canceled and will be downgraded to a free account on {{date}}.",
|
"account_basics_tier_paid_until": "Subscription paid until {{date}}, and will auto-renew",
|
||||||
"account_usage_manage_billing_button": "Manage billing",
|
"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_messages_title": "Published messages",
|
||||||
"account_usage_emails_title": "Emails sent",
|
"account_usage_emails_title": "Emails sent",
|
||||||
"account_usage_reservations_title": "Reserved topics",
|
"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_title": "Attachment storage",
|
||||||
"account_usage_attachment_storage_description": "{{filesize}} per file, deleted after {{expiry}}",
|
"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_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_title": "Delete account",
|
||||||
"account_delete_description": "Permanently delete your 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_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": "Type '{{username}}' to delete account",
|
"account_delete_dialog_label": "Password",
|
||||||
"account_delete_dialog_button_cancel": "Cancel",
|
"account_delete_dialog_button_cancel": "Cancel",
|
||||||
"account_delete_dialog_button_submit": "Permanently delete account",
|
"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_delete_dialog_billing_warning": "Deleting your account also cancels your billing subscription immediately. You will not have access to the billing dashboard anymore.",
|
||||||
@@ -219,6 +231,34 @@
|
|||||||
"account_upgrade_dialog_button_pay_now": "Pay now and subscribe",
|
"account_upgrade_dialog_button_pay_now": "Pay now and subscribe",
|
||||||
"account_upgrade_dialog_button_cancel_subscription": "Cancel subscription",
|
"account_upgrade_dialog_button_cancel_subscription": "Cancel subscription",
|
||||||
"account_upgrade_dialog_button_update_subscription": "Update 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_title": "Notifications",
|
||||||
"prefs_notifications_sound_title": "Notification sound",
|
"prefs_notifications_sound_title": "Notification sound",
|
||||||
"prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive",
|
"prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive",
|
||||||
@@ -260,9 +300,6 @@
|
|||||||
"prefs_users_dialog_base_url_label": "Service URL, e.g. https://ntfy.sh",
|
"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_username_label": "Username, e.g. phil",
|
||||||
"prefs_users_dialog_password_label": "Password",
|
"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_title": "Appearance",
|
||||||
"prefs_appearance_language_title": "Language",
|
"prefs_appearance_language_title": "Language",
|
||||||
"prefs_reservations_title": "Reserved topics",
|
"prefs_reservations_title": "Reserved topics",
|
||||||
@@ -278,11 +315,20 @@
|
|||||||
"prefs_reservations_table_everyone_read_only": "I can publish and subscribe, everyone can subscribe",
|
"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_write_only": "I can publish and subscribe, everyone can publish",
|
||||||
"prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe",
|
"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_add": "Reserve topic",
|
||||||
"prefs_reservations_dialog_title_edit": "Edit reserved 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_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_topic_label": "Topic",
|
||||||
"prefs_reservations_dialog_access_label": "Access",
|
"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_min": "min",
|
||||||
"priority_low": "low",
|
"priority_low": "low",
|
||||||
"priority_default": "default",
|
"priority_default": "default",
|
||||||
|
|||||||
@@ -101,8 +101,8 @@
|
|||||||
"prefs_users_add_button": "Añadir usuario",
|
"prefs_users_add_button": "Añadir usuario",
|
||||||
"prefs_users_dialog_title_edit": "Editar usuario",
|
"prefs_users_dialog_title_edit": "Editar usuario",
|
||||||
"prefs_users_dialog_base_url_label": "URL del servicio, ej. https://ntfy.sh",
|
"prefs_users_dialog_base_url_label": "URL del servicio, ej. https://ntfy.sh",
|
||||||
"prefs_users_dialog_button_add": "Añadir",
|
"common_add": "Añadir",
|
||||||
"prefs_users_dialog_button_save": "Guardar",
|
"common_save": "Guardar",
|
||||||
"prefs_appearance_title": "Apariencia",
|
"prefs_appearance_title": "Apariencia",
|
||||||
"prefs_appearance_language_title": "Idioma",
|
"prefs_appearance_language_title": "Idioma",
|
||||||
"error_boundary_title": "Oh no, ntfy tuvo un error",
|
"error_boundary_title": "Oh no, ntfy tuvo un error",
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
"prefs_users_dialog_password_label": "Contraseña",
|
"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>.",
|
"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_title_add": "Añadir usuario",
|
||||||
"prefs_users_dialog_button_cancel": "Cancelar",
|
"common_cancel": "Cancelar",
|
||||||
"prefs_users_dialog_username_label": "Nombre de usuario, ej. phil",
|
"prefs_users_dialog_username_label": "Nombre de usuario, ej. phil",
|
||||||
"priority_max": "máx",
|
"priority_max": "máx",
|
||||||
"priority_high": "alta",
|
"priority_high": "alta",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"message_bar_type_message": "Tapez un message ici",
|
"message_bar_type_message": "Tapez un message ici",
|
||||||
"notifications_attachment_open_button": "Ouvrir la pièce jointe",
|
"notifications_attachment_open_button": "Ouvrir la pièce jointe",
|
||||||
"notifications_attachment_link_expires": "le lien expire {{date}}",
|
"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_all_notifications": "Toutes les notifications",
|
||||||
"nav_button_settings": "Paramètres",
|
"nav_button_settings": "Paramètres",
|
||||||
"nav_button_documentation": "Documentation",
|
"nav_button_documentation": "Documentation",
|
||||||
@@ -79,8 +79,8 @@
|
|||||||
"subscribe_dialog_subscribe_title": "S'abonner au sujet",
|
"subscribe_dialog_subscribe_title": "S'abonner au sujet",
|
||||||
"subscribe_dialog_login_title": "Connexion nécessaire",
|
"subscribe_dialog_login_title": "Connexion nécessaire",
|
||||||
"prefs_notifications_min_priority_low_and_higher": "Priorité basse et au-dessus",
|
"prefs_notifications_min_priority_low_and_higher": "Priorité basse et au-dessus",
|
||||||
"prefs_users_dialog_button_cancel": "Annuler",
|
"common_cancel": "Annuler",
|
||||||
"error_boundary_button_copy_stack_trace": "Copier la stack strace",
|
"error_boundary_button_copy_stack_trace": "Copier la trace d'appels",
|
||||||
"publish_dialog_attached_file_title": "Fichier joint :",
|
"publish_dialog_attached_file_title": "Fichier joint :",
|
||||||
"publish_dialog_checkbox_publish_another": "Publier un autre",
|
"publish_dialog_checkbox_publish_another": "Publier un autre",
|
||||||
"publish_dialog_attached_file_filename_placeholder": "Nom du fichier joint",
|
"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_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_table_user_header": "Utilisateur",
|
||||||
"prefs_users_dialog_title_edit": "Éditer l'utilisateur",
|
"prefs_users_dialog_title_edit": "Éditer l'utilisateur",
|
||||||
"prefs_users_dialog_button_add": "Ajouter",
|
"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>Matric</matrixLink>.",
|
"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",
|
"prefs_users_dialog_title_add": "Ajouter un utilisateur",
|
||||||
"error_boundary_stack_trace": "Trace de pile d'appels",
|
"error_boundary_stack_trace": "Trace de pile d'appels",
|
||||||
"error_boundary_gathering_info": "Récupérer plus d'information…",
|
"error_boundary_gathering_info": "Récupérer plus d'information…",
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
"publish_dialog_chip_topic_label": "Changer de sujet",
|
"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_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",
|
"publish_dialog_button_cancel_sending": "Annuler l'envoi",
|
||||||
"prefs_users_dialog_button_save": "Enregistrer",
|
"common_save": "Enregistrer",
|
||||||
"notifications_new_indicator": "Nouvelle notification",
|
"notifications_new_indicator": "Nouvelle notification",
|
||||||
"publish_dialog_delay_reset": "Retirer le délai de réception",
|
"publish_dialog_delay_reset": "Retirer le délai de réception",
|
||||||
"notifications_list_item": "Notification",
|
"notifications_list_item": "Notification",
|
||||||
|
|||||||
@@ -108,7 +108,7 @@
|
|||||||
"prefs_users_dialog_title_edit": "Felhasználó szerkesztése",
|
"prefs_users_dialog_title_edit": "Felhasználó szerkesztése",
|
||||||
"prefs_users_dialog_username_label": "Felhasználónév, pl: jozsi",
|
"prefs_users_dialog_username_label": "Felhasználónév, pl: jozsi",
|
||||||
"prefs_users_dialog_password_label": "Jelszó",
|
"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",
|
"prefs_users_dialog_base_url_label": "Szerver címe, pl: https://ntfy.sh",
|
||||||
"notifications_loading": "Értesítések betöltése …",
|
"notifications_loading": "Értesítések betöltése …",
|
||||||
"publish_dialog_progress_uploading": "Feltöltés …",
|
"publish_dialog_progress_uploading": "Feltöltés …",
|
||||||
@@ -144,8 +144,8 @@
|
|||||||
"error_boundary_gathering_info": "Több információ…",
|
"error_boundary_gathering_info": "Több információ…",
|
||||||
"publish_dialog_attachment_limits_file_reached": "túllépi a fájlméret korlátot ({{fileSizeLimit}})",
|
"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_title": "Felhasználók kezelése",
|
||||||
"prefs_users_dialog_button_cancel": "Mégsem",
|
"common_cancel": "Mégsem",
|
||||||
"prefs_users_dialog_button_save": "Mentés",
|
"common_save": "Mentés",
|
||||||
"prefs_users_dialog_title_add": "Felhasználó hozzáadása",
|
"prefs_users_dialog_title_add": "Felhasználó hozzáadása",
|
||||||
"prefs_appearance_language_title": "Nyelv",
|
"prefs_appearance_language_title": "Nyelv",
|
||||||
"priority_low": "alacsony",
|
"priority_low": "alacsony",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"publish_dialog_message_label": "Pesan",
|
"publish_dialog_message_label": "Pesan",
|
||||||
"nav_button_settings": "Pengaturan",
|
"nav_button_settings": "Pengaturan",
|
||||||
"nav_button_documentation": "Dokumentasi",
|
"nav_button_documentation": "Dokumentasi",
|
||||||
"prefs_users_dialog_button_add": "Tambahkan",
|
"common_add": "Tambahkan",
|
||||||
"nav_topics_title": "Topik yang dilanggani",
|
"nav_topics_title": "Topik yang dilanggani",
|
||||||
"nav_button_subscribe": "Berlangganan ke topik",
|
"nav_button_subscribe": "Berlangganan ke topik",
|
||||||
"alert_grant_title": "Notifikasi dinonaktifkan",
|
"alert_grant_title": "Notifikasi dinonaktifkan",
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
"prefs_notifications_sound_no_sound": "Tidak ada suara",
|
"prefs_notifications_sound_no_sound": "Tidak ada suara",
|
||||||
"prefs_users_table_user_header": "Pengguna",
|
"prefs_users_table_user_header": "Pengguna",
|
||||||
"prefs_users_dialog_base_url_label": "URL Layanan, mis. https://ntfy.sh",
|
"prefs_users_dialog_base_url_label": "URL Layanan, mis. https://ntfy.sh",
|
||||||
"prefs_users_dialog_button_save": "Simpan",
|
"common_save": "Simpan",
|
||||||
"prefs_appearance_title": "Tampilan",
|
"prefs_appearance_title": "Tampilan",
|
||||||
"subscribe_dialog_login_password_label": "Kata sandi",
|
"subscribe_dialog_login_password_label": "Kata sandi",
|
||||||
"subscribe_dialog_login_button_back": "Kembali",
|
"subscribe_dialog_login_button_back": "Kembali",
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
"prefs_users_dialog_title_add": "Tambahkan pengguna",
|
"prefs_users_dialog_title_add": "Tambahkan pengguna",
|
||||||
"prefs_users_dialog_title_edit": "Edit pengguna",
|
"prefs_users_dialog_title_edit": "Edit pengguna",
|
||||||
"prefs_users_dialog_password_label": "Kata sandi",
|
"prefs_users_dialog_password_label": "Kata sandi",
|
||||||
"prefs_users_dialog_button_cancel": "Batal",
|
"common_cancel": "Batal",
|
||||||
"error_boundary_title": "Aduh, ntfy mogok",
|
"error_boundary_title": "Aduh, ntfy mogok",
|
||||||
"error_boundary_description": "Seharusnya ini tidak terjadi. Maaf sekali tentang hal ini.<br/>Jika Anda punya beberapa menit, silakan <githubLink>laporkan ini di GitHub</githubLink>, atau beritahu kami melalui <discordLink>Discord</discordLink> atau <matrixLink>Matrix</matrixLink>.",
|
"error_boundary_description": "Seharusnya ini tidak terjadi. Maaf sekali tentang hal ini.<br/>Jika Anda punya beberapa menit, silakan <githubLink>laporkan ini di GitHub</githubLink>, atau beritahu kami melalui <discordLink>Discord</discordLink> atau <matrixLink>Matrix</matrixLink>.",
|
||||||
"error_boundary_stack_trace": "Jejak tumpukan",
|
"error_boundary_stack_trace": "Jejak tumpukan",
|
||||||
|
|||||||
@@ -134,9 +134,9 @@
|
|||||||
"prefs_users_dialog_base_url_label": "URL del servizio, ad es. https://ntfy.sh",
|
"prefs_users_dialog_base_url_label": "URL del servizio, ad es. https://ntfy.sh",
|
||||||
"prefs_users_dialog_username_label": "Nome utente, ad es. phil",
|
"prefs_users_dialog_username_label": "Nome utente, ad es. phil",
|
||||||
"prefs_users_dialog_password_label": "Password",
|
"prefs_users_dialog_password_label": "Password",
|
||||||
"prefs_users_dialog_button_cancel": "Annulla",
|
"common_cancel": "Annulla",
|
||||||
"prefs_users_dialog_button_add": "Aggiungere",
|
"common_add": "Aggiungere",
|
||||||
"prefs_users_dialog_button_save": "Salva",
|
"common_save": "Salva",
|
||||||
"prefs_appearance_title": "Aspetto",
|
"prefs_appearance_title": "Aspetto",
|
||||||
"prefs_appearance_language_title": "Lingua",
|
"prefs_appearance_language_title": "Lingua",
|
||||||
"priority_min": "min",
|
"priority_min": "min",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
"prefs_notifications_delete_after_three_hours": "3時間後",
|
"prefs_notifications_delete_after_three_hours": "3時間後",
|
||||||
"prefs_users_description": "保護トピックのユーザーを追加/削除できます。ユーザー名とパスワードはブラウザのローカルストレージに保存されることに留意してください。",
|
"prefs_users_description": "保護トピックのユーザーを追加/削除できます。ユーザー名とパスワードはブラウザのローカルストレージに保存されることに留意してください。",
|
||||||
"prefs_users_add_button": "ユーザー追加",
|
"prefs_users_add_button": "ユーザー追加",
|
||||||
"prefs_users_dialog_button_add": "追加",
|
"common_add": "追加",
|
||||||
"subscribe_dialog_subscribe_use_another_label": "他のサーバーを使用",
|
"subscribe_dialog_subscribe_use_another_label": "他のサーバーを使用",
|
||||||
"subscribe_dialog_error_user_not_authorized": "ユーザー名 {{username}} は許可されていません",
|
"subscribe_dialog_error_user_not_authorized": "ユーザー名 {{username}} は許可されていません",
|
||||||
"prefs_notifications_delete_after_one_week": "1週間後",
|
"prefs_notifications_delete_after_one_week": "1週間後",
|
||||||
@@ -118,8 +118,8 @@
|
|||||||
"prefs_notifications_min_priority_title": "表示する優先度",
|
"prefs_notifications_min_priority_title": "表示する優先度",
|
||||||
"prefs_notifications_min_priority_default_and_higher": "優先度通常 およびそれ以上",
|
"prefs_notifications_min_priority_default_and_higher": "優先度通常 およびそれ以上",
|
||||||
"prefs_notifications_delete_after_title": "通知を削除",
|
"prefs_notifications_delete_after_title": "通知を削除",
|
||||||
"prefs_users_dialog_button_cancel": "キャンセル",
|
"common_cancel": "キャンセル",
|
||||||
"prefs_users_dialog_button_save": "保存",
|
"common_save": "保存",
|
||||||
"prefs_users_table_user_header": "ユーザー名",
|
"prefs_users_table_user_header": "ユーザー名",
|
||||||
"prefs_users_dialog_title_add": "ユーザー追加",
|
"prefs_users_dialog_title_add": "ユーザー追加",
|
||||||
"prefs_users_dialog_title_edit": "ユーザー編集",
|
"prefs_users_dialog_title_edit": "ユーザー編集",
|
||||||
|
|||||||
@@ -126,10 +126,10 @@
|
|||||||
"prefs_users_dialog_title_add": "사용자 추가",
|
"prefs_users_dialog_title_add": "사용자 추가",
|
||||||
"prefs_users_dialog_title_edit": "사용자 편집",
|
"prefs_users_dialog_title_edit": "사용자 편집",
|
||||||
"prefs_users_dialog_base_url_label": "서비스 URL, 예를 들면 https://ntfy.sh",
|
"prefs_users_dialog_base_url_label": "서비스 URL, 예를 들면 https://ntfy.sh",
|
||||||
"prefs_users_dialog_button_cancel": "취소",
|
"common_cancel": "취소",
|
||||||
"prefs_users_dialog_button_save": "저장",
|
"common_save": "저장",
|
||||||
"prefs_appearance_title": "표시 설정",
|
"prefs_appearance_title": "표시 설정",
|
||||||
"prefs_users_dialog_button_add": "추가",
|
"common_add": "추가",
|
||||||
"prefs_appearance_language_title": "언어",
|
"prefs_appearance_language_title": "언어",
|
||||||
"priority_min": "최하",
|
"priority_min": "최하",
|
||||||
"priority_low": "낮음",
|
"priority_low": "낮음",
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
"prefs_users_dialog_title_edit": "Rediger bruker",
|
"prefs_users_dialog_title_edit": "Rediger bruker",
|
||||||
"prefs_users_dialog_base_url_label": "Tjeneste-nettadresse, f.eks. https://ntfy.sh",
|
"prefs_users_dialog_base_url_label": "Tjeneste-nettadresse, f.eks. https://ntfy.sh",
|
||||||
"prefs_users_dialog_password_label": "Passord",
|
"prefs_users_dialog_password_label": "Passord",
|
||||||
"prefs_users_dialog_button_save": "Lagre",
|
"common_save": "Lagre",
|
||||||
"prefs_appearance_title": "Utseende",
|
"prefs_appearance_title": "Utseende",
|
||||||
"prefs_appearance_language_title": "Språk",
|
"prefs_appearance_language_title": "Språk",
|
||||||
"prefs_users_dialog_username_label": "Brukernavn, f.eks. phil",
|
"prefs_users_dialog_username_label": "Brukernavn, f.eks. phil",
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
"publish_dialog_topic_label": "Emnenavn",
|
"publish_dialog_topic_label": "Emnenavn",
|
||||||
"prefs_notifications_delete_after_one_day_description": "Merknader slettes automatisk etter én dag",
|
"prefs_notifications_delete_after_one_day_description": "Merknader slettes automatisk etter én dag",
|
||||||
"notifications_click_copy_url_button": "Kopier lenke",
|
"notifications_click_copy_url_button": "Kopier lenke",
|
||||||
"error_boundary_title": "Oida. Ntfy krasjet.",
|
"error_boundary_title": "Oida, ntfy krasjet",
|
||||||
"publish_dialog_message_placeholder": "Skriv en melding her",
|
"publish_dialog_message_placeholder": "Skriv en melding her",
|
||||||
"publish_dialog_button_cancel": "Avbryt",
|
"publish_dialog_button_cancel": "Avbryt",
|
||||||
"prefs_notifications_min_priority_title": "Minimumsprioritet",
|
"prefs_notifications_min_priority_title": "Minimumsprioritet",
|
||||||
@@ -116,11 +116,76 @@
|
|||||||
"subscribe_dialog_login_button_back": "Tilbake",
|
"subscribe_dialog_login_button_back": "Tilbake",
|
||||||
"prefs_notifications_delete_after_three_hours": "Etter tre timer",
|
"prefs_notifications_delete_after_three_hours": "Etter tre timer",
|
||||||
"prefs_users_table_base_url_header": "Tjeneste-nettadresse",
|
"prefs_users_table_base_url_header": "Tjeneste-nettadresse",
|
||||||
"prefs_users_dialog_button_cancel": "Avbryt",
|
"common_cancel": "Avbryt",
|
||||||
"prefs_users_dialog_button_add": "Legg til",
|
"common_add": "Legg til",
|
||||||
"publish_dialog_chip_attach_url_label": "Legg ved fil per nettadresse",
|
"publish_dialog_chip_attach_url_label": "Legg til fil med nettadresse",
|
||||||
"publish_dialog_tags_placeholder": "Kommainndelt liste over etiketter, f.eks. advarsel, srv1-sikkerhetskopi",
|
"publish_dialog_tags_placeholder": "Kommainndelt liste over etiketter, f.eks. advarsel, srv1-sikkerhetskopi",
|
||||||
"prefs_notifications_sound_description_none": "Merknader er lydløse når de mottas",
|
"prefs_notifications_sound_description_none": "Merknader spiller ikke lyd når de mottas",
|
||||||
"subscribe_dialog_subscribe_topic_placeholder": "Emnenavn, f.eks. phil_varsler",
|
"subscribe_dialog_subscribe_topic_placeholder": "Emnenavn, f.eks. phil_varsler",
|
||||||
"prefs_notifications_min_priority_default_and_higher": "Forvalgt prioritet og høyere"
|
"prefs_notifications_min_priority_default_and_higher": "Forvalgt prioritet og høyere",
|
||||||
|
"notifications_no_subscriptions_title": "Det ser ut til at du ikke har noen abonnementer ennå.",
|
||||||
|
"publish_dialog_attachment_limits_file_and_quota_reached": "overskrider {{fileSizeLimit}} filgrense og kvote, {{remainingBytes}} gjenstår",
|
||||||
|
"publish_dialog_attachment_limits_file_reached": "overskrider filgrensen på {{fileSizeLimit}}",
|
||||||
|
"publish_dialog_title_label": "Tittel",
|
||||||
|
"publish_dialog_title_placeholder": "Varslingstittel, f.eks. Diskplassvarsel",
|
||||||
|
"publish_dialog_topic_placeholder": "Emnenavn, f.eks. halgeir_varsler",
|
||||||
|
"publish_dialog_chip_click_label": "Klikk URL",
|
||||||
|
"publish_dialog_chip_delay_label": "Forsink leveringen",
|
||||||
|
"publish_dialog_details_examples_description": "For eksempler og en detaljert beskrivelse av alle sendefunksjoner, se <docsLink>dokumentasjonen</docsLink>.",
|
||||||
|
"publish_dialog_base_url_placeholder": "Tjeneste-URL, f.eks. https://example.com",
|
||||||
|
"alert_grant_description": "Gi nettleseren din tillatelse til å vise skrivebordsvarsler.",
|
||||||
|
"alert_not_supported_description": "Varsler støttes ikke i nettleseren din.",
|
||||||
|
"notifications_attachment_file_app": "Android-app-fil",
|
||||||
|
"notifications_no_subscriptions_description": "Klikk på \"{{linktext}}\"-koblingen for å opprette eller abonnere på et emne. Etter det kan du sende meldinger via PUT eller POST, og du vil motta varsler her.",
|
||||||
|
"notifications_actions_http_request_title": "Send HTTP {{metode}} til {{url}}",
|
||||||
|
"notifications_none_for_any_description": "For å sende varsler til et emne, bare PUT eller POST til emne-URLen. Her er et eksempel som bruker et av emnene dine.",
|
||||||
|
"notifications_more_details": "For mer informasjon, sjekk ut <websiteLink>nettstedet</websiteLink> eller <docsLink>dokumentasjonen</docsLink>.",
|
||||||
|
"publish_dialog_attachment_limits_quota_reached": "overskrider kvoten, {{remainingBytes}} gjenstår",
|
||||||
|
"publish_dialog_click_reset": "Fjern klikk-URL",
|
||||||
|
"publish_dialog_delay_placeholder": "Forsinket levering, f.eks. {{unixTimestamp}}, {{relativeTime}} eller \"{{naturalLanguage}}\" (bare på engelsk)",
|
||||||
|
"emoji_picker_search_clear": "Tøm søk",
|
||||||
|
"subscribe_dialog_subscribe_description": "Det kan hende emner ikke er passordsbeskyttet, så velg et navn som ikke er enkelt å gjette. Når du har abonnert kan du utføre PUT/POST av merknader.",
|
||||||
|
"publish_dialog_checkbox_publish_another": "Publiser enda en",
|
||||||
|
"subscribe_dialog_login_description": "Dette emnet er passordbeskyttet. Vennligst skriv inn brukernavn og passord for å abonnere.",
|
||||||
|
"prefs_notifications_sound_play": "Spill av valgt lyd",
|
||||||
|
"subscribe_dialog_error_user_not_authorized": "Bruker {{brukernavn}} ikke autorisert",
|
||||||
|
"prefs_users_delete_button": "Slett bruker",
|
||||||
|
"error_boundary_unsupported_indexeddb_description": "ntfy-nettappen trenger IndexedDB for å fungere, og nettleseren din støtter ikke IndexedDB i privat nettlesingsmodus.<br/><br/>Selv om dette er uheldig, gir det heller ikke så mye mening å bruke ntfy-nettappen i privat surfemodus uansett, fordi alt er lagret i nettleserlagringen. Du kan lese mer om det <githubLink>i denne GitHub-feilmeldingen</githubLink>, eller snakk med oss på <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.",
|
||||||
|
"action_bar_show_menu": "Vis meny",
|
||||||
|
"action_bar_toggle_mute": "Aktiver/deaktiver notifikasjoner",
|
||||||
|
"prefs_notifications_min_priority_description_max": "Vis merknader hvis prioritet er 5 (maks.)",
|
||||||
|
"prefs_notifications_min_priority_any": "Hvilken som helst prioritet",
|
||||||
|
"prefs_notifications_min_priority_low_and_higher": "Lav prioritet og høyere",
|
||||||
|
"prefs_users_description": "Legg til/fjern brukere for dine beskyttede emner her. Vær oppmerksom på at brukernavn og passord er lagret i nettleserens lokale lagring.",
|
||||||
|
"error_boundary_description": "Dette skal åpenbart ikke skje. Beklager dette.<br/>Hvis du har et minutt, vennligst <githubLink>rapporter dette på GitHub</githubLink>, eller gi oss beskjed via <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.",
|
||||||
|
"action_bar_logo_alt": "ntfy logo",
|
||||||
|
"message_bar_publish": "Publiser melding",
|
||||||
|
"action_bar_toggle_action_menu": "Åpne/lukk handlingsmeny",
|
||||||
|
"message_bar_show_dialog": "Vis publiseringsdialog",
|
||||||
|
"nav_button_muted": "Varsler dempet",
|
||||||
|
"nav_button_connecting": "kobler til",
|
||||||
|
"notifications_list": "Varslingsliste",
|
||||||
|
"notifications_list_item": "Varsling",
|
||||||
|
"notifications_mark_read": "Merk som lest",
|
||||||
|
"notifications_delete": "Slett",
|
||||||
|
"notifications_priority_x": "Prioritet {{prioritet}}",
|
||||||
|
"notifications_new_indicator": "Nytt varsel",
|
||||||
|
"notifications_attachment_image": "Vedlagt bilde",
|
||||||
|
"notifications_attachment_file_image": "bildefil",
|
||||||
|
"notifications_attachment_file_video": "videofil",
|
||||||
|
"notifications_attachment_file_audio": "lydfil",
|
||||||
|
"notifications_attachment_file_document": "annet dokument",
|
||||||
|
"notifications_actions_not_supported": "Handling støttes ikke i nettappen",
|
||||||
|
"notifications_none_for_topic_description": "For å sende varsler til dette emnet, bare PUT eller POST til emne-URLen.",
|
||||||
|
"publish_dialog_emoji_picker_show": "Velg emoji",
|
||||||
|
"publish_dialog_topic_reset": "Tilbakestill emne",
|
||||||
|
"publish_dialog_click_label": "Klikk URL",
|
||||||
|
"publish_dialog_email_reset": "Fjern videresending av e-post",
|
||||||
|
"publish_dialog_attach_reset": "Fjern URL vedlegg",
|
||||||
|
"publish_dialog_delay_reset": "Fjern forsinket levering",
|
||||||
|
"publish_dialog_attached_file_remove": "Fjern vedlagt fil",
|
||||||
|
"subscribe_dialog_subscribe_base_url_label": "Tjeneste-URL",
|
||||||
|
"prefs_users_table": "Brukertabell",
|
||||||
|
"prefs_users_edit_button": "Rediger bruker",
|
||||||
|
"error_boundary_unsupported_indexeddb_title": "Privat surfing støttes ikke"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"action_bar_settings": "Instellingen",
|
"action_bar_settings": "Instellingen",
|
||||||
"action_bar_send_test_notification": "Stuur test notificatie",
|
"action_bar_send_test_notification": "Verstuur testnotificatie.",
|
||||||
"action_bar_clear_notifications": "Wis alle notificaties",
|
"action_bar_clear_notifications": "Wis alle notificaties",
|
||||||
"message_bar_type_message": "Typ hier een bericht",
|
"message_bar_type_message": "Typ hier een bericht",
|
||||||
"action_bar_unsubscribe": "Afmelden",
|
"action_bar_unsubscribe": "Afmelden",
|
||||||
"message_bar_error_publishing": "Fout bij publiceren notificatie",
|
"message_bar_error_publishing": "Fout bij publiceren notificatie",
|
||||||
"nav_topics_title": "Geabonneerde onderwerpen",
|
"nav_topics_title": "Geabonneerde onderwerpen",
|
||||||
"nav_button_settings": "Instellingen",
|
"nav_button_settings": "Instellingen",
|
||||||
"alert_not_supported_description": "Notificaties worden niet ondersteund in je browser.",
|
"alert_not_supported_description": "Notificaties worden niet ondersteund door je browser.",
|
||||||
"notifications_none_for_any_title": "Je hebt nog geen notificaties ontvangen.",
|
"notifications_none_for_any_title": "Je hebt nog geen notificaties ontvangen.",
|
||||||
"publish_dialog_tags_label": "Tags",
|
"publish_dialog_tags_label": "Tags",
|
||||||
"publish_dialog_chip_attach_file_label": "Lokaal bestand bijvoegen",
|
"publish_dialog_chip_attach_file_label": "Lokaal bestand bijvoegen",
|
||||||
@@ -26,20 +26,20 @@
|
|||||||
"action_bar_show_menu": "Toon menu",
|
"action_bar_show_menu": "Toon menu",
|
||||||
"action_bar_logo_alt": "ntfy logo",
|
"action_bar_logo_alt": "ntfy logo",
|
||||||
"action_bar_toggle_mute": "Notificaties dempen/opheffen",
|
"action_bar_toggle_mute": "Notificaties dempen/opheffen",
|
||||||
"action_bar_toggle_action_menu": "Actie menu openen/sluiten",
|
"action_bar_toggle_action_menu": "Open/Sluit actiemenu",
|
||||||
"message_bar_show_dialog": "Toon publicatie venster",
|
"message_bar_show_dialog": "Toon publicatie venster",
|
||||||
"message_bar_publish": "Bericht publiceren",
|
"message_bar_publish": "Bericht publiceren",
|
||||||
"nav_button_all_notifications": "Alle notificaties",
|
"nav_button_all_notifications": "Alle notificaties",
|
||||||
"nav_button_documentation": "Documentatie",
|
"nav_button_documentation": "Documentatie",
|
||||||
"nav_button_publish_message": "Notificatie publiceren",
|
"nav_button_publish_message": "Notificatie publiceren",
|
||||||
"nav_button_subscribe": "Onderwerp abonneren",
|
"nav_button_subscribe": "Abonneer op onderwerp",
|
||||||
"nav_button_muted": "Notificaties gedempt",
|
"nav_button_muted": "Notificaties gedempt",
|
||||||
"nav_button_connecting": "verbinden",
|
"nav_button_connecting": "verbinden",
|
||||||
"alert_grant_title": "Notificaties zijn uitgeschakeld",
|
"alert_grant_title": "Notificaties zijn uitgeschakeld",
|
||||||
"alert_grant_description": "Geef je browser toestemming om meldingen weer te geven.",
|
"alert_grant_description": "Verleen je browser toestemming voor het weergeven van notificaties.",
|
||||||
"alert_grant_button": "Nu toestaan",
|
"alert_grant_button": "Nu toestaan",
|
||||||
"alert_not_supported_title": "Notificaties zijn niet ondersteund",
|
"alert_not_supported_title": "Notificaties zijn niet ondersteund",
|
||||||
"notifications_list": "Notificaties lijst",
|
"notifications_list": "Notificatielijst",
|
||||||
"notifications_list_item": "Notificatie",
|
"notifications_list_item": "Notificatie",
|
||||||
"notifications_mark_read": "Markeer als gelezen",
|
"notifications_mark_read": "Markeer als gelezen",
|
||||||
"notifications_delete": "Verwijder",
|
"notifications_delete": "Verwijder",
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
"notifications_attachment_file_audio": "audiobestand",
|
"notifications_attachment_file_audio": "audiobestand",
|
||||||
"notifications_attachment_file_app": "Android app bestand",
|
"notifications_attachment_file_app": "Android app bestand",
|
||||||
"notifications_attachment_file_document": "overig document",
|
"notifications_attachment_file_document": "overig document",
|
||||||
"notifications_click_copy_url_title": "URL naar klembord kopiëren",
|
"notifications_click_copy_url_title": "link URL naar klembord kopiëren",
|
||||||
"notifications_click_copy_url_button": "Link kopiëren",
|
"notifications_click_copy_url_button": "Link kopiëren",
|
||||||
"notifications_click_open_button": "Link openen",
|
"notifications_click_open_button": "Link openen",
|
||||||
"notifications_none_for_topic_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar de URL van het onderwerp.",
|
"notifications_none_for_topic_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar de URL van het onderwerp.",
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
"publish_dialog_title_no_topic": "Notificatie publiceren",
|
"publish_dialog_title_no_topic": "Notificatie publiceren",
|
||||||
"publish_dialog_progress_uploading": "Uploaden …",
|
"publish_dialog_progress_uploading": "Uploaden …",
|
||||||
"notifications_actions_open_url_title": "Ga naar {{url}}",
|
"notifications_actions_open_url_title": "Ga naar {{url}}",
|
||||||
"notifications_actions_not_supported": "Deze actie is niet ondersteund in de web applicatie",
|
"notifications_actions_not_supported": "Actie wordt niet ondersteund in de webapplicatie",
|
||||||
"notifications_actions_http_request_title": "Stuur HTTP {{method}} naar {{url}}",
|
"notifications_actions_http_request_title": "Stuur HTTP {{method}} naar {{url}}",
|
||||||
"notifications_none_for_topic_title": "Je hebt nog geen notificaties ontvangen voor dit onderwerp.",
|
"notifications_none_for_topic_title": "Je hebt nog geen notificaties ontvangen voor dit onderwerp.",
|
||||||
"publish_dialog_priority_low": "Lage prioriteit",
|
"publish_dialog_priority_low": "Lage prioriteit",
|
||||||
@@ -177,9 +177,9 @@
|
|||||||
"prefs_users_table_base_url_header": "Service URL",
|
"prefs_users_table_base_url_header": "Service URL",
|
||||||
"prefs_users_dialog_base_url_label": "Service URL, bijv. https://ntfy.sh",
|
"prefs_users_dialog_base_url_label": "Service URL, bijv. https://ntfy.sh",
|
||||||
"prefs_users_dialog_username_label": "Gebruikersnaam, bijv. phil",
|
"prefs_users_dialog_username_label": "Gebruikersnaam, bijv. phil",
|
||||||
"prefs_users_dialog_button_cancel": "Annuleren",
|
"common_cancel": "Annuleren",
|
||||||
"prefs_users_dialog_button_add": "Toevoegen",
|
"common_add": "Toevoegen",
|
||||||
"prefs_users_dialog_button_save": "Bewaren",
|
"common_save": "Bewaren",
|
||||||
"prefs_appearance_title": "Weergave",
|
"prefs_appearance_title": "Weergave",
|
||||||
"prefs_appearance_language_title": "Taal",
|
"prefs_appearance_language_title": "Taal",
|
||||||
"priority_min": "min",
|
"priority_min": "min",
|
||||||
|
|||||||
@@ -141,9 +141,9 @@
|
|||||||
"prefs_users_delete_button": "Usuń użytkownika",
|
"prefs_users_delete_button": "Usuń użytkownika",
|
||||||
"prefs_users_table_base_url_header": "Adres URL usługi",
|
"prefs_users_table_base_url_header": "Adres URL usługi",
|
||||||
"prefs_users_dialog_title_add": "Dodaj użytkownika",
|
"prefs_users_dialog_title_add": "Dodaj użytkownika",
|
||||||
"prefs_users_dialog_button_cancel": "Anuluj",
|
"common_cancel": "Anuluj",
|
||||||
"prefs_users_dialog_button_add": "Dodaj",
|
"common_add": "Dodaj",
|
||||||
"prefs_users_dialog_button_save": "Zapisz",
|
"common_save": "Zapisz",
|
||||||
"prefs_appearance_title": "Wygląd",
|
"prefs_appearance_title": "Wygląd",
|
||||||
"prefs_appearance_language_title": "Język",
|
"prefs_appearance_language_title": "Język",
|
||||||
"error_boundary_title": "Oh nie, ntfy przestało działać",
|
"error_boundary_title": "Oh nie, ntfy przestało działać",
|
||||||
|
|||||||
@@ -1 +1,191 @@
|
|||||||
{}
|
{
|
||||||
|
"action_bar_clear_notifications": "Limpar todas as notificações",
|
||||||
|
"action_bar_send_test_notification": "Enviar notificação de teste",
|
||||||
|
"action_bar_unsubscribe": "Anular subscrição",
|
||||||
|
"action_bar_toggle_mute": "Ativa/Desativa notificações",
|
||||||
|
"action_bar_toggle_action_menu": "Abrir/fechar menu de ação",
|
||||||
|
"message_bar_type_message": "Escreva uma mensagem aqui",
|
||||||
|
"message_bar_error_publishing": "Erro ao publicar notificação",
|
||||||
|
"message_bar_publish": "Publicar mensagem",
|
||||||
|
"nav_topics_title": "Tópicos subscritos",
|
||||||
|
"nav_button_all_notifications": "Todas notificações",
|
||||||
|
"nav_button_settings": "Configurações",
|
||||||
|
"nav_button_documentation": "Documentação",
|
||||||
|
"nav_button_publish_message": "Publicar notificação",
|
||||||
|
"nav_button_subscribe": "Subscrever tópico",
|
||||||
|
"nav_button_muted": "Notificações desativadas",
|
||||||
|
"nav_button_connecting": "A ligar",
|
||||||
|
"alert_grant_title": "As notificações estão desativadas",
|
||||||
|
"alert_grant_description": "Conceder permissão ao seu navegador para mostrar notificações.",
|
||||||
|
"alert_not_supported_title": "Notificações não suportadas",
|
||||||
|
"notifications_list": "Lista de notificações",
|
||||||
|
"alert_not_supported_description": "As notificações não são suportadas pelo seu navegador.",
|
||||||
|
"notifications_list_item": "Notificação",
|
||||||
|
"notifications_mark_read": "Marcar como lido",
|
||||||
|
"notifications_delete": "Apagar",
|
||||||
|
"notifications_copied_to_clipboard": "Copiado para a área de transferência",
|
||||||
|
"notifications_tags": "Etiquetas",
|
||||||
|
"notifications_priority_x": "Prioridade {{priority}}",
|
||||||
|
"notifications_new_indicator": "Nova notificação",
|
||||||
|
"notifications_attachment_image": "Imagem anexada",
|
||||||
|
"notifications_attachment_copy_url_title": "Copiar URL do anexo para a área de transferência",
|
||||||
|
"notifications_attachment_copy_url_button": "Copiar URL",
|
||||||
|
"notifications_attachment_open_title": "Ir para {{url}}",
|
||||||
|
"notifications_attachment_link_expired": "a ligação de transferência expirou",
|
||||||
|
"notifications_attachment_open_button": "Abrir anexo",
|
||||||
|
"notifications_attachment_link_expires": "a ligação expira em {{date}}",
|
||||||
|
"notifications_attachment_file_image": "ficheiro de imagem",
|
||||||
|
"notifications_attachment_file_video": "ficheiro de vídeo",
|
||||||
|
"notifications_attachment_file_audio": "ficheiro de áudio",
|
||||||
|
"notifications_attachment_file_app": "ficheiro apk Android",
|
||||||
|
"notifications_attachment_file_document": "outros documentos",
|
||||||
|
"notifications_click_copy_url_title": "Copiar URL da ligação para a área de transferência",
|
||||||
|
"notifications_click_copy_url_button": "Copiar ligação",
|
||||||
|
"notifications_click_open_button": "Abrir ligação",
|
||||||
|
"notifications_actions_open_url_title": "Ir para {{url}}",
|
||||||
|
"notifications_actions_not_supported": "Ação não suportada na app web",
|
||||||
|
"notifications_actions_http_request_title": "Enviar HTTP {{method}} para {{url}}",
|
||||||
|
"notifications_none_for_topic_title": "Ainda não recebeu nenhuma notificação deste tópico.",
|
||||||
|
"notifications_none_for_topic_description": "Para enviar notificações deste tópico, basta usar os métodos PUT ou POST no URL do tópico.",
|
||||||
|
"notifications_none_for_any_title": "Ainda não recebeu nenhuma notificação.",
|
||||||
|
"notifications_none_for_any_description": "Para enviar notificações dum tópico, basta usar os métodos PUT ou POST no URL do tópico. Eis um exemplo usando um dos seus tópicos.",
|
||||||
|
"notifications_no_subscriptions_title": "Parece que ainda não tem nenhuma inscrição.",
|
||||||
|
"notifications_no_subscriptions_description": "Clique na ligação \"{{linktext}}\" para criar ou subscrever um tópico. Depois, poderá enviar mensagens via PUT ou POST e receberá notificações aqui.",
|
||||||
|
"notifications_example": "Exemplo",
|
||||||
|
"notifications_more_details": "Para mais informações, aceda ao <websiteLink>site</websiteLink> ou à <docsLink>documentação</docsLink>.",
|
||||||
|
"notifications_loading": "A carregar notificações…",
|
||||||
|
"publish_dialog_title_topic": "Publicar em {{topic}}",
|
||||||
|
"publish_dialog_title_no_topic": "Publicar notificação",
|
||||||
|
"publish_dialog_progress_uploading": "A enviar …",
|
||||||
|
"publish_dialog_progress_uploading_detail": "A enviar {{loaded}}/{{total}} ({{percent}}%)…",
|
||||||
|
"publish_dialog_message_published": "Notificação publicada",
|
||||||
|
"publish_dialog_attachment_limits_file_and_quota_reached": "excede limite de ficheiro de {{fileSizeLimit}} e cota, {{remainingBytes}} restante(s)",
|
||||||
|
"publish_dialog_attachment_limits_quota_reached": "excede a cota, {{remainingBytes}} restante(s)",
|
||||||
|
"publish_dialog_priority_min": "Prioridade mínima",
|
||||||
|
"publish_dialog_priority_low": "Prioridade baixa",
|
||||||
|
"publish_dialog_priority_default": "Prioridade padrão",
|
||||||
|
"publish_dialog_priority_high": "Prioridade alta",
|
||||||
|
"publish_dialog_base_url_label": "URL de serviço",
|
||||||
|
"publish_dialog_base_url_placeholder": "URL de serviço, por exemplo: https://exemplo.com",
|
||||||
|
"publish_dialog_topic_label": "Nome do tópico",
|
||||||
|
"publish_dialog_topic_placeholder": "Nome do tópico, por exemplo: \"avisos_do_filipe\"",
|
||||||
|
"publish_dialog_topic_reset": "Limpar tópico",
|
||||||
|
"publish_dialog_title_placeholder": "Título da notificação, por exemplo: \"Alerta de espaço em disco\"",
|
||||||
|
"publish_dialog_message_label": "Mensagem",
|
||||||
|
"publish_dialog_message_placeholder": "Escreva uma mensagem aqui",
|
||||||
|
"publish_dialog_tags_label": "Etiquetas",
|
||||||
|
"publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, por exemplo: aviso, srv1-backup",
|
||||||
|
"publish_dialog_priority_label": "Prioridade",
|
||||||
|
"publish_dialog_click_label": "URL de clique",
|
||||||
|
"publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada",
|
||||||
|
"publish_dialog_click_reset": "Remover URL de clique",
|
||||||
|
"publish_dialog_email_label": "Email",
|
||||||
|
"publish_dialog_filename_placeholder": "Nome do ficheiro anexado",
|
||||||
|
"publish_dialog_email_placeholder": "Endereça para o qual encaminhar a notificação, por exemplo: filipe@exemplo.com",
|
||||||
|
"publish_dialog_email_reset": "Remover encaminhamento por email",
|
||||||
|
"publish_dialog_attach_label": "URL de anexo",
|
||||||
|
"publish_dialog_attach_placeholder": "Anexar ficheiro por URL, por exemplo: https://f-droid.org/F-Droid.apk",
|
||||||
|
"publish_dialog_attach_reset": "Remover URL de anexo",
|
||||||
|
"publish_dialog_filename_label": "Nome do ficheiro",
|
||||||
|
"publish_dialog_delay_label": "Atraso",
|
||||||
|
"publish_dialog_delay_placeholder": "Atraso na entrega, por exemplo \"{{{unixTimestamp}}\", \"{{relativeTime}}\", ou \"{{naturalLanguage}}\" (apenas em Inglês)",
|
||||||
|
"publish_dialog_other_features": "Outras funcionalidades:",
|
||||||
|
"publish_dialog_chip_click_label": "URL de clique",
|
||||||
|
"publish_dialog_chip_topic_label": "Alterar tópico",
|
||||||
|
"publish_dialog_details_examples_description": "Para obter exemplos e uma descrição detalhada de todas as funcionalidades de envio, consulte a <docsLink>documentação</docsLink>.",
|
||||||
|
"publish_dialog_button_cancel_sending": "Cancelar o envio",
|
||||||
|
"publish_dialog_attached_file_filename_placeholder": "Nome do ficheiro anexado",
|
||||||
|
"publish_dialog_attached_file_remove": "Remover ficheiro anexado",
|
||||||
|
"emoji_picker_search_clear": "Limpar pesquisa",
|
||||||
|
"subscribe_dialog_subscribe_description": "Os tópicos podem não ser protegidos por palavra-passe, por isso escolha um nome que não seja fácil de adivinhar. Uma vez subscrito, pode usar os métodos PUT/POST para publicar notificações.",
|
||||||
|
"subscribe_dialog_subscribe_use_another_label": "Usar outro servidor",
|
||||||
|
"subscribe_dialog_error_user_not_authorized": "Utilizador {{username}} não autorizado",
|
||||||
|
"prefs_notifications_min_priority_description_max": "Mostrar notificações se prioridade for 5 (máxima)",
|
||||||
|
"prefs_notifications_delete_after_one_week": "Após uma semana",
|
||||||
|
"prefs_notifications_delete_after_one_month": "Após um mês",
|
||||||
|
"prefs_notifications_delete_after_never_description": "As notificações nunca serão eliminadas automaticamente",
|
||||||
|
"prefs_notifications_delete_after_one_week_description": "As notificações serão eliminadas automaticamente após uma semana",
|
||||||
|
"prefs_notifications_delete_after_one_month_description": "As notificações serão eliminadas automaticamente após um mês",
|
||||||
|
"prefs_users_dialog_username_label": "Utilizador, por exemplo: \"filipe\"",
|
||||||
|
"prefs_users_dialog_password_label": "Palavra-passe",
|
||||||
|
"common_cancel": "Cancelar",
|
||||||
|
"common_add": "Adicionar",
|
||||||
|
"error_boundary_description": "Obviamente, isto não devia acontecer, lamentamos o sucedido.<br/>Se tiver um minuto, por favor <githubLink>relate isto no GitHub</githubLink>, ou informe-nos através de <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
|
||||||
|
"error_boundary_stack_trace": "Erro (\"stack trace\")",
|
||||||
|
"error_boundary_gathering_info": "A recolher mais informações …",
|
||||||
|
"error_boundary_unsupported_indexeddb_title": "Navegação anónima não suportada",
|
||||||
|
"error_boundary_unsupported_indexeddb_description": "A aplicação web ntfy necessita da \"IndexedDB\" para funcionar e o seu navegador não a suporta no modo de navegação privada.<br/><br/>Embora isso seja inconveniente, também não faz muito sentido usar a aplicação no modo de navegação privada de qualquer maneira, visto que tudo é guardado no armazenamento do navegador. Pode ler mais sobre isso <githubLink>nesta questão no GitHub</githubLink>, ou falar connosco por <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
|
||||||
|
"action_bar_show_menu": "Mostrar menu",
|
||||||
|
"action_bar_logo_alt": "logótipo do ntfy",
|
||||||
|
"action_bar_settings": "Configurações",
|
||||||
|
"message_bar_show_dialog": "Mostrar caixa de publicação",
|
||||||
|
"alert_grant_button": "Conceder agora",
|
||||||
|
"publish_dialog_attachment_limits_file_reached": "excede o limite de ficheiro de {{fileSizeLimit}}",
|
||||||
|
"publish_dialog_emoji_picker_show": "Escolher emoji",
|
||||||
|
"publish_dialog_priority_max": "Prioridade máxima",
|
||||||
|
"publish_dialog_title_label": "Título",
|
||||||
|
"publish_dialog_delay_reset": "Remover atraso de entrega",
|
||||||
|
"publish_dialog_chip_email_label": "Encaminhar para email",
|
||||||
|
"publish_dialog_chip_attach_url_label": "Anexar ficheiro por URL",
|
||||||
|
"publish_dialog_chip_attach_file_label": "Anexar ficheiro local",
|
||||||
|
"publish_dialog_chip_delay_label": "Atraso de entrega",
|
||||||
|
"publish_dialog_button_cancel": "Cancelar",
|
||||||
|
"publish_dialog_button_send": "Enviar",
|
||||||
|
"publish_dialog_checkbox_publish_another": "Publicar outra",
|
||||||
|
"publish_dialog_attached_file_title": "Ficheiro anexado:",
|
||||||
|
"publish_dialog_drop_file_here": "Arraste o ficheiro para aqui",
|
||||||
|
"emoji_picker_search_placeholder": "Pesquisar emoji",
|
||||||
|
"subscribe_dialog_subscribe_title": "Subscrever tópico",
|
||||||
|
"subscribe_dialog_subscribe_topic_placeholder": "Nome do tópico, por exemplo: \"alertas_do_filipe\"",
|
||||||
|
"subscribe_dialog_subscribe_base_url_label": "URL de serviço",
|
||||||
|
"subscribe_dialog_subscribe_button_cancel": "Cancelar",
|
||||||
|
"subscribe_dialog_subscribe_button_subscribe": "Subscrever",
|
||||||
|
"subscribe_dialog_login_title": "Autenticação necessária",
|
||||||
|
"subscribe_dialog_login_description": "Esse tópico é protegido por palavra-passe. Por favor insira um nome de utilizador e palavra-passe para subscrever.",
|
||||||
|
"subscribe_dialog_login_username_label": "Nome, por exemplo: \"filipe\"",
|
||||||
|
"subscribe_dialog_login_password_label": "Palavra-passe",
|
||||||
|
"subscribe_dialog_login_button_back": "Voltar",
|
||||||
|
"subscribe_dialog_login_button_login": "Autenticar",
|
||||||
|
"subscribe_dialog_error_user_anonymous": "anónimo",
|
||||||
|
"prefs_notifications_title": "Notificações",
|
||||||
|
"prefs_notifications_sound_title": "Som de notificações",
|
||||||
|
"prefs_notifications_sound_description_none": "Notificações não reproduzem nenhum som quando chegam",
|
||||||
|
"prefs_notifications_sound_description_some": "Notificações reproduzem som {{sound}} quando chegam",
|
||||||
|
"prefs_notifications_sound_no_sound": "Sem som",
|
||||||
|
"prefs_notifications_sound_play": "Reproduzir som selecionado",
|
||||||
|
"prefs_notifications_min_priority_title": "Prioridade mínima",
|
||||||
|
"prefs_notifications_min_priority_description_any": "A mostrar todas as notificações, independentemente da prioridade",
|
||||||
|
"prefs_notifications_min_priority_description_x_or_higher": "Mostrar notificações se prioridade for {{number}} ({{name}}) ou acima",
|
||||||
|
"prefs_notifications_min_priority_any": "Qualquer prioridade",
|
||||||
|
"prefs_notifications_min_priority_low_and_higher": "Prioridade baixa e acima",
|
||||||
|
"prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima",
|
||||||
|
"prefs_notifications_min_priority_high_and_higher": "Prioridade alta e acima",
|
||||||
|
"prefs_notifications_min_priority_max_only": "Apenas prioridade máxima",
|
||||||
|
"prefs_notifications_delete_after_title": "Eliminar notificações",
|
||||||
|
"prefs_notifications_delete_after_never": "Nunca",
|
||||||
|
"prefs_notifications_delete_after_three_hours": "Após três horas",
|
||||||
|
"prefs_notifications_delete_after_one_day": "Após um dia",
|
||||||
|
"prefs_notifications_delete_after_three_hours_description": "As notificações serão eliminadas automaticamente após três horas",
|
||||||
|
"prefs_notifications_delete_after_one_day_description": "As notificações serão eliminadas automaticamente após um dia",
|
||||||
|
"prefs_users_title": "Gerir utilizadores",
|
||||||
|
"prefs_users_description": "Adicionar/remover utilizadores aos seus tópicos protegidos. Note que o utilizador e palavra-passe são guardados no armazenamento local do navegador.",
|
||||||
|
"prefs_users_table": "Tabela de utilizadores",
|
||||||
|
"prefs_users_add_button": "Adicionar utilizador",
|
||||||
|
"prefs_users_edit_button": "Editar utilizador",
|
||||||
|
"prefs_users_delete_button": "Apagar utilizador",
|
||||||
|
"prefs_users_table_user_header": "Utilizador",
|
||||||
|
"prefs_users_table_base_url_header": "URL de serviço",
|
||||||
|
"prefs_users_dialog_title_add": "Adicionar utilizador",
|
||||||
|
"prefs_users_dialog_title_edit": "Editar utilizador",
|
||||||
|
"prefs_users_dialog_base_url_label": "URL de serviço, por exemplo: https://ntfy.sh",
|
||||||
|
"common_save": "Gravar",
|
||||||
|
"prefs_appearance_title": "Aparência",
|
||||||
|
"prefs_appearance_language_title": "Idioma",
|
||||||
|
"priority_min": "mínima",
|
||||||
|
"priority_low": "baixa",
|
||||||
|
"priority_default": "padrão",
|
||||||
|
"priority_high": "alta",
|
||||||
|
"priority_max": "máxima",
|
||||||
|
"error_boundary_title": "Oh não, o ntfy parou de funcionar",
|
||||||
|
"error_boundary_button_copy_stack_trace": "Copiar erro (\"stack trace\")"
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user