Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a75fb08ef1 | ||
|
|
58a0c2a6c6 | ||
|
|
d050956007 | ||
|
|
bdae48afba | ||
|
|
cb5c4c5483 | ||
|
|
e91f07a081 | ||
|
|
7d96be6fb3 | ||
|
|
46c798c71a | ||
|
|
037a51a9d0 | ||
|
|
4596e4bcab | ||
|
|
9b30ada880 | ||
|
|
96d711e19e | ||
|
|
5af5565fb1 | ||
|
|
29c9551548 | ||
|
|
23c5d4e345 | ||
|
|
ff5bf4acd0 | ||
|
|
34c42c55f6 | ||
|
|
07e5b28868 | ||
|
|
06a0654a5a | ||
|
|
8cc23117fe | ||
|
|
f8c4f20a8f | ||
|
|
8053e992e4 | ||
|
|
9db96140e2 | ||
|
|
502d0a0abd | ||
|
|
80b0a94f7e | ||
|
|
338cab1660 | ||
|
|
b8836d674a | ||
|
|
c6a96d19e2 | ||
|
|
bcb24aecd3 | ||
|
|
d72ae47d1f | ||
|
|
a5d2fc172b | ||
|
|
bbab81a1a2 | ||
|
|
78a1ca81e3 | ||
|
|
f090d1313e | ||
|
|
afa4efa140 | ||
|
|
d2b88005f0 | ||
|
|
9eb1f6a186 | ||
|
|
2d8d5b3b95 | ||
|
|
844f4a3931 | ||
|
|
8aaec62d7f | ||
|
|
d97c3d2afc | ||
|
|
29ddd2a4b5 | ||
|
|
73069ae9a0 | ||
|
|
05d7c65e42 | ||
|
|
d11d7b13e6 | ||
|
|
14285a95e5 | ||
|
|
c3ec809727 | ||
|
|
e72a2703db | ||
|
|
e20fd0f84f | ||
|
|
6989643a49 | ||
|
|
ca9fed7b67 | ||
|
|
358b344916 | ||
|
|
b51294dc2c | ||
|
|
bb3fe4f830 | ||
|
|
84d5fde24b | ||
|
|
fe731d43cd | ||
|
|
835dad9eba | ||
|
|
77eb898528 | ||
|
|
ad9f8a5400 | ||
|
|
ceba7503a4 | ||
|
|
754b456320 | ||
|
|
6903e1677d | ||
|
|
8de26a7fdf | ||
|
|
6d672a7a71 | ||
|
|
d7b7bea701 | ||
|
|
b1916b5066 | ||
|
|
13a90172c2 | ||
|
|
394bca0ca6 | ||
|
|
c2af85b894 | ||
|
|
8ebc70261f | ||
|
|
390e8d18c7 | ||
|
|
284d992fb8 | ||
|
|
e808cace29 | ||
|
|
762dc8449c | ||
|
|
385bb5634d | ||
|
|
1aaa82b631 | ||
|
|
e0bc2f13f0 | ||
|
|
6ab974e50f | ||
|
|
75217bf61b | ||
|
|
2ee2395bd0 | ||
|
|
db7baf73c0 | ||
|
|
c6bfdd45be | ||
|
|
f953302c27 | ||
|
|
b69b4490bb | ||
|
|
92d9c28a70 | ||
|
|
fd6e470f3c | ||
|
|
6f312dad07 | ||
|
|
bd2dc5376c | ||
|
|
823963b934 | ||
|
|
d30c5acf0d | ||
|
|
961b62ad87 | ||
|
|
3f0cc828f2 | ||
|
|
394a30784b | ||
|
|
d887e41cf7 | ||
|
|
2565802721 | ||
|
|
d4a044366d | ||
|
|
9370acbcfe | ||
|
|
e5e8003ee0 | ||
|
|
3777feae8f | ||
|
|
2783a52cad | ||
|
|
3f754f2d02 | ||
|
|
ee97e1110d | ||
|
|
758eb3f371 | ||
|
|
1797dec2ba | ||
|
|
25be5b47e4 | ||
|
|
bc0e72e3ef | ||
|
|
0b854286f5 | ||
|
|
e633a40ef1 | ||
|
|
fc75937072 | ||
|
|
5e0d8ab9f8 | ||
|
|
323ce6274a | ||
|
|
79281fdd21 | ||
|
|
e7d58ccdf2 | ||
|
|
0328ba2a32 | ||
|
|
477c9d3ed5 | ||
|
|
e44f0ef6e7 | ||
|
|
6f4b260035 | ||
|
|
bb7a751e58 | ||
|
|
97c9266cc8 | ||
|
|
a139a3df89 | ||
|
|
346d8d7967 | ||
|
|
3eeeac2c13 | ||
|
|
94f6d2d5b5 | ||
|
|
1c4420bca8 | ||
|
|
ecff7258ba | ||
|
|
72d4f67524 | ||
|
|
1ce92714c4 | ||
|
|
1c6c2cf332 | ||
|
|
9d42ee9391 | ||
|
|
b62204054f | ||
|
|
166dc6b4fa | ||
|
|
02a1e99db2 | ||
|
|
250637cf92 | ||
|
|
b46de7402d | ||
|
|
9334a94886 | ||
|
|
9b9aa4306a | ||
|
|
90db1283dd | ||
|
|
8cc00a6ac6 | ||
|
|
315034c8cd | ||
|
|
23ac9d44a1 | ||
|
|
70db2f994c | ||
|
|
64b3c3c2fa | ||
|
|
983afb2b45 | ||
|
|
4d22ccc7f6 | ||
|
|
cd3429842b | ||
|
|
d89df315e4 | ||
|
|
fe3a225f8f | ||
|
|
f862341997 | ||
|
|
8ca08ce868 | ||
|
|
ba46630138 | ||
|
|
a3087047b6 | ||
|
|
217ca81b17 | ||
|
|
7edcebad1f | ||
|
|
0af3e29ce1 | ||
|
|
dd6462de13 | ||
|
|
52f18d048c | ||
|
|
c522ee1dd8 | ||
|
|
33e3f7ae46 | ||
|
|
87f9f88e32 | ||
|
|
0fe1e109ed | ||
|
|
90b04417cf | ||
|
|
221004af39 | ||
|
|
c3f6077f95 | ||
|
|
4f9227f100 | ||
|
|
ae6f649a06 | ||
|
|
26f9eddfc4 | ||
|
|
00879d11d3 |
26
.github/ISSUE_TEMPLATE/1_bug_report.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/1_bug_report.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: Report any errors and problems
|
||||
title: ''
|
||||
labels: '🪲 bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
:lady_beetle: **Describe the bug**
|
||||
<!-- A clear and concise description of the problem. -->
|
||||
|
||||
:computer: **Components impacted**
|
||||
<!-- ntfy server, Android app, iOS app, web app -->
|
||||
|
||||
:bulb: **Screenshots and/or logs**
|
||||
<!--
|
||||
If applicable, add screenshots or share logs help explain your problem.
|
||||
To get logs from the ...
|
||||
- ntfy server: Enable "log-level: trace" in your server.yml file
|
||||
- Android app: Go to "Settings" -> "Record logs", then eventually "Copy/upload logs"
|
||||
- web app: Press "F12" and find the "Console" window
|
||||
-->
|
||||
|
||||
:crystal_ball: **Additional context**
|
||||
<!-- Add any other context about the problem here. -->
|
||||
26
.github/ISSUE_TEMPLATE/2_enhancement_request.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/2_enhancement_request.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: 💡 Feature/Enhancement Request
|
||||
about: Got a great idea? Let us know!
|
||||
title: ''
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
Before you submit, consider asking on Discord/Matrix instead. You'll usually get an answer
|
||||
sooner, and there are more people there to help!
|
||||
|
||||
- Discord: https://discord.gg/cT7ECsZj9w
|
||||
- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org
|
||||
|
||||
-->
|
||||
|
||||
:bulb: **Idea**
|
||||
<!-- Share your thoughts; try to be detailed if you can -->
|
||||
|
||||
:computer: **Target components**
|
||||
<!-- Where should this feature/enhancement be added? -->
|
||||
<!-- e.g. ntfy server, Android app, iOS app, web app -->
|
||||
|
||||
21
.github/ISSUE_TEMPLATE/3_tech_support.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/3_tech_support.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: 🆘 I need help with ...
|
||||
about: Installing ntfy, configuring the app, etc.
|
||||
title: ''
|
||||
labels: 'tech-support'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
<!--
|
||||
|
||||
STOP!
|
||||
|
||||
This is not the right place to ask for help. Consider asking on Discord/Matrix instead.
|
||||
You'll usually get an answer sooner, and there are more people there to help!
|
||||
|
||||
- Discord: https://discord.gg/cT7ECsZj9w
|
||||
- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org
|
||||
|
||||
-->
|
||||
21
.github/ISSUE_TEMPLATE/4_question.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/4_question.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: ❓ Question
|
||||
about: Ask a question about ntfy
|
||||
title: ''
|
||||
labels: 'question'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
Before you submit, consider asking on Discord/Matrix instead. You'll usually get an answer
|
||||
sooner, and there are more people there to help!
|
||||
|
||||
- Discord: https://discord.gg/cT7ECsZj9w
|
||||
- Matrix: https://matrix.to/#/#ntfy:matrix.org / https://matrix.to/#/#ntfy-space:matrix.org
|
||||
|
||||
-->
|
||||
|
||||
:question: **Question**
|
||||
<!-- Go ahead and ask your question here :) -->
|
||||
2
.github/workflows/build.yaml
vendored
2
.github/workflows/build.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '17'
|
||||
node-version: '18'
|
||||
-
|
||||
name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
2
.github/workflows/release.yaml
vendored
2
.github/workflows/release.yaml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '17'
|
||||
node-version: '18'
|
||||
-
|
||||
name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '17'
|
||||
node-version: '18'
|
||||
-
|
||||
name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ secrets/
|
||||
*.iml
|
||||
node_modules/
|
||||
.DS_Store
|
||||
__pycache__
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,9 +1,15 @@
|
||||
FROM alpine
|
||||
MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com>
|
||||
|
||||
LABEL org.opencontainers.image.authors="philipp.heckel@gmail.com"
|
||||
LABEL org.opencontainers.image.url="https://ntfy.sh/"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.ntfy.sh/"
|
||||
LABEL org.opencontainers.image.source="https://github.com/binwiederhier/ntfy"
|
||||
LABEL org.opencontainers.image.vendor="Philipp C. Heckel"
|
||||
LABEL org.opencontainers.image.licenses="Apache-2.0, GPL-2.0"
|
||||
LABEL org.opencontainers.image.title="ntfy"
|
||||
LABEL org.opencontainers.image.description="Send push notifications to your phone or desktop using PUT/POST"
|
||||
|
||||
COPY ntfy /usr/bin
|
||||
|
||||
HEALTHCHECK --interval=60s --timeout=10s CMD wget -q --tries=1 http://localhost/v1/health -O - | grep -Eo '"healthy"\s*:\s*true' || exit 1
|
||||
|
||||
EXPOSE 80/tcp
|
||||
ENTRYPOINT ["ntfy"]
|
||||
|
||||
22
Makefile
22
Makefile
@@ -141,25 +141,25 @@ web-deps-update:
|
||||
# Main server/client build
|
||||
|
||||
cli: cli-deps
|
||||
goreleaser build --snapshot --rm-dist
|
||||
goreleaser build --snapshot --clean
|
||||
|
||||
cli-linux-amd64: cli-deps-static-sites
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_linux_amd64
|
||||
goreleaser build --snapshot --clean --id ntfy_linux_amd64
|
||||
|
||||
cli-linux-armv6: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_linux_armv6
|
||||
goreleaser build --snapshot --clean --id ntfy_linux_armv6
|
||||
|
||||
cli-linux-armv7: cli-deps-static-sites cli-deps-gcc-armv6-armv7
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_linux_armv7
|
||||
goreleaser build --snapshot --clean --id ntfy_linux_armv7
|
||||
|
||||
cli-linux-arm64: cli-deps-static-sites cli-deps-gcc-arm64
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_linux_arm64
|
||||
goreleaser build --snapshot --clean --id ntfy_linux_arm64
|
||||
|
||||
cli-windows-amd64: cli-deps-static-sites
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_windows_amd64
|
||||
goreleaser build --snapshot --clean --id ntfy_windows_amd64
|
||||
|
||||
cli-darwin-all: cli-deps-static-sites
|
||||
goreleaser build --snapshot --rm-dist --id ntfy_darwin_all
|
||||
goreleaser build --snapshot --clean --id ntfy_darwin_all
|
||||
|
||||
cli-linux-server: cli-deps-static-sites
|
||||
# This is a target to build the CLI (including the server) manually.
|
||||
@@ -277,11 +277,11 @@ staticcheck: .PHONY
|
||||
|
||||
# Releasing targets
|
||||
|
||||
release: clean update cli-deps release-checks docs web check
|
||||
goreleaser release --rm-dist
|
||||
release: clean cli-deps release-checks docs web check
|
||||
goreleaser release --clean
|
||||
|
||||
release-snapshot: clean update cli-deps docs web check
|
||||
goreleaser release --snapshot --skip-publish --rm-dist
|
||||
release-snapshot: clean cli-deps docs web check
|
||||
goreleaser release --snapshot --skip-publish --clean
|
||||
|
||||
release-checks:
|
||||
$(eval LATEST_TAG := $(shell git describe --abbrev=0 --tags | cut -c2-))
|
||||
|
||||
15
README.md
15
README.md
@@ -13,9 +13,14 @@
|
||||
[](https://ntfy.statuspage.io/)
|
||||
[](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
|
||||
|
||||
**ntfy** (pronounced "*notify*") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer, **without having to sign up or pay any fees**. If you'd like to run your own instance of the service, you can easily do so since ntfy is open source.
|
||||
**ntfy** (pronounced "*notify*") is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern)
|
||||
notification service. With ntfy, you can **send notifications to your phone or desktop via scripts** from any computer,
|
||||
**without having to sign up or pay any fees**. If you'd like to run your own instance of the service, you can easily do
|
||||
so since ntfy is open source.
|
||||
|
||||
You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There is also an [open source Android app](https://github.com/binwiederhier/ntfy-android) available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/), as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||
You can access the free version of ntfy at **[ntfy.sh](https://ntfy.sh)**. There is also an [open source Android app](https://github.com/binwiederhier/ntfy-android)
|
||||
available on [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/),
|
||||
as well as an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) available on the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||
|
||||
<p>
|
||||
<img src="web/public/static/img/screenshot-curl.png" height="180">
|
||||
@@ -119,6 +124,12 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
|
||||
<a href="https://github.com/KucharczykL"><img src="https://github.com/KucharczykL.png" width="40px" /></a>
|
||||
<a href="https://github.com/hansbickhofe"><img src="https://github.com/hansbickhofe.png" width="40px" /></a>
|
||||
<a href="https://github.com/caseodilla"><img src="https://github.com/caseodilla.png" width="40px" /></a>
|
||||
<a href="https://github.com/0xAF"><img src="https://github.com/0xAF.png" width="40px" /></a>
|
||||
<a href="https://github.com/soonoo"><img src="https://github.com/soonoo.png" width="40px" /></a>
|
||||
<a href="https://github.com/nichu42"><img src="https://github.com/nichu42.png" width="40px" /></a>
|
||||
<a href="https://github.com/samliebow"><img src="https://github.com/samliebow.png" width="40px" /></a>
|
||||
<a href="https://github.com/johman10"><img src="https://github.com/johman10.png" width="40px" /></a>
|
||||
<a href="https://github.com/R-Gld"><img src="https://github.com/R-Gld.png" width="40px" /></a>
|
||||
|
||||
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
|
||||
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
|
||||
|
||||
10
SECURITY.md
Normal file
10
SECURITY.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
As of today, I only support the latest version of ntfy. Please make sure you stay up-to-date.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report severe security issues privately via ntfy@heckel.io, [Discord](https://discord.gg/cT7ECsZj9w),
|
||||
or [Matrix](https://matrix.to/#/#ntfy:matrix.org) (my username is `binwiederhier`).
|
||||
@@ -5,10 +5,12 @@
|
||||
#
|
||||
# default-host: https://ntfy.sh
|
||||
|
||||
# Default username and password will be used with "ntfy publish" if no credentials are provided on command line
|
||||
# Default username and password will be used with "ntfy subscribe" if no credentials are provided in subscription below
|
||||
# For an empty password, use empty double-quotes ("")
|
||||
#
|
||||
# Default credentials will be used with "ntfy publish" and "ntfy subscribe" if no other credentials are provided.
|
||||
# You can set a default token to use or a default user:password combination, but not both. For an empty password,
|
||||
# use empty double-quotes ("")
|
||||
|
||||
# default-token:
|
||||
|
||||
# default-user:
|
||||
# default-password:
|
||||
|
||||
@@ -30,6 +32,8 @@
|
||||
# command: 'notify-send "$m"'
|
||||
# user: phill
|
||||
# password: mypass
|
||||
# - topic: token_topic
|
||||
# token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
#
|
||||
# Variables:
|
||||
# Variable Aliases Description
|
||||
|
||||
@@ -12,17 +12,22 @@ const (
|
||||
|
||||
// Config is the config struct for a Client
|
||||
type Config struct {
|
||||
DefaultHost string `yaml:"default-host"`
|
||||
DefaultUser string `yaml:"default-user"`
|
||||
DefaultPassword *string `yaml:"default-password"`
|
||||
DefaultCommand string `yaml:"default-command"`
|
||||
Subscribe []struct {
|
||||
Topic string `yaml:"topic"`
|
||||
User string `yaml:"user"`
|
||||
Password *string `yaml:"password"`
|
||||
Command string `yaml:"command"`
|
||||
If map[string]string `yaml:"if"`
|
||||
} `yaml:"subscribe"`
|
||||
DefaultHost string `yaml:"default-host"`
|
||||
DefaultUser string `yaml:"default-user"`
|
||||
DefaultPassword *string `yaml:"default-password"`
|
||||
DefaultToken string `yaml:"default-token"`
|
||||
DefaultCommand string `yaml:"default-command"`
|
||||
Subscribe []Subscribe `yaml:"subscribe"`
|
||||
}
|
||||
|
||||
// Subscribe is the struct for a Subscription within Config
|
||||
type Subscribe struct {
|
||||
Topic string `yaml:"topic"`
|
||||
User string `yaml:"user"`
|
||||
Password *string `yaml:"password"`
|
||||
Token string `yaml:"token"`
|
||||
Command string `yaml:"command"`
|
||||
If map[string]string `yaml:"if"`
|
||||
}
|
||||
|
||||
// NewConfig creates a new Config struct for a Client
|
||||
@@ -31,6 +36,7 @@ func NewConfig() *Config {
|
||||
DefaultHost: DefaultBaseURL,
|
||||
DefaultUser: "",
|
||||
DefaultPassword: nil,
|
||||
DefaultToken: "",
|
||||
DefaultCommand: "",
|
||||
Subscribe: nil,
|
||||
}
|
||||
|
||||
@@ -116,3 +116,25 @@ subscribe:
|
||||
require.Equal(t, "phil", conf.Subscribe[0].User)
|
||||
require.Nil(t, conf.Subscribe[0].Password)
|
||||
}
|
||||
|
||||
func TestConfig_DefaultToken(t *testing.T) {
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(`
|
||||
default-host: http://localhost
|
||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
`), 0600))
|
||||
|
||||
conf, err := client.LoadConfig(filename)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "http://localhost", conf.DefaultHost)
|
||||
require.Equal(t, "", conf.DefaultUser)
|
||||
require.Nil(t, conf.DefaultPassword)
|
||||
require.Equal(t, "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", conf.DefaultToken)
|
||||
require.Equal(t, 1, len(conf.Subscribe))
|
||||
require.Equal(t, "mytopic", conf.Subscribe[0].Topic)
|
||||
require.Equal(t, "", conf.Subscribe[0].User)
|
||||
require.Nil(t, conf.Subscribe[0].Password)
|
||||
require.Equal(t, "", conf.Subscribe[0].Token)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ var flagsPublish = append(
|
||||
&cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"wait_cmd", "cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"},
|
||||
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"no_cache", "C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
|
||||
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"no_firebase", "F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
|
||||
&cli.BoolFlag{Name: "env-topic", Aliases: []string{"env_topic", "P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
|
||||
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do not print message"},
|
||||
)
|
||||
|
||||
@@ -155,8 +154,7 @@ func execPublish(c *cli.Context) error {
|
||||
}
|
||||
if token != "" {
|
||||
options = append(options, client.WithBearerAuth(token))
|
||||
}
|
||||
if user != "" {
|
||||
} else if user != "" {
|
||||
var pass string
|
||||
parts := strings.SplitN(user, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
@@ -172,6 +170,8 @@ func execPublish(c *cli.Context) error {
|
||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
||||
}
|
||||
options = append(options, client.WithBasicAuth(user, pass))
|
||||
} else if conf.DefaultToken != "" {
|
||||
options = append(options, client.WithBearerAuth(conf.DefaultToken))
|
||||
} else if conf.DefaultUser != "" && conf.DefaultPassword != nil {
|
||||
options = append(options, client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword))
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/test"
|
||||
"heckel.io/ntfy/util"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -86,7 +89,6 @@ func TestCLI_Publish_All_The_Things(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
|
||||
t.Parallel()
|
||||
s, port := test.StartServer(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port)
|
||||
@@ -131,11 +133,11 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
|
||||
require.Equal(t, `command failed: does-not-exist-no-really "really though", error: exec: "does-not-exist-no-really": executable file not found in $PATH`, err.Error())
|
||||
|
||||
// Tests with NTFY_TOPIC set ////
|
||||
require.Nil(t, os.Setenv("NTFY_TOPIC", topic))
|
||||
t.Setenv("NTFY_TOPIC", topic)
|
||||
|
||||
// Test: Successful command with NTFY_TOPIC
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--cmd", "echo", "hi there"}))
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--cmd", "echo", "hi there"}))
|
||||
m = toMessage(t, stdout.String())
|
||||
require.Equal(t, "mytopic", m.Topic)
|
||||
|
||||
@@ -144,7 +146,155 @@ func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
|
||||
require.Nil(t, sleep.Start())
|
||||
go sleep.Wait() // Must be called to release resources
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--wait-pid", strconv.Itoa(sleep.Process.Pid)}))
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid)}))
|
||||
m = toMessage(t, stdout.String())
|
||||
require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Default_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-user: philipp
|
||||
default-password: mypass
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"}))
|
||||
m := toMessage(t, stdout.String())
|
||||
require.Equal(t, "triggered", m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Default_Token(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "mytopic", "triggered"}))
|
||||
m := toMessage(t, stdout.String())
|
||||
require.Equal(t, "triggered", m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Default_UserPass_CLI_Token(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-user: philipp
|
||||
default-password: mypass
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"}))
|
||||
m := toMessage(t, stdout.String())
|
||||
require.Equal(t, "triggered", m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Default_Token_CLI_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"}))
|
||||
m := toMessage(t, stdout.String())
|
||||
require.Equal(t, "triggered", m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Default_Token_CLI_Token(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_FAKETOKEN01234567890FAKETOKEN
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic", "triggered"}))
|
||||
m := toMessage(t, stdout.String())
|
||||
require.Equal(t, "triggered", m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Default_UserPass_CLI_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-user: philipp
|
||||
default-password: fakepass
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--config=" + filename, "--user", "philipp:mypass", "mytopic", "triggered"}))
|
||||
m := toMessage(t, stdout.String())
|
||||
require.Equal(t, "triggered", m.Message)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Token_And_UserPass(t *testing.T) {
|
||||
app, _, _, _ := newTestApp()
|
||||
err := app.Run([]string{"ntfy", "publish", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "cannot set both --user and --token", err.Error())
|
||||
}
|
||||
|
||||
23
cmd/serve.go
23
cmd/serve.go
@@ -37,8 +37,8 @@ var flagsServe = append(
|
||||
append([]cli.Flag{}, flagsDefault...),
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: defaultServerConfigFile, DefaultText: defaultServerConfigFile, Usage: "config file"},
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"base_url", "B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"listen_http", "l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used as HTTP listen address"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"listen_https", "L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used as HTTPS listen address"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"listen_unix", "U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "listen-unix-mode", Aliases: []string{"listen_unix_mode"}, EnvVars: []string{"NTFY_LISTEN_UNIX_MODE"}, DefaultText: "system default", Usage: "file permissions of unix socket, e.g. 0700"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"key_file", "K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
|
||||
@@ -81,9 +81,14 @@ var flagsServe = append(
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-metrics", Aliases: []string{"enable_metrics"}, EnvVars: []string{"NTFY_ENABLE_METRICS"}, Value: false, Usage: "if set, Prometheus metrics are exposed via the /metrics endpoint"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "metrics-listen-http", Aliases: []string{"metrics_listen_http"}, EnvVars: []string{"NTFY_METRICS_LISTEN_HTTP"}, Usage: "ip:port used to expose the metrics endpoint (implicitly enables metrics)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "profile-listen-http", Aliases: []string{"profile_listen_http"}, EnvVars: []string{"NTFY_PROFILE_LISTEN_HTTP"}, Usage: "ip:port used to expose the profiling endpoints (implicitly enables profiling)"}),
|
||||
)
|
||||
|
||||
var cmdServe = &cli.Command{
|
||||
@@ -148,6 +153,7 @@ func execServe(c *cli.Context) error {
|
||||
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
|
||||
totalTopicLimit := c.Int("global-topic-limit")
|
||||
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
||||
visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting")
|
||||
visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit")
|
||||
visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
|
||||
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
||||
@@ -159,6 +165,10 @@ func execServe(c *cli.Context) error {
|
||||
behindProxy := c.Bool("behind-proxy")
|
||||
stripeSecretKey := c.String("stripe-secret-key")
|
||||
stripeWebhookKey := c.String("stripe-webhook-key")
|
||||
billingContact := c.String("billing-contact")
|
||||
metricsListenHTTP := c.String("metrics-listen-http")
|
||||
enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != ""
|
||||
profileListenHTTP := c.String("profile-listen-http")
|
||||
|
||||
// Check values
|
||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||
@@ -175,8 +185,8 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("if set, certificate file must exist")
|
||||
} else if listenHTTPS != "" && (keyFile == "" || certFile == "") {
|
||||
return errors.New("if listen-https is set, both key-file and cert-file must be set")
|
||||
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderUser == "" || smtpSenderPass == "" || smtpSenderFrom == "") {
|
||||
return errors.New("if smtp-sender-addr is set, base-url, smtp-sender-user, smtp-sender-pass and smtp-sender-from must also be set")
|
||||
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") {
|
||||
return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set")
|
||||
} else if smtpServerListen != "" && smtpServerDomain == "" {
|
||||
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
|
||||
} else if attachmentCacheDir != "" && baseURL == "" {
|
||||
@@ -302,13 +312,18 @@ func execServe(c *cli.Context) error {
|
||||
conf.VisitorMessageDailyLimit = visitorMessageDailyLimit
|
||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||
conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting
|
||||
conf.BehindProxy = behindProxy
|
||||
conf.StripeSecretKey = stripeSecretKey
|
||||
conf.StripeWebhookKey = stripeWebhookKey
|
||||
conf.BillingContact = billingContact
|
||||
conf.EnableWeb = enableWeb
|
||||
conf.EnableSignup = enableSignup
|
||||
conf.EnableLogin = enableLogin
|
||||
conf.EnableReservations = enableReservations
|
||||
conf.EnableMetrics = enableMetrics
|
||||
conf.MetricsListenHTTP = metricsListenHTTP
|
||||
conf.ProfileListenHTTP = profileListenHTTP
|
||||
conf.Version = c.App.Version
|
||||
|
||||
// Set up hot-reloading of config
|
||||
|
||||
@@ -22,7 +22,6 @@ func init() {
|
||||
}
|
||||
|
||||
func TestCLI_Serve_Unix_Curl(t *testing.T) {
|
||||
t.Parallel()
|
||||
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
|
||||
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
|
||||
go func() {
|
||||
|
||||
@@ -30,6 +30,7 @@ var flagsSubscribe = append(
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
||||
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
|
||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
||||
&cli.StringFlag{Name: "token", Aliases: []string{"k"}, EnvVars: []string{"NTFY_TOKEN"}, Usage: "access token used to auth against the server"},
|
||||
&cli.BoolFlag{Name: "from-config", Aliases: []string{"from_config", "C"}, Usage: "read subscriptions from config file (service mode)"},
|
||||
&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
|
||||
&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
|
||||
@@ -97,11 +98,18 @@ func execSubscribe(c *cli.Context) error {
|
||||
cl := client.New(conf)
|
||||
since := c.String("since")
|
||||
user := c.String("user")
|
||||
token := c.String("token")
|
||||
poll := c.Bool("poll")
|
||||
scheduled := c.Bool("scheduled")
|
||||
fromConfig := c.Bool("from-config")
|
||||
topic := c.Args().Get(0)
|
||||
command := c.Args().Get(1)
|
||||
|
||||
// Checks
|
||||
if user != "" && token != "" {
|
||||
return errors.New("cannot set both --user and --token")
|
||||
}
|
||||
|
||||
if !fromConfig {
|
||||
conf.Subscribe = nil // wipe if --from-config not passed
|
||||
}
|
||||
@@ -109,6 +117,9 @@ func execSubscribe(c *cli.Context) error {
|
||||
if since != "" {
|
||||
options = append(options, client.WithSince(since))
|
||||
}
|
||||
if token != "" {
|
||||
options = append(options, client.WithBearerAuth(token))
|
||||
}
|
||||
if user != "" {
|
||||
var pass string
|
||||
parts := strings.SplitN(user, ":", 2)
|
||||
@@ -126,9 +137,6 @@ func execSubscribe(c *cli.Context) error {
|
||||
}
|
||||
options = append(options, client.WithBasicAuth(user, pass))
|
||||
}
|
||||
if poll {
|
||||
options = append(options, client.WithPoll())
|
||||
}
|
||||
if scheduled {
|
||||
options = append(options, client.WithScheduled())
|
||||
}
|
||||
@@ -145,6 +153,9 @@ func execSubscribe(c *cli.Context) error {
|
||||
|
||||
func doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
|
||||
for _, s := range conf.Subscribe { // may be nil
|
||||
if auth := maybeAddAuthHeader(s, conf); auth != nil {
|
||||
options = append(options, auth)
|
||||
}
|
||||
if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -175,21 +186,11 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
|
||||
for filter, value := range s.If {
|
||||
topicOptions = append(topicOptions, client.WithFilter(filter, value))
|
||||
}
|
||||
var user string
|
||||
var password *string
|
||||
if s.User != "" {
|
||||
user = s.User
|
||||
} else if conf.DefaultUser != "" {
|
||||
user = conf.DefaultUser
|
||||
}
|
||||
if s.Password != nil {
|
||||
password = s.Password
|
||||
} else if conf.DefaultPassword != nil {
|
||||
password = conf.DefaultPassword
|
||||
}
|
||||
if user != "" && password != nil {
|
||||
topicOptions = append(topicOptions, client.WithBasicAuth(user, *password))
|
||||
|
||||
if auth := maybeAddAuthHeader(s, conf); auth != nil {
|
||||
topicOptions = append(topicOptions, auth)
|
||||
}
|
||||
|
||||
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
|
||||
if s.Command != "" {
|
||||
cmds[subscriptionID] = s.Command
|
||||
@@ -214,6 +215,25 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
|
||||
return nil
|
||||
}
|
||||
|
||||
func maybeAddAuthHeader(s client.Subscribe, conf *client.Config) client.SubscribeOption {
|
||||
// check for subscription token then subscription user:pass
|
||||
if s.Token != "" {
|
||||
return client.WithBearerAuth(s.Token)
|
||||
}
|
||||
if s.User != "" && s.Password != nil {
|
||||
return client.WithBasicAuth(s.User, *s.Password)
|
||||
}
|
||||
|
||||
// if no subscription token nor subscription user:pass, check for default token then default user:pass
|
||||
if conf.DefaultToken != "" {
|
||||
return client.WithBearerAuth(conf.DefaultToken)
|
||||
}
|
||||
if conf.DefaultUser != "" && conf.DefaultPassword != nil {
|
||||
return client.WithBasicAuth(conf.DefaultUser, *conf.DefaultPassword)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) {
|
||||
if command != "" {
|
||||
runCommand(c, command, m)
|
||||
|
||||
312
cmd/subscribe_test.go
Normal file
312
cmd/subscribe_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCLI_Subscribe_Default_UserPass_Subscription_Token(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-user: philipp
|
||||
default-password: mypass
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Token_Subscription_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
user: philipp
|
||||
password: mypass
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Token_Subscription_Token(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_FAKETOKEN01234567890FAKETOKEN
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_UserPass_Subscription_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-user: fake
|
||||
default-password: password
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
user: philipp
|
||||
password: mypass
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Token_Subscription_Empty(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_UserPass_Subscription_Empty(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-user: philipp
|
||||
default-password: mypass
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Empty_Subscription_Token(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Empty_Subscription_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
user: philipp
|
||||
password: mypass
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Token_CLI_Token(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_FAKETOKEN0123456789FAKETOKEN
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "mytopic"}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Token_CLI_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Basic cGhpbGlwcDpteXBhc3M=", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass", "mytopic"}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Default_Token_Subscription_Token_CLI_UserPass(t *testing.T) {
|
||||
message := `{"id":"RXIQBFaieLVr","time":124,"expires":1124,"event":"message","topic":"mytopic","message":"triggered"}`
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/mytopic/json", r.URL.Path)
|
||||
require.Equal(t, "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(message))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
filename := filepath.Join(t.TempDir(), "client.yml")
|
||||
require.Nil(t, os.WriteFile(filename, []byte(fmt.Sprintf(`
|
||||
default-host: %s
|
||||
default-token: tk_FAKETOKEN01234567890FAKETOKEN
|
||||
subscribe:
|
||||
- topic: mytopic
|
||||
token: tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2
|
||||
`, server.URL)), 0600))
|
||||
|
||||
app, _, stdout, _ := newTestApp()
|
||||
|
||||
require.Nil(t, app.Run([]string{"ntfy", "subscribe", "--poll", "--from-config", "--config=" + filename, "--user", "philipp:mypass"}))
|
||||
|
||||
require.Equal(t, message, strings.TrimSpace(stdout.String()))
|
||||
}
|
||||
|
||||
func TestCLI_Subscribe_Token_And_UserPass(t *testing.T) {
|
||||
app, _, _, _ := newTestApp()
|
||||
err := app.Run([]string{"ntfy", "subscribe", "--poll", "--token", "tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "--user", "philipp:mypass", "mytopic", "triggered"})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "cannot set both --user and --token", err.Error())
|
||||
}
|
||||
35
cmd/tier.go
35
cmd/tier.go
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/user"
|
||||
"heckel.io/ntfy/util"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -17,12 +16,12 @@ func init() {
|
||||
|
||||
const (
|
||||
defaultMessageLimit = 5000
|
||||
defaultMessageExpiryDuration = 12 * time.Hour
|
||||
defaultMessageExpiryDuration = "12h"
|
||||
defaultEmailLimit = 20
|
||||
defaultReservationLimit = 3
|
||||
defaultAttachmentFileSizeLimit = "15M"
|
||||
defaultAttachmentTotalSizeLimit = "100M"
|
||||
defaultAttachmentExpiryDuration = 6 * time.Hour
|
||||
defaultAttachmentExpiryDuration = "6h"
|
||||
defaultAttachmentBandwidthLimit = "1G"
|
||||
)
|
||||
|
||||
@@ -47,12 +46,12 @@ var cmdTier = &cli.Command{
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "name", Usage: "tier name"},
|
||||
&cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"},
|
||||
&cli.DurationFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
|
||||
&cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"},
|
||||
&cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"},
|
||||
&cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"},
|
||||
&cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"},
|
||||
&cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"},
|
||||
&cli.DurationFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"},
|
||||
&cli.StringFlag{Name: "attachment-expiry-duration", Value: defaultAttachmentExpiryDuration, Usage: "duration after which attachments are deleted"},
|
||||
&cli.StringFlag{Name: "attachment-bandwidth-limit", Value: defaultAttachmentBandwidthLimit, Usage: "daily bandwidth limit for attachment uploads/downloads"},
|
||||
&cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
&cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
@@ -90,12 +89,12 @@ Examples:
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "name", Usage: "tier name"},
|
||||
&cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"},
|
||||
&cli.DurationFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
|
||||
&cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"},
|
||||
&cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"},
|
||||
&cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"},
|
||||
&cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"},
|
||||
&cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"},
|
||||
&cli.DurationFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"},
|
||||
&cli.StringFlag{Name: "attachment-expiry-duration", Usage: "duration after which attachments are deleted"},
|
||||
&cli.StringFlag{Name: "attachment-bandwidth-limit", Usage: "daily bandwidth limit for attachment uploads/downloads"},
|
||||
&cli.StringFlag{Name: "stripe-monthly-price-id", Usage: "Monthly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
&cli.StringFlag{Name: "stripe-yearly-price-id", Usage: "Yearly Stripe price ID for paid tiers (e.g. price_12345)"},
|
||||
@@ -189,6 +188,10 @@ func execTierAdd(c *cli.Context) error {
|
||||
if name == "" {
|
||||
name = code
|
||||
}
|
||||
messageExpiryDuration, err := util.ParseDuration(c.String("message-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentFileSizeLimit, err := util.ParseSize(c.String("attachment-file-size-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -201,17 +204,21 @@ func execTierAdd(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentExpiryDuration, err := util.ParseDuration(c.String("attachment-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tier := &user.Tier{
|
||||
ID: "", // Generated
|
||||
Code: code,
|
||||
Name: name,
|
||||
MessageLimit: c.Int64("message-limit"),
|
||||
MessageExpiryDuration: c.Duration("message-expiry-duration"),
|
||||
MessageExpiryDuration: messageExpiryDuration,
|
||||
EmailLimit: c.Int64("email-limit"),
|
||||
ReservationLimit: c.Int64("reservation-limit"),
|
||||
AttachmentFileSizeLimit: attachmentFileSizeLimit,
|
||||
AttachmentTotalSizeLimit: attachmentTotalSizeLimit,
|
||||
AttachmentExpiryDuration: c.Duration("attachment-expiry-duration"),
|
||||
AttachmentExpiryDuration: attachmentExpiryDuration,
|
||||
AttachmentBandwidthLimit: attachmentBandwidthLimit,
|
||||
StripeMonthlyPriceID: c.String("stripe-monthly-price-id"),
|
||||
StripeYearlyPriceID: c.String("stripe-yearly-price-id"),
|
||||
@@ -252,7 +259,10 @@ func execTierChange(c *cli.Context) error {
|
||||
tier.MessageLimit = c.Int64("message-limit")
|
||||
}
|
||||
if c.IsSet("message-expiry-duration") {
|
||||
tier.MessageExpiryDuration = c.Duration("message-expiry-duration")
|
||||
tier.MessageExpiryDuration, err = util.ParseDuration(c.String("message-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IsSet("email-limit") {
|
||||
tier.EmailLimit = c.Int64("email-limit")
|
||||
@@ -273,7 +283,10 @@ func execTierChange(c *cli.Context) error {
|
||||
}
|
||||
}
|
||||
if c.IsSet("attachment-expiry-duration") {
|
||||
tier.AttachmentExpiryDuration = c.Duration("attachment-expiry-duration")
|
||||
tier.AttachmentExpiryDuration, err = util.ParseDuration(c.String("attachment-expiry-duration"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.IsSet("attachment-bandwidth-limit") {
|
||||
tier.AttachmentBandwidthLimit, err = util.ParseSize(c.String("attachment-bandwidth-limit"))
|
||||
|
||||
@@ -29,11 +29,11 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
||||
app, _, _, stderr = newTestApp()
|
||||
require.Nil(t, runTierCommand(app, conf, "change",
|
||||
"--message-limit=999",
|
||||
"--message-expiry-duration=99h",
|
||||
"--message-expiry-duration=2d",
|
||||
"--email-limit=91",
|
||||
"--reservation-limit=98",
|
||||
"--attachment-file-size-limit=100m",
|
||||
"--attachment-expiry-duration=7h",
|
||||
"--attachment-expiry-duration=1d",
|
||||
"--attachment-total-size-limit=10G",
|
||||
"--attachment-bandwidth-limit=100G",
|
||||
"--stripe-monthly-price-id=price_991",
|
||||
@@ -41,11 +41,11 @@ func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
|
||||
"pro",
|
||||
))
|
||||
require.Contains(t, stderr.String(), "- Message limit: 999")
|
||||
require.Contains(t, stderr.String(), "- Message expiry duration: 99h")
|
||||
require.Contains(t, stderr.String(), "- Message expiry duration: 48h")
|
||||
require.Contains(t, stderr.String(), "- Email limit: 91")
|
||||
require.Contains(t, stderr.String(), "- Reservation limit: 98")
|
||||
require.Contains(t, stderr.String(), "- Attachment file size limit: 100.0 MB")
|
||||
require.Contains(t, stderr.String(), "- Attachment expiry duration: 7h")
|
||||
require.Contains(t, stderr.String(), "- Attachment expiry duration: 24h")
|
||||
require.Contains(t, stderr.String(), "- Attachment total size limit: 10.0 GB")
|
||||
require.Contains(t, stderr.String(), "- Stripe prices (monthly/yearly): price_991 / price_992")
|
||||
|
||||
|
||||
@@ -839,6 +839,8 @@ config options:
|
||||
enables payments in the ntfy web app (e.g. Upgrade dialog). See [API keys](https://dashboard.stripe.com/apikeys).
|
||||
* `stripe-webhook-key` is the key required to validate the authenticity of incoming webhooks from Stripe.
|
||||
Webhooks are essential to keep the local database in sync with the payment provider. See [Webhooks](https://dashboard.stripe.com/webhooks).
|
||||
* `billing-contact` is an email address or website displayed in the "Upgrade tier" dialog to let people reach
|
||||
out with billing questions. If unset, nothing will be displayed.
|
||||
|
||||
In addition to setting these two options, you also need to define a [Stripe webhook](https://dashboard.stripe.com/webhooks)
|
||||
for the `customer.subscription.updated` and `customer.subscription.deleted` event, which points
|
||||
@@ -849,6 +851,7 @@ Here's an example:
|
||||
``` yaml
|
||||
stripe-secret-key: "sk_test_ZmhzZGtmbGhkc2tqZmhzYcO2a2hmbGtnaHNkbGtnaGRsc2hnbG"
|
||||
stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR"
|
||||
billing-contact: "phil@example.com"
|
||||
```
|
||||
|
||||
## Rate limiting
|
||||
@@ -929,6 +932,25 @@ If this ever happens, there will be a log message that looks something like this
|
||||
WARN Firebase quota exceeded (likely for topic), temporarily denying Firebase access to visitor
|
||||
```
|
||||
|
||||
### Subscriber-based rate limiting
|
||||
By default, ntfy puts almost all rate limits on the message publisher, e.g. number of messages, requests, and attachment
|
||||
size are all based on the visitor who publishes a message. **Subscriber-based rate limiting is a way to use the rate limits
|
||||
of a topic's subscriber, instead of the limits of the publisher.**
|
||||
|
||||
If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed
|
||||
to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume
|
||||
publishers (e.g. Matrix/Mastodon servers) are allowed to send.
|
||||
|
||||
Once enabled, a client may send a `Rate-Topics: <topic1>,<topic2>,...` header when subscribing to topics via
|
||||
HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits
|
||||
to use when publishing on this topic. Note that setting the rate visitor requires **read-write permission** on the topic.
|
||||
|
||||
UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to an `HTTP 507 Insufficient Storage`
|
||||
response if no "rate visitor" has been previously registered. This is to avoid burning the publisher's
|
||||
`visitor-message-daily-limit`.
|
||||
|
||||
To enable subscriber-based rate limiting, set `visitor-subscriber-rate-limiting: true`.
|
||||
|
||||
## Tuning for scale
|
||||
If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config,
|
||||
if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**.
|
||||
@@ -1067,6 +1089,60 @@ and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-a
|
||||
maxretry = 10
|
||||
```
|
||||
|
||||
## Health checks
|
||||
A preliminary health check API endpoint is exposed at `/v1/health`. The endpoint returns a `json` response in the format shown below.
|
||||
If a non-200 HTTP status code is returned or if the returned `health` field is `false` the ntfy service should be considered as unhealthy.
|
||||
|
||||
```json
|
||||
{"health":true}
|
||||
```
|
||||
|
||||
See [Installation for Docker](install.md#docker) for an example of how this could be used in a `docker-compose` environment.
|
||||
|
||||
## Monitoring
|
||||
If configured, ntfy can expose a `/metrics` endpoint for [Prometheus](https://prometheus.io/), which can then be used to
|
||||
create dashboards and alerts (e.g. via [Grafana](https://grafana.com/)).
|
||||
|
||||
To configure the metrics endpoint, either set `enable-metrics` and/or set the `listen-metrics-http` option to a dedicated
|
||||
listen address. Metrics may be considered sensitive information, so before you enable them, be sure you know what you are
|
||||
doing, and/or secure access to the endpoint in your reverse proxy.
|
||||
|
||||
- `enable-metrics` enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket)
|
||||
- `metrics-listen-http` exposes the metrics endpoint via a dedicated `[IP]:port`. If set, this option implicitly
|
||||
enables metrics as well, e.g. "10.0.1.1:9090" or ":9090"
|
||||
|
||||
=== "server.yml (Using default port)"
|
||||
```yaml
|
||||
enable-metrics: true
|
||||
```
|
||||
|
||||
=== "server.yml (Using dedicated IP/port)"
|
||||
```yaml
|
||||
metrics-listen-http: "10.0.1.1:9090"
|
||||
```
|
||||
|
||||
In Prometheus, an example scrape config would look like this:
|
||||
|
||||
=== "prometheus.yml"
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: "ntfy"
|
||||
static_configs:
|
||||
- targets: ["10.0.1.1:9090"]
|
||||
```
|
||||
|
||||
Here's an example Grafana dashboard built from the metrics (see [Grafana JSON on GitHub](https://raw.githubusercontent.com/binwiederhier/ntfy/main/examples/grafana-dashboard/ntfy-grafana.json)):
|
||||
|
||||
<figure markdown style="padding-left: 50px; padding-right: 50px">
|
||||
<a href="../../static/img/grafana-dashboard.png" target="_blank"><img src="../../static/img/grafana-dashboard.png"/></a>
|
||||
<figcaption>ntfy Grafana dashboard</figcaption>
|
||||
</figure>
|
||||
|
||||
## Profiling
|
||||
ntfy can expose Go's [net/http/pprof](https://pkg.go.dev/net/http/pprof) endpoints to support profiling of the ntfy server.
|
||||
If enabled, ntfy will listen on a dedicated listen IP/port, which can be accessed via the web browser on `http://<ip>:<port>/debug/pprof/`.
|
||||
This can be helpful to expose bottlenecks, and visualize call flows. To enable, simply set the `profile-listen-http` config option.
|
||||
|
||||
## Logging & debugging
|
||||
By default, ntfy logs to the console (stderr), with an `info` log level, and in a human-readable text format.
|
||||
|
||||
@@ -1178,12 +1254,14 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
||||
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
|
||||
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
||||
| `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting |
|
||||
| `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) |
|
||||
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
|
||||
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
|
||||
| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
|
||||
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
|
||||
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
|
||||
| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact |
|
||||
|
||||
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
||||
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
||||
@@ -1267,6 +1345,7 @@ OPTIONS:
|
||||
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
||||
--stripe-secret-key value, --stripe_secret_key value key used for the Stripe API communication, this enables payments [$NTFY_STRIPE_SECRET_KEY]
|
||||
--stripe-webhook-key value, --stripe_webhook_key value key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY]
|
||||
--billing-contact value, --billing_contact value e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT]
|
||||
--help, -h show help (default: false)
|
||||
```
|
||||
|
||||
|
||||
@@ -43,9 +43,9 @@ of the app and [self-host your own ntfy server](install.md).
|
||||
## How much battery does the Android app use?
|
||||
If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,
|
||||
the Android/iOS app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,
|
||||
or you use *instant delivery* (Android only), the app has to maintain a constant connection to the server, which consumes
|
||||
about 0-1% of battery in 17h of use (on my phone). There has been a ton of testing and improvement around this. I think it's pretty
|
||||
decent now.
|
||||
or you use *instant delivery* (Android only), or install from F-droid ([which does not support FCM](https://f-droid.org/docs/Inclusion_Policy/)),
|
||||
the app has to maintain a constant connection to the server, which consumes about 0-1% of battery in 17h of use (on my phone).
|
||||
There has been a ton of testing and improvement around this. I think it's pretty decent now.
|
||||
|
||||
## Paid plans? I thought it was open source?
|
||||
All of ntfy will remain open source, with a free software license (Apache 2.0 and GPLv2). If you'd like to self-host, you
|
||||
|
||||
6
docs/hooks.py
Normal file
6
docs/hooks.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import os
|
||||
import shutil
|
||||
|
||||
def copy_fonts(config, **kwargs):
|
||||
site_dir = config['site_dir']
|
||||
shutil.copytree('docs/static/fonts', os.path.join(site_dir, 'get'))
|
||||
@@ -26,37 +26,37 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_2.1.0_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_2.1.0_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_2.3.1_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_2.3.1_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.3.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.1.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.1.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.3.1_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.3.1_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.3.1_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.1.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.1.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.3.1_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.3.1_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.3.1_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.1.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.1.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.1.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.3.1_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.3.1_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.3.1_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -106,7 +106,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -114,7 +114,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -122,7 +122,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -130,7 +130,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -140,28 +140,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -189,18 +189,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
|
||||
|
||||
## macOS
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_macOS_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_macOS_all.tar.gz),
|
||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||
|
||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||
|
||||
```bash
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_macOS_all.tar.gz > ntfy_2.1.0_macOS_all.tar.gz
|
||||
tar zxvf ntfy_2.1.0_macOS_all.tar.gz
|
||||
sudo cp -a ntfy_2.1.0_macOS_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_macOS_all.tar.gz > ntfy_2.3.1_macOS_all.tar.gz
|
||||
tar zxvf ntfy_2.3.1_macOS_all.tar.gz
|
||||
sudo cp -a ntfy_2.3.1_macOS_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.1.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.3.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -212,7 +212,7 @@ ntfy --help
|
||||
|
||||
## Windows
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.1.0/ntfy_2.1.0_windows_x86_64.zip),
|
||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.3.1/ntfy_2.3.1_windows_x86_64.zip),
|
||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||
|
||||
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||
@@ -266,7 +266,7 @@ docker run \
|
||||
serve
|
||||
```
|
||||
|
||||
Using docker-compose with non-root user:
|
||||
Using docker-compose with non-root user and healthchecks enabled:
|
||||
```yaml
|
||||
version: "2.1"
|
||||
|
||||
@@ -284,6 +284,12 @@ services:
|
||||
- /etc/ntfy:/etc/ntfy
|
||||
ports:
|
||||
- 80:80
|
||||
healthcheck: # optional: remember to adapt the host:port to your environment
|
||||
test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ ntfy community. Thanks to everyone running a public server. **You guys rock!**
|
||||
| [ntfy.jae.fi](https://ntfy.jae.fi/) | 🇫🇮 Finland |
|
||||
| [ntfy.adminforge.de](https://ntfy.adminforge.de/) | 🇩🇪 Germany |
|
||||
| [ntfy.envs.net](https://ntfy.envs.net) | 🇩🇪 Germany |
|
||||
| [ntfy.mzte.de](https://ntfy.mzte.de/) | 🇩🇪 Germany |
|
||||
| [ntfy.hostux.net](https://ntfy.hostux.net/) | 🇫🇷 France |
|
||||
|
||||
Please be aware that **server operators can log your messages**. The project also cannot guarantee the reliability
|
||||
and uptime of third party servers, so use of each server is **at your own discretion**.
|
||||
@@ -75,6 +77,7 @@ and uptime of third party servers, so use of each server is **at your own discre
|
||||
|
||||
- [Grafana-to-ntfy](https://github.com/kittyandrew/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Rust)
|
||||
- [Grafana-ntfy-webhook-integration](https://github.com/academo/grafana-alerting-ntfy-webhook-integration) - Integrates Grafana alerts webhooks (Go)
|
||||
- [Grafana-to-ntfy](https://gitlab.com/Saibe1111/grafana-to-ntfy) - Grafana-to-ntfy alerts channel (Node Js)
|
||||
- [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh)
|
||||
- [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell)
|
||||
- [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell)
|
||||
@@ -114,9 +117,16 @@ and uptime of third party servers, so use of each server is **at your own discre
|
||||
- [nodebb-plugin-ntfy](https://github.com/NodeBB/nodebb-plugin-ntfy) - Push notifications for NodeBB forums
|
||||
- [n8n-ntfy](https://github.com/raghavanand98/n8n-ntfy.sh) - n8n community node that lets you use ntfy in your workflows
|
||||
- [nlog-ntfy](https://github.com/MichelMichels/nlog-ntfy) - Send NLog messages over ntfy (C# / .NET / NLog)
|
||||
- [helm-charts](https://github.com/sarab97/helm-charts) - Helm charts of some of the selfhosted services, incl. ntfy
|
||||
- [ntfy_ansible_role](https://github.com/stevenengland/ntfy_ansible_role) (on [Ansible Galaxy](https://galaxy.ansible.com/stevenengland/ntfy)) - Ansible role to install ntfy
|
||||
|
||||
## Blog + forum posts
|
||||
|
||||
- [ntfy.sh](https://neo-sahara.com/wp/2023/03/25/ntfy-sh/) - neo-sahara.com - 3/2023
|
||||
- [Using Ntfy to send and receive push notifications - Samuel Rosa de Oliveria - Delphicon 2023](https://www.youtube.com/watch?v=feu0skpI9QI) - youtube.com - 3/2023
|
||||
- [ntfy: własny darmowy system powiadomień](https://sprawdzone.it/ntfy-wlasny-darmowy-system-powiadomien/) - sprawdzone.it - 3/2023
|
||||
- [Deploying ntfy on railway](https://www.youtube.com/watch?v=auJICXtxoNA) - youtube.com - 3/2023
|
||||
- [Start-Job,Variables, and ntfy.sh](https://klingele.dev/2023/03/01/start-jobvariables-and-ntfy-sh/) - klingele.dev - 3/2023
|
||||
- [enviar notificaciones automáticas usando ntfy.sh](https://osiux.com/2023-02-15-send-automatic-notifications-using-ntfy.html) - osiux.com - 2/2023
|
||||
- [Carnet IP动态解析以及通过ntfy推送IP信息](https://blog.wslll.cn/index.php/archives/201/) - blog.wslll.cn - 2/2023
|
||||
- [Open-Source-Brieftaube: ntfy verschickt Push-Meldungen auf Smartphone und PC](https://www.heise.de/news/Open-Source-Brieftaube-ntfy-verschickt-Push-Meldungen-auf-Smartphone-und-PC-7521583.html) ⭐ - heise.de - 2/2023
|
||||
@@ -128,6 +138,7 @@ and uptime of third party servers, so use of each server is **at your own discre
|
||||
- [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022
|
||||
- [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022
|
||||
- [Ntfy Self-Hosted Push Notifications](https://lachlanlife.net/posts/2022-12-ntfy/) - lachlanlife.net - 12/2022
|
||||
- [NTFY - système de notification hyper simple et complet](https://www.youtube.com/watch?v=UieZYWVVgA4) - youtube.com - 12/2022
|
||||
- [ntfy.sh](https://paramdeo.com/til/ntfy-sh) - paramdeo.com - 11/2022
|
||||
- [Using ntfy to warn me when my computer is discharging](https://ulysseszh.github.io/programming/2022/11/28/ntfy-warn-discharge.html) - ulysseszh.github.io - 11/2022
|
||||
- [ntfy - Push Notification Service](https://dizzytech.de/posts/ntfy/) - dizzytech.de - 11/2022
|
||||
|
||||
@@ -8,7 +8,7 @@ For some (many?) users, the iOS app is not refreshing the view when new notifica
|
||||
swipe down, you do not see the newly arrived messages, even though the popup appeared before.
|
||||
|
||||
This is caused by some weirdness between the Notification Service Extension (NSE), SwiftUI and Core Data. I am entirely
|
||||
clueless on how to fix it, sadly, as it is ephemeral and now clear to me what is causing it.
|
||||
clueless on how to fix it, sadly, as it is ephemeral and not clear to me what is causing it.
|
||||
|
||||
Please send experienced iOS developers my way to help me figure this out.
|
||||
|
||||
|
||||
@@ -3161,22 +3161,27 @@ There are a few limitations to the API to prevent abuse and to keep the server h
|
||||
are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into,
|
||||
but just in case, let's list them all:
|
||||
|
||||
| Limit | Description |
|
||||
|----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
|
||||
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. |
|
||||
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. |
|
||||
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
|
||||
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. |
|
||||
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
|
||||
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. |
|
||||
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
|
||||
| Limit | Description |
|
||||
|---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
|
||||
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. |
|
||||
| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 1,000. |
|
||||
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 10. |
|
||||
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
|
||||
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 5 MB, and the per-visitor total is 50 MB. |
|
||||
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
|
||||
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. |
|
||||
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
|
||||
|
||||
These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing
|
||||
a higher tier. ntfy.sh offers multiple paid tiers, which allows for much hier limits than the ones listed above.
|
||||
|
||||
## List of all parameters
|
||||
The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**,
|
||||
and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form.
|
||||
The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**
|
||||
when used in **HTTP headers**, and must be **lowercase** when used as **query parameters in the URL**. They are listed in the
|
||||
table in their canonical form.
|
||||
|
||||
| Parameter | Aliases (case-insensitive) | Description |
|
||||
| Parameter | Aliases | Description |
|
||||
|-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------|
|
||||
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
|
||||
| `X-Title` | `Title`, `t` | [Message title](#message-title) |
|
||||
|
||||
128
docs/releases.md
128
docs/releases.md
@@ -2,6 +2,113 @@
|
||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||
|
||||
### ntfy server v2.3.1
|
||||
Released March 30, 2023
|
||||
|
||||
This release disables server-initiated polling of iOS devices entirely, thereby eliminating the thundering herd problem
|
||||
on ntfy.sh that we observe every 20 minutes. The polling was never strictly necessary, and has actually caused duplicate
|
||||
delivery issues as well, so disabling it should not have any negative effects. iOS users, please reach out via Discord
|
||||
or Matrix if there are issues.
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Disable iOS polling entirely ([#677](https://github.com/binwiederhier/ntfy/issues/677)/[#509](https://github.com/binwiederhier/ntfy/issues/509))
|
||||
|
||||
## ntfy server v2.3.0
|
||||
Released March 29, 2023
|
||||
|
||||
This release primarily fixes an issue with delayed messages, and it adds support for Go's profiler (if enabled), which
|
||||
will allow investigating usage spikes in more detail. There will likely be a follow-up release this week to fix the
|
||||
actual spikes [caused by iOS devices](https://github.com/binwiederhier/ntfy/issues/677).
|
||||
|
||||
**Features:**
|
||||
|
||||
* ntfy now supports Go's `pprof` profiler, if enabled (relates to [#677](https://github.com/binwiederhier/ntfy/issues/677))
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix delayed message sending from authenticated users ([#679](https://github.com/binwiederhier/ntfy/issues/679))
|
||||
* Fixed plural for Polish and other translations ([#678](https://github.com/binwiederhier/ntfy/pull/678), thanks to [@bmoczulski](https://github.com/bmoczulski))
|
||||
|
||||
## ntfy server v2.2.0
|
||||
Released March 17, 2023
|
||||
|
||||
With this release, ntfy is now able to expose metrics via a `/metrics` endpoint for [Prometheus](https://prometheus.io/), if enabled.
|
||||
The endpoint exposes about 20 different counters and gauges, from the number of published messages and emails, to active subscribers,
|
||||
visitors and topics. If you'd like more metrics, pop in the Discord/Matrix or file an issue on GitHub.
|
||||
|
||||
On top of this, you can now use access tokens in the ntfy CLI (defined in the `client.yml` file), fixed a bug in `ntfy subscribe`,
|
||||
removed the dependency on Google Fonts, and more.
|
||||
|
||||
🔥 Reminder: Purchase one of three **ntfy Pro plans** for **50% off** for a limited time (if you use promo code `MYTOPIC`).
|
||||
ntfy Pro gives you higher rate limits and lets you reserve topic names. [Buy through web app](https://ntfy.sh/app).
|
||||
|
||||
❤️ If you don't need ntfy Pro, please consider sponsoring ntfy via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)
|
||||
and [Liberapay](https://en.liberapay.com/ntfy/). ntfy will stay open source forever.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Monitoring: ntfy now exposes a `/metrics` endpoint for [Prometheus](https://prometheus.io/) if [configured](config.md#monitoring) ([#210](https://github.com/binwiederhier/ntfy/issues/210), thanks to [@rogeliodh](https://github.com/rogeliodh) for reporting)
|
||||
* You can now use tokens in `client.yml` for publishing and subscribing ([#653](https://github.com/binwiederhier/ntfy/issues/653), thanks to [@wunter8](https://github.com/wunter8))
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* `ntfy sub --poll --from-config` will now include authentication headers from client.yml (if applicable) ([#658](https://github.com/binwiederhier/ntfy/issues/658), thanks to [@wunter8](https://github.com/wunter8))
|
||||
* Docs: Removed dependency on Google Fonts in docs ([#554](https://github.com/binwiederhier/ntfy/issues/554), thanks to [@bt90](https://github.com/bt90) for reporting, and [@ozskywalker](https://github.com/ozskywalker) for implementing)
|
||||
* Increase allowed auth failure attempts per IP address to 30 (no ticket)
|
||||
* Web app: Increase maximum incremental backoff retry interval to 2 minutes (no ticket)
|
||||
|
||||
**Documentation:**
|
||||
|
||||
* Make query parameter description more clear ([#630](https://github.com/binwiederhier/ntfy/issues/630), thanks to [@bbaa-bbaa](https://github.com/bbaa-bbaa) for reporting, and to [@wunter8](https://github.com/wunter8) for a fix)
|
||||
|
||||
## ntfy server v2.1.2
|
||||
Released March 4, 2023
|
||||
|
||||
This is a hotfix release, mostly to combat the ridiculous amount of Matrix requests with invalid/dead pushkeys, and the
|
||||
corresponding HTTP 507 responses the ntfy.sh server is sending out. We're up to >600k HTTP 507 responses per day 🤦. This
|
||||
release solves this issue by rejecting Matrix pushkeys, if nobody has subscribed to the corresponding topic for 12 hours.
|
||||
|
||||
The release furthermore reverts the default rate limiting behavior for UnifiedPush to be publisher-based, and introduces
|
||||
a flag to enable [subscriber-based rate limiting](config.md#subscriber-based-rate-limiting) for high volume servers.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support SMTP servers without auth ([#645](https://github.com/binwiederhier/ntfy/issues/645), thanks to [@Sharknoon](https://github.com/Sharknoon) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Token auth doesn't work if default user credentials are defined in `client.yml` ([#650](https://github.com/binwiederhier/ntfy/issues/650), thanks to [@Xinayder](https://github.com/Xinayder))
|
||||
* Add `visitor-subscriber-rate-limiting` flag to allow enabling [subscriber-based rate limiting](config.md#subscriber-based-rate-limiting) (off by default now, [#649](https://github.com/binwiederhier/ntfy/issues/649)/[#655](https://github.com/binwiederhier/ntfy/pull/655), thanks to [@barathrm](https://github.com/barathrm) for reporting, and to [@karmanyaahm](https://github.com/karmanyaahm) and [@p1gp1g](https://github.com/p1gp1g) for help with the design)
|
||||
* Reject Matrix pushkey after 12 hours of inactivity on a topic, if `visitor-subscriber-rate-limiting` is enabled ([#643](https://github.com/binwiederhier/ntfy/pull/643), thanks to [@karmanyaahm](https://github.com/karmanyaahm) and [@p1gp1g](https://github.com/p1gp1g) for help with the design)
|
||||
|
||||
**Additional languages:**
|
||||
|
||||
* Danish (thanks to [@Andersbiha](https://hosted.weblate.org/user/Andersbiha/))
|
||||
|
||||
## ntfy server v2.1.1
|
||||
Released March 1, 2023
|
||||
|
||||
This is a tiny release with a few bug fixes, but it's big for me personally. After almost three months of work,
|
||||
**today I am finally launching the paid plans on ntfy.sh** 🥳 🎉.
|
||||
|
||||
You are now able to purchase one of three plans that'll give you **higher rate limits** (messages, emails, attachment sizes, ...),
|
||||
as well as the ability to **reserve topic names** for your personal use, while at the same time supporting me and the
|
||||
ntfy open source project ❤️. You can check out the pricing, and [purchase plans through the web app](https://ntfy.sh/app) (use
|
||||
promo code `MYTOPIC` for a **50% discount**, limited time only).
|
||||
|
||||
And as I've said many times: Do not worry. **ntfy will always stay open source**, and that includes all features. There
|
||||
are no closed-source features. So if you'd like to run your own server, you can!
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Fix panic when using Firebase without users ([#641](https://github.com/binwiederhier/ntfy/issues/641), thanks to [u/heavybell](https://www.reddit.com/user/heavybell/) for reporting)
|
||||
* Remove health check from `Dockerfile` and [document it](config.md#health-checks) ([#635](https://github.com/binwiederhier/ntfy/issues/635), thanks to [@Andersbiha](https://github.com/Andersbiha))
|
||||
* Upgrade dialog: Disable submit button for free tier (no ticket)
|
||||
* Allow multiple `log-level-overrides` on the same field (no ticket)
|
||||
* Actually remove `ntfy publish --env-topic` flag (as per [deprecations](deprecations.md), no ticket)
|
||||
* Added `billing-contact` config option (no ticket)
|
||||
|
||||
## ntfy server v2.1.0
|
||||
Released February 25, 2023
|
||||
|
||||
@@ -16,6 +123,10 @@ which ntfy rejected with an HTTP 401. We now ignore unsupported header values.
|
||||
As of this release, ntfy also supports sending emails to protected topics, and it ships code to support annual billing
|
||||
cycles (not live yet).
|
||||
|
||||
As part of this release, I also enabled sign-up and login (free accounts only), and I also started reducing the rate
|
||||
limits for anonymous & free users a bit. With the next release and the launch of the paid plan, I'll reduce the limits
|
||||
a bit more. For 90% of users, you should not feel the difference.
|
||||
|
||||
**Features:**
|
||||
|
||||
* UnifiedPush: Subscriber-based rate limiting for `up*` topics ([#584](https://github.com/binwiederhier/ntfy/pull/584)/[#609](https://github.com/binwiederhier/ntfy/pull/609)/[#633](https://github.com/binwiederhier/ntfy/pull/633), thanks to [@karmanyaahm](https://github.com/karmanyaahm))
|
||||
@@ -1035,3 +1146,20 @@ Released Dec 28, 2021
|
||||
## Older releases
|
||||
For older releases, check out the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||
|
||||
## Not released yet
|
||||
|
||||
### ntfy Android app v1.16.1 (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
* You can now disable UnifiedPush so ntfy does not act as a UnifiedPush distributor ([#646](https://github.com/binwiederhier/ntfy/issues/646), thanks to [@ollien](https://github.com/ollien) for reporting and to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8))
|
||||
* Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing)
|
||||
|
||||
**Additional languages:**
|
||||
|
||||
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/))
|
||||
|
||||
56
docs/static/css/extra.css
vendored
56
docs/static/css/extra.css
vendored
@@ -3,6 +3,8 @@
|
||||
--md-primary-fg-color--light: #338574;
|
||||
--md-primary-fg-color--dark: #338574;
|
||||
--md-footer-bg-color: #353744;
|
||||
--md-text-font: "Roboto";
|
||||
--md-code-font: "Roboto Mono";
|
||||
}
|
||||
|
||||
.md-header__button.md-logo :is(img, svg) {
|
||||
@@ -147,3 +149,57 @@ figure video {
|
||||
.lightbox .close-lightbox:hover::before {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* roboto-300 - latin */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: url('../fonts/roboto-v30-latin-300.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-regular - latin */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/roboto-v30-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-italic - latin */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Roboto';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/roboto-v30-latin-italic.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-500 - latin */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('../fonts/roboto-v30-latin-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-700 - latin */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('../fonts/roboto-v30-latin-700.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-mono - latin */
|
||||
@font-face {
|
||||
font-display: swap;
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('../fonts/roboto-mono-v22-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
BIN
docs/static/fonts/roboto-mono-v22-latin-regular.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-mono-v22-latin-regular.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/static/fonts/roboto-v30-latin-300.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-v30-latin-300.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/static/fonts/roboto-v30-latin-500.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-v30-latin-500.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/static/fonts/roboto-v30-latin-700.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-v30-latin-700.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/static/fonts/roboto-v30-latin-italic.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-v30-latin-italic.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/static/fonts/roboto-v30-latin-regular.woff2
vendored
Normal file
BIN
docs/static/fonts/roboto-v30-latin-regular.woff2
vendored
Normal file
Binary file not shown.
BIN
docs/static/img/android-screenshot-logs.jpg
vendored
Normal file
BIN
docs/static/img/android-screenshot-logs.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
BIN
docs/static/img/grafana-dashboard.png
vendored
Normal file
BIN
docs/static/img/grafana-dashboard.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
BIN
docs/static/img/web-logs.png
vendored
Normal file
BIN
docs/static/img/web-logs.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -254,13 +254,13 @@ I hope this shows how powerful this command is. Here's a short video that demons
|
||||
<figcaption>Execute all the things</figcaption>
|
||||
</figure>
|
||||
|
||||
If most (or all) of your subscription usernames, passwords, and commands are the same, you can specify a `default-user`, `default-password`, and `default-command` at the top of the
|
||||
`client.yml`. If a subscription does not specify a username/password to use or does not have a command, the defaults will be used, otherwise, the subscription settings will
|
||||
override the defaults.
|
||||
If most (or all) of your subscriptions use the same credentials, you can set defaults in `client.yml`. Use `default-user` and `default-password` or `default-token` (but not both).
|
||||
You can also specify a `default-command` that will run when a message is received. If a subscription does not include credentials to use or does not have a command, the defaults
|
||||
will be used, otherwise, the subscription settings will override the defaults.
|
||||
|
||||
!!! warning
|
||||
Because the `default-user` and `default-password` will be sent for each topic that does not have its own username/password (even if the topic does not require authentication),
|
||||
be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password.
|
||||
Because the `default-user`, `default-password`, and `default-token` will be sent for each topic that does not have its own username/password (even if the topic does not
|
||||
require authentication), be sure that the servers/topics you subscribe to use HTTPS to prevent leaking the username and password.
|
||||
|
||||
### Using the systemd service
|
||||
You can use the `ntfy-client` systemd service (see [ntfy-client.service](https://github.com/binwiederhier/ntfy/blob/main/client/ntfy-client.service))
|
||||
|
||||
131
docs/troubleshooting.md
Normal file
131
docs/troubleshooting.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Troubleshooting
|
||||
This page lists a few suggestions of what to do when things don't work as expected. This is not a complete list.
|
||||
If this page does not help, feel free to drop by the [Discord](https://discord.gg/cT7ECsZj9w) or [Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
||||
and ask there. We're happy to help.
|
||||
|
||||
## ntfy server
|
||||
If you host your own ntfy server, and you're having issues with any component, it is always helpful to enable debugging/tracing
|
||||
in the server. You can find detailed instructions in the [Logging & Debugging](config.md#logging-debugging) section, but it ultimately
|
||||
boils down to setting `log-level: debug` or `log-level: trace` in the `server.yml` file:
|
||||
|
||||
=== "server.yml (debug)"
|
||||
``` yaml
|
||||
log-level: debug
|
||||
```
|
||||
|
||||
=== "server.yml (trace)"
|
||||
``` yaml
|
||||
log-level: trace
|
||||
```
|
||||
|
||||
If you're using environment variables, set `NTFY_LOG_LEVEL=debug` (or `trace`) instead. You can also pass `--debug` or `--trace`
|
||||
to the `ntfy serve` command, e.g. `ntfy serve --trace`. If you're using systemd (i.e. `systemctl`) to run ntfy, you can look at
|
||||
the logs using `journalctl -u ntfy -f`. The logs will look something like this:
|
||||
|
||||
=== "Example logs (debug)"
|
||||
```
|
||||
$ ntfy serve --debug
|
||||
2023/03/20 14:45:38 INFO Listening on :2586[http] :1025[smtp], ntfy 2.1.2, log level is DEBUG (tag=startup)
|
||||
2023/03/20 14:45:38 DEBUG Waiting until 2023-03-21 00:00:00 +0000 UTC to reset visitor stats (tag=resetter)
|
||||
2023/03/20 14:45:39 DEBUG Rate limiters reset for visitor (visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:45:39.7-04:00)
|
||||
2023/03/20 14:45:39 DEBUG HTTP request started (http_method=POST, http_path=/mytopic, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:45:39.7-04:00)
|
||||
2023/03/20 14:45:39 DEBUG Received message (http_method=POST, http_path=/mytopic, message_body_size=2, message_delayed=false, message_email=, message_event=message, message_firebase=true, message_id=EZu6i2WZjH0v, message_sender=127.0.0.1, message_time=1679337939, message_unifiedpush=false, tag=publish, topic=mytopic, topic_last_access=2023-03-20T14:45:38.319-04:00, topic_subscribers=0, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002132248, visitor_seen=2023-03-20T14:45:39.7-04:00)
|
||||
2023/03/20 14:45:39 DEBUG Adding message to cache (http_method=POST, http_path=/mytopic, message_body_size=2, message_event=message, message_id=EZu6i2WZjH0v, message_sender=127.0.0.1, message_time=1679337939, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.000259165, visitor_seen=2023-03-20T14:45:39.7-04:00)
|
||||
2023/03/20 14:45:39 DEBUG HTTP request finished (http_method=POST, http_path=/mytopic, tag=http, time_taken_ms=2, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0004147334, visitor_seen=2023-03-20T14:45:39.7-04:00)
|
||||
2023/03/20 14:45:39 DEBUG Wrote 1 message(s) in 8.285712ms (tag=message_cache)
|
||||
...
|
||||
```
|
||||
|
||||
=== "Example logs (trace)"
|
||||
```
|
||||
$ ntfy serve --trace
|
||||
2023/03/20 14:40:42 INFO Listening on :2586[http] :1025[smtp], ntfy 2.1.2, log level is TRACE (tag=startup)
|
||||
2023/03/20 14:40:42 DEBUG Waiting until 2023-03-21 00:00:00 +0000 UTC to reset visitor stats (tag=resetter)
|
||||
2023/03/20 14:40:59 DEBUG Rate limiters reset for visitor (visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:40:59.893-04:00)
|
||||
2023/03/20 14:40:59 TRACE HTTP request started (http_method=POST, http_path=/mytopic, http_request=POST /mytopic HTTP/1.1
|
||||
User-Agent: curl/7.81.0
|
||||
Accept: */*
|
||||
Content-Length: 2
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
hi, tag=http, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=0, visitor_messages_limit=500, visitor_messages_remaining=500, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=60, visitor_seen=2023-03-20T14:40:59.893-04:00)
|
||||
2023/03/20 14:40:59 TRACE Received message (http_method=POST, http_path=/mytopic, message_body={
|
||||
"id": "Khaup1RVclU3",
|
||||
"time": 1679337659,
|
||||
"expires": 1679380859,
|
||||
"event": "message",
|
||||
"topic": "mytopic",
|
||||
"message": "hi"
|
||||
}, message_body_size=2, message_delayed=false, message_email=, message_event=message, message_firebase=true, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, message_unifiedpush=false, tag=publish, topic=mytopic, topic_last_access=2023-03-20T14:40:59.893-04:00, topic_subscribers=0, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0001785048, visitor_seen=2023-03-20T14:40:59.893-04:00)
|
||||
2023/03/20 14:40:59 DEBUG Adding message to cache (http_method=POST, http_path=/mytopic, message_body_size=2, message_event=message, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002044368, visitor_seen=2023-03-20T14:40:59.893-04:00)
|
||||
2023/03/20 14:40:59 DEBUG HTTP request finished (http_method=POST, http_path=/mytopic, tag=http, time_taken_ms=1, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.000220502, visitor_seen=2023-03-20T14:40:59.893-04:00)
|
||||
2023/03/20 14:40:59 TRACE No stream or WebSocket subscribers, not forwarding (message_body_size=2, message_event=message, message_id=Khaup1RVclU3, message_sender=127.0.0.1, message_time=1679337659, tag=publish, topic=mytopic, visitor_auth_limiter_limit=0.016666666666666666, visitor_auth_limiter_tokens=10, visitor_emails=0, visitor_emails_limit=12, visitor_emails_remaining=12, visitor_id=ip:127.0.0.1, visitor_ip=127.0.0.1, visitor_messages=1, visitor_messages_limit=500, visitor_messages_remaining=499, visitor_request_limiter_limit=0.2, visitor_request_limiter_tokens=59.0002369212, visitor_seen=2023-03-20T14:40:59.893-04:00)
|
||||
2023/03/20 14:41:00 DEBUG Wrote 1 message(s) in 9.529196ms (tag=message_cache)
|
||||
...
|
||||
```
|
||||
|
||||
## Android app
|
||||
On Android, you can turn on logging in the settings under **Settings → Record logs**. This will store up to 1,000 log
|
||||
entries, which you can then copy or upload.
|
||||
|
||||
<figure markdown>
|
||||
{ width=400 }
|
||||
<figcaption>Recording logs on Android</figcaption>
|
||||
</figure>
|
||||
|
||||
When you copy or upload the logs, you can censor them to make it easier to share them with others. ntfy will replace all
|
||||
topics and hostnames with fruits. Here's an example:
|
||||
|
||||
```
|
||||
This is a log of the ntfy Android app. The log shows up to 1,000 entries.
|
||||
Server URLs (aside from ntfy.sh) and topics have been replaced with fruits 🍌🥝🍋🥥🥑🍊🍎🍑.
|
||||
|
||||
Device info:
|
||||
--
|
||||
ntfy: 1.16.0 (play)
|
||||
OS: 4.19.157-perf+
|
||||
Android: 13 (SDK 33)
|
||||
...
|
||||
|
||||
Logs
|
||||
--
|
||||
|
||||
1679339199507 2023-03-20 15:06:39.507 D NtfyMainActivity Battery: ignoring optimizations = true (we want this to be true); instant subscriptions = true; remind time reached = true; banner = false
|
||||
1679339199507 2023-03-20 15:06:39.507 D NtfySubscriberMgr Enqueuing work to refresh subscriber service
|
||||
1679339199589 2023-03-20 15:06:39.589 D NtfySubscriberMgr ServiceStartWorker: Starting foreground service with action START (work ID: a7eeeae9-9356-40df-afbd-236e5ed10a0b)
|
||||
1679339199602 2023-03-20 15:06:39.602 D NtfySubscriberService onStartCommand executed with startId: 262
|
||||
1679339199602 2023-03-20 15:06:39.602 D NtfySubscriberService using an intent with action START
|
||||
1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService Refreshing subscriptions
|
||||
1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService - Desired connections: [ConnectionId(baseUrl=https://ntfy.sh, topicsToSubscriptionIds={avocado=23801492, lemon=49013182, banana=1309176509201171073, peach=573300885184666424, pineapple=-5956897229801209316, durian=81453333, starfruit=30489279, fruit12=82532869}), ConnectionId(baseUrl=https://orange.example.com, topicsToSubscriptionIds={apple=4971265, dragonfruit=66809328})]
|
||||
1679339199629 2023-03-20 15:06:39.629 D NtfySubscriberService - Active connections: [ConnectionId(baseUrl=https://orange.example.com, topicsToSubscriptionIds={apple=4971265, dragonfruit=66809328}), ConnectionId(baseUrl=https://ntfy.sh, topicsToSubscriptionIds={avocado=23801492, lemon=49013182, banana=1309176509201171073, peach=573300885184666424, pineapple=-5956897229801209316, durian=81453333, starfruit=30489279, fruit12=82532869})]
|
||||
...
|
||||
```
|
||||
|
||||
To get live logs, or to get more advanced access to an Android phone, you can use [adb](https://developer.android.com/studio/command-line/adb).
|
||||
After you install and [enable adb debugging](https://developer.android.com/studio/command-line/adb#Enabling), you can
|
||||
get detailed logs like so:
|
||||
|
||||
```
|
||||
# Connect to phone (enable Wireless debugging first)
|
||||
adb connect 192.168.1.137:39539
|
||||
|
||||
# Print all logs; you may have to pass the -s option
|
||||
adb logcat
|
||||
adb -s 192.168.1.137:39539 logcat
|
||||
|
||||
# Only list ntfy logs
|
||||
adb logcat --pid=$(adb shell pidof -s io.heckel.ntfy)
|
||||
adb -s 192.168.1.137:39539 logcat --pid=$(adb -s 192.168.1.137:39539 shell pidof -s io.heckel.ntfy)
|
||||
```
|
||||
|
||||
## Web app
|
||||
The web app logs everything to the **developer console**, which you can open by **pressing the F12 key** on your
|
||||
keyboard.
|
||||
|
||||
<figure markdown>
|
||||

|
||||
<figcaption>Web app logs in the developer console</figcaption>
|
||||
</figure>
|
||||
|
||||
## iOS app
|
||||
Sorry, there is no way to debug or get the logs from the iOS app (yet), outside of running the app in Xcode.
|
||||
2400
examples/grafana-dashboard/ntfy-grafana.json
Normal file
2400
examples/grafana-dashboard/ntfy-grafana.json
Normal file
File diff suppressed because it is too large
Load Diff
44
go.mod
44
go.mod
@@ -4,22 +4,22 @@ go 1.18
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.9.0 // indirect
|
||||
cloud.google.com/go/storage v1.29.0 // indirect
|
||||
cloud.google.com/go/storage v1.30.1 // indirect
|
||||
github.com/BurntSushi/toml v1.2.1 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
github.com/emersion/go-smtp v0.16.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.1
|
||||
github.com/gabriel-vasile/mimetype v1.4.2
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/mattn/go-sqlite3 v1.14.16
|
||||
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
|
||||
github.com/stretchr/testify v1.8.1
|
||||
github.com/urfave/cli/v2 v2.24.4
|
||||
golang.org/x/crypto v0.6.0
|
||||
golang.org/x/oauth2 v0.5.0 // indirect
|
||||
github.com/urfave/cli/v2 v2.25.1
|
||||
golang.org/x/crypto v0.7.0
|
||||
golang.org/x/oauth2 v0.6.0 // indirect
|
||||
golang.org/x/sync v0.1.0
|
||||
golang.org/x/term v0.5.0
|
||||
golang.org/x/term v0.6.0
|
||||
golang.org/x/time v0.3.0
|
||||
google.golang.org/api v0.110.0
|
||||
google.golang.org/api v0.114.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -27,39 +27,47 @@ require github.com/pkg/errors v0.9.1 // indirect
|
||||
|
||||
require (
|
||||
firebase.google.com/go/v4 v4.10.0
|
||||
github.com/stripe/stripe-go/v74 v74.9.0
|
||||
github.com/prometheus/client_golang v1.14.0
|
||||
github.com/stripe/stripe-go/v74 v74.14.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.110.0 // indirect
|
||||
cloud.google.com/go/compute v1.18.0 // indirect
|
||||
cloud.google.com/go/compute v1.19.0 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
||||
cloud.google.com/go/iam v0.12.0 // indirect
|
||||
cloud.google.com/go/iam v0.13.0 // indirect
|
||||
cloud.google.com/go/longrunning v0.4.1 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.8.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.3.0 // indirect
|
||||
github.com/prometheus/common v0.42.0 // indirect
|
||||
github.com/prometheus/procfs v0.9.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/stretchr/objx v0.5.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
golang.org/x/net v0.7.0 // indirect
|
||||
golang.org/x/sys v0.5.0 // indirect
|
||||
golang.org/x/text v0.7.0 // indirect
|
||||
golang.org/x/net v0.8.0 // indirect
|
||||
golang.org/x/sys v0.6.0 // indirect
|
||||
golang.org/x/text v0.8.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.2 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230223222841-637eb2293923 // indirect
|
||||
google.golang.org/grpc v1.53.0 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230330200707-38013875ee22 // indirect
|
||||
google.golang.org/grpc v1.54.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
112
go.sum
112
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.110.0 h1:Zc8gqp3+a9/Eyph2KDmcGaPtbKRIoqq4YTlL4NMD0Ys=
|
||||
cloud.google.com/go v0.110.0/go.mod h1:SJnCLqQ0FCFGSZMUNUf84MV3Aia54kn7pi8st7tMzaY=
|
||||
cloud.google.com/go/compute v1.18.0 h1:FEigFqoDbys2cvFkZ9Fjq4gnHBP55anJ0yQyau2f9oY=
|
||||
cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs=
|
||||
cloud.google.com/go/compute v1.19.0 h1:+9zda3WGgW1ZSTlVppLCYFIr48Pa35q1uG2N1itbCEQ=
|
||||
cloud.google.com/go/compute v1.19.0/go.mod h1:rikpw2y+UMidAe9tISo04EHNOIf42RLYF/q8Bs93scU=
|
||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
|
||||
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
|
||||
cloud.google.com/go/iam v0.12.0 h1:DRtTY29b75ciH6Ov1PHb4/iat2CLCvrOm40Q0a6DFpE=
|
||||
cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY=
|
||||
cloud.google.com/go/iam v0.13.0 h1:+CmB+K0J/33d0zSQ9SlFWUeCCEn5XJA0ZMZ3pHE9u8k=
|
||||
cloud.google.com/go/iam v0.13.0/go.mod h1:ljOg+rcNfzZ5d6f1nAUJ8ZIxOaZUVoS14bKCtaLZ/D0=
|
||||
cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
|
||||
cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo=
|
||||
cloud.google.com/go/storage v1.29.0 h1:6weCgzRvMg7lzuUurI4697AqIRPU1SvzHhynwpW31jI=
|
||||
cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4=
|
||||
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
|
||||
cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
|
||||
firebase.google.com/go/v4 v4.10.0 h1:dgK/8uwfJbzc5LZK/GyRRfIkZEDObN9q0kgEXsjlXN4=
|
||||
firebase.google.com/go/v4 v4.10.0/go.mod h1:m0gLwPY9fxKggizzglgCNWOGnFnVPifLpqZzo5u3e/A=
|
||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||
@@ -22,11 +22,16 @@ github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak
|
||||
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -39,11 +44,9 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
|
||||
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/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
|
||||
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU=
|
||||
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
@@ -54,6 +57,7 @@ github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfb
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
@@ -62,8 +66,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
@@ -79,19 +83,36 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
|
||||
github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ=
|
||||
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
|
||||
github.com/googleapis/gax-go/v2 v2.8.0 h1:UBtEZqx1bjXtOQ5BVTkuYghXrr3N4V123VKJK67vJZc=
|
||||
github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8 h1:0uFGkScHef2Xd8g74BMHU1jFcnKEm0PzrPn4CluQ9FI=
|
||||
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
|
||||
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
|
||||
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
|
||||
github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
|
||||
github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
|
||||
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
|
||||
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -103,20 +124,18 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stripe/stripe-go/v74 v74.8.0 h1:0+3EfQSBhMg8SQ1+w+AP6Gxyko2crWbUG2uXbzYs8SU=
|
||||
github.com/stripe/stripe-go/v74 v74.8.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
|
||||
github.com/stripe/stripe-go/v74 v74.9.0 h1:yQ3O8jmtoAjKARzjLGmwYj2ZxqYbdtWVjFeovNGDtjg=
|
||||
github.com/stripe/stripe-go/v74 v74.9.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
|
||||
github.com/urfave/cli/v2 v2.24.4 h1:0gyJJEBYtCV87zI/x2nZCPyDxD51K6xM8SkwjHFCNEU=
|
||||
github.com/urfave/cli/v2 v2.24.4/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
||||
github.com/stripe/stripe-go/v74 v74.14.0 h1:hB1Ocu/m3BUZ+PrTePsPSv8TKcXTrleCL5Y5JfB8zCo=
|
||||
github.com/stripe/stripe-go/v74 v74.14.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
||||
github.com/urfave/cli/v2 v2.25.1 h1:zw8dSP7ghX0Gmm8vugrs6q9Ku0wzweqPyshy+syu9Gw=
|
||||
github.com/urfave/cli/v2 v2.25.1/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@@ -127,38 +146,41 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-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-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s=
|
||||
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
|
||||
golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw=
|
||||
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -169,8 +191,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||
google.golang.org/api v0.110.0 h1:l+rh0KYUooe9JGbGVx71tbFo4SMbMTXK3I3ia2QSEeU=
|
||||
google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
|
||||
google.golang.org/api v0.114.0 h1:1xQPji6cO2E2vLiI+C/XiFAnsn1WV3mjaEwGLhi3grE=
|
||||
google.golang.org/api v0.114.0/go.mod h1:ifYI2ZsFK6/uGddGfAD5BMxlnkBqCmqHSDUVi45N5Yg=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
@@ -180,17 +202,15 @@ google.golang.org/appengine/v2 v2.0.2/go.mod h1:PkgRUWz4o1XOvbqtWTkBtCitEJ5Tp4Ho
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44 h1:EfLuoKW5WfkgVdDy7dTK8qSbH37AX5mj/MFh+bGPz14=
|
||||
google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44/go.mod h1:8B0gmkoRebU8ukX6HP+4wrVQUY1+6PkQ44BSyIlflHA=
|
||||
google.golang.org/genproto v0.0.0-20230223222841-637eb2293923 h1:znp6mq/drrY+6khTAlJUDNFFcDGV2ENLYKpMq8SyCds=
|
||||
google.golang.org/genproto v0.0.0-20230223222841-637eb2293923/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw=
|
||||
google.golang.org/genproto v0.0.0-20230330200707-38013875ee22 h1:n3ThVoQnHbCbnkhZZ1fx3+3fBAisViSwrpbtLV7vydY=
|
||||
google.golang.org/genproto v0.0.0-20230330200707-38013875ee22/go.mod h1:UUQDJDOlWu4KYeJZffbWgBkS1YFobzKbLVfK69pe0Ak=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=
|
||||
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
|
||||
google.golang.org/grpc v1.54.0 h1:EhTqbhiYeixwWQtAEZAxmV9MGqcjEU2mFx52xCzNyag=
|
||||
google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
@@ -202,10 +222,10 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
22
log/event.go
22
log/event.go
@@ -3,6 +3,7 @@ package log
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/util"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
@@ -11,12 +12,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
fieldTag = "tag"
|
||||
fieldError = "error"
|
||||
fieldTimeTaken = "time_taken_ms"
|
||||
fieldExitCode = "exit_code"
|
||||
tagStdLog = "stdlog"
|
||||
timestampFormat = "2006-01-02T15:04:05.999Z07:00"
|
||||
fieldTag = "tag"
|
||||
fieldError = "error"
|
||||
fieldTimeTaken = "time_taken_ms"
|
||||
fieldExitCode = "exit_code"
|
||||
tagStdLog = "stdlog"
|
||||
)
|
||||
|
||||
// Event represents a single log event
|
||||
@@ -143,7 +143,7 @@ func (e *Event) Render(l Level, message string, v ...any) string {
|
||||
}
|
||||
e.Message = fmt.Sprintf(message, v...)
|
||||
e.Level = l
|
||||
e.Timestamp = e.time.Format(timestampFormat)
|
||||
e.Timestamp = util.FormatTime(e.time)
|
||||
if !appliedContexters {
|
||||
e.applyContexters()
|
||||
}
|
||||
@@ -210,11 +210,13 @@ func (e *Event) globalLevelWithOverride() Level {
|
||||
if e.fields == nil {
|
||||
return l
|
||||
}
|
||||
for field, override := range ov {
|
||||
for field, fieldOverrides := range ov {
|
||||
value, exists := e.fields[field]
|
||||
if exists {
|
||||
if override.value == "" || override.value == value || override.value == fmt.Sprintf("%v", value) {
|
||||
return override.level
|
||||
for _, o := range fieldOverrides {
|
||||
if o.value == "" || o.value == value || o.value == fmt.Sprintf("%v", value) {
|
||||
return o.level
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ var (
|
||||
var (
|
||||
level = DefaultLevel
|
||||
format = DefaultFormat
|
||||
overrides = make(map[string]*levelOverride)
|
||||
overrides = make(map[string][]*levelOverride)
|
||||
output io.Writer = DefaultOutput
|
||||
filename = ""
|
||||
mu = &sync.RWMutex{}
|
||||
@@ -111,14 +111,17 @@ func SetLevel(newLevel Level) {
|
||||
func SetLevelOverride(field string, value string, level Level) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
overrides[field] = &levelOverride{value: value, level: level}
|
||||
if _, ok := overrides[field]; !ok {
|
||||
overrides[field] = make([]*levelOverride, 0)
|
||||
}
|
||||
overrides[field] = append(overrides[field], &levelOverride{value: value, level: level})
|
||||
}
|
||||
|
||||
// ResetLevelOverrides removes all log level overrides
|
||||
func ResetLevelOverrides() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
overrides = make(map[string]*levelOverride)
|
||||
overrides = make(map[string][]*levelOverride)
|
||||
}
|
||||
|
||||
// CurrentFormat returns the current log format
|
||||
|
||||
@@ -177,6 +177,27 @@ func TestLog_LevelOverrideAny(t *testing.T) {
|
||||
require.Equal(t, "", File())
|
||||
}
|
||||
|
||||
func TestLog_LevelOverride_ManyOnSameField(t *testing.T) {
|
||||
t.Cleanup(resetState)
|
||||
|
||||
var out bytes.Buffer
|
||||
SetOutput(&out)
|
||||
SetFormat(JSONFormat)
|
||||
SetLevelOverride("tag", "manager", DebugLevel)
|
||||
SetLevelOverride("tag", "publish", DebugLevel)
|
||||
|
||||
Time(time.Unix(11, 0).UTC()).Field("tag", "manager").Debug("this is logged")
|
||||
Time(time.Unix(12, 0).UTC()).Field("tag", "no-match").Debug("this is not logged")
|
||||
Time(time.Unix(13, 0).UTC()).Field("tag", "publish").Info("this is also logged")
|
||||
|
||||
expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"this is logged","tag":"manager"}
|
||||
{"time":"1970-01-01T00:00:13Z","level":"INFO","message":"this is also logged","tag":"publish"}
|
||||
`
|
||||
require.Equal(t, expected, out.String())
|
||||
require.False(t, IsFile())
|
||||
require.Equal(t, "", File())
|
||||
}
|
||||
|
||||
func TestLog_UsingStdLogger_JSON(t *testing.T) {
|
||||
t.Cleanup(resetState)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ edit_uri: blob/main/docs/
|
||||
|
||||
theme:
|
||||
name: material
|
||||
font: false
|
||||
language: en
|
||||
custom_dir: docs/_overrides
|
||||
logo: static/img/ntfy.png
|
||||
@@ -70,6 +71,9 @@ plugins:
|
||||
- search
|
||||
- minify:
|
||||
minify_html: true
|
||||
- mkdocs-simple-hooks:
|
||||
hooks:
|
||||
on_post_build: "docs.hooks:copy_fonts"
|
||||
|
||||
nav:
|
||||
- "Getting started": index.md
|
||||
@@ -89,6 +93,7 @@ nav:
|
||||
- "Integrations + projects": integrations.md
|
||||
- "Release notes": releases.md
|
||||
- "Emojis 🥳 🎉": emojis.md
|
||||
- "Troubleshooting": troubleshooting.md
|
||||
- "Known issues": known-issues.md
|
||||
- "Deprecation notices": deprecations.md
|
||||
- "Development": develop.md
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# The documentation uses 'mkdocs', which is written in Python
|
||||
mkdocs-material
|
||||
mkdocs-minify-plugin
|
||||
mkdocs-simple-hooks
|
||||
|
||||
@@ -49,7 +49,7 @@ const (
|
||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||
DefaultVisitorAccountCreationLimitBurst = 3
|
||||
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
|
||||
DefaultVisitorAuthFailureLimitBurst = 10
|
||||
DefaultVisitorAuthFailureLimitBurst = 30
|
||||
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
||||
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
||||
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
||||
@@ -61,7 +61,7 @@ var (
|
||||
|
||||
// DefaultDisallowedTopics defines the topics that are forbidden, because they are used elsewhere. This array can be
|
||||
// extended using the server.yml config. If updated, also update in Android and web app.
|
||||
DefaultDisallowedTopics = []string{"docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"}
|
||||
DefaultDisallowedTopics = []string{"docs", "static", "file", "app", "metrics", "account", "settings", "signup", "login", "v1"}
|
||||
)
|
||||
|
||||
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
||||
@@ -105,6 +105,9 @@ type Config struct {
|
||||
SMTPServerListen string
|
||||
SMTPServerDomain string
|
||||
SMTPServerAddrPrefix string
|
||||
MetricsEnable bool
|
||||
MetricsListenHTTP string
|
||||
ProfileListenHTTP string
|
||||
MessageLimit int
|
||||
MinDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
@@ -124,14 +127,17 @@ type Config struct {
|
||||
VisitorAuthFailureLimitBurst int
|
||||
VisitorAuthFailureLimitReplenish time.Duration
|
||||
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
|
||||
VisitorSubscriberRateLimiting bool // Enable subscriber-based rate limiting for UnifiedPush topics
|
||||
BehindProxy bool
|
||||
StripeSecretKey string
|
||||
StripeWebhookKey string
|
||||
StripePriceCacheDuration time.Duration
|
||||
BillingContact string
|
||||
EnableWeb bool
|
||||
EnableSignup bool // Enable creation of accounts via API and UI
|
||||
EnableLogin bool
|
||||
EnableReservations bool // Allow users with role "user" to own/reserve topics
|
||||
EnableReservations bool // Allow users with role "user" to own/reserve topics
|
||||
EnableMetrics bool
|
||||
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
||||
Version string // injected by App
|
||||
}
|
||||
@@ -197,10 +203,12 @@ func NewConfig() *Config {
|
||||
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
|
||||
VisitorAuthFailureLimitReplenish: DefaultVisitorAuthFailureLimitReplenish,
|
||||
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
||||
VisitorSubscriberRateLimiting: false,
|
||||
BehindProxy: false,
|
||||
StripeSecretKey: "",
|
||||
StripeWebhookKey: "",
|
||||
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
||||
BillingContact: "",
|
||||
EnableWeb: true,
|
||||
EnableSignup: false,
|
||||
EnableLogin: false,
|
||||
|
||||
@@ -39,7 +39,7 @@ func (e errHTTP) Context() log.Context {
|
||||
|
||||
func (e errHTTP) Wrap(message string, args ...any) *errHTTP {
|
||||
clone := e.clone()
|
||||
clone.Message = fmt.Sprintf("%s, %s", clone.Message, fmt.Sprintf(message, args...))
|
||||
clone.Message = fmt.Sprintf("%s; %s", clone.Message, fmt.Sprintf(message, args...))
|
||||
return &clone
|
||||
}
|
||||
|
||||
@@ -115,9 +115,9 @@ var (
|
||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
||||
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil}
|
||||
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||
@@ -127,5 +127,5 @@ var (
|
||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
|
||||
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
|
||||
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
|
||||
errHTTPInsufficientStorage = &errHTTP{50701, http.StatusInsufficientStorage, "internal server error: cannot publish to UnifiedPush topic without previously active subscriber", "", nil}
|
||||
errHTTPInsufficientStorageUnifiedPush = &errHTTP{50701, http.StatusInsufficientStorage, "cannot publish to UnifiedPush topic without previously active subscriber", "", nil}
|
||||
)
|
||||
|
||||
@@ -67,6 +67,7 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (in
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.totalSizeCurrent += size
|
||||
mset(metricAttachmentsTotalSize, c.totalSizeCurrent)
|
||||
c.mu.Unlock()
|
||||
return size, nil
|
||||
}
|
||||
@@ -89,6 +90,7 @@ func (c *fileCache) Remove(ids ...string) error {
|
||||
c.mu.Lock()
|
||||
c.totalSizeCurrent = size
|
||||
c.mu.Unlock()
|
||||
mset(metricAttachmentsTotalSize, size)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusInsufficientStorage}
|
||||
normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage}
|
||||
rateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge}
|
||||
)
|
||||
|
||||
// logr creates a new log event with HTTP request fields
|
||||
|
||||
183
server/server.go
183
server/server.go
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/user"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -37,6 +39,8 @@ type Server struct {
|
||||
config *Config
|
||||
httpServer *http.Server
|
||||
httpsServer *http.Server
|
||||
httpMetricsServer *http.Server
|
||||
httpProfileServer *http.Server
|
||||
unixListener net.Listener
|
||||
smtpServer *smtp.Server
|
||||
smtpServerBackend *smtpBackend
|
||||
@@ -50,6 +54,7 @@ type Server struct {
|
||||
fileCache *fileCache // File system based cache that stores attachments
|
||||
stripe stripeAPI // Stripe API, can be replaced with a mock
|
||||
priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
|
||||
metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set
|
||||
closeChan chan bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
@@ -72,6 +77,7 @@ var (
|
||||
webConfigPath = "/config.js"
|
||||
accountPath = "/account"
|
||||
matrixPushPath = "/_matrix/push/v1/notify"
|
||||
metricsPath = "/metrics"
|
||||
apiHealthPath = "/v1/health"
|
||||
apiTiers = "/v1/tiers"
|
||||
apiAccountPath = "/v1/account"
|
||||
@@ -162,7 +168,13 @@ func New(conf *Config) (*Server, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
firebaseClient = newFirebaseClient(sender, userManager)
|
||||
// This awkward logic is required because Go is weird about nil types and interfaces.
|
||||
// See issue #641, and https://go.dev/play/p/uur1flrv1t3 for an example
|
||||
var auther user.Auther
|
||||
if userManager != nil {
|
||||
auther = userManager
|
||||
}
|
||||
firebaseClient = newFirebaseClient(sender, auther)
|
||||
}
|
||||
s := &Server{
|
||||
config: conf,
|
||||
@@ -204,6 +216,12 @@ func (s *Server) Run() error {
|
||||
if s.config.SMTPServerListen != "" {
|
||||
listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen)
|
||||
}
|
||||
if s.config.MetricsListenHTTP != "" {
|
||||
listenStr += fmt.Sprintf(" %s[http/metrics]", s.config.MetricsListenHTTP)
|
||||
}
|
||||
if s.config.ProfileListenHTTP != "" {
|
||||
listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP)
|
||||
}
|
||||
log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String())
|
||||
if log.IsFile() {
|
||||
fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.Version)
|
||||
@@ -250,6 +268,28 @@ func (s *Server) Run() error {
|
||||
errChan <- httpServer.Serve(s.unixListener)
|
||||
}()
|
||||
}
|
||||
if s.config.MetricsListenHTTP != "" {
|
||||
initMetrics()
|
||||
s.httpMetricsServer = &http.Server{Addr: s.config.MetricsListenHTTP, Handler: promhttp.Handler()}
|
||||
go func() {
|
||||
errChan <- s.httpMetricsServer.ListenAndServe()
|
||||
}()
|
||||
} else if s.config.EnableMetrics {
|
||||
initMetrics()
|
||||
s.metricsHandler = promhttp.Handler()
|
||||
}
|
||||
if s.config.ProfileListenHTTP != "" {
|
||||
profileMux := http.NewServeMux()
|
||||
profileMux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
profileMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
profileMux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
profileMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
profileMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
s.httpProfileServer = &http.Server{Addr: s.config.ProfileListenHTTP, Handler: profileMux}
|
||||
go func() {
|
||||
errChan <- s.httpProfileServer.ListenAndServe()
|
||||
}()
|
||||
}
|
||||
if s.config.SMTPServerListen != "" {
|
||||
go func() {
|
||||
errChan <- s.runSMTPServer()
|
||||
@@ -310,6 +350,9 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleError(w, r, v, err)
|
||||
return
|
||||
}
|
||||
if metricHTTPRequests != nil {
|
||||
metricHTTPRequests.WithLabelValues("200", "20000", r.Method).Inc()
|
||||
}
|
||||
}).
|
||||
Debug("HTTP request finished")
|
||||
}
|
||||
@@ -319,6 +362,10 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
|
||||
if !ok {
|
||||
httpErr = errHTTPInternalError
|
||||
}
|
||||
if metricHTTPRequests != nil {
|
||||
metricHTTPRequests.WithLabelValues(fmt.Sprintf("%d", httpErr.HTTPCode), fmt.Sprintf("%d", httpErr.Code), r.Method).Inc()
|
||||
}
|
||||
isRateLimiting := util.Contains(rateLimitingErrorCodes, httpErr.HTTPCode)
|
||||
isNormalError := strings.Contains(err.Error(), "i/o timeout") || util.Contains(normalErrorCodes, httpErr.HTTPCode)
|
||||
ev := logvr(v, r).Err(err)
|
||||
if websocket.IsWebSocketUpgrade(r) {
|
||||
@@ -335,6 +382,12 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
|
||||
} else {
|
||||
ev.Info("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code)
|
||||
}
|
||||
if isRateLimiting && s.config.StripeSecretKey != "" {
|
||||
u := v.User()
|
||||
if u == nil || u.Tier == nil {
|
||||
httpErr = httpErr.Wrap("increase your limits with a paid plan, see %s", s.config.BaseURL)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
w.WriteHeader(httpErr.HTTPCode)
|
||||
@@ -392,6 +445,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.ensurePaymentsEnabled(s.handleBillingTiersGet)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
|
||||
return s.handleMatrixDiscovery(w)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
|
||||
return s.handleMetrics(w, r, v)
|
||||
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
||||
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
|
||||
@@ -472,6 +527,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||
EnableSignup: s.config.EnableSignup,
|
||||
EnablePayments: s.config.StripeSecretKey != "",
|
||||
EnableReservations: s.config.EnableReservations,
|
||||
BillingContact: s.config.BillingContact,
|
||||
DisallowedTopics: s.config.DisallowedTopics,
|
||||
}
|
||||
b, err := json.MarshalIndent(response, "", " ")
|
||||
@@ -483,6 +539,13 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||
return err
|
||||
}
|
||||
|
||||
// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,
|
||||
// and listen-metrics-http is not set.
|
||||
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
s.metricsHandler.ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
r.URL.Path = webSiteDir + r.URL.Path
|
||||
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
||||
@@ -509,7 +572,10 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
||||
file := filepath.Join(s.config.AttachmentCacheDir, messageID)
|
||||
stat, err := os.Stat(file)
|
||||
if err != nil {
|
||||
return errHTTPNotFound
|
||||
return errHTTPNotFound.Fields(log.Context{
|
||||
"message_id": messageID,
|
||||
"error_context": "filesystem",
|
||||
})
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
||||
@@ -530,7 +596,10 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
||||
}, s.config.CacheBatchTimeout, 100*time.Millisecond, 300*time.Millisecond, 600*time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
return errHTTPNotFound
|
||||
return errHTTPNotFound.Fields(log.Context{
|
||||
"message_id": messageID,
|
||||
"error_context": "message_cache",
|
||||
})
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
@@ -546,7 +615,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
||||
bandwidthVisitor = s.visitor(m.Sender, nil)
|
||||
}
|
||||
if !bandwidthVisitor.BandwidthAllowed(stat.Size()) {
|
||||
return errHTTPTooManyRequestsLimitAttachmentBandwidth
|
||||
return errHTTPTooManyRequestsLimitAttachmentBandwidth.With(m)
|
||||
}
|
||||
// Actually send file
|
||||
f, err := os.Open(file)
|
||||
@@ -565,9 +634,16 @@ func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
|
||||
return writeMatrixDiscoveryResponse(w)
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*message, error) {
|
||||
t := fromContext[topic](r, contextTopic)
|
||||
vrate := fromContext[visitor](r, contextRateVisitor)
|
||||
func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, error) {
|
||||
start := time.Now()
|
||||
t, err := fromContext[*topic](r, contextTopic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vrate, err := fromContext[*visitor](r, contextRateVisitor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := util.Peek(r.Body, s.config.MessageLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -577,12 +653,12 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
|
||||
if e != nil {
|
||||
return nil, e.With(t)
|
||||
}
|
||||
if unifiedpush && t.RateVisitor() == nil {
|
||||
if unifiedpush && s.config.VisitorSubscriberRateLimiting && t.RateVisitor() == nil {
|
||||
// UnifiedPush clients must subscribe before publishing to allow proper subscriber-based rate limiting (see
|
||||
// Rate-Topics header). The 5xx response is because some app servers (in particular Mastodon) will remove
|
||||
// the subscription as invalid if any 400-499 code (except 429/408) is returned.
|
||||
// See https://github.com/mastodon/mastodon/blob/730bb3e211a84a2f30e3e2bbeae3f77149824a68/app/workers/web/push_notification_worker.rb#L35-L46
|
||||
return nil, errHTTPInsufficientStorage.With(t)
|
||||
return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
|
||||
} else if !util.ContainsIP(s.config.VisitorRequestExemptIPAddrs, v.ip) && !vrate.MessageAllowed() {
|
||||
return nil, errHTTPTooManyRequestsLimitMessages.With(t)
|
||||
} else if email != "" && !vrate.EmailAllowed() {
|
||||
@@ -646,41 +722,70 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
|
||||
s.mu.Lock()
|
||||
s.messages++
|
||||
s.mu.Unlock()
|
||||
if unifiedpush {
|
||||
minc(metricUnifiedPushPublishedSuccess)
|
||||
}
|
||||
mset(metricMessagePublishDurationMillis, time.Since(start).Milliseconds())
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
m, err := s.handlePublishWithoutResponse(r, v)
|
||||
m, err := s.handlePublishInternal(r, v)
|
||||
if err != nil {
|
||||
minc(metricMessagesPublishedFailure)
|
||||
return err
|
||||
}
|
||||
minc(metricMessagesPublishedSuccess)
|
||||
return s.writeJSON(w, m)
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
_, err := s.handlePublishWithoutResponse(r, v)
|
||||
_, err := s.handlePublishInternal(r, v)
|
||||
if err != nil {
|
||||
minc(metricMessagesPublishedFailure)
|
||||
minc(metricMatrixPublishedFailure)
|
||||
if e, ok := err.(*errHTTP); ok && e.HTTPCode == errHTTPInsufficientStorageUnifiedPush.HTTPCode {
|
||||
topic, err := fromContext[*topic](r, contextTopic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pushKey, err := fromContext[string](r, contextMatrixPushKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if time.Since(topic.LastAccess()) > matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter {
|
||||
return writeMatrixResponse(w, pushKey)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
minc(metricMessagesPublishedSuccess)
|
||||
minc(metricMatrixPublishedSuccess)
|
||||
return writeMatrixSuccess(w)
|
||||
}
|
||||
|
||||
func (s *Server) sendToFirebase(v *visitor, m *message) {
|
||||
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
|
||||
if err := s.firebaseClient.Send(v, m); err != nil {
|
||||
minc(metricFirebasePublishedFailure)
|
||||
if err == errFirebaseTemporarilyBanned {
|
||||
logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error())
|
||||
} else {
|
||||
logvm(v, m).Tag(tagFirebase).Err(err).Warn("Unable to publish to Firebase: %v", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
minc(metricFirebasePublishedSuccess)
|
||||
}
|
||||
|
||||
func (s *Server) sendEmail(v *visitor, m *message, email string) {
|
||||
logvm(v, m).Tag(tagEmail).Field("email", email).Debug("Sending email to %s", email)
|
||||
if err := s.smtpSender.Send(v, m, email); err != nil {
|
||||
logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error())
|
||||
minc(metricEmailsPublishedFailure)
|
||||
return
|
||||
}
|
||||
minc(metricEmailsPublishedSuccess)
|
||||
}
|
||||
|
||||
func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||
@@ -866,13 +971,17 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||
}
|
||||
attachmentExpiry := time.Now().Add(vinfo.Limits.AttachmentExpiryDuration).Unix()
|
||||
if m.Time > attachmentExpiry {
|
||||
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
|
||||
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery.With(m)
|
||||
}
|
||||
contentLengthStr := r.Header.Get("Content-Length")
|
||||
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
|
||||
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
||||
if err == nil && (contentLength > vinfo.Stats.AttachmentTotalSizeRemaining || contentLength > vinfo.Limits.AttachmentFileSizeLimit) {
|
||||
return errHTTPEntityTooLargeAttachment
|
||||
return errHTTPEntityTooLargeAttachment.With(m).Fields(log.Context{
|
||||
"message_content_length": contentLength,
|
||||
"attachment_total_size_remaining": vinfo.Stats.AttachmentTotalSizeRemaining,
|
||||
"attachment_file_size_limit": vinfo.Limits.AttachmentFileSizeLimit,
|
||||
})
|
||||
}
|
||||
}
|
||||
if m.Attachment == nil {
|
||||
@@ -984,6 +1093,9 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
w.Header().Set("Content-Type", contentType+"; charset=utf-8") // Android/Volley client needs charset!
|
||||
if poll {
|
||||
for _, t := range topics {
|
||||
t.Keepalive()
|
||||
}
|
||||
return s.sendOldMessages(topics, since, scheduled, v, sub)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -1010,8 +1122,16 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
case <-r.Context().Done():
|
||||
return nil
|
||||
case <-time.After(s.config.KeepaliveInterval):
|
||||
logvr(v, r).Tag(tagSubscribe).Trace("Sending keepalive message")
|
||||
ev := logvr(v, r).Tag(tagSubscribe)
|
||||
if len(topics) == 1 {
|
||||
ev.With(topics[0]).Trace("Sending keepalive message to %s", topics[0].ID)
|
||||
} else {
|
||||
ev.Trace("Sending keepalive message to %d topics", len(topics))
|
||||
}
|
||||
v.Keepalive()
|
||||
for _, t := range topics {
|
||||
t.Keepalive()
|
||||
}
|
||||
if err := sub(v, newKeepaliveMessage(topicsStr)); err != nil { // Send keepalive message
|
||||
return err
|
||||
}
|
||||
@@ -1099,6 +1219,9 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
return &websocket.CloseError{Code: websocket.CloseNormalClosure, Text: "subscription was canceled"}
|
||||
case <-time.After(s.config.KeepaliveInterval):
|
||||
v.Keepalive()
|
||||
for _, t := range topics {
|
||||
t.Keepalive()
|
||||
}
|
||||
if err := ping(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1121,6 +1244,9 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||
if poll {
|
||||
for _, t := range topics {
|
||||
t.Keepalive()
|
||||
}
|
||||
return s.sendOldMessages(topics, since, scheduled, v, sub)
|
||||
}
|
||||
subscriberIDs := make([]int, 0)
|
||||
@@ -1164,14 +1290,19 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
|
||||
// maybeSetRateVisitors sets the rate visitor on a topic (v.SetRateVisitor), indicating that all messages published
|
||||
// to that topic will be rate limited against the rate visitor instead of the publishing visitor.
|
||||
//
|
||||
// Setting the rate visitor is ony allowed if
|
||||
// Setting the rate visitor is ony allowed if the `visitor-subscriber-rate-limiting` setting is enabled, AND
|
||||
// - auth-file is not set (everything is open by default)
|
||||
// - the topic is reserved, and v.user is the owner
|
||||
// - the topic is not reserved, and v.user has write access
|
||||
// - or the topic is reserved, and v.user is the owner
|
||||
// - or the topic is not reserved, and v.user has write access
|
||||
//
|
||||
// Note: This TEMPORARILY also registers all topics starting with "up" (= UnifiedPush). This is to ease the transition
|
||||
// until the Android app will send the "Rate-Topics" header.
|
||||
func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*topic, rateTopics []string) error {
|
||||
// Bail out if not enabled
|
||||
if !s.config.VisitorSubscriberRateLimiting {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Make a list of topics that we'll actually set the RateVisitor on
|
||||
eligibleRateTopics := make([]*topic, 0)
|
||||
for _, t := range topics {
|
||||
@@ -1398,8 +1529,14 @@ func (s *Server) runFirebaseKeepaliver() {
|
||||
select {
|
||||
case <-time.After(s.config.FirebaseKeepaliveInterval):
|
||||
s.sendToFirebase(v, newKeepaliveMessage(firebaseControlTopic))
|
||||
case <-time.After(s.config.FirebasePollInterval):
|
||||
s.sendToFirebase(v, newKeepaliveMessage(firebasePollTopic))
|
||||
/*
|
||||
FIXME: Disable iOS polling entirely for now due to thundering herd problem (see #677)
|
||||
To solve this, we'd have to shard the iOS poll topics to spread out the polling evenly.
|
||||
Given that it's not really necessary to poll, turning it off for now should not have any impact.
|
||||
|
||||
case <-time.After(s.config.FirebasePollInterval):
|
||||
s.sendToFirebase(v, newKeepaliveMessage(firebasePollTopic))
|
||||
*/
|
||||
case <-s.closeChan:
|
||||
return
|
||||
}
|
||||
@@ -1427,7 +1564,7 @@ func (s *Server) sendDelayedMessages() error {
|
||||
for _, m := range messages {
|
||||
var u *user.User
|
||||
if s.userManager != nil && m.User != "" {
|
||||
u, err = s.userManager.User(m.User)
|
||||
u, err = s.userManager.UserByID(m.User)
|
||||
if err != nil {
|
||||
log.With(m).Err(err).Warn("Error sending delayed message")
|
||||
continue
|
||||
@@ -1569,6 +1706,7 @@ func (s *Server) autorizeTopic(next handleFunc, perm user.Permission) handleFunc
|
||||
// maybeAuthenticate reads the "Authorization" header and will try to authenticate the user
|
||||
// if it is set.
|
||||
//
|
||||
// - If auth-file is not configured, immediately return an IP-based visitor
|
||||
// - If the header is not set or not supported (anything non-Basic and non-Bearer),
|
||||
// an IP-based visitor is returned
|
||||
// - If the header is set, authenticate will be called to check the username/password (Basic auth),
|
||||
@@ -1580,13 +1718,14 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) {
|
||||
// Read "Authorization" header value, and exit out early if it's not set
|
||||
ip := extractIPAddress(r, s.config.BehindProxy)
|
||||
vip := s.visitor(ip, nil)
|
||||
if s.userManager == nil {
|
||||
return vip, nil
|
||||
}
|
||||
header, err := readAuthHeader(r)
|
||||
if err != nil {
|
||||
return vip, err
|
||||
} else if !supportedAuthHeader(header) {
|
||||
return vip, nil
|
||||
} else if s.userManager == nil {
|
||||
return vip, errHTTPUnauthorized
|
||||
}
|
||||
// If we're trying to auth, check the rate limiter first
|
||||
if !vip.AuthAllowed() {
|
||||
|
||||
@@ -117,18 +117,19 @@
|
||||
# attachment-expiry-duration: "3h"
|
||||
|
||||
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
||||
# messages will additionally be sent out as e-mail using an external SMTP server. As of today, only
|
||||
# SMTP servers with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings
|
||||
# below (visitor-email-limit-burst & visitor-email-limit-burst).
|
||||
# messages will additionally be sent out as e-mail using an external SMTP server.
|
||||
#
|
||||
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTLS are supported.
|
||||
# Please also refer to the rate limiting settings below (visitor-email-limit-burst & visitor-email-limit-burst).
|
||||
#
|
||||
# - smtp-sender-addr is the hostname:port of the SMTP server
|
||||
# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user
|
||||
# - smtp-sender-from is the e-mail address of the sender
|
||||
# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user (leave blank for no auth)
|
||||
#
|
||||
# smtp-sender-addr:
|
||||
# smtp-sender-from:
|
||||
# smtp-sender-user:
|
||||
# smtp-sender-pass:
|
||||
# smtp-sender-from:
|
||||
|
||||
# If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send
|
||||
# emails to a topic e-mail address to publish messages to a topic.
|
||||
@@ -234,15 +235,54 @@
|
||||
# visitor-attachment-total-size-limit: "100M"
|
||||
# visitor-attachment-daily-bandwidth-limit: "500M"
|
||||
|
||||
# Rate limiting: Enable subscriber-based rate limiting (mostly used for UnifiedPush)
|
||||
#
|
||||
# If enabled, subscribers may opt to have published messages counted against their own rate limits, as opposed
|
||||
# to the publisher's rate limits. This is especially useful to increase the amount of messages that high-volume
|
||||
# publishers (e.g. Matrix/Mastodon servers) are allowed to send.
|
||||
#
|
||||
# Once enabled, a client may send a "Rate-Topics: <topic1>,<topic2>,..." header when subscribing to topics via
|
||||
# HTTP stream, or websockets, thereby registering itself as the "rate visitor", i.e. the visitor whose rate limits
|
||||
# to use when publishing on this topic. Note: Setting the rate visitor requires READ-WRITE permission on the topic.
|
||||
#
|
||||
# UnifiedPush only: If this setting is enabled, publishing to UnifiedPush topics will lead to a HTTP 507 response if
|
||||
# no "rate visitor" has been previously registered. This is to avoid burning the publisher's "visitor-message-daily-limit".
|
||||
#
|
||||
# visitor-subscriber-rate-limiting: false
|
||||
|
||||
# Payments integration via Stripe
|
||||
#
|
||||
# - stripe-secret-key is the key used for the Stripe API communication. Setting this values
|
||||
# enables payments in the ntfy web app (e.g. Upgrade dialog). See https://dashboard.stripe.com/apikeys.
|
||||
# - stripe-webhook-key is the key required to validate the authenticity of incoming webhooks from Stripe.
|
||||
# Webhooks are essential up keep the local database in sync with the payment provider. See https://dashboard.stripe.com/webhooks.
|
||||
# - billing-contact is an email address or website displayed in the "Upgrade tier" dialog to let people reach
|
||||
# out with billing questions. If unset, nothing will be displayed.
|
||||
#
|
||||
# stripe-secret-key:
|
||||
# stripe-webhook-key:
|
||||
# billing-contact:
|
||||
|
||||
# Metrics
|
||||
#
|
||||
# ntfy can expose Prometheus-style metrics via a /metrics endpoint, or on a dedicated listen IP/port.
|
||||
# Metrics may be considered sensitive information, so before you enable them, be sure you know what you are
|
||||
# doing, and/or secure access to the endpoint in your reverse proxy.
|
||||
#
|
||||
# - enable-metrics enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket)
|
||||
# - metrics-listen-http exposes the metrics endpoint via a dedicated [IP]:port. If set, this option implicitly
|
||||
# enables metrics as well, e.g. "10.0.1.1:9090" or ":9090"
|
||||
#
|
||||
# enable-metrics: false
|
||||
# metrics-listen-http:
|
||||
|
||||
# Profiling
|
||||
#
|
||||
# ntfy can expose Go's net/http/pprof endpoints to support profiling of the ntfy server. If enabled, ntfy will listen
|
||||
# on a dedicated listen IP/port, which can be accessed via the web browser on http://<ip>:<port>/debug/pprof/.
|
||||
# This can be helpful to expose bottlenecks, and visualize call flows. See https://pkg.go.dev/net/http/pprof for details.
|
||||
#
|
||||
# profile-listen-http:
|
||||
|
||||
# Logging options
|
||||
#
|
||||
|
||||
@@ -657,6 +657,17 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||
m2 := toMessage(t, rr.Body.String())
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
|
||||
|
||||
// Pre-verify message count and file
|
||||
ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(ms))
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
||||
|
||||
ms, err = s.messageCache.Messages("mytopic2", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(ms))
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
|
||||
|
||||
// Delete reservation
|
||||
rr = request(t, s, "DELETE", "/v1/account/reservation/mytopic1", ``, map[string]string{
|
||||
"X-Delete-Messages": "true",
|
||||
@@ -672,9 +683,13 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||
|
||||
// Verify that messages and attachments were deleted
|
||||
// This does not explicitly call the manager!
|
||||
time.Sleep(time.Second)
|
||||
waitFor(t, func() bool {
|
||||
ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
return len(ms) == 0 && !util.FileExists(filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
||||
})
|
||||
|
||||
ms, err := s.messageCache.Messages("mytopic1", sinceAllMessages, false)
|
||||
ms, err = s.messageCache.Messages("mytopic1", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(ms))
|
||||
require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, m1.ID))
|
||||
@@ -686,93 +701,10 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, m2.ID))
|
||||
}
|
||||
|
||||
func TestAccount_Reservation_Add_Kills_Other_Subscribers(t *testing.T) {
|
||||
t.Parallel()
|
||||
/*func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.AuthDefault = user.PermissionReadWrite
|
||||
conf.EnableSignup = true
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create user with tier
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 20,
|
||||
ReservationLimit: 2,
|
||||
}))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
|
||||
// Subscribe anonymously
|
||||
anonCh, userCh := make(chan bool), make(chan bool)
|
||||
go func() {
|
||||
rr := request(t, s, "GET", "/mytopic/json", ``, nil) // This blocks until it's killed!
|
||||
require.Equal(t, 200, rr.Code)
|
||||
messages := toMessages(t, rr.Body.String())
|
||||
require.Equal(t, 2, len(messages)) // This is the meat. We should NOT receive the second message!
|
||||
require.Equal(t, "open", messages[0].Event)
|
||||
require.Equal(t, "message before reservation", messages[1].Message)
|
||||
anonCh <- true
|
||||
log.Info("Anonymous subscription ended")
|
||||
}()
|
||||
|
||||
// Subscribe with user
|
||||
go func() {
|
||||
rr := request(t, s, "GET", "/mytopic/json", ``, map[string]string{ // Blocks!
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
messages := toMessages(t, rr.Body.String())
|
||||
require.Equal(t, 3, len(messages))
|
||||
require.Equal(t, "open", messages[0].Event)
|
||||
require.Equal(t, "message before reservation", messages[1].Message)
|
||||
require.Equal(t, "message after reservation", messages[2].Message)
|
||||
userCh <- true
|
||||
log.Info("User subscription ended")
|
||||
}()
|
||||
|
||||
// Publish message (before reservation)
|
||||
time.Sleep(2 * time.Second) // Wait for subscribers
|
||||
rr = request(t, s, "POST", "/mytopic", "message before reservation", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
time.Sleep(2 * time.Second) // Wait for subscribers to receive message
|
||||
|
||||
// Reserve a topic
|
||||
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Everyone but phil should be killed
|
||||
select {
|
||||
case <-anonCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Waiting for anonymous subscription to be killed failed")
|
||||
}
|
||||
|
||||
// Publish a message
|
||||
rr = request(t, s, "POST", "/mytopic", "message after reservation", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Kill user Go routine
|
||||
s.topics["mytopic"].CancelSubscribers("<invalid>")
|
||||
|
||||
select {
|
||||
case <-userCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Waiting for user subscription to be killed failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
t.Parallel()
|
||||
conf := newTestConfigWithAuthFile(t)
|
||||
conf.AuthDefault = user.PermissionReadWrite
|
||||
conf.AuthStatsQueueWriterInterval = 200 * time.Millisecond
|
||||
conf.AuthStatsQueueWriterInterval = 300 * time.Millisecond
|
||||
s := newTestServer(t, conf)
|
||||
defer s.closeDatabases()
|
||||
|
||||
@@ -794,13 +726,12 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Wait for stats queue writer
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Verify that message stats were persisted
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(1), u.Stats.Messages)
|
||||
// Wait for stats queue writer, verify that message stats were persisted
|
||||
waitFor(t, func() bool {
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
return int64(1) == u.Stats.Messages
|
||||
})
|
||||
|
||||
// Change tier, make a request (to reset limiters)
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
@@ -818,10 +749,11 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Verify that message stats were persisted
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
u, err = s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(2), u.Stats.Messages) // v.EnqueueUserStats had run!
|
||||
waitFor(t, func() bool {
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
return int64(2) == u.Stats.Messages // v.EnqueueUserStats had run!
|
||||
})
|
||||
|
||||
// Stats keep counting
|
||||
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||
@@ -830,5 +762,4 @@ func TestAccount_Persist_UserStats_After_Tier_Change(t *testing.T) {
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Equal(t, int64(2), account.Stats.Messages) // Is not reset!
|
||||
|
||||
}
|
||||
}*/
|
||||
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -34,16 +35,20 @@ func (s *Server) execManager() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, t := range s.topics {
|
||||
subs := t.SubscribersCount()
|
||||
log.Tag(tagManager).With(t).Trace("- topic %s: %d subscribers", t.ID, subs)
|
||||
msgs, exists := messageCounts[t.ID]
|
||||
if t.Stale() && (!exists || msgs == 0) {
|
||||
log.Tag(tagManager).With(t).Trace("Deleting empty topic %s", t.ID)
|
||||
subs, lastAccess := t.Stats()
|
||||
ev := log.Tag(tagManager).With(t)
|
||||
if t.Stale() {
|
||||
if ev.IsTrace() {
|
||||
ev.Trace("- topic %s: Deleting stale topic (%d subscribers, accessed %s)", t.ID, subs, util.FormatTime(lastAccess))
|
||||
}
|
||||
emptyTopics++
|
||||
delete(s.topics, t.ID)
|
||||
continue
|
||||
} else {
|
||||
if ev.IsTrace() {
|
||||
ev.Trace("- topic %s: %d subscribers, accessed %s", t.ID, subs, util.FormatTime(lastAccess))
|
||||
}
|
||||
subscribers += subs
|
||||
}
|
||||
subscribers += subs
|
||||
}
|
||||
}).
|
||||
Debug("Removed %d empty topic(s)", emptyTopics)
|
||||
@@ -58,6 +63,15 @@ func (s *Server) execManager() {
|
||||
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
|
||||
}
|
||||
|
||||
// Users
|
||||
var usersCount int64
|
||||
if s.userManager != nil {
|
||||
usersCount, err = s.userManager.UsersCount()
|
||||
if err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error counting users")
|
||||
}
|
||||
}
|
||||
|
||||
// Print stats
|
||||
s.mu.Lock()
|
||||
messagesCount, topicsCount, visitorsCount := s.messages, len(s.topics), len(s.visitors)
|
||||
@@ -70,6 +84,7 @@ func (s *Server) execManager() {
|
||||
"topics_active": topicsCount,
|
||||
"subscribers": subscribers,
|
||||
"visitors": visitorsCount,
|
||||
"users": usersCount,
|
||||
"emails_received": receivedMailTotal,
|
||||
"emails_received_success": receivedMailSuccess,
|
||||
"emails_received_failure": receivedMailFailure,
|
||||
@@ -78,6 +93,11 @@ func (s *Server) execManager() {
|
||||
"emails_sent_failure": sentMailFailure,
|
||||
}).
|
||||
Info("Server stats")
|
||||
mset(metricMessagesCached, messagesCached)
|
||||
mset(metricVisitors, visitorsCount)
|
||||
mset(metricUsers, usersCount)
|
||||
mset(metricSubscribers, subscribers)
|
||||
mset(metricTopics, topicsCount)
|
||||
}
|
||||
|
||||
func (s *Server) pruneVisitors() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Matrix Push Gateway / UnifiedPush / ntfy integration:
|
||||
@@ -71,6 +72,14 @@ type matrixResponse struct {
|
||||
Rejected []string `json:"rejected"`
|
||||
}
|
||||
|
||||
const (
|
||||
// matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter is the time after which a Matrix response
|
||||
// will return an HTTP 200 with the push key (i.e. "rejected":["<pushkey>"]}), if no rate visitor has been set on
|
||||
// the topic. Rejecting the push key will instruct the Matrix server to invalidate the pushkey and stop sending
|
||||
// messages to it. This must be longer than topicExpungeAfter. See https://spec.matrix.org/v1.6/push-gateway-api/
|
||||
matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter = 12 * time.Hour
|
||||
)
|
||||
|
||||
// errMatrixPushkeyRejected represents an error when handing Matrix gateway messages
|
||||
//
|
||||
// If the push key is set, the app server will remove it and will never send messages using the same
|
||||
@@ -126,6 +135,9 @@ func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int)
|
||||
if r.Header.Get("X-Forwarded-For") != "" {
|
||||
newRequest.Header.Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
|
||||
}
|
||||
newRequest = withContext(newRequest, map[contextKey]any{
|
||||
contextMatrixPushKey: pushKey,
|
||||
})
|
||||
return newRequest, nil
|
||||
}
|
||||
|
||||
|
||||
122
server/server_metrics.go
Normal file
122
server/server_metrics.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var (
|
||||
metricMessagesPublishedSuccess prometheus.Counter
|
||||
metricMessagesPublishedFailure prometheus.Counter
|
||||
metricMessagesCached prometheus.Gauge
|
||||
metricMessagePublishDurationMillis prometheus.Gauge
|
||||
metricFirebasePublishedSuccess prometheus.Counter
|
||||
metricFirebasePublishedFailure prometheus.Counter
|
||||
metricEmailsPublishedSuccess prometheus.Counter
|
||||
metricEmailsPublishedFailure prometheus.Counter
|
||||
metricEmailsReceivedSuccess prometheus.Counter
|
||||
metricEmailsReceivedFailure prometheus.Counter
|
||||
metricUnifiedPushPublishedSuccess prometheus.Counter
|
||||
metricMatrixPublishedSuccess prometheus.Counter
|
||||
metricMatrixPublishedFailure prometheus.Counter
|
||||
metricAttachmentsTotalSize prometheus.Gauge
|
||||
metricVisitors prometheus.Gauge
|
||||
metricSubscribers prometheus.Gauge
|
||||
metricTopics prometheus.Gauge
|
||||
metricUsers prometheus.Gauge
|
||||
metricHTTPRequests *prometheus.CounterVec
|
||||
)
|
||||
|
||||
func initMetrics() {
|
||||
metricMessagesPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_messages_published_success",
|
||||
})
|
||||
metricMessagesPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_messages_published_failure",
|
||||
})
|
||||
metricMessagesCached = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_messages_cached_total",
|
||||
})
|
||||
metricMessagePublishDurationMillis = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_message_publish_duration_ms",
|
||||
})
|
||||
metricFirebasePublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_firebase_published_success",
|
||||
})
|
||||
metricFirebasePublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_firebase_published_failure",
|
||||
})
|
||||
metricEmailsPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_sent_success",
|
||||
})
|
||||
metricEmailsPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_sent_failure",
|
||||
})
|
||||
metricEmailsReceivedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_received_success",
|
||||
})
|
||||
metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_emails_received_failure",
|
||||
})
|
||||
metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_unifiedpush_published_success",
|
||||
})
|
||||
metricMatrixPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_matrix_published_success",
|
||||
})
|
||||
metricMatrixPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "ntfy_matrix_published_failure",
|
||||
})
|
||||
metricAttachmentsTotalSize = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_attachments_total_size",
|
||||
})
|
||||
metricVisitors = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_visitors_total",
|
||||
})
|
||||
metricUsers = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_users_total",
|
||||
})
|
||||
metricSubscribers = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_subscribers_total",
|
||||
})
|
||||
metricTopics = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "ntfy_topics_total",
|
||||
})
|
||||
metricHTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ntfy_http_requests_total",
|
||||
}, []string{"http_code", "ntfy_code", "http_method"})
|
||||
prometheus.MustRegister(
|
||||
metricMessagesPublishedSuccess,
|
||||
metricMessagesPublishedFailure,
|
||||
metricMessagesCached,
|
||||
metricMessagePublishDurationMillis,
|
||||
metricFirebasePublishedSuccess,
|
||||
metricFirebasePublishedFailure,
|
||||
metricEmailsPublishedSuccess,
|
||||
metricEmailsPublishedFailure,
|
||||
metricEmailsReceivedSuccess,
|
||||
metricEmailsReceivedFailure,
|
||||
metricUnifiedPushPublishedSuccess,
|
||||
metricMatrixPublishedSuccess,
|
||||
metricMatrixPublishedFailure,
|
||||
metricAttachmentsTotalSize,
|
||||
metricVisitors,
|
||||
metricUsers,
|
||||
metricSubscribers,
|
||||
metricTopics,
|
||||
metricHTTPRequests,
|
||||
)
|
||||
}
|
||||
|
||||
// minc increments a prometheus.Counter if it is non-nil
|
||||
func minc(counter prometheus.Counter) {
|
||||
if counter != nil {
|
||||
counter.Inc()
|
||||
}
|
||||
}
|
||||
|
||||
// mset sets a prometheus.Gauge if it is non-nil
|
||||
func mset[T int | int64 | float64](gauge prometheus.Gauge, value T) {
|
||||
if gauge != nil {
|
||||
gauge.Set(float64(value))
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ type contextKey int
|
||||
const (
|
||||
contextRateVisitor contextKey = iota + 2586
|
||||
contextTopic
|
||||
contextMatrixPushKey
|
||||
)
|
||||
|
||||
func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -83,6 +84,32 @@ func TestServer_PublishWithFirebase(t *testing.T) {
|
||||
require.Equal(t, "my first message", sender.Messages()[0].APNS.Payload.CustomData["message"])
|
||||
}
|
||||
|
||||
func TestServer_PublishWithFirebase_WithoutUsers_AndWithoutPanic(t *testing.T) {
|
||||
// This tests issue #641, which used to panic before the fix
|
||||
|
||||
firebaseKeyFile := filepath.Join(t.TempDir(), "firebase.json")
|
||||
contents := `{
|
||||
"type": "service_account",
|
||||
"project_id": "ntfy-test",
|
||||
"private_key_id": "fsfhskjdfhskdhfskdjfhsdf",
|
||||
"private_key": "lalala",
|
||||
"client_email": "firebase-adminsdk-muv04@ntfy-test.iam.gserviceaccount.com",
|
||||
"client_id": "123123213",
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-muv04%40ntfy-test.iam.gserviceaccount.com"
|
||||
}
|
||||
`
|
||||
require.Nil(t, os.WriteFile(firebaseKeyFile, []byte(contents), 0600))
|
||||
c := newTestConfig(t)
|
||||
c.FirebaseKeyFile = firebaseKeyFile
|
||||
s := newTestServer(t, c)
|
||||
|
||||
response := request(t, s, "PUT", "/mytopic", "my first message", nil)
|
||||
require.Equal(t, "my first message", toMessage(t, response.Body.String()).Message)
|
||||
}
|
||||
|
||||
func TestServer_SubscribeOpenAndKeepalive(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t)
|
||||
@@ -300,13 +327,10 @@ func TestServer_PublishNoCache(t *testing.T) {
|
||||
|
||||
func TestServer_PublishAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := newTestConfig(t)
|
||||
c.MinDelay = time.Second
|
||||
c.DelayedSenderInterval = 100 * time.Millisecond
|
||||
s := newTestServer(t, c)
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
|
||||
"In": "1s",
|
||||
"In": "1h",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
@@ -314,22 +338,62 @@ func TestServer_PublishAt(t *testing.T) {
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 0, len(messages))
|
||||
|
||||
time.Sleep(time.Second)
|
||||
require.Nil(t, s.sendDelayedMessages())
|
||||
// Update message time to the past
|
||||
fakeTime := time.Now().Add(-10 * time.Second).Unix()
|
||||
_, err := s.messageCache.db.Exec(`UPDATE messages SET time=?`, fakeTime)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Trigger delayed message sending
|
||||
require.Nil(t, s.sendDelayedMessages())
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
messages = toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "a message", messages[0].Message)
|
||||
require.Equal(t, netip.Addr{}, messages[0].Sender) // Never return the sender!
|
||||
|
||||
messages, err := s.messageCache.Messages("mytopic", sinceAllMessages, true)
|
||||
messages, err = s.messageCache.Messages("mytopic", sinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "a message", messages[0].Message)
|
||||
require.Equal(t, "9.9.9.9", messages[0].Sender.String()) // It's stored in the DB though!
|
||||
}
|
||||
|
||||
func TestServer_PublishAt_FromUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin))
|
||||
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
"In": "1h",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Message doesn't show up immediately
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 0, len(messages))
|
||||
|
||||
// Update message time to the past
|
||||
fakeTime := time.Now().Add(-10 * time.Second).Unix()
|
||||
_, err := s.messageCache.db.Exec(`UPDATE messages SET time=?`, fakeTime)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Trigger delayed message sending
|
||||
require.Nil(t, s.sendDelayedMessages())
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
messages = toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, fakeTime, messages[0].Time)
|
||||
require.Equal(t, "a message", messages[0].Message)
|
||||
|
||||
messages, err = s.messageCache.Messages("mytopic", sinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "a message", messages[0].Message)
|
||||
require.True(t, strings.HasPrefix(messages[0].User, "u_"))
|
||||
}
|
||||
|
||||
func TestServer_PublishAt_Expires(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
@@ -769,6 +833,7 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
|
||||
|
||||
func TestServer_Auth_Fail_Rate_Limiting(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.VisitorAuthFailureLimitBurst = 10
|
||||
s := newTestServer(t, c)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
@@ -888,7 +953,15 @@ func TestServer_StatsResetter(t *testing.T) {
|
||||
require.Equal(t, int64(2), account.Stats.Messages)
|
||||
|
||||
// Wait for stats resetter to run
|
||||
time.Sleep(2200 * time.Millisecond)
|
||||
waitFor(t, func() bool {
|
||||
response = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
|
||||
require.Nil(t, err)
|
||||
return account.Stats.Messages == 0
|
||||
})
|
||||
|
||||
// User stats show 0 messages now!
|
||||
response = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||
@@ -1137,6 +1210,63 @@ func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
|
||||
require.Equal(t, 400, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishAndExpungeTopicAfter16Hours(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
subFn := func(v *visitor, msg *message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Publish and check last access
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"Cache": "no",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool {
|
||||
// .lastAccess set in t.Publish() -> t.Keepalive() in Goroutine
|
||||
s.topics["mytopic"].mu.RLock()
|
||||
defer s.topics["mytopic"].mu.RUnlock()
|
||||
return s.topics["mytopic"].lastAccess.Unix() >= time.Now().Unix()-2 &&
|
||||
s.topics["mytopic"].lastAccess.Unix() <= time.Now().Unix()+2
|
||||
})
|
||||
|
||||
// Topic won't get pruned
|
||||
s.execManager()
|
||||
require.NotNil(t, s.topics["mytopic"])
|
||||
|
||||
// Fudge with last access, but subscribe, and see that it won't get pruned (because of subscriber)
|
||||
subID := s.topics["mytopic"].Subscribe(subFn, "", func() {})
|
||||
s.topics["mytopic"].mu.Lock()
|
||||
s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour)
|
||||
s.topics["mytopic"].mu.Unlock()
|
||||
s.execManager()
|
||||
require.NotNil(t, s.topics["mytopic"])
|
||||
|
||||
// It'll finally get pruned now that there are no subscribers and last access is 17 hours ago
|
||||
s.topics["mytopic"].Unsubscribe(subID)
|
||||
s.execManager()
|
||||
require.Nil(t, s.topics["mytopic"])
|
||||
}
|
||||
|
||||
func TestServer_TopicKeepaliveOnPoll(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
// Create topic by polling once
|
||||
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Mess with last access time
|
||||
s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour)
|
||||
|
||||
// Poll again and check keepalive time
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.True(t, s.topics["mytopic"].lastAccess.Unix() >= time.Now().Unix()-2)
|
||||
require.True(t, s.topics["mytopic"].lastAccess.Unix() <= time.Now().Unix()+2)
|
||||
}
|
||||
|
||||
func TestServer_UnifiedPushDiscovery(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "GET", "/mytopic?up=1", "", nil)
|
||||
@@ -1257,13 +1387,41 @@ func TestServer_MatrixGateway_Push_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Push_Failure_NoSubscriber(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
c := newTestConfig(t)
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 507, response.Code)
|
||||
require.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Push_Failure_NoSubscriber_After13Hours(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
|
||||
|
||||
// No success if no rate visitor set (this also creates the topic in memory)
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 507, response.Code)
|
||||
require.Equal(t, 50701, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Nil(t, s.topics["mytopic"].rateVisitor)
|
||||
|
||||
// Fake: This topic has been around for 13 hours without a rate visitor
|
||||
s.topics["mytopic"].lastAccess = time.Now().Add(-13 * time.Hour)
|
||||
|
||||
// Same request should now return HTTP 200 with a rejected pushkey
|
||||
response = request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"rejected":["http://127.0.0.1:12345/mytopic?up=1"]}`, strings.TrimSpace(response.Body.String()))
|
||||
|
||||
// Slightly unrelated: Test that topic is pruned after 16 hours
|
||||
s.topics["mytopic"].lastAccess = time.Now().Add(-17 * time.Hour)
|
||||
s.execManager()
|
||||
require.Nil(t, s.topics["mytopic"])
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Push_Failure_InvalidPushkey(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
notification := `{"notification":{"devices":[{"pushkey":"http://wrong-base-url.com/mytopic?up=1"}]}}`
|
||||
@@ -1635,9 +1793,10 @@ func TestServer_PublishAttachmentAndExpire(t *testing.T) {
|
||||
require.Equal(t, content, response.Body.String())
|
||||
|
||||
// Prune and makes sure it's gone
|
||||
time.Sleep(time.Second) // Sigh ...
|
||||
s.execManager()
|
||||
require.NoFileExists(t, file)
|
||||
waitFor(t, func() bool {
|
||||
s.execManager() // May run many times
|
||||
return !util.FileExists(file)
|
||||
})
|
||||
response = request(t, s, "GET", path, "", nil)
|
||||
require.Equal(t, 404, response.Code)
|
||||
}
|
||||
@@ -1994,6 +2153,7 @@ func TestServer_AnonymousUser_And_NonTierUser_Are_Same_Visitor(t *testing.T) {
|
||||
func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// "Register" visitor 1.2.3.4 to topic "subscriber1topic" as a rate limit visitor
|
||||
@@ -2005,6 +2165,7 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
|
||||
}, subscriber1Fn)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "", rr.Body.String())
|
||||
require.Equal(t, "1.2.3.4", s.topics["subscriber1topic"].rateVisitor.ip.String())
|
||||
|
||||
// "Register" visitor 8.7.7.1 to topic "up012345678912" as a rate limit visitor (implicitly via topic name)
|
||||
subscriber2Fn := func(r *http.Request) {
|
||||
@@ -2013,6 +2174,7 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
|
||||
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, subscriber2Fn)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "", rr.Body.String())
|
||||
require.Equal(t, "8.7.7.1", s.topics["up012345678912"].rateVisitor.ip.String())
|
||||
|
||||
// Publish 2 messages to "subscriber1topic" as visitor 9.9.9.9. It'd be 3 normally, but the
|
||||
// GET request before is also counted towards the request limiter.
|
||||
@@ -2044,9 +2206,47 @@ func TestServer_SubscriberRateLimiting_Success(t *testing.T) {
|
||||
require.Equal(t, 429, rr.Code)
|
||||
}
|
||||
|
||||
func TestServer_SubscriberRateLimiting_NotEnabled_Failed(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
c.VisitorSubscriberRateLimiting = false
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Subscriber rate limiting is disabled!
|
||||
|
||||
// Registering visitor 1.2.3.4 to topic has no effect
|
||||
rr := request(t, s, "GET", "/subscriber1topic/json?poll=1", "", map[string]string{
|
||||
"Rate-Topics": "subscriber1topic",
|
||||
}, func(r *http.Request) {
|
||||
r.RemoteAddr = "1.2.3.4"
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "", rr.Body.String())
|
||||
require.Nil(t, s.topics["subscriber1topic"].rateVisitor)
|
||||
|
||||
// Registering visitor 8.7.7.1 to topic has no effect
|
||||
rr = request(t, s, "GET", "/up012345678912/json?poll=1", "", nil, func(r *http.Request) {
|
||||
r.RemoteAddr = "8.7.7.1"
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "", rr.Body.String())
|
||||
require.Nil(t, s.topics["up012345678912"].rateVisitor)
|
||||
|
||||
// Publish 3 messages to "subscriber1topic" as visitor 9.9.9.9
|
||||
for i := 0; i < 3; i++ {
|
||||
rr := request(t, s, "PUT", "/subscriber1topic", "some message", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
rr = request(t, s, "PUT", "/subscriber1topic", "some message", nil)
|
||||
require.Equal(t, 429, rr.Code)
|
||||
rr = request(t, s, "PUT", "/up012345678912", "some message", nil)
|
||||
require.Equal(t, 429, rr.Code)
|
||||
}
|
||||
|
||||
func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// "Register" 5 different UnifiedPush visitors
|
||||
@@ -2070,6 +2270,7 @@ func TestServer_SubscriberRateLimiting_UP_Only(t *testing.T) {
|
||||
func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// "Register" 5 different UnifiedPush visitors
|
||||
@@ -2097,6 +2298,7 @@ func TestServer_Matrix_SubscriberRateLimiting_UP_Only(t *testing.T) {
|
||||
func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.VisitorRequestLimitBurst = 3
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// "Register" rate visitor
|
||||
@@ -2132,6 +2334,7 @@ func TestServer_SubscriberRateLimiting_VisitorExpiration(t *testing.T) {
|
||||
func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AuthDefault = user.PermissionDenyAll
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Create some ACLs
|
||||
@@ -2179,6 +2382,7 @@ func TestServer_SubscriberRateLimiting_ProtectedTopics(t *testing.T) {
|
||||
func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AuthDefault = user.PermissionReadWrite
|
||||
c.VisitorSubscriberRateLimiting = true
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Create some ACLs
|
||||
@@ -2285,3 +2489,18 @@ func readAll(t *testing.T, rc io.ReadCloser) string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, f func() bool) {
|
||||
waitForWithMaxWait(t, 5*time.Second, f)
|
||||
}
|
||||
|
||||
func waitForWithMaxWait(t *testing.T, maxWait time.Duration, f func() bool) {
|
||||
start := time.Now()
|
||||
for time.Since(start) < maxWait {
|
||||
if f() {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("Function f did not succeed after %v: %v", maxWait, string(debug.Stack()))
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@ func (s *smtpSender) Send(v *visitor, m *message, to string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
||||
var auth smtp.Auth
|
||||
if s.config.SMTPSenderUser != "" {
|
||||
auth = smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
||||
}
|
||||
ev := logvm(v, m).
|
||||
Tag(tagEmail).
|
||||
Fields(log.Context{
|
||||
|
||||
@@ -165,6 +165,7 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
s.backend.mu.Lock()
|
||||
s.backend.success++
|
||||
s.backend.mu.Unlock()
|
||||
minc(metricEmailsReceivedSuccess)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -217,6 +218,7 @@ func (s *smtpSession) withFailCount(fn func() error) error {
|
||||
// We do not want to spam the log with WARN messages.
|
||||
logem(s.conn).Err(err).Debug("Incoming mail error")
|
||||
s.backend.failure++
|
||||
minc(metricEmailsReceivedFailure)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,8 +2,17 @@ package server
|
||||
|
||||
import (
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// topicExpungeAfter defines how long a topic is active before it is removed from memory.
|
||||
// This must be larger than matrixRejectPushKeyForUnifiedPushTopicWithoutRateVisitorAfter to give
|
||||
// time for more requests to come in, so that we can send a {"rejected":["<pushkey>"]} response back.
|
||||
topicExpungeAfter = 16 * time.Hour
|
||||
)
|
||||
|
||||
// topic represents a channel to which subscribers can subscribe, and publishers
|
||||
@@ -12,6 +21,7 @@ type topic struct {
|
||||
ID string
|
||||
subscribers map[int]*topicSubscriber
|
||||
rateVisitor *visitor
|
||||
lastAccess time.Time
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -29,6 +39,7 @@ func newTopic(id string) *topic {
|
||||
return &topic{
|
||||
ID: id,
|
||||
subscribers: make(map[int]*topicSubscriber),
|
||||
lastAccess: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +53,7 @@ func (t *topic) Subscribe(s subscriber, userID string, cancel func()) int {
|
||||
subscriber: s,
|
||||
cancel: cancel,
|
||||
}
|
||||
t.lastAccess = time.Now()
|
||||
return subscriberID
|
||||
}
|
||||
|
||||
@@ -51,13 +63,20 @@ func (t *topic) Stale() bool {
|
||||
if t.rateVisitor != nil && !t.rateVisitor.Stale() {
|
||||
return false
|
||||
}
|
||||
return len(t.subscribers) == 0
|
||||
return len(t.subscribers) == 0 && time.Since(t.lastAccess) > topicExpungeAfter
|
||||
}
|
||||
|
||||
func (t *topic) LastAccess() time.Time {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return t.lastAccess
|
||||
}
|
||||
|
||||
func (t *topic) SetRateVisitor(v *visitor) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.rateVisitor = v
|
||||
t.lastAccess = time.Now()
|
||||
}
|
||||
|
||||
func (t *topic) RateVisitor() *visitor {
|
||||
@@ -96,15 +115,23 @@ func (t *topic) Publish(v *visitor, m *message) error {
|
||||
} else {
|
||||
logvm(v, m).Tag(tagPublish).Trace("No stream or WebSocket subscribers, not forwarding")
|
||||
}
|
||||
t.Keepalive()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubscribersCount returns the number of subscribers to this topic
|
||||
func (t *topic) SubscribersCount() int {
|
||||
// Stats returns the number of subscribers and last access to this topic
|
||||
func (t *topic) Stats() (int, time.Time) {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return len(t.subscribers)
|
||||
return len(t.subscribers), t.lastAccess
|
||||
}
|
||||
|
||||
// Keepalive sets the last access time and ensures that Stale does not return true
|
||||
func (t *topic) Keepalive() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.lastAccess = time.Now()
|
||||
}
|
||||
|
||||
// CancelSubscribers calls the cancel function for all subscribers, forcing
|
||||
@@ -131,6 +158,7 @@ func (t *topic) Context() log.Context {
|
||||
fields := map[string]any{
|
||||
"topic": t.ID,
|
||||
"topic_subscribers": len(t.subscribers),
|
||||
"topic_last_access": util.FormatTime(t.lastAccess),
|
||||
}
|
||||
if t.rateVisitor != nil {
|
||||
for k, v := range t.rateVisitor.Context() {
|
||||
|
||||
41
server/topic_test.go
Normal file
41
server/topic_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTopic_CancelSubscribers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
subFn := func(v *visitor, msg *message) error {
|
||||
return nil
|
||||
}
|
||||
canceled1 := atomic.Bool{}
|
||||
cancelFn1 := func() {
|
||||
canceled1.Store(true)
|
||||
}
|
||||
canceled2 := atomic.Bool{}
|
||||
cancelFn2 := func() {
|
||||
canceled2.Store(true)
|
||||
}
|
||||
to := newTopic("mytopic")
|
||||
to.Subscribe(subFn, "", cancelFn1)
|
||||
to.Subscribe(subFn, "u_phil", cancelFn2)
|
||||
|
||||
to.CancelSubscribers("u_phil")
|
||||
require.True(t, canceled1.Load())
|
||||
require.False(t, canceled2.Load())
|
||||
}
|
||||
|
||||
func TestTopic_Keepalive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
to := newTopic("mytopic")
|
||||
to.lastAccess = time.Now().Add(-1 * time.Hour)
|
||||
to.Keepalive()
|
||||
require.True(t, to.LastAccess().Unix() >= time.Now().Unix()-2)
|
||||
require.True(t, to.LastAccess().Unix() <= time.Now().Unix()+2)
|
||||
}
|
||||
@@ -341,6 +341,7 @@ type apiConfigResponse struct {
|
||||
EnableSignup bool `json:"enable_signup"`
|
||||
EnablePayments bool `json:"enable_payments"`
|
||||
EnableReservations bool `json:"enable_reservations"`
|
||||
BillingContact string `json:"billing_contact"`
|
||||
DisallowedTopics []string `json:"disallowed_topics"`
|
||||
}
|
||||
|
||||
|
||||
@@ -107,10 +107,10 @@ func withContext(r *http.Request, ctx map[contextKey]any) *http.Request {
|
||||
return r.WithContext(c)
|
||||
}
|
||||
|
||||
func fromContext[T any](r *http.Request, key contextKey) *T {
|
||||
t, ok := r.Context().Value(key).(*T)
|
||||
func fromContext[T any](r *http.Request, key contextKey) (T, error) {
|
||||
t, ok := r.Context().Value(key).(T)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("cannot find key %v in request context", key))
|
||||
return t, fmt.Errorf("cannot find key %v in request context", key)
|
||||
}
|
||||
return t
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ func (v *visitor) contextNoLock() log.Context {
|
||||
fields := log.Context{
|
||||
"visitor_id": visitorID(v.ip, v.user),
|
||||
"visitor_ip": v.ip.String(),
|
||||
"visitor_seen": util.FormatTime(v.seen),
|
||||
"visitor_messages": info.Stats.Messages,
|
||||
"visitor_messages_limit": info.Limits.MessageLimit,
|
||||
"visitor_messages_remaining": info.Stats.MessagesRemaining,
|
||||
|
||||
69
tools/loadgen/main.go
Normal file
69
tools/loadgen/main.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
baseURL := "https://staging.ntfy.sh"
|
||||
if len(os.Args) > 1 {
|
||||
baseURL = os.Args[1]
|
||||
}
|
||||
for i := 0; i < 2000; i++ {
|
||||
go subscribe(i, baseURL)
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
for i := 0; i < 2000; i++ {
|
||||
go func(worker int) {
|
||||
for {
|
||||
poll(worker, baseURL)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
time.Sleep(time.Hour)
|
||||
}
|
||||
|
||||
func subscribe(worker int, baseURL string) {
|
||||
fmt.Printf("[subscribe] worker=%d STARTING\n", worker)
|
||||
start := time.Now()
|
||||
topic, ip := fmt.Sprintf("subtopic%d", worker), fmt.Sprintf("1.2.%d.%d", (worker/255)%255, worker%255)
|
||||
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/%s/json", baseURL, topic), nil)
|
||||
req.Header.Set("X-Forwarded-For", ip)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("[subscribe] worker=%d time=%d error=%s\n", worker, time.Since(start).Milliseconds(), err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
// Do nothing
|
||||
}
|
||||
fmt.Printf("[subscribe] worker=%d status=%d time=%d EXITED\n", worker, resp.StatusCode, time.Since(start).Milliseconds())
|
||||
}
|
||||
|
||||
func poll(worker int, baseURL string) {
|
||||
fmt.Printf("[poll] worker=%d STARTING\n", worker)
|
||||
topic, ip := fmt.Sprintf("polltopic%d", worker), fmt.Sprintf("1.2.%d.%d", (worker/255)%255, worker%255)
|
||||
start := time.Now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
|
||||
defer cancel()
|
||||
|
||||
//req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://staging.ntfy.sh/%s/json?poll=1&since=all", topic), nil)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/%s/json?poll=1&since=all", baseURL, topic), nil)
|
||||
req.Header.Set("X-Forwarded-For", ip)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("[poll] worker=%d time=%d status=- error=%s\n", worker, time.Since(start).Milliseconds(), err.Error())
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
fmt.Printf("[poll] worker=%d time=%d status=%s\n", worker, time.Since(start).Milliseconds(), resp.Status)
|
||||
}
|
||||
@@ -169,6 +169,7 @@ const (
|
||||
ELSE 2
|
||||
END, user
|
||||
`
|
||||
selectUserCountQuery = `SELECT COUNT(*) FROM user`
|
||||
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
|
||||
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
|
||||
updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?`
|
||||
@@ -853,6 +854,23 @@ func (a *Manager) Users() ([]*User, error) {
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// UsersCount returns the number of users in the databsae
|
||||
func (a *Manager) UsersCount() (int64, error) {
|
||||
rows, err := a.db.Query(selectUserCountQuery)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return 0, errNoRows
|
||||
}
|
||||
var count int64
|
||||
if err := rows.Scan(&count); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// User returns the user with the given username if it exists, or ErrUserNotFound otherwise.
|
||||
// You may also pass Everyone to retrieve the anonymous user and its Grant list.
|
||||
func (a *Manager) User(username string) (*User, error) {
|
||||
|
||||
@@ -133,29 +133,6 @@ func TestManager_AddUser_And_Query(t *testing.T) {
|
||||
require.Equal(t, u.ID, u3.ID)
|
||||
}
|
||||
|
||||
func TestManager_Authenticate_Timing(t *testing.T) {
|
||||
a := newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", PermissionDenyAll, DefaultUserPasswordBcryptCost, DefaultUserStatsQueueWriterInterval)
|
||||
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
|
||||
|
||||
// Timing a correct attempt
|
||||
start := time.Now().UnixMilli()
|
||||
_, err := a.Authenticate("user", "pass")
|
||||
require.Nil(t, err)
|
||||
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||
|
||||
// Timing an incorrect attempt
|
||||
start = time.Now().UnixMilli()
|
||||
_, err = a.Authenticate("user", "INCORRECT")
|
||||
require.Equal(t, ErrUnauthenticated, err)
|
||||
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||
|
||||
// Timing a non-existing user attempt
|
||||
start = time.Now().UnixMilli()
|
||||
_, err = a.Authenticate("DOES-NOT-EXIST", "hithere")
|
||||
require.Equal(t, ErrUnauthenticated, err)
|
||||
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||
}
|
||||
|
||||
func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
|
||||
a := newTestManager(t, PermissionDenyAll)
|
||||
|
||||
|
||||
29
util/time.go
29
util/time.go
@@ -14,6 +14,15 @@ var (
|
||||
durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
|
||||
)
|
||||
|
||||
const (
|
||||
timestampFormat = "2006-01-02T15:04:05.999Z07:00" // Like RFC3339, but with milliseconds
|
||||
)
|
||||
|
||||
// FormatTime formats a time.Time in a RFC339-like format that includes milliseconds
|
||||
func FormatTime(t time.Time) string {
|
||||
return t.Format(timestampFormat)
|
||||
}
|
||||
|
||||
// NextOccurrenceUTC takes a time of day (e.g. 9:00am), and returns the next occurrence
|
||||
// of that time from the current time (in UTC).
|
||||
func NextOccurrenceUTC(timeOfDay, base time.Time) time.Time {
|
||||
@@ -45,15 +54,9 @@ func ParseFutureTime(s string, now time.Time) (time.Time, error) {
|
||||
return time.Time{}, errUnparsableTime
|
||||
}
|
||||
|
||||
func parseFromDuration(s string, now time.Time) (time.Time, error) {
|
||||
d, err := parseDuration(s)
|
||||
if err == nil {
|
||||
return now.Add(d), nil
|
||||
}
|
||||
return time.Time{}, errUnparsableTime
|
||||
}
|
||||
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
// ParseDuration is like time.ParseDuration, except that it also understands days (d), which
|
||||
// translates to 24 hours, e.g. "2d" or "20h".
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
d, err := time.ParseDuration(s)
|
||||
if err == nil {
|
||||
return d, nil
|
||||
@@ -80,6 +83,14 @@ func parseDuration(s string) (time.Duration, error) {
|
||||
return 0, errUnparsableTime
|
||||
}
|
||||
|
||||
func parseFromDuration(s string, now time.Time) (time.Time, error) {
|
||||
d, err := ParseDuration(s)
|
||||
if err == nil {
|
||||
return now.Add(d), nil
|
||||
}
|
||||
return time.Time{}, errUnparsableTime
|
||||
}
|
||||
|
||||
func parseUnixTime(s string, now time.Time) (time.Time, error) {
|
||||
t, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
|
||||
@@ -78,3 +78,17 @@ func TestParseFutureTime_UnixTime(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, time.Date(2021, 12, 11, 0, 51, 51, 0, time.UTC), d)
|
||||
}
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
d, err := ParseDuration("2d")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 48*time.Hour, d)
|
||||
|
||||
d, err = ParseDuration("2h")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2*time.Hour, d)
|
||||
|
||||
d, err = ParseDuration("0")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, time.Duration(0), d)
|
||||
}
|
||||
|
||||
850
web/package-lock.json
generated
850
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,12 @@
|
||||
// During web development, you may change values here for rapid testing.
|
||||
|
||||
var config = {
|
||||
base_url: "https://127.0.0.1", // to test against a different server
|
||||
base_url: window.location.origin, // Change to test against a different server
|
||||
app_root: "/app",
|
||||
enable_login: true,
|
||||
enable_signup: true,
|
||||
enable_payments: true,
|
||||
enable_reservations: true,
|
||||
billing_contact: "",
|
||||
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"]
|
||||
};
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: local(''),
|
||||
url('../fonts/roboto-v29-latin-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../fonts/roboto-v29-latin-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
url('../fonts/roboto-v29-latin-300.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-regular - latin */
|
||||
@@ -16,8 +15,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local(''),
|
||||
url('../fonts/roboto-v29-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../fonts/roboto-v29-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
url('../fonts/roboto-v29-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-500 - latin */
|
||||
@@ -26,8 +24,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: local(''),
|
||||
url('../fonts/roboto-v29-latin-500.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../fonts/roboto-v29-latin-500.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
url('../fonts/roboto-v29-latin-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* roboto-700 - latin */
|
||||
@@ -36,6 +33,5 @@
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: local(''),
|
||||
url('../fonts/roboto-v29-latin-700.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../fonts/roboto-v29-latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
url('../fonts/roboto-v29-latin-700.woff2') format('woff2');
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -39,7 +39,7 @@
|
||||
"message_bar_type_message": "اكتب رسالة هنا",
|
||||
"alert_not_supported_title": "الإشعارات غير مدعومة",
|
||||
"alert_not_supported_description": "الإشعارات غير مدعومة في متصفحك.",
|
||||
"message_bar_error_publishing": "خطأ أثناء نشر الإشعار",
|
||||
"message_bar_error_publishing": "خطأ خلال نشر الإشعار",
|
||||
"notifications_delete": "حذف",
|
||||
"notifications_copied_to_clipboard": "تم نسخه إلى الحافظة",
|
||||
"action_bar_toggle_mute": "كتم / إلغاء كتم الإشعارات",
|
||||
@@ -56,12 +56,12 @@
|
||||
"publish_dialog_title_topic": "أنشُر إلى {{topic}}",
|
||||
"publish_dialog_title_no_topic": "انشُر الإشعار",
|
||||
"publish_dialog_emoji_picker_show": "اختر رمزًا تعبيريًا",
|
||||
"publish_dialog_priority_min": "الحد الأدنى للأولوية",
|
||||
"publish_dialog_priority_min": "أولوية دنيا",
|
||||
"publish_dialog_priority_low": "أولوية منخفضة",
|
||||
"publish_dialog_priority_default": "الأولوية الافتراضية",
|
||||
"publish_dialog_priority_high": "أولوية عالية",
|
||||
"publish_dialog_base_url_label": "الرابط التشعبي للخدمة",
|
||||
"publish_dialog_priority_max": "الأولوية القصوى",
|
||||
"publish_dialog_priority_max": "أولوية قصوى",
|
||||
"publish_dialog_topic_placeholder": "اسم الموضوع، على سبيل المثال phil_alerts",
|
||||
"publish_dialog_title_label": "العنوان",
|
||||
"publish_dialog_title_placeholder": "عنوان الإشعار، على سبيل المثال تنبيه مساحة القرص",
|
||||
@@ -154,7 +154,7 @@
|
||||
"subscribe_dialog_subscribe_button_cancel": "إلغاء",
|
||||
"subscribe_dialog_login_button_back": "العودة",
|
||||
"prefs_notifications_sound_play": "تشغيل الصوت المحدد",
|
||||
"prefs_notifications_min_priority_title": "الحد الأدنى للأولوية",
|
||||
"prefs_notifications_min_priority_title": "أولوية دنيا",
|
||||
"prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط",
|
||||
"notifications_no_subscriptions_description": "انقر فوق الرابط \"{{linktext}}\" لإنشاء موضوع أو الاشتراك فيه. بعد ذلك، يمكنك إرسال رسائل عبر PUT أو POST وستتلقى إشعارات هنا.",
|
||||
"publish_dialog_click_label": "الرابط التشعبي URL للنقر",
|
||||
@@ -214,8 +214,8 @@
|
||||
"account_delete_description": "احذف حسابك نهائيا",
|
||||
"account_delete_dialog_label": "كلمة المرور",
|
||||
"account_upgrade_dialog_title": "تغيير فئة الحساب",
|
||||
"account_upgrade_dialog_tier_features_messages": "{{messages}} رسائل يومية",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{emails}} من رسائل البريد الإلكتروني اليومية",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} رسائل يومية",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} من رسائل البريد الإلكتروني اليومية",
|
||||
"account_upgrade_dialog_button_cancel": "إلغاء",
|
||||
"account_upgrade_dialog_button_pay_now": "ادفع الآن واشترك",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "إلغاء الاشتراك",
|
||||
@@ -277,5 +277,57 @@
|
||||
"prefs_reservations_table_click_to_subscribe": "انقر للاشتراك",
|
||||
"reservation_delete_dialog_action_keep_title": "الاحتفاظ بالرسائل والمرفقات المخزنة مؤقتًا",
|
||||
"action_bar_reservation_delete": "إزالة الحجز",
|
||||
"display_name_dialog_description": "قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر."
|
||||
"display_name_dialog_description": "قم بتعيين اسم بديل للموضوع المعروض في قائمة الاشتراك. يساعد هذا في تحديد الموضوعات ذات الأسماء المعقدة بسهولة أكبر.",
|
||||
"prefs_users_description": "إضافة / إزالة المستخدمين لمواضيعك المحمية هنا. يرجى الأخذ بعين الاعتبار أنه يتم تخزين اسم المستخدم وكلمة المرور في التخزين المحلي للمتصفح.",
|
||||
"notifications_more_details": "لمزيد من المعلومات، الرجاء الاطّلاع على <websiteLink>موقع الويب</websiteLink> أو على <docsLink>الدليل</docsLink>.",
|
||||
"publish_dialog_details_examples_description": "للحصول على أمثلة ووصف مُفصّل لجميع ميزات الإرسال، يرجى الاستناد إلى <docsLink>الدليل</docsLink>.",
|
||||
"subscribe_dialog_subscribe_description": "قد لا تكون الموضوعات محمية بكلمة سر لذا اختر اسمًا ليس من السهل تخمينه وبمجرد اشتراكك، يمكنك الحصول على إشعارات عبر \"PUT/POST\".",
|
||||
"prefs_notifications_sound_description_some": "تقوم الإشعارات بتشغيل صوت {{sound}} عند وصولها",
|
||||
"notifications_none_for_topic_description": "لإرسال إشعارات إلى هذا الموضوع، ما عليك سوى PUT أو POST إلى عنوان URL الخاص بالموضوع.",
|
||||
"priority_low": "منخفضة",
|
||||
"signup_form_toggle_password_visibility": "تبديل رؤية كلمة المرور",
|
||||
"account_usage_limits_reset_daily": "يعاد تحديد حدود الاستخدام يوميا في منتصف الليل (UTC)",
|
||||
"account_tokens_table_label_header": "المُلصَقة",
|
||||
"account_upgrade_dialog_button_redirect_signup": "تسجيل فوري",
|
||||
"account_upgrade_dialog_tier_current_label": "الحالي",
|
||||
"account_tokens_dialog_expires_x_days": "تنتهي صلاحية الرمز المميز في غضون {{days}} أيام",
|
||||
"prefs_reservations_dialog_title_add": "حجز موضوع",
|
||||
"prefs_reservations_description": "يمكنك حجز أسماء الموضوعات للاستخدام الشخصي هنا. يمنحك حجز موضوع ما ملكية الموضوع، ويسمح لك بتحديد تصريحات الوصول للمستخدمين الآخرين إلى الموضوع.",
|
||||
"prefs_users_description_no_sync": "لا تتم مزامنة المستخدمين وكلمات المرور مع حسابك.",
|
||||
"reservation_delete_dialog_action_delete_description": "سيتم حذف الرسائل والمرفقات المخزنة مؤقتا نهائيا. لا يمكن التراجع عن هذا الإجراء.",
|
||||
"notifications_actions_http_request_title": "إرسال طلب HTTP {{method}} إلى {{url}}",
|
||||
"notifications_none_for_any_description": "لإرسال إشعارات إلى موضوع ما، ما عليك سوى إرسال طلب PUT أو POST إلى الرابط التشعبي URL للموضوع. إليك مثال باستخدام أحد مواضيعك.",
|
||||
"error_boundary_description": "من الواضح أن هذا لا ينبغي أن يحدث. آسف جدًا بشأن هذا. <br/> إن كان لديك دقيقة، يرجى <githubLink> الإبلاغ عن ذلك على GitHub </githubLink> ، أو إعلامنا عبر <discordLink> Discord </discordLink> أو <matrixLink> Matrix </matrixLink>.",
|
||||
"nav_button_muted": "الإشعارات المكتومة",
|
||||
"priority_min": "دنيا",
|
||||
"signup_error_username_taken": "تم حجز اسم المستخدم {{username}} مِن قَبلُ",
|
||||
"action_bar_reservation_limit_reached": "بلغت الحد الأقصى",
|
||||
"prefs_reservations_delete_button": "إعادة تعيين الوصول إلى الموضوع",
|
||||
"prefs_reservations_edit_button": "تعديل الوصول إلى موضوع",
|
||||
"prefs_reservations_limit_reached": "لقد بلغت الحد الأقصى من المواضيع المحجوزة.",
|
||||
"reservation_delete_dialog_action_keep_description": "ستصبح الرسائل والمرفقات المخزنة مؤقتًا على الخادم مرئية للعموم وللأشخاص الذين لديهم معرفة باسم الموضوع.",
|
||||
"reservation_delete_dialog_description": "تؤدي إزالة الحجز إلى التخلي عن ملكية الموضوع، مما يسمح للآخرين بحجزه. يمكنك الاحتفاظ بالرسائل والمرفقات الموجودة أو حذفها.",
|
||||
"prefs_reservations_dialog_description": "يمنحك حجز موضوع ما ملكية الموضوع، ويسمح لك بتحديد تصريحات وصول المستخدمين الآخرين إليه.",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "توفير ما يصل إلى {{discount}}٪",
|
||||
"account_upgrade_dialog_interval_monthly": "شهريا",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "إجمالي مساحة التخزين {{totalsize}}",
|
||||
"publish_dialog_progress_uploading_detail": "تحميل {{loaded}}/{{total}} ({{percent}}٪) …",
|
||||
"account_basics_tier_interval_monthly": "شهريا",
|
||||
"account_basics_tier_interval_yearly": "سنويا",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} مواضيع محجوزة",
|
||||
"account_upgrade_dialog_billing_contact_website": "للأسئلة المتعلقة بالفوترة، يرجى الرجوع إلى <Link>موقعنا على الويب</Link>.",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "إظهار الإشعارات إذا كانت الأولوية {{number}} ({{name}}) أو أعلى",
|
||||
"account_upgrade_dialog_billing_contact_email": "للأسئلة المتعلقة بالفوترة، الرجاء <Link>الاتصال بنا</Link> مباشرة.",
|
||||
"account_upgrade_dialog_tier_selected_label": "المحدد",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} لكل ملف",
|
||||
"account_upgrade_dialog_interval_yearly": "سنويا",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "لا توجد مواضيع محجوزة",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "وفر {{discount}}٪",
|
||||
"publish_dialog_click_reset": "إزالة الرابط التشعبي URL للنقر",
|
||||
"prefs_notifications_min_priority_description_max": "إظهار الإشعارات إذا كانت الأولوية 5 (كحد أقصى)",
|
||||
"publish_dialog_attachment_limits_file_reached": "يتجاوز الحد الأقصى للملف {{fileSizeLimit}}",
|
||||
"publish_dialog_attachment_limits_quota_reached": "يتجاوز الحصة، {{remainingBytes}} متبقية",
|
||||
"account_basics_tier_paid_until": "تم دفع مبلغ الاشتراك إلى غاية {{date}}، وسيتم تجديده تِلْقائيًا",
|
||||
"account_basics_tier_canceled_subscription": "تم إلغاء اشتراكك وسيتم إعادته إلى مستوى حساب مجاني بداية مِن {{date}}.",
|
||||
"account_delete_dialog_billing_warning": "إلغاء حسابك أيضاً يلغي اشتراكك في الفوترة فوراً ولن تتمكن من الوصول إلى لوح الفوترة بعد الآن."
|
||||
}
|
||||
|
||||
@@ -217,5 +217,66 @@
|
||||
"action_bar_reservation_edit": "Промяна на резервацията",
|
||||
"action_bar_sign_up": "Регистриране",
|
||||
"account_basics_title": "Профил",
|
||||
"alert_not_supported_context_description": "Известията се поддържат само през HTTPS. Това е ограничение на <mdnLink>Notifications API</mdnLink>."
|
||||
"alert_not_supported_context_description": "Известията се поддържат само през HTTPS. Това е ограничение на <mdnLink>Notifications API</mdnLink>.",
|
||||
"display_name_dialog_description": "Изберете друго име за темата, което да се показва в списъка с абонаменти. Помага за по-лесното разпознаване на теми със сложни имена.",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Темата вече е резервирана",
|
||||
"nav_upgrade_banner_description": "Резервиране на теми, повече съобщения и имейли и по-големи прикачени файлове",
|
||||
"display_name_dialog_placeholder": "Наименование",
|
||||
"reserve_dialog_checkbox_label": "Резервиране на тема и настройки за достъп",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Произволно име",
|
||||
"account_basics_username_title": "Потребител",
|
||||
"account_basics_username_description": "Хей, това сте вие ❤",
|
||||
"account_basics_username_admin_tooltip": "Вие сте администратор",
|
||||
"account_basics_password_title": "Парола",
|
||||
"account_delete_dialog_label": "Парола",
|
||||
"account_basics_password_dialog_title": "Смяна на парола",
|
||||
"account_basics_password_dialog_current_password_label": "Текуща парола",
|
||||
"account_basics_password_dialog_new_password_label": "Нова парола",
|
||||
"account_basics_password_dialog_confirm_password_label": "Парола отново",
|
||||
"account_basics_password_dialog_button_submit": "Смяна на парола",
|
||||
"account_usage_title": "Употреба",
|
||||
"account_usage_of_limit": "от {{limit}}",
|
||||
"account_usage_unlimited": "Неограничено",
|
||||
"account_usage_limits_reset_daily": "Ограниченията се нулират всеки ден в полунощ (UTC)",
|
||||
"account_basics_tier_interval_monthly": "месечно",
|
||||
"account_basics_tier_interval_yearly": "годишно",
|
||||
"account_basics_password_description": "Промяна на паролата на профила",
|
||||
"account_basics_tier_title": "Вид на профила",
|
||||
"account_basics_tier_admin": "Администратор",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(с {{tier}} ниво)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(без ниво)",
|
||||
"account_basics_tier_free": "безплатен",
|
||||
"account_basics_tier_basic": "базов",
|
||||
"account_basics_tier_change_button": "Променяне",
|
||||
"account_basics_tier_paid_until": "Абонаментът е платен до {{date}} и автоматично ще се поднови",
|
||||
"account_usage_attachment_storage_title": "Хранилище за прикачени файлове",
|
||||
"account_delete_dialog_button_cancel": "Отказ",
|
||||
"account_upgrade_dialog_interval_monthly": "Месечно",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} резервирани теми",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "Няма резервирани теми",
|
||||
"account_tokens_dialog_button_cancel": "Отказ",
|
||||
"account_delete_title": "Премахване на профила",
|
||||
"account_upgrade_dialog_title": "Промяна нивото на профила",
|
||||
"account_usage_emails_title": "Изпратени съобщения",
|
||||
"account_usage_reservations_title": "Резервирани теми",
|
||||
"account_usage_reservations_none": "Няма резервирани теми",
|
||||
"account_usage_cannot_create_portal_session": "Порталът за разплащане не може да бъде отворен",
|
||||
"account_upgrade_dialog_interval_yearly": "Годишно",
|
||||
"account_delete_description": "Безвъзвратно премахване на профила",
|
||||
"account_delete_dialog_button_submit": "Безвъзвратно премахване на профила",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "отстъпка {{discount}}%",
|
||||
"account_upgrade_dialog_button_cancel": "Отказ",
|
||||
"account_upgrade_dialog_button_redirect_signup": "Регистриране",
|
||||
"account_tokens_table_label_header": "Етикет",
|
||||
"prefs_reservations_edit_button": "Настройки на достъпа",
|
||||
"prefs_reservations_table_topic_header": "Тема",
|
||||
"prefs_reservations_table_access_header": "Достъп",
|
||||
"prefs_reservations_dialog_topic_label": "Тема",
|
||||
"prefs_reservations_dialog_access_label": "Достъп",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Грешна парола",
|
||||
"account_basics_tier_description": "Ниво на профила",
|
||||
"account_basics_tier_upgrade_button": "Надграждане до Pro",
|
||||
"account_usage_messages_title": "Публикувани съобщения",
|
||||
"account_tokens_table_last_access_header": "Последен достъп",
|
||||
"account_basics_tier_payment_overdue": "Имате просрочено задължение. Обновете начина на плащане, защото в противен случай скоро профилът ви ще загуби предимствата на абонамента."
|
||||
}
|
||||
|
||||
@@ -285,11 +285,11 @@
|
||||
"account_delete_dialog_button_submit": "Trvale odstranit účet",
|
||||
"account_delete_dialog_billing_warning": "Odstraněním účtu se také okamžitě zruší vaše předplatné. Nebudete již mít přístup k fakturačnímu panelu.",
|
||||
"account_upgrade_dialog_title": "Změna úrovně účtu",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Prohlášení</strong>: Při přechodu mezi placenými úrovněmi bude rozdíl v ceně účtován nebo vrácen v následující faktuře. Další fakturu obdržíte až na konci dalšího zúčtovacího období.",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Prohlášení</strong>: Při přechodu mezi placenými úrovněmi bude rozdíl v ceně <strong>zaúčtován okamžitě</strong>. Při přechodu na nižší úroveň se zůstatek použije na platbu za budoucí zúčtovací období.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Vybraná úroveň umožňuje méně rezervovaných témat než vaše aktuální úroveň. Než změníte svou úroveň, <strong>odstraňte alespoň jednu rezervaci</strong>. Rezervace můžete odstranit v <Link>Nastavení</Link>.",
|
||||
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} rezervovaných témat",
|
||||
"account_upgrade_dialog_tier_features_messages": "{{messages}} denních zpráv",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{emails}} denních e-mailů",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} rezervovaných témat",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} denních zpráv",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} denních e-mailů",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} na soubor",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} celkový úložný prostor",
|
||||
"account_upgrade_dialog_tier_selected_label": "Vybráno",
|
||||
@@ -340,5 +340,17 @@
|
||||
"reservation_delete_dialog_action_keep_description": "Zprávy a přílohy, které jsou uloženy v mezipaměti serveru, se stanou veřejně viditelnými pro osoby, které znají název tématu.",
|
||||
"reservation_delete_dialog_action_delete_title": "Odstranění zpráv a příloh uložených v mezipaměti",
|
||||
"reservation_delete_dialog_action_delete_description": "Zprávy a přílohy uložené v mezipaměti budou trvale odstraněny. Tuto akci nelze vrátit zpět.",
|
||||
"reservation_delete_dialog_submit_button": "Odstranit rezervaci"
|
||||
"reservation_delete_dialog_submit_button": "Odstranit rezervaci",
|
||||
"account_basics_tier_interval_yearly": "roční",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "ušetříte {{discount}}%",
|
||||
"account_upgrade_dialog_tier_price_per_month": "měsíc",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "Žádná rezervovaná témata",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "ušetříte až {{discount}}%",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} účtováno ročně. Ušetříte {{save}}.",
|
||||
"account_basics_tier_interval_monthly": "měsíční",
|
||||
"account_upgrade_dialog_interval_monthly": "Měsíční",
|
||||
"account_upgrade_dialog_interval_yearly": "Roční",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} za rok. Účtuje se měsíčně.",
|
||||
"account_upgrade_dialog_billing_contact_email": "V případě dotazů týkajících se fakturace nás prosím <Link>kontaktujte</Link> přímo.",
|
||||
"account_upgrade_dialog_billing_contact_website": "Otázky týkající se fakturace naleznete na našich <Link>webových stránkách</Link>."
|
||||
}
|
||||
|
||||
283
web/public/static/langs/da.json
Normal file
283
web/public/static/langs/da.json
Normal file
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"common_save": "Gem",
|
||||
"common_add": "Tilføj",
|
||||
"signup_title": "Opret en ntfy konto",
|
||||
"signup_form_username": "Brugernavn",
|
||||
"signup_form_password": "Kodeord",
|
||||
"signup_form_confirm_password": "Bekræft kodeord",
|
||||
"common_cancel": "Annuller",
|
||||
"action_bar_account": "Konto",
|
||||
"signup_error_username_taken": "Brugernavnet {{username}} er optaget",
|
||||
"login_form_button_submit": "Log ind",
|
||||
"action_bar_show_menu": "Vis menu",
|
||||
"action_bar_logo_alt": "ntfy logo",
|
||||
"action_bar_settings": "Indstillinger",
|
||||
"signup_form_button_submit": "Opret konto",
|
||||
"signup_form_toggle_password_visibility": "Skift synlighed af adgangskode",
|
||||
"signup_disabled": "Tilmelding er deaktiveret",
|
||||
"signup_error_creation_limit_reached": "Grænsen for kontooprettelse er nået",
|
||||
"login_title": "Log ind på din ntfy konto",
|
||||
"login_link_signup": "Opret konto",
|
||||
"login_disabled": "Login er deaktiveret",
|
||||
"action_bar_reservation_add": "Reserver emne",
|
||||
"action_bar_reservation_edit": "Rediger reservation",
|
||||
"action_bar_reservation_delete": "Fjern reservation",
|
||||
"action_bar_reservation_limit_reached": "Grænsen er nået",
|
||||
"action_bar_send_test_notification": "Send test notifikation",
|
||||
"action_bar_unsubscribe": "Afmeld",
|
||||
"action_bar_toggle_mute": "Slå lyden fra/til for notifikationer",
|
||||
"action_bar_change_display_name": "Skift visningsnavn",
|
||||
"action_bar_toggle_action_menu": "Åben/luk handlings menu",
|
||||
"action_bar_profile_title": "Profil",
|
||||
"action_bar_profile_settings": "Indstillinger",
|
||||
"action_bar_profile_logout": "Log ud",
|
||||
"action_bar_sign_in": "Log ind",
|
||||
"action_bar_sign_up": "Opret konto",
|
||||
"message_bar_type_message": "Skriv en besked her",
|
||||
"nav_button_settings": "Indstillinger",
|
||||
"message_bar_publish": "Offentliggør besked",
|
||||
"nav_topics_title": "Tilmeldte emner",
|
||||
"nav_button_all_notifications": "Alle notifikationer",
|
||||
"nav_button_connecting": "forbinder",
|
||||
"nav_upgrade_banner_label": "Opgrader til ntfy Pro",
|
||||
"alert_grant_title": "Notifikationer er deaktiveret",
|
||||
"alert_grant_description": "Giv din browser tilladelse til at vise skrivebordsnotifikationer.",
|
||||
"alert_not_supported_title": "Notifikationer understøttes ikke",
|
||||
"alert_not_supported_description": "Notifikationer understøttes ikke i din browser.",
|
||||
"alert_not_supported_context_description": "Notifikationer understøttes kun via HTTPS. Dette skyldes en begrænsning i <mdnLink>Notifications API</mdnLink>.",
|
||||
"nav_button_subscribe": "Abonner på emne",
|
||||
"notifications_list_item": "Notifikation",
|
||||
"notifications_delete": "Slet",
|
||||
"notifications_tags": "Tags",
|
||||
"notifications_list": "Notifikationsliste",
|
||||
"notifications_mark_read": "Marker som læst",
|
||||
"notifications_copied_to_clipboard": "Kopieret til udklipsholder",
|
||||
"notifications_priority_x": "Prioritet {{priority}}",
|
||||
"notifications_attachment_copy_url_title": "Kopier URL-adresse til vedhæftet fil til udklipsholder",
|
||||
"notifications_attachment_copy_url_button": "Kopier URL",
|
||||
"notifications_attachment_open_title": "Gå til {{url}}",
|
||||
"notifications_attachment_open_button": "Åben vedhæftning",
|
||||
"notifications_attachment_link_expires": "link udløber {{date}}",
|
||||
"notifications_attachment_link_expired": "download link er udløbet",
|
||||
"notifications_attachment_file_image": "billedfil",
|
||||
"notifications_attachment_file_app": "Android app fil",
|
||||
"notifications_attachment_file_document": "andet dokument",
|
||||
"notifications_click_copy_url_title": "Kopier linkets URL til udklipsholderen",
|
||||
"notifications_click_copy_url_button": "Kopier link",
|
||||
"notifications_example": "Eksempel",
|
||||
"notifications_click_open_button": "Åbn link",
|
||||
"notifications_actions_not_supported": "Handlingen understøttes ikke i webappen",
|
||||
"notifications_actions_http_request_title": "Send HTTP {{method}} til {{url}}",
|
||||
"notifications_none_for_topic_title": "Du har ikke modtaget nogen notifikationer om dette emne endnu.",
|
||||
"notifications_none_for_any_title": "Du har ikke modtaget nogen notifikationer.",
|
||||
"display_name_dialog_placeholder": "Vist navn",
|
||||
"publish_dialog_progress_uploading": "Uploader…",
|
||||
"display_name_dialog_title": "Skift visningsnavn",
|
||||
"publish_dialog_progress_uploading_detail": "Uploader {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"publish_dialog_emoji_picker_show": "Vælg emoji",
|
||||
"publish_dialog_priority_min": "Min. prioritet",
|
||||
"publish_dialog_priority_low": "Lav prioritet",
|
||||
"publish_dialog_priority_default": "Standardprioritet",
|
||||
"publish_dialog_priority_high": "Høj prioritet",
|
||||
"publish_dialog_title_label": "Titel",
|
||||
"publish_dialog_message_label": "Besked",
|
||||
"publish_dialog_tags_label": "Tags",
|
||||
"publish_dialog_priority_label": "Prioritet",
|
||||
"publish_dialog_message_placeholder": "Skriv en besked her",
|
||||
"publish_dialog_tags_placeholder": "Komma-separeret liste over tags, f.eks. warning, srv1-backup",
|
||||
"publish_dialog_click_label": "Klik på URL",
|
||||
"publish_dialog_email_reset": "Fjern videresendelse af e-mail",
|
||||
"publish_dialog_attach_placeholder": "Vedhæft fil via URL, f.eks. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_delay_label": "Forsinkelse",
|
||||
"publish_dialog_button_send": "Send",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "Tilmeld",
|
||||
"subscribe_dialog_login_button_back": "Tilbage",
|
||||
"subscribe_dialog_login_username_label": "Brugernavn, f.eks. phil",
|
||||
"account_basics_title": "Konto",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Emnet er allerede reserveret",
|
||||
"account_basics_username_admin_tooltip": "Du er Admin",
|
||||
"account_basics_password_dialog_confirm_password_label": "Bekræft kodeord",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Forkert kodeord",
|
||||
"account_usage_of_limit": "af {{limit}}",
|
||||
"account_basics_tier_basic": "Grundlæggende",
|
||||
"account_basics_tier_free": "Gratis",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(intet niveau)",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(med {{tier}}} niveau)",
|
||||
"account_usage_messages_title": "Offentliggjorte meddelelser",
|
||||
"account_delete_dialog_button_submit": "Slet konto permanent",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} pr. fil",
|
||||
"account_upgrade_dialog_button_redirect_signup": "Tilmeld dig nu",
|
||||
"account_tokens_table_expires_header": "Udløber",
|
||||
"account_tokens_table_last_access_header": "Seneste adgang",
|
||||
"account_tokens_delete_dialog_title": "Slet adgangstoken",
|
||||
"prefs_notifications_sound_no_sound": "Ingen lyd",
|
||||
"prefs_notifications_min_priority_title": "Minimumsprioritet",
|
||||
"prefs_notifications_sound_play": "Afspil den valgte lyd",
|
||||
"prefs_notifications_min_priority_max_only": "Kun maks. prioritet",
|
||||
"prefs_notifications_delete_after_three_hours": "Efter tre timer",
|
||||
"prefs_users_add_button": "Tilføj bruger",
|
||||
"prefs_users_dialog_title_edit": "Rediger bruger",
|
||||
"prefs_reservations_title": "Reserverede emner",
|
||||
"prefs_reservations_add_button": "Tilføj reserveret emne",
|
||||
"prefs_reservations_table_access_header": "Adgang",
|
||||
"prefs_reservations_delete_button": "Nulstil emneadgang",
|
||||
"prefs_reservations_dialog_title_edit": "Rediger reserveret emne",
|
||||
"prefs_reservations_dialog_access_label": "Adgang",
|
||||
"prefs_reservations_dialog_title_delete": "Slet emnereservation",
|
||||
"priority_low": "lav",
|
||||
"priority_min": "min",
|
||||
"reservation_delete_dialog_submit_button": "Slet reservation",
|
||||
"priority_high": "høj",
|
||||
"priority_max": "maks",
|
||||
"error_boundary_stack_trace": "Strack trace",
|
||||
"error_boundary_button_copy_stack_trace": "Kopier stack trace",
|
||||
"signup_already_have_account": "Har du allerede en konto? Log ind!",
|
||||
"action_bar_clear_notifications": "Ryd alle notifikationer",
|
||||
"notifications_new_indicator": "Ny notifikation",
|
||||
"notifications_attachment_image": "Vedhæftet billede",
|
||||
"account_delete_dialog_label": "Kodeord",
|
||||
"error_boundary_unsupported_indexeddb_title": "Privat browsing understøttes ikke",
|
||||
"notifications_actions_open_url_title": "Gå til {{url}}",
|
||||
"notifications_attachment_file_audio": "lydfil",
|
||||
"publish_dialog_click_placeholder": "URL der åbnes, når der klikkes på notifikationen",
|
||||
"publish_dialog_email_placeholder": "Adresse, som meddelelsen skal videresendes til, f.eks. phil@example.com",
|
||||
"notifications_attachment_file_video": "videofil",
|
||||
"account_basics_tier_title": "Kontotype",
|
||||
"publish_dialog_filename_label": "Filnavn",
|
||||
"account_basics_tier_manage_billing_button": "Administrer fakturering",
|
||||
"account_usage_emails_title": "Afsendte e-mails",
|
||||
"account_usage_reservations_title": "Reserverede emner",
|
||||
"account_delete_title": "Slet konto",
|
||||
"nav_button_account": "Konto",
|
||||
"nav_button_documentation": "Dokumentation",
|
||||
"publish_dialog_priority_max": "Maks. prioritet",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "Opsig abonnement",
|
||||
"account_upgrade_dialog_button_update_subscription": "Opdater abonnement",
|
||||
"publish_dialog_button_cancel": "Annuller",
|
||||
"publish_dialog_email_label": "Email",
|
||||
"account_tokens_title": "Adgangstokens",
|
||||
"account_tokens_table_never_expires": "Udløber aldrig",
|
||||
"prefs_notifications_sound_title": "Notifikationslyd",
|
||||
"account_tokens_dialog_button_update": "Opdater token",
|
||||
"account_tokens_dialog_button_create": "Opret token",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Annuller",
|
||||
"prefs_users_table_user_header": "Bruger",
|
||||
"prefs_appearance_title": "Udseende",
|
||||
"subscribe_dialog_login_button_login": "Log ind",
|
||||
"subscribe_dialog_login_password_label": "Kodeord",
|
||||
"subscribe_dialog_error_user_anonymous": "anonym",
|
||||
"account_usage_title": "Anvendelse",
|
||||
"account_basics_username_title": "Brugernavn",
|
||||
"account_basics_tier_admin": "Admin",
|
||||
"account_basics_password_title": "Kodeord",
|
||||
"account_upgrade_dialog_tier_selected_label": "Valgt",
|
||||
"account_usage_unlimited": "Ubegrænset",
|
||||
"account_tokens_table_label_header": "Label",
|
||||
"account_tokens_dialog_button_cancel": "Annuller",
|
||||
"account_basics_tier_change_button": "Rediger",
|
||||
"account_delete_dialog_button_cancel": "Annuller",
|
||||
"account_upgrade_dialog_button_cancel": "Annuller",
|
||||
"account_tokens_table_token_header": "Token",
|
||||
"account_upgrade_dialog_tier_current_label": "Nuværende",
|
||||
"prefs_notifications_title": "Notifikationer",
|
||||
"prefs_notifications_delete_after_never": "Aldrig",
|
||||
"prefs_reservations_table_topic_header": "Emne",
|
||||
"prefs_users_dialog_password_label": "Kodeord",
|
||||
"prefs_appearance_language_title": "Sprog",
|
||||
"prefs_reservations_dialog_topic_label": "Emne",
|
||||
"priority_default": "standard",
|
||||
"publish_dialog_attached_file_remove": "Fjern vedhæftet fil",
|
||||
"prefs_users_table": "Bruger tabel",
|
||||
"prefs_users_edit_button": "Rediger bruger",
|
||||
"prefs_users_dialog_title_add": "Tilføj bruger",
|
||||
"prefs_users_delete_button": "Slet bruger",
|
||||
"account_tokens_table_copied_to_clipboard": "Adgangstoken kopieret",
|
||||
"prefs_notifications_min_priority_any": "Enhver prioritet",
|
||||
"prefs_notifications_delete_after_title": "Slet notifikationer",
|
||||
"publish_dialog_delay_reset": "Fjern forsinket levering",
|
||||
"prefs_users_title": "Administrer brugere",
|
||||
"account_basics_password_dialog_button_submit": "Skift kodeord",
|
||||
"prefs_reservations_dialog_title_add": "Reserver emne",
|
||||
"account_basics_password_dialog_current_password_label": "Nuværende kodeord",
|
||||
"account_basics_password_dialog_new_password_label": "Nyt kodeord",
|
||||
"notifications_loading": "Indlæser notifikationer…",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} daglige e-mails",
|
||||
"account_tokens_table_create_token_button": "Opret adgangstoken",
|
||||
"account_tokens_dialog_title_delete": "Slet adgangstoken",
|
||||
"publish_dialog_chip_email_label": "Videresend til e-mail",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} samlet lagerplads",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Brug en anden server",
|
||||
"account_basics_tier_upgrade_button": "Opgrader til Pro",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} daglige beskeder",
|
||||
"account_tokens_table_copy_to_clipboard": "Kopier til udklipsholder",
|
||||
"prefs_reservations_edit_button": "Rediger emneadgang",
|
||||
"account_upgrade_dialog_title": "Skift kontoniveau",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserverede emner",
|
||||
"account_tokens_dialog_expires_never": "Token udløber aldrig",
|
||||
"account_tokens_table_current_session": "Nuværende browsersession",
|
||||
"account_tokens_dialog_title_edit": "Rediger adgangstoken",
|
||||
"account_tokens_dialog_title_create": "Opret adgangstoken",
|
||||
"prefs_notifications_delete_after_one_day": "Efter en dag",
|
||||
"account_tokens_delete_dialog_submit_button": "Slet token permanent",
|
||||
"prefs_notifications_delete_after_one_month": "Efter en måned",
|
||||
"prefs_notifications_delete_after_one_week": "Efter en uge",
|
||||
"prefs_users_dialog_username_label": "Brugernavn, f.eks. phil",
|
||||
"prefs_notifications_delete_after_one_day_description": "Notifikationer slettes automatisk efter en dag",
|
||||
"notifications_none_for_topic_description": "For at sende en notifikation til dette emne, skal du blot sende en PUT eller POST til emne-URL'en.",
|
||||
"notifications_none_for_any_description": "For at sende en notifikation til et emne, skal du blot sende en PUT eller POST til emne-URL'en. Her er et eksempel med et af dine emner.",
|
||||
"notifications_no_subscriptions_title": "Det ser ud til, at du ikke har nogen abonnementer endnu.",
|
||||
"notifications_more_details": "For mere information, se <websiteLink>webstedet</websiteLink> eller <docsLink>dokumentationen</docsLink>.",
|
||||
"display_name_dialog_description": "Angiv et alternativt navn for et emne, der vises på abonnementslisten. Dette gør det nemmere at identificere emner med komplicerede navne.",
|
||||
"reserve_dialog_checkbox_label": "Reserver emne og konfigurer adgang",
|
||||
"publish_dialog_attachment_limits_file_reached": "overskrider {{fileSizeLimit}} filgrænse",
|
||||
"publish_dialog_attachment_limits_quota_reached": "overskrider kvote, {{remainingBytes}} tilbage",
|
||||
"publish_dialog_topic_label": "Emnenavn",
|
||||
"publish_dialog_topic_placeholder": "Emnenavn, f.eks. phil_alerts",
|
||||
"publish_dialog_topic_reset": "Nulstil emne",
|
||||
"publish_dialog_click_reset": "Fjern klik-URL",
|
||||
"publish_dialog_delay_placeholder": "Forsink levering, f.eks. {{unixTimestamp}}, {{relativeTime}} eller \"{{naturalLanguage}}\" (kun på engelsk)",
|
||||
"publish_dialog_other_features": "Andre funktioner:",
|
||||
"publish_dialog_chip_attach_url_label": "Vedhæft fil via URL",
|
||||
"publish_dialog_chip_attach_file_label": "Vedhæft lokal fil",
|
||||
"publish_dialog_details_examples_description": "For eksempler og en detaljeret beskrivelse af alle afsendelsesfunktioner henvises til <docsLink>dokumentationen</docsLink>.",
|
||||
"publish_dialog_button_cancel_sending": "Annuller afsendelse",
|
||||
"publish_dialog_attached_file_title": "Vedhæftet fil:",
|
||||
"emoji_picker_search_placeholder": "Søg emoji",
|
||||
"emoji_picker_search_clear": "Ryd søgning",
|
||||
"subscribe_dialog_subscribe_title": "Abonner på emne",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Emnenavn, f.eks. phil_alerts",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Generer navn",
|
||||
"subscribe_dialog_login_title": "Login påkrævet",
|
||||
"subscribe_dialog_login_description": "Dette emne er adgangskodebeskyttet. Indtast venligst brugernavn og adgangskode for at abonnere.",
|
||||
"subscribe_dialog_error_user_not_authorized": "Brugeren {{username}} er ikke autoriseret",
|
||||
"account_basics_password_description": "Skift adgangskoden til din konto",
|
||||
"account_usage_limits_reset_daily": "Brugsgrænser nulstilles dagligt ved midnat (UTC)",
|
||||
"account_basics_tier_paid_until": "Abonnementet er betalt indtil {{date}} og fornys automatisk",
|
||||
"account_basics_tier_payment_overdue": "Din betaling er forfalden. Opdater venligst din betalingsmetode, ellers bliver din konto snart nedgraderet.",
|
||||
"account_basics_tier_canceled_subscription": "Dit abonnement blev annulleret og vil blive nedgraderet til en gratis konto den {{date}}.",
|
||||
"account_usage_cannot_create_portal_session": "Kan ikke åbne faktureringsportalen",
|
||||
"account_delete_description": "Slet din konto permanent",
|
||||
"account_delete_dialog_description": "Dette vil slette din konto permanent, inklusive alle data, der er gemt på serveren. Efter sletning vil dit brugernavn være utilgængeligt i 7 dage. Hvis du virkelig ønsker at fortsætte, bedes du bekræfte med dit kodeord i feltet nedenfor.",
|
||||
"account_upgrade_dialog_button_pay_now": "Betal nu og abonner",
|
||||
"account_tokens_table_last_origin_tooltip": "Fra IP-adresse {{ip}}, klik for at slå op",
|
||||
"account_tokens_dialog_label": "Label, f.eks. radarmeddelelser",
|
||||
"account_tokens_dialog_expires_label": "Adgangstoken udløber om",
|
||||
"account_tokens_dialog_expires_unchanged": "Lad udløbsdatoen forblive uændret",
|
||||
"account_tokens_dialog_expires_x_hours": "Token udløber om {{hours}} timer",
|
||||
"account_tokens_dialog_expires_x_days": "Token udløber om {{days}} dage",
|
||||
"prefs_notifications_sound_description_none": "Notifikationer afspiller ingen lyd, når de ankommer",
|
||||
"prefs_notifications_sound_description_some": "Notifikationer afspiller {{sound}}-lyden, når de ankommer",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Lav prioritet og højere",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Standardprioritet og højere",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Høj prioritet og højere",
|
||||
"prefs_notifications_delete_after_never_description": "Notifikationer slettes aldrig automatisk",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Notifikationer slettes automatisk efter tre timer",
|
||||
"prefs_notifications_delete_after_one_week_description": "Notifikationer slettes automatisk efter en uge",
|
||||
"prefs_notifications_delete_after_one_month_description": "Notifikationer slettes automatisk efter en måned",
|
||||
"prefs_reservations_limit_reached": "Du har nået din grænse for reserverede emner.",
|
||||
"prefs_reservations_table_click_to_subscribe": "Klik for at abonnere",
|
||||
"reservation_delete_dialog_action_keep_title": "Behold cachelagrede meddelelser og vedhæftede filer",
|
||||
"reservation_delete_dialog_action_delete_title": "Slet cachelagrede meddelelser og vedhæftede filer",
|
||||
"error_boundary_title": "Oh nej, ntfy brød sammen",
|
||||
"error_boundary_description": "Dette bør naturligvis ikke ske. Det beklager vi meget.<br/>Hvis du har et øjeblik, bedes du <githubLink>rapportere dette på GitHub</githubLink>, eller give os besked via <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>."
|
||||
}
|
||||
@@ -82,7 +82,7 @@
|
||||
"publish_dialog_attach_placeholder": "Datei von URL anhängen, z.B. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_filename_placeholder": "Dateiname des Anhangs",
|
||||
"publish_dialog_delay_label": "Verzögerung",
|
||||
"publish_dialog_email_placeholder": "E-Mail-Adresse, an die die Benachrichtigung gesendet werden soll, z. B. phil@example.com",
|
||||
"publish_dialog_email_placeholder": "E-Mail-Adresse, an welche die Benachrichtigung gesendet werden soll, z. B. phil@example.com",
|
||||
"publish_dialog_chip_click_label": "Klick-URL",
|
||||
"publish_dialog_button_cancel_sending": "Senden abbrechen",
|
||||
"publish_dialog_drop_file_here": "Datei hierher ziehen",
|
||||
@@ -261,12 +261,12 @@
|
||||
"account_usage_basis_ip_description": "Nutzungsstatistiken und Limits für diesen Account basieren auf Deiner IP-Adresse, können also mit anderen Usern geteilt sein. Die oben gezeigten Limits sind Schätzungen basierend auf den bestehenden Limits.",
|
||||
"account_delete_dialog_billing_warning": "Das Löschen Deines Kontos storniert auch sofort Deine Zahlung. Du wirst dann keinen Zugang zum Abrechnungs-Dashboard haben.",
|
||||
"account_upgrade_dialog_title": "Konto-Level ändern",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Anrechnung</strong>: Wenn Du zwischen kostenpflichtigen Leveln wechselst wir die Differenz bei der nächsten Abrechnung nachberechnet oder erstattet. Du erhältst bis zum Ende der Abrechnungsperiode keine neue Rechnung.",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Anrechnung</strong>: Wenn Du auf einen höheren kostenpflichtigen Level wechselst wird die Differenz <strong>sofort berechnet</strong>. Beim Wechsel auf ein kleineres Level verwenden wir Dein Guthaben für zukünftige Abrechnungsperioden.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. <strong>Bitte löschen vor dem Wechsel Deines Levels mindestens eine Reservierung</strong>. Du kannst Reservierungen in den <Link>Einstellungen</Link> löschen.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Das gewählte Level erlaubt weniger reservierte Themen als Dein aktueller Level. <strong>Bitte löschen vor dem Wechsel Deines Levels mindestens {{count}} Reservierungen</strong>. Du kannst Reservierungen in den <Link>Einstellungen</Link> löschen.",
|
||||
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} reservierte Themen",
|
||||
"account_upgrade_dialog_tier_features_messages": "{{messages}} Nachrichten pro Tag",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{emails}} Emails pro Tag",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reservierte Themen",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} Nachrichten pro Tag",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} Emails pro Tag",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} pro Datei",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} gesamter Speicherplatz",
|
||||
"account_upgrade_dialog_tier_selected_label": "Ausgewählt",
|
||||
@@ -340,5 +340,17 @@
|
||||
"nav_upgrade_banner_label": "Upgrade auf ntfy Pro",
|
||||
"alert_not_supported_context_description": "Benachrichtigungen werden nur über HTTPS unterstützt. Das ist eine Einschränkung der <mdnLink>Notifications API</mdnLink>.",
|
||||
"display_name_dialog_description": "Lege einen alternativen Namen für ein Thema fest, der in der Abo-Liste angezeigt wird. So kannst Du Themen mit komplizierten Namen leichter finden.",
|
||||
"account_basics_username_admin_tooltip": "Du bist Admin"
|
||||
"account_basics_username_admin_tooltip": "Du bist Admin",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "spare {{discount}}%",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "spare bis zu {{discount}}%",
|
||||
"account_upgrade_dialog_tier_price_per_month": "Monat",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} pro Jahr. Spare {{save}}.",
|
||||
"account_upgrade_dialog_billing_contact_email": "Bei Fragen zur Abrechnung, <Link>kontaktiere uns</Link> bitte direkt.",
|
||||
"account_upgrade_dialog_billing_contact_website": "Bei Fragen zur Abrechnung sieh bitte auf unserer <Link>Webseite</Link> nach.",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "Keine reservierten Themen",
|
||||
"account_basics_tier_interval_yearly": "jährlich",
|
||||
"account_basics_tier_interval_monthly": "monatlich",
|
||||
"account_upgrade_dialog_interval_monthly": "Monatlich",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} pro Jahr. Monatlich abgerechnet.",
|
||||
"account_upgrade_dialog_interval_yearly": "Jährlich"
|
||||
}
|
||||
|
||||
@@ -225,10 +225,13 @@
|
||||
"account_upgrade_dialog_proration_info": "<strong>Proration</strong>: When upgrading between paid plans, the price difference will be <strong>charged immediately</strong>. When downgrading to a lower tier, the balance will be used to pay for future billing periods.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "The selected tier allows fewer reserved topics than your current tier. Before changing your tier, <strong>please delete at least one reservation</strong>. You can remove reservations in the <Link>Settings</Link>.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "The selected tier allows fewer reserved topics than your current tier. Before changing your tier, <strong>please delete at least {{count}} reservations</strong>. You can remove reservations in the <Link>Settings</Link>.",
|
||||
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} reserved topics",
|
||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserved topic",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserved topics",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "No reserved topics",
|
||||
"account_upgrade_dialog_tier_features_messages": "{{messages}} daily messages",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{emails}} daily emails",
|
||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} daily message",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages",
|
||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage",
|
||||
"account_upgrade_dialog_tier_price_per_month": "month",
|
||||
@@ -236,6 +239,8 @@
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} billed annually. Save {{save}}.",
|
||||
"account_upgrade_dialog_tier_selected_label": "Selected",
|
||||
"account_upgrade_dialog_tier_current_label": "Current",
|
||||
"account_upgrade_dialog_billing_contact_email": "For billing questions, please <Link>contact us</Link> directly.",
|
||||
"account_upgrade_dialog_billing_contact_website": "For billing questions, please refer to our <Link>website</Link>.",
|
||||
"account_upgrade_dialog_button_cancel": "Cancel",
|
||||
"account_upgrade_dialog_button_redirect_signup": "Sign up now",
|
||||
"account_upgrade_dialog_button_pay_now": "Pay now and subscribe",
|
||||
|
||||
@@ -240,5 +240,117 @@
|
||||
"account_basics_password_title": "Contraseña",
|
||||
"account_basics_password_dialog_title": "Cambiar contraseña",
|
||||
"account_basics_password_dialog_current_password_label": "Contraseña actual",
|
||||
"account_basics_password_dialog_new_password_label": "Contraseña nueva"
|
||||
"account_basics_password_dialog_new_password_label": "Contraseña nueva",
|
||||
"account_basics_tier_basic": "Básico",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(con nivel {{tier}})",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(sin nivel)",
|
||||
"account_basics_tier_free": "Gratis",
|
||||
"account_basics_tier_upgrade_button": "Actualizar a Pro",
|
||||
"account_basics_tier_change_button": "Cambiar",
|
||||
"account_basics_tier_paid_until": "Suscripción pagada hasta {{fecha}}, y se renovará automáticamente",
|
||||
"account_basics_tier_manage_billing_button": "Administrar la facturación",
|
||||
"account_basics_tier_title": "Tipo de cuenta",
|
||||
"account_tokens_description": "Utilice tokens de acceso al publicar y suscribirse a través de la API de ntfy para no tener que enviar las credenciales de su cuenta. Consulte la <Link>documentación</Link> para obtener más información.",
|
||||
"account_tokens_table_token_header": "Token",
|
||||
"account_tokens_table_label_header": "Etiqueta",
|
||||
"account_tokens_table_last_access_header": "Último acceso",
|
||||
"account_tokens_table_expires_header": "Expira",
|
||||
"account_tokens_table_never_expires": "Nunca expira",
|
||||
"account_tokens_table_current_session": "Sesión del navegador actual",
|
||||
"account_tokens_table_copy_to_clipboard": "Copiar al portapapeles",
|
||||
"account_tokens_table_copied_to_clipboard": "Token de acceso copiado",
|
||||
"account_tokens_table_cannot_delete_or_edit": "No se puede editar ni eliminar el token de sesión actual",
|
||||
"account_tokens_table_create_token_button": "Crear token de acceso",
|
||||
"account_tokens_table_last_origin_tooltip": "Desde la dirección IP {{ip}}, haga clic para buscar",
|
||||
"account_tokens_dialog_title_create": "Crear token de acceso",
|
||||
"account_tokens_dialog_title_edit": "Editar token de acceso",
|
||||
"account_tokens_dialog_title_delete": "Eliminar token de acceso",
|
||||
"account_tokens_dialog_label": "Etiqueta, por ejemplo, notificaciones de Radarr",
|
||||
"account_tokens_dialog_button_create": "Crear token",
|
||||
"prefs_reservations_table_everyone_write_only": "Puedo publicar y suscribirme, todo el mundo puede publicar",
|
||||
"account_usage_messages_title": "Mensajes publicados",
|
||||
"account_usage_reservations_title": "Tópicos reservados",
|
||||
"account_usage_reservations_none": "No hay tópicos reservados para esta cuenta",
|
||||
"account_usage_cannot_create_portal_session": "No se puede abrir el portal de facturación",
|
||||
"account_upgrade_dialog_title": "Cambiar nivel de cuenta",
|
||||
"account_basics_tier_payment_overdue": "Su pago ha vencido. Por favor actualice su método de pago o su cuenta será degradada en breve.",
|
||||
"account_basics_tier_canceled_subscription": "Su suscripción fue cancelada y será degradada a una cuenta gratuita el {{date}}.",
|
||||
"account_usage_emails_title": "Correos enviados",
|
||||
"account_usage_attachment_storage_title": "Almacenamiento de archivos adjuntos",
|
||||
"account_usage_attachment_storage_description": "{{filesize}} por archivo, eliminado después de {{expiry}}",
|
||||
"account_usage_basis_ip_description": "Las estadísticas de uso y los límites de esta cuenta se basan en su dirección IP, por lo que podrían ser compartidos con otros usuarios. Los límites mostrados anteriormente son aproximados basados en los límites existentes.",
|
||||
"account_delete_title": "Elimina cuenta",
|
||||
"account_delete_dialog_button_cancel": "Cancelar",
|
||||
"account_delete_dialog_billing_warning": "La eliminación de su cuenta también cancela su suscripción de facturación inmediatamente. Ya no tendrá acceso al panel de facturación.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "El nivel seleccionado permite menos tópicos reservados que su nivel actual. Antes de cambiar de nivel, <strong>por favor elimine al menos una reserva</strong>. Puede eliminar reservas en <Link>Configuración</Link>.",
|
||||
"account_upgrade_dialog_tier_selected_label": "Seleccionado",
|
||||
"account_upgrade_dialog_button_cancel": "Cancelar",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "Cancelar suscripción",
|
||||
"account_tokens_title": "Tokens de acceso",
|
||||
"account_delete_description": "Eliminar permanentemente su cuenta",
|
||||
"account_delete_dialog_description": "Esto borrará permanentemente su cuenta, incluyendo todos los datos almacenados en el servidor. Tras la eliminación, su nombre de usuario no estará disponible durante 7 días. Si realmente desea continuar, por favor confirme su contraseña en la casilla de abajo.",
|
||||
"account_delete_dialog_label": "Contraseña",
|
||||
"account_delete_dialog_button_submit": "Eliminar permanentemente la cuenta",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} tópicos reservados",
|
||||
"account_upgrade_dialog_cancel_warning": "Esto <strong>cancelará su suscripción</strong> y degradará su cuenta en {{date}}. En esa fecha, sus tópicos reservados y sus mensajes almacenados en caché en el servidor <strong>serán eliminados</strong>.",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Prorrateo</strong>: al actualizar entre planes pagos, la diferencia de precio se <strong>cobrará de inmediato</strong>. Al cambiar a un nivel inferior, el saldo se utilizará para pagar futuros períodos de facturación.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "El nivel seleccionado permite menos tópicos reservados que su nivel actual. Antes de cambiar de nivel, <strong>por favor elimine al menos {{count}} reservaciones</strong>. Puede eliminar reservaciones en <Link>Configuración</Link>.",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} mensajes diarios",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} correos diarios",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} por archivo",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} almacenamiento total",
|
||||
"account_upgrade_dialog_tier_current_label": "Actual",
|
||||
"account_upgrade_dialog_button_redirect_signup": "Regístrese ahora",
|
||||
"account_upgrade_dialog_button_pay_now": "Pague ahora y suscríbase",
|
||||
"account_upgrade_dialog_button_update_subscription": "Actualizar suscripción",
|
||||
"account_tokens_dialog_button_update": "Actualizar token",
|
||||
"account_tokens_dialog_expires_label": "El token de acceso expira en",
|
||||
"prefs_reservations_table": "Tabla de tópicos reservados",
|
||||
"prefs_reservations_dialog_description": "Reservar un tópico le otorga la propiedad sobre el mismo y le permite definir permisos de acceso para otros usuarios sobre el tópico.",
|
||||
"account_tokens_dialog_button_cancel": "Cancelar",
|
||||
"account_tokens_dialog_expires_unchanged": "No modificar la fecha de expiración",
|
||||
"prefs_reservations_add_button": "Agregar tópico reservado",
|
||||
"prefs_reservations_table_access_header": "Acceso",
|
||||
"reservation_delete_dialog_action_delete_description": "Los mensajes y archivos adjuntos almacenados en caché se eliminarán de forma permanente. Esta acción no se puede deshacer.",
|
||||
"account_tokens_dialog_expires_x_hours": "El token expira en {{hours}} horas",
|
||||
"account_tokens_delete_dialog_title": "Eliminar token de acceso",
|
||||
"prefs_reservations_limit_reached": "Ha alcanzado su límite de tópicos reservados.",
|
||||
"prefs_reservations_table_everyone_read_write": "Todo el mundo puede publicar y suscribirse",
|
||||
"reservation_delete_dialog_action_keep_description": "Los mensajes y archivos adjuntos que se almacenen en caché en el servidor pasarán a ser visibles públicamente para las personas que conozcan el nombre del tópico.",
|
||||
"account_tokens_dialog_expires_x_days": "El token expira en {{days}} días",
|
||||
"account_tokens_dialog_expires_never": "El token nunca expira",
|
||||
"account_tokens_delete_dialog_description": "Antes de eliminar un token de acceso, asegúrese de que ninguna aplicación o script lo está utilizando activamente. <strong>Esta acción no se puede deshacer</strong>.",
|
||||
"prefs_users_table_cannot_delete_or_edit": "No se puede eliminar o editar el usuario conectado",
|
||||
"prefs_reservations_title": "Tópicos reservados",
|
||||
"prefs_reservations_edit_button": "Editar acceso al tópico",
|
||||
"prefs_reservations_table_topic_header": "Tópico",
|
||||
"prefs_reservations_table_everyone_read_only": "Puedo publicar y suscribirme, todo el mundo puede suscribirse",
|
||||
"prefs_reservations_table_everyone_deny_all": "Sólo yo puedo publicar y suscribirme",
|
||||
"prefs_reservations_table_click_to_subscribe": "Haga clic para suscribirse",
|
||||
"prefs_reservations_dialog_title_edit": "Edita tópico reservado",
|
||||
"account_tokens_delete_dialog_submit_button": "Eliminar permanentemente el token",
|
||||
"prefs_reservations_description": "Aquí puede reservar nombres de tópicos para uso personal. Reservar un tópico le otorga la propiedad sobre el mismo y le permite definir permisos de acceso para otros usuarios sobre el tópico.",
|
||||
"prefs_reservations_delete_button": "Restablecer acceso a tópico",
|
||||
"prefs_reservations_table_not_subscribed": "No suscrito",
|
||||
"prefs_reservations_dialog_title_add": "Reservar tópico",
|
||||
"prefs_users_description_no_sync": "Los usuarios y las contraseñas no están sincronizados con su cuenta.",
|
||||
"prefs_reservations_dialog_title_delete": "Borrar reserva de tópico",
|
||||
"prefs_reservations_dialog_access_label": "Acceso",
|
||||
"reservation_delete_dialog_action_keep_title": "Conservar mensajes y archivos adjuntos en caché",
|
||||
"prefs_reservations_dialog_topic_label": "Tópico",
|
||||
"reservation_delete_dialog_description": "Al eliminar una reserva se renuncia a la propiedad sobre el tópico y se permite que otros lo reserven. Puede conservar o eliminar los mensajes y archivos adjuntos existentes.",
|
||||
"reservation_delete_dialog_action_delete_title": "Eliminar mensajes y archivos adjuntos en caché",
|
||||
"reservation_delete_dialog_submit_button": "Eliminar reserva",
|
||||
"account_basics_tier_interval_monthly": "mensualmente",
|
||||
"account_basics_tier_interval_yearly": "anualmente",
|
||||
"account_upgrade_dialog_interval_monthly": "Mensualmente",
|
||||
"account_upgrade_dialog_interval_yearly": "Anualmente",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "ahorrar {{discount}}%",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "ahorra hasta un {{discount}}%",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "Ningún tema reservado",
|
||||
"account_upgrade_dialog_tier_price_per_month": "mes",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} facturado anualmente. Guardar {{save}}.",
|
||||
"account_upgrade_dialog_billing_contact_website": "Si tiene preguntas sobre facturación, consulte nuestra <Link>página web</Link>.",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} al año. Facturación mensual.",
|
||||
"account_upgrade_dialog_billing_contact_email": "Para preguntas sobre facturación, por favor <Link>contáctenos</Link> directamente."
|
||||
}
|
||||
|
||||
@@ -273,10 +273,10 @@
|
||||
"account_delete_dialog_billing_warning": "Supprimer votre compte annule aussi immédiatement votre facturation. Vous n'aurez plus accès à votre tableau de bord de facturation.",
|
||||
"account_upgrade_dialog_title": "Changer le tarif du compte",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Facturation</strong> : Lors d'un changement entre un plan payant et un autre, la différence de prix sera créditée ou remboursée sur la prochaine facture. Vous ne recevrez pas d'autre facture avant la fin de la prochaine période de facturation.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, <strong>veuillez supprimer au moins {{count}} sujets réservés</strong>. Vous pouvez supprimer des sujets réservés dans les <Link>Settings</Link>.",
|
||||
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} sujets réservés",
|
||||
"account_upgrade_dialog_tier_features_messages": "{{messages}} messages journaliers",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{emails}} emails journaliers",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, <strong>veuillez supprimer au moins {{count}} sujets réservés</strong>. Vous pouvez supprimer des sujets réservés dans les <Link>Paramètres</Link>.",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} sujets réservés",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} messages journaliers",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} emails journaliers",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} par fichier",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} stockage total",
|
||||
"account_upgrade_dialog_tier_selected_label": "Sélectionné",
|
||||
@@ -337,8 +337,20 @@
|
||||
"alert_not_supported_context_description": "Les notifications ne sont supportées qu'en HTTPS. C'est une limitation de la <mdnLink>Notifications API</mdnLink>.",
|
||||
"account_basics_tier_payment_overdue": "Votre paiement est en retard. Veuillez mettre à jour votre méthode de paiement, ou votre compte va bientôt être rétrogradé.",
|
||||
"account_upgrade_dialog_cancel_warning": "Cela va <strong>annuler votre abonnement</strong> et rétrograder votre compte le {{date}}. Ce jour là, les sujets réservés ainsi que tous les messages dans le cache du serveur <strong>seront supprimés</strong>.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, <strong>veuillez supprimer au moins un sujet réservé</strong>. Vous pouvez supprimer des sujets réservés dans les <Link>Settings</Link>.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Le tarif sélectionné autorise moins de sujets réservés que votre tarif actuel. Avant de changer de tarif, <strong>veuillez supprimer au moins un sujet réservé</strong>. Vous pouvez supprimer des sujets réservés dans les <Link>Paramètres</Link>.",
|
||||
"account_tokens_description": "Utilisez des jetons d'accès lors de la publication ou de l'abonnement via l'API de ntfy, afin d'éviter d'envoyer vos identifiants de compte. Regardez la <Link>documentation</Link> pour en savoir plus.",
|
||||
"account_tokens_delete_dialog_description": "Avant de supprimer un jeton d'accès, assurez-vous qu'aucune application ou script ne soit en train de l'utiliser. <strong>Cette action ne peut pas être annulée</strong>.",
|
||||
"prefs_reservations_description": "Vous pouvez réserver les noms de sujet à usage personnel ici. Réserver un sujet vous donne la propriété sur ce sujet et vous permet de définir les permissions d'accès à ce sujet pour d'autres utilisateurs."
|
||||
"prefs_reservations_description": "Vous pouvez réserver les noms de sujet à usage personnel ici. Réserver un sujet vous donne la propriété sur ce sujet et vous permet de définir les permissions d'accès à ce sujet pour d'autres utilisateurs.",
|
||||
"account_basics_tier_interval_yearly": "annuel",
|
||||
"account_upgrade_dialog_interval_yearly": "Annuel",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "économisez {{discount}}%",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "Aucun sujet(s) réservé(s)",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} par an. Prélevé mensuellement.",
|
||||
"account_upgrade_dialog_billing_contact_website": "Pour des questions en rapport avec la facturation, se référer à notre <Link>site internet</Link>.",
|
||||
"account_basics_tier_interval_monthly": "mensuel",
|
||||
"account_upgrade_dialog_interval_monthly": "Mensuel",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "économisez jusqu'à {{discount}}%",
|
||||
"account_upgrade_dialog_tier_price_per_month": "mois",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} prélevé annuellement. Économisez {{save}}.",
|
||||
"account_upgrade_dialog_billing_contact_email": "Pour des questions concernant la facturation, merci de nous <Link>contacter</Link> directement."
|
||||
}
|
||||
|
||||
@@ -256,11 +256,11 @@
|
||||
"account_usage_cannot_create_portal_session": "Tidak dapat membuka portal tagihan",
|
||||
"account_delete_dialog_billing_warning": "Menghapus akun Anda juga membatalkan tagihan langganan dengan segera. Anda tidak akan memiliki akses lagi ke dasbor tagihan.",
|
||||
"account_upgrade_dialog_title": "Ubah peringkat akun",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Prorasi</strong>: Ketika mengubah rencana berbayar, perubahan harga akan ditagih atau dikembalikan di faktur berikutnya. Anda tidak akan menerima faktur lain sampai akhir periode tagihan.",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Prorasi</strong>: Saat melakukan upgrade antar paket berbayar, selisih harga akan <strong>langsung dibebankan ke</strong>. Saat menurunkan ke tingkat yang lebih rendah, saldo akan digunakan untuk membayar periode penagihan di masa mendatang.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Peringkat yang dipilih memperbolehkan lebih sedikit reservasi topik daripada peringkat Anda saat ini. Sebelum mengubah peringkat Anda, <strong>silakan menghapus setidaknya {{count}} reservasi</strong>. Anda dapat menghapus reservasi di <Link>Pengaturan</Link>.",
|
||||
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} topik yang telah direservasi",
|
||||
"account_upgrade_dialog_tier_features_messages": "{{messages}} pesan harian",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{emails}} surel harian",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} topik yang telah direservasi",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} pesan harian",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} surel harian",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per berkas",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} jumlah penyimpanan",
|
||||
"account_upgrade_dialog_tier_selected_label": "Dipilih",
|
||||
@@ -340,5 +340,17 @@
|
||||
"prefs_reservations_dialog_description": "Mereservasikan sebuah topik memberikan Anda kemilikan pada topik, dan memungkinkan Anda untuk mendefinisikan perizinan akses untuk pengguna lain melalui topik.",
|
||||
"prefs_reservations_dialog_topic_label": "Topik",
|
||||
"prefs_reservations_dialog_access_label": "Akses",
|
||||
"reservation_delete_dialog_description": "Menghapus sebuah reservasi menghapus kemilikan pada topik, dan memperbolehkan orang-orang lain untuk mereservasinya."
|
||||
"reservation_delete_dialog_description": "Menghapus sebuah reservasi menghapus kemilikan pada topik, dan memperbolehkan orang-orang lain untuk mereservasinya.",
|
||||
"account_upgrade_dialog_interval_yearly": "Setiap tahun",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "Ditagih {{price}} setiap tahun. Hemat {{save}}.",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "hemat {{discount}}%",
|
||||
"account_upgrade_dialog_interval_monthly": "Setiap bulan",
|
||||
"account_basics_tier_interval_monthly": "setiap bulan",
|
||||
"account_basics_tier_interval_yearly": "setiap tahun",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "hemat sampai {{discount}}%",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "Tidak ada topik yang direservasi",
|
||||
"account_upgrade_dialog_tier_price_per_month": "bulan",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} per bulan. Ditagih setiap bulan.",
|
||||
"account_upgrade_dialog_billing_contact_email": "Untuk pertanyaan penagihan, silakan <Link>hubungi kami</Link> secara langsung.",
|
||||
"account_upgrade_dialog_billing_contact_website": "Untuk pertanyaan penagihan, silakan menuju ke <Link>situs web</Link> kami."
|
||||
}
|
||||
|
||||
@@ -187,5 +187,74 @@
|
||||
"prefs_notifications_delete_after_one_week": "Dopo una settimana",
|
||||
"prefs_notifications_delete_after_one_month": "Dopo un mese",
|
||||
"prefs_notifications_delete_after_three_hours_description": "Le notifiche vengono eliminate automaticamente dopo tre ore",
|
||||
"error_boundary_unsupported_indexeddb_description": "L'app web ntfy ha bisogno di IndexedDB per funzionare e il tuo browser non supporta IndexedDB in modalità di navigazione privata.<br/><br/>Anche se questo è un peccato, non ha molto senso usare il web ntfy app in modalità di navigazione privata comunque, perché tutto è archiviato nella memoria del browser. Puoi leggere di più a riguardo <githubLink>in questo numero di GitHub</githubLink> o parlarci su <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>."
|
||||
"error_boundary_unsupported_indexeddb_description": "L'app web ntfy ha bisogno di IndexedDB per funzionare e il tuo browser non supporta IndexedDB in modalità di navigazione privata.<br/><br/>Anche se questo è un peccato, non ha molto senso usare il web ntfy app in modalità di navigazione privata comunque, perché tutto è archiviato nella memoria del browser. Puoi leggere di più a riguardo <githubLink>in questo numero di GitHub</githubLink> o parlarci su <discordLink>Discord</discordLink> o <matrixLink>Matrix</matrixLink>.",
|
||||
"nav_upgrade_banner_label": "Passa alla versione Pro di ntfy",
|
||||
"alert_not_supported_context_description": "Le Notificche sono supportate solo tramite HTTPS. Questa è una limitazione delle <mdnLink>Notifications API</mdnLink>.",
|
||||
"account_basics_password_dialog_new_password_label": "Nuova password",
|
||||
"action_bar_profile_logout": "Esci",
|
||||
"account_basics_tier_interval_monthly": "mensile",
|
||||
"account_basics_tier_interval_yearly": "annuale",
|
||||
"account_basics_tier_upgrade_button": "Passa alla versione Pro",
|
||||
"account_basics_tier_change_button": "Cambia",
|
||||
"account_basics_tier_paid_until": "Abbonamento pagato fino a {{data}}, e si rinnoverà automaticamente",
|
||||
"account_basics_tier_payment_overdue": "Il pagamento è scaduto. La preghiamo di aggiornare il suo metodo di pagamento, altrimenti il suo account verrà presto declassato.",
|
||||
"account_basics_tier_canceled_subscription": "L'abbonamento è stato annullato e sarà declassato ad account gratuito a partire dalla {{data}}.",
|
||||
"account_basics_tier_manage_billing_button": "Gestire la fatturazione",
|
||||
"account_usage_messages_title": "Messaggi pubblicati",
|
||||
"account_usage_reservations_title": "Argomenti riservati",
|
||||
"account_usage_reservations_none": "Non ci sono argomenti riservati per questo account",
|
||||
"signup_form_toggle_password_visibility": "Imposta la visibilità della password",
|
||||
"signup_already_have_account": "Hai già un account? Accedi!",
|
||||
"signup_disabled": "Registrazione disabilitata",
|
||||
"signup_title": "Crea un account ntfy",
|
||||
"signup_form_username": "Nome utente",
|
||||
"signup_form_password": "Password",
|
||||
"signup_form_confirm_password": "Conferma password",
|
||||
"signup_form_button_submit": "Registrazione",
|
||||
"signup_error_username_taken": "Il nome utente {{username}} è già utilizzato",
|
||||
"signup_error_creation_limit_reached": "Il limite per la creazione di account è stato raggiunto",
|
||||
"login_title": "Accedi al tuo account ntfy",
|
||||
"login_form_button_submit": "Accedi",
|
||||
"login_link_signup": "Registrati",
|
||||
"login_disabled": "L'accesso è disabilitato",
|
||||
"action_bar_account": "Account",
|
||||
"action_bar_change_display_name": "Cambia il nome da visualizzare",
|
||||
"action_bar_reservation_limit_reached": "Limite raggiunto",
|
||||
"action_bar_profile_title": "Profilo",
|
||||
"action_bar_profile_settings": "Impostazioni",
|
||||
"action_bar_reservation_add": "Riserva un argomento",
|
||||
"action_bar_reservation_edit": "Modifica l'argomento riservato",
|
||||
"action_bar_reservation_delete": "Rimuovi l'argomento riservato",
|
||||
"action_bar_sign_in": "Accedi",
|
||||
"action_bar_sign_up": "Registrati",
|
||||
"nav_button_account": "Account",
|
||||
"nav_upgrade_banner_description": "Riserva argomenti, più messaggi ed e-mail e allegati più grandi",
|
||||
"display_name_dialog_description": "Imposta un nome alternativo per un argomento che viene visualizzato nell'elenco delle sottoscrizioni. Questo aiuta a identificare più facilmente gli argomenti con nomi complicati.",
|
||||
"display_name_dialog_title": "Cambia il nome visualizzato",
|
||||
"display_name_dialog_placeholder": "Nome visualizzato",
|
||||
"reserve_dialog_checkbox_label": "Riserva un argomento e configura l'accesso",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Genera un nome",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Argomento già in uso",
|
||||
"account_basics_title": "Account",
|
||||
"account_basics_username_title": "Nome utente",
|
||||
"account_basics_username_admin_tooltip": "Sei Amministratore",
|
||||
"account_basics_password_title": "Password",
|
||||
"account_basics_password_description": "Cambia la password del tuo account",
|
||||
"account_basics_password_dialog_title": "Cambia la password",
|
||||
"account_basics_password_dialog_current_password_label": "Password attuale",
|
||||
"account_basics_password_dialog_confirm_password_label": "Conferma la password",
|
||||
"account_basics_password_dialog_button_submit": "Cambia la password",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Password errata",
|
||||
"account_usage_title": "Utilizzo",
|
||||
"account_usage_of_limit": "di {{limit}}",
|
||||
"account_usage_unlimited": "Illimitato",
|
||||
"account_usage_limits_reset_daily": "I limiti di utilizzo vengono azzerati ogni giorno a mezzanotte (orario UTC)",
|
||||
"account_basics_tier_title": "Tipo di account",
|
||||
"account_basics_tier_description": "Permessi del tuo account",
|
||||
"account_basics_tier_admin": "Amministratore",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(con livello {{tier}})",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(nessun livello)",
|
||||
"account_basics_tier_basic": "Base",
|
||||
"account_basics_tier_free": "Gratuito",
|
||||
"account_usage_emails_title": "Email inviate"
|
||||
}
|
||||
|
||||
@@ -219,5 +219,138 @@
|
||||
"nav_upgrade_banner_description": "トピックを予約、より多くのメッセージとメール、より大きい添付ファイル",
|
||||
"signup_error_username_taken": "ユーザー名 {{username}} は既に使用されています",
|
||||
"action_bar_reservation_delete": "予約を削除する",
|
||||
"display_name_dialog_description": "購読リストに表示されるトピックの別名を設定して、複雑な名前のトピックの識別を容易にします。"
|
||||
"display_name_dialog_description": "購読リストに表示されるトピックの別名を設定して、複雑な名前のトピックの識別を容易にします。",
|
||||
"reserve_dialog_checkbox_label": "トピックを保存してアクセスを編集",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "名前を生成",
|
||||
"subscribe_dialog_error_topic_already_reserved": "このトピックは予約済みです",
|
||||
"account_basics_title": "アカウント",
|
||||
"account_basics_tier_description": "アカウントのパワーレベル",
|
||||
"account_basics_tier_admin": "管理者",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(ティア {{tier}})",
|
||||
"account_basics_tier_free": "無料",
|
||||
"account_usage_attachment_storage_description": "1ファイルあたり{{filesize}}、{{expiry}}を過ぎると削除",
|
||||
"account_usage_basis_ip_description": "アカウントの使用量統計および制限はあなたのIPアドレスに基づいているため、他のユーザーと共有される可能性があります。上記制限は既存のレート制限に基づく概算値です。",
|
||||
"account_usage_cannot_create_portal_session": "支払いポータルを開けませんでした",
|
||||
"account_delete_title": "アカウントを削除",
|
||||
"account_delete_description": "アカウントを永久的に削除",
|
||||
"account_delete_dialog_description": "サーバーに保存されている全てのデータを含むあなたのアカウント情報を削除します。削除後、あなたのユーザー名は7日間利用できません。もし本当に先に進めたい場合、下の入力欄にパスワードを入力して確認して下さい。",
|
||||
"account_delete_dialog_label": "パスワード",
|
||||
"account_delete_dialog_button_cancel": "キャンセル",
|
||||
"account_delete_dialog_button_submit": "永久的にアカウントを削除",
|
||||
"account_delete_dialog_billing_warning": "アカウントを削除するとサブスクリプション支払いも即時キャンセルされます。支払いダッシュボードにもアクセスできなくなります。",
|
||||
"account_upgrade_dialog_title": "アカウントティアを変更",
|
||||
"account_upgrade_dialog_cancel_warning": "これにより<strong>サブスクリプションをキャンセルし</strong>{{date}}にアカウントをダウングレードします。同日、トピック予約およびサーバーにキャッシュされたメッセージは<strong>削除されます</strong>。",
|
||||
"account_upgrade_dialog_proration_info": "<strong>追記</strong>。有料プランをアップグレードする場合、価格差は<strong>即座に請求されます</strong>。ダウングレードする場合、差額は次の請求期間の支払いに利用されます。",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "予約のトピック{{reservations}}件",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "日次メール{{emails}}件",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "日次メッセージ{{messages}}件",
|
||||
"account_upgrade_dialog_tier_selected_label": "選択",
|
||||
"account_upgrade_dialog_tier_current_label": "現在",
|
||||
"account_upgrade_dialog_button_cancel": "キャンセル",
|
||||
"account_upgrade_dialog_button_redirect_signup": "サインアップ",
|
||||
"account_upgrade_dialog_button_pay_now": "支払いしてサブスクライブする",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "サブスクリプションをキャンセル",
|
||||
"account_upgrade_dialog_button_update_subscription": "サブスクリプションを更新",
|
||||
"account_tokens_description": "ntfy APIで発行または購読する際にアクセストークンを使うことで、アカウント認証情報を送信する必要がなくなります。詳細は<Link>ドキュメント</Link>を確認して下さい。",
|
||||
"account_tokens_table_token_header": "トークン",
|
||||
"account_tokens_table_label_header": "ラベル",
|
||||
"account_tokens_table_last_access_header": "最終アクセス",
|
||||
"account_tokens_table_expires_header": "期限",
|
||||
"account_tokens_table_never_expires": "無期限",
|
||||
"account_tokens_table_current_session": "現在のブラウザセッション",
|
||||
"account_tokens_table_copy_to_clipboard": "クリップボードにコピー",
|
||||
"account_tokens_table_copied_to_clipboard": "アクセストークンをコピーしました",
|
||||
"account_tokens_table_cannot_delete_or_edit": "現在のセッショントークンは編集または削除できません",
|
||||
"account_tokens_table_create_token_button": "アクセストークンを生成",
|
||||
"account_tokens_table_last_origin_tooltip": "IPアドレス {{ip}} から、クリックして参照",
|
||||
"account_tokens_dialog_title_create": "アクセストークンを生成",
|
||||
"account_tokens_dialog_title_edit": "アクセストークンを編集",
|
||||
"account_tokens_dialog_title_delete": "アクセストークンを削除",
|
||||
"account_tokens_dialog_label": "ラベル、例:Radarr通知",
|
||||
"account_tokens_dialog_button_create": "トークンを生成",
|
||||
"account_tokens_dialog_button_update": "トークンを更新",
|
||||
"account_tokens_dialog_button_cancel": "キャンセル",
|
||||
"account_tokens_dialog_expires_label": "アクセストークン有効期限",
|
||||
"account_tokens_dialog_expires_unchanged": "有効期限を変更しない",
|
||||
"account_tokens_dialog_expires_x_hours": "トークンは {{hours}} 時間後に失効します",
|
||||
"account_tokens_dialog_expires_x_days": "トークンは {{days}} 日後に失効します",
|
||||
"account_tokens_dialog_expires_never": "トークン失効なし",
|
||||
"account_tokens_delete_dialog_title": "アクセストークンを削除",
|
||||
"account_tokens_delete_dialog_submit_button": "トークンを永久削除",
|
||||
"prefs_users_description_no_sync": "ユーザー名とパスワードはアカウントと同期されません。",
|
||||
"prefs_users_table_cannot_delete_or_edit": "ログインしているユーザーは削除または編集できません",
|
||||
"prefs_reservations_title": "予約されたトピック",
|
||||
"prefs_reservations_description": "ここでトピック名を個人利用の為に予約する事ができます。トピックを予約する事でそのトピックの所有権が付与され、他のユーザーにアクセス権を付与する事ができるようになります。",
|
||||
"prefs_reservations_add_button": "予約トピックを追加する",
|
||||
"prefs_reservations_edit_button": "トピックへのアクセスを編集する",
|
||||
"prefs_reservations_delete_button": "トピックへのアクセスをリセットする",
|
||||
"prefs_reservations_table": "予約トピックの一覧",
|
||||
"prefs_reservations_table_topic_header": "トピック",
|
||||
"prefs_reservations_table_everyone_deny_all": "自分のみ発行と購読が可能",
|
||||
"prefs_reservations_table_everyone_read_only": "自分は発行と購読が可能、誰でも購読可能",
|
||||
"prefs_reservations_table_everyone_write_only": "自分は発行と購読可能、誰でも発行可能",
|
||||
"prefs_reservations_table_everyone_read_write": "誰でも発行と購読が可能",
|
||||
"prefs_reservations_table_not_subscribed": "購読されていません",
|
||||
"prefs_reservations_table_click_to_subscribe": "クリックして購読",
|
||||
"prefs_reservations_dialog_title_edit": "予約トピックを編集",
|
||||
"prefs_reservations_dialog_title_delete": "トピック予約を削除",
|
||||
"prefs_reservations_dialog_topic_label": "トピック",
|
||||
"prefs_reservations_dialog_access_label": "アクセス",
|
||||
"reservation_delete_dialog_action_keep_title": "キャッシュされたメッセージと添付ファイルを保持する",
|
||||
"reservation_delete_dialog_action_keep_description": "サーバーにキャッシュされたメッセージと添付ファイルは公開されてトピック名を知っている人が閲覧できるようになります。",
|
||||
"reservation_delete_dialog_action_delete_title": "キャッシュされたメッセージと添付ファイルを削除する",
|
||||
"reservation_delete_dialog_action_delete_description": "キャッシュされたメッセージと添付ファイルは永久的に削除されます。この操作は元に戻せません。",
|
||||
"account_basics_username_admin_tooltip": "あなたは管理者です",
|
||||
"account_basics_password_title": "パスワード",
|
||||
"account_basics_password_dialog_current_password_label": "現在のパスワード",
|
||||
"account_usage_limits_reset_daily": "使用量制限は世界協定時 (UTC) の深夜に毎日リセットされます",
|
||||
"account_basics_tier_basic": "ベーシック",
|
||||
"account_basics_tier_paid_until": "サブスクリプションは{{date}}まで有効で、自動更新されます",
|
||||
"account_basics_username_title": "ユーザー名",
|
||||
"account_basics_username_description": "あなたのお名前です ❤",
|
||||
"account_basics_password_description": "アカウントパスワードを変更",
|
||||
"account_basics_password_dialog_title": "パスワード変更",
|
||||
"account_basics_password_dialog_confirm_password_label": "パスワードを確認",
|
||||
"account_basics_password_dialog_current_password_incorrect": "パスワードが異なります",
|
||||
"account_usage_of_limit": ": {{limit}}",
|
||||
"account_usage_unlimited": "無制限",
|
||||
"account_basics_tier_upgrade_button": "プロにアップグレード",
|
||||
"account_basics_tier_manage_billing_button": "支払い方法を管理",
|
||||
"account_basics_password_dialog_new_password_label": "新しいパスワード",
|
||||
"account_basics_password_dialog_button_submit": "パスワードを変更",
|
||||
"account_usage_title": "使用量",
|
||||
"account_basics_tier_title": "アカウントタイプ",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(ティアなし)",
|
||||
"account_basics_tier_change_button": "変更",
|
||||
"account_basics_tier_payment_overdue": "支払期限を過ぎています。支払い方法を更新しないと、近日中にアカウントはダウングレードされます。",
|
||||
"account_basics_tier_canceled_subscription": "あなたのサブスクリプションはキャンセルされ{{date}}に無料アカウントにダウングレードされます。",
|
||||
"account_usage_messages_title": "発行されたメッセージ",
|
||||
"account_usage_reservations_none": "このアカウントで予約されたトピックはありません",
|
||||
"account_usage_attachment_storage_title": "添付ストレージ",
|
||||
"account_usage_emails_title": "送信済みメール",
|
||||
"account_upgrade_dialog_reservations_warning_one": "選択されたティアは、現在のティアよりも少ない予約トピックを利用できます。ティアを変更する前に、<strong>少なくとも1つの予約を削除してください</strong>。予約の削除は、<Link>設定</Link>で行うことができます。",
|
||||
"account_usage_reservations_title": "予約されたトピック",
|
||||
"account_upgrade_dialog_reservations_warning_other": "選択されたティアは、現在のティアよりも少ない予約トピックを利用できます。ティアを変更する前に、<strong>少なくとも{{count}}個の予約を削除してください</strong>。予約の削除は、<Link>設定</Link>で行うことができます。",
|
||||
"account_tokens_delete_dialog_description": "アクセストークンを削除する前に、アプリやスクリプトが利用中でないか確認して下さい。<strong>この操作は元に戻せません</strong>。",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "1ファイルあたり{{filesize}}",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "総ストレージ{{totalsize}}",
|
||||
"account_tokens_title": "アクセストークン",
|
||||
"prefs_reservations_limit_reached": "予約トピック数の上限に達しました。",
|
||||
"prefs_reservations_table_access_header": "アクセス",
|
||||
"prefs_reservations_dialog_title_add": "トピックを予約",
|
||||
"prefs_reservations_dialog_description": "トピックを予約する事でそのトピックの所有権が付与され、他のユーザーにアクセス権を付与する事ができるようになります。",
|
||||
"reservation_delete_dialog_description": "予約を削除するとトピックの所有権を失い、他の人が予約できるようになります。既存のメッセージや添付ファイルは保持または削除することができます。",
|
||||
"reservation_delete_dialog_submit_button": "予約を削除",
|
||||
"account_basics_tier_interval_monthly": "毎月",
|
||||
"account_upgrade_dialog_interval_monthly": "毎月",
|
||||
"account_upgrade_dialog_interval_yearly": "毎年",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "最大{{discount}}%節約",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "予約トピックなし",
|
||||
"account_upgrade_dialog_billing_contact_email": "支払いについての問い合わせは、直接<Link>お問い合わせください</Link>。",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "{{discount}}%節約",
|
||||
"account_basics_tier_interval_yearly": "毎年",
|
||||
"account_upgrade_dialog_tier_price_per_month": "月",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "年間{{price}}。月毎の支払い。",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "年間{{price}}の支払い。{{save}}節約。",
|
||||
"account_upgrade_dialog_billing_contact_website": "支払いに関する質問は、<Link>ウェブサイト</Link>を参照して下さい。"
|
||||
}
|
||||
|
||||
@@ -187,5 +187,135 @@
|
||||
"prefs_notifications_delete_after_never": "Nigdy",
|
||||
"prefs_users_dialog_title_edit": "Edytuj użytkownika",
|
||||
"priority_min": "minimum",
|
||||
"error_boundary_unsupported_indexeddb_description": "Aplikacja ntfy potrzebuje IndexedDB, aby działać poprawnie, a Twoja przeglądarka nie obsługuje IndexedDB w prywatnych zakładkach.<br/><br/>To denerwujące, ale używanie ntfy w prywatnej zakładce nie ma sensu, ponieważ wszystkie dane są przechowywane w przeglądarce. Więcej informacji można uzyskać <githubLink>w tym wydaniu GitHub</githubLink>, lub na czacie w <discordLink>Discord</discordLink> lub <matrixLink>Matrix</matrixLink>."
|
||||
"error_boundary_unsupported_indexeddb_description": "Aplikacja ntfy potrzebuje IndexedDB, aby działać poprawnie, a Twoja przeglądarka nie obsługuje IndexedDB w prywatnych zakładkach.<br/><br/>To denerwujące, ale używanie ntfy w prywatnej zakładce nie ma sensu, ponieważ wszystkie dane są przechowywane w przeglądarce. Więcej informacji można uzyskać <githubLink>w tym wydaniu GitHub</githubLink>, lub na czacie w <discordLink>Discord</discordLink> lub <matrixLink>Matrix</matrixLink>.",
|
||||
"signup_form_password": "Hasło",
|
||||
"signup_title": "Załóż konto ntfy",
|
||||
"signup_error_creation_limit_reached": "Przekroczono limit zakładania kont",
|
||||
"action_bar_reservation_limit_reached": "Limit wyczerpany",
|
||||
"display_name_dialog_title": "Zmień wyświetlaną nazwę",
|
||||
"display_name_dialog_description": "Ustaw alternatywną nazwę dla tematu wyświetlanego na liście subskrybcji. To ułatwia identyfikację tematów o skomplikowanych nazwach.",
|
||||
"account_basics_title": "Konto",
|
||||
"account_basics_password_dialog_title": "Zmień hasło",
|
||||
"signup_form_username": "Nawa użytkownika",
|
||||
"signup_form_confirm_password": "Powtórz hasło",
|
||||
"signup_form_button_submit": "Załóż konto",
|
||||
"signup_form_toggle_password_visibility": "Pokaż lub ukryj hasło",
|
||||
"signup_already_have_account": "Masz już konto? Zaloguj się!",
|
||||
"signup_disabled": "Zakładanie kont jest wyłączone",
|
||||
"signup_error_username_taken": "Nazwa użytkownika {{username}} jest już zajęta",
|
||||
"login_title": "Zaloguj się do swojego konta ntfy",
|
||||
"login_form_button_submit": "Zaloguj się",
|
||||
"login_link_signup": "Załóż konto",
|
||||
"login_disabled": "Logowanie jet wyłączone",
|
||||
"action_bar_account": "Konto",
|
||||
"action_bar_change_display_name": "Zmień wyświetlaną nazwę",
|
||||
"action_bar_reservation_add": "Zarezerwuj temat",
|
||||
"action_bar_reservation_edit": "Zmień rezerwację",
|
||||
"action_bar_reservation_delete": "Usuń rezerwację",
|
||||
"action_bar_profile_title": "Profil",
|
||||
"action_bar_profile_settings": "Ustawienia",
|
||||
"action_bar_profile_logout": "Wyloguj",
|
||||
"action_bar_sign_in": "Zaloguj",
|
||||
"action_bar_sign_up": "Załóż konto",
|
||||
"nav_button_account": "Konto",
|
||||
"display_name_dialog_placeholder": "Nazwa wyświetlana",
|
||||
"reserve_dialog_checkbox_label": "Zarezerwuj temat i skonfiguruj dostęp",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Wygeneruj nazwę",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Temat już jest zarezerwowany",
|
||||
"account_basics_username_title": "Nazwa użytkownika",
|
||||
"account_basics_username_description": "Hej, to Ty ❤",
|
||||
"account_basics_username_admin_tooltip": "Jesteś Administratorem",
|
||||
"account_basics_password_title": "Hasło",
|
||||
"account_basics_password_description": "Zmień hasło do konta",
|
||||
"account_basics_password_dialog_current_password_label": "Aktualne hasło",
|
||||
"account_basics_password_dialog_new_password_label": "Nowe hasło",
|
||||
"account_basics_password_dialog_confirm_password_label": "Powtórz hasło",
|
||||
"account_basics_password_dialog_button_submit": "Zmień hasło",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Błędne hasło",
|
||||
"account_usage_title": "Użycie",
|
||||
"account_usage_of_limit": "z {{limit}}",
|
||||
"account_usage_unlimited": "Bez limitu",
|
||||
"account_usage_limits_reset_daily": "Limity są resetowane codziennie o północy (UTC)",
|
||||
"account_delete_dialog_button_submit": "Nieodwracalnie usuń konto",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "Brak rezerwacji tematów",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} na plik",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} pamięci łącznie",
|
||||
"account_upgrade_dialog_tier_price_per_month": "miesiąc",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} na rok. Płatne miesięcznie.",
|
||||
"account_upgrade_dialog_billing_contact_email": "W razie pytań dotyczących rozliczeń <Link>skontaktuj się z nami</Link> bezpośrednio.",
|
||||
"account_upgrade_dialog_billing_contact_website": "W razie pytań dotyczących rozliczeń sprawdź naszą <Link>stronę</Link>.",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "Anuluj subskrypcję",
|
||||
"account_upgrade_dialog_button_update_subscription": "Zmień subskrypcję",
|
||||
"account_tokens_title": "Tokeny dostępowe",
|
||||
"account_tokens_table_token_header": "Token",
|
||||
"account_tokens_table_label_header": "Etykieta",
|
||||
"account_tokens_table_last_access_header": "Ostatnie użycie",
|
||||
"account_tokens_table_expires_header": "Termin ważności",
|
||||
"account_tokens_table_never_expires": "Bezterminowy",
|
||||
"account_tokens_table_current_session": "Aktualna sesja przeglądarki",
|
||||
"account_tokens_table_copy_to_clipboard": "Kopiuj do schowka",
|
||||
"account_tokens_table_copied_to_clipboard": "Token został skopiowany",
|
||||
"account_tokens_table_cannot_delete_or_edit": "Nie można edytować ani usunąć tokenu aktualnej sesji",
|
||||
"account_tokens_table_create_token_button": "Utwórz token dostępowy",
|
||||
"account_tokens_dialog_label": "Etykieta, np. Powiadomienia Radarr",
|
||||
"account_tokens_dialog_button_update": "Zmień token",
|
||||
"account_basics_tier_interval_monthly": "miesięcznie",
|
||||
"account_basics_tier_interval_yearly": "rocznie",
|
||||
"account_upgrade_dialog_interval_monthly": "Miesięcznie",
|
||||
"account_upgrade_dialog_title": "Zmień plan konta",
|
||||
"account_delete_dialog_description": "Konto, wraz ze wszystkimi związanymi z nim danymi przechowywanymi na serwerze, będzie nieodwracalnie usunięte. Po usunięciu Twoja nazwa użytkownika będzie niedostępna jeszcze przez 7 dni. Jeśli chcesz kontynuować, potwierdź wpisując swoje hasło w polu poniżej.",
|
||||
"account_delete_dialog_billing_warning": "Usunięcie konta powoduje natychmiastowe anulowanie subskrypcji. Nie będziesz już mieć dostępu do strony z rachunkami.",
|
||||
"account_upgrade_dialog_interval_yearly": "Rocznie",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "taniej o {{discount}}%",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "nawet {{discount}}% taniej",
|
||||
"account_upgrade_dialog_button_cancel": "Anuluj",
|
||||
"account_tokens_description": "Używaj tokenów do publikowania wiadomości i subskrybowania tematów przez API ntfy, żeby uniknąć konieczności podawania danych do logowania. Szczegóły znajdziesz w <Link>dokumentacji</Link>.",
|
||||
"account_tokens_dialog_title_create": "Utwórz token dostępowy",
|
||||
"account_tokens_table_last_origin_tooltip": "Z adresu IP {{ip}}, kliknij żeby sprawdzić",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} płatne jednorazowo. Oszczędzasz {{save}}.",
|
||||
"account_tokens_dialog_title_edit": "Edytuj token dostępowy",
|
||||
"account_tokens_dialog_title_delete": "Usuń token dostępowy",
|
||||
"account_tokens_dialog_button_create": "Utwórz token",
|
||||
"nav_upgrade_banner_label": "Przejdź na ntfy Pro",
|
||||
"nav_upgrade_banner_description": "Rezerwuj tematy, więcej powiadomień i maili oraz większe załączniki",
|
||||
"alert_not_supported_context_description": "Powiadomienia działają tylko przez HTTPS. To jest ograniczenie <mdnLink>Notifications API</mdnLink>.",
|
||||
"account_basics_tier_canceled_subscription": "Twoja subskrypcja została anulowana i konto zostanie ograniczone do wersji darmowej w dniu {{date}}.",
|
||||
"account_basics_tier_manage_billing_button": "Zarządzaj rachunkami",
|
||||
"account_usage_messages_title": "Wysłane wiadomości",
|
||||
"account_usage_emails_title": "Wysłane maile",
|
||||
"account_basics_tier_title": "Rodzaj konta",
|
||||
"account_basics_tier_description": "Mocarność Twojego konta",
|
||||
"account_basics_tier_admin": "Administrator",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(plan {{tier}})",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(brak planu)",
|
||||
"account_basics_tier_basic": "Podstawowe",
|
||||
"account_basics_tier_free": "Darmowe",
|
||||
"account_basics_tier_upgrade_button": "Przejdź na Pro",
|
||||
"account_basics_tier_change_button": "Zmień",
|
||||
"account_basics_tier_paid_until": "Subskrypcja opłacona do {{date}} i będzie odnowiona automatycznie",
|
||||
"account_basics_tier_payment_overdue": "Minął termin płatności. Zaktualizuj metodę płatności, w przeciwnym razie Twoje konto wkrótce zostanie ograniczone.",
|
||||
"account_usage_reservations_title": "Zarezerwowane tematy",
|
||||
"account_usage_reservations_none": "Brak zarezerwowanych tematów na tym koncie",
|
||||
"account_usage_attachment_storage_title": "Miejsce na załączniki",
|
||||
"account_usage_attachment_storage_description": "{{filesize}} na każdy plik, przechowywane przez {{expiry}}",
|
||||
"account_usage_basis_ip_description": "Statystyki i limity dla tego konta bazują na Twoim adresie IP, więc mogą być współdzielone z innymi użytkownikami. Limity pokazane powyżej to wartości przybliżone bazujące na rzeczywistych limitach.",
|
||||
"account_usage_cannot_create_portal_session": "Nie można otworzyć portalu z rachunkami",
|
||||
"account_delete_title": "Usuń konto",
|
||||
"account_delete_description": "Usuń swoje konto nieodwracalnie",
|
||||
"account_delete_dialog_label": "Hasło",
|
||||
"account_delete_dialog_button_cancel": "Anuluj",
|
||||
"account_upgrade_dialog_button_redirect_signup": "Załóż konto",
|
||||
"account_upgrade_dialog_button_pay_now": "Zapłać i aktywuj subskrypcję",
|
||||
"account_tokens_dialog_button_cancel": "Anuluj",
|
||||
"account_tokens_dialog_expires_label": "Token dostępowy wygasa po",
|
||||
"account_tokens_dialog_expires_unchanged": "Pozostaw termin ważności bez zmian",
|
||||
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} rezerwacja tematu",
|
||||
"account_upgrade_dialog_tier_features_reservations_few": "{{reservations}} rezerwacje tematów",
|
||||
"account_upgrade_dialog_tier_features_reservations_many": "{{reservations}} rezerwacji tematów",
|
||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} mail dziennie",
|
||||
"account_upgrade_dialog_tier_features_emails_few": "{{emails}} maile dziennie",
|
||||
"account_upgrade_dialog_tier_features_emails_many": "{{emails}} maili dziennie",
|
||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} wiadomość dziennie",
|
||||
"account_upgrade_dialog_tier_features_messages_few": "{{messages}} wiadomości dziennie",
|
||||
"account_upgrade_dialog_tier_features_messages_many": "{{messages}} wiadomości dziennie"
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"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_link_expired": "a ligação de descarga expirou",
|
||||
"notifications_attachment_open_button": "Abrir anexo",
|
||||
"notifications_attachment_link_expires": "a ligação expira em {{date}}",
|
||||
"notifications_attachment_file_image": "ficheiro de imagem",
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
{
|
||||
"publish_dialog_priority_min": "Мин. приоритет",
|
||||
"publish_dialog_priority_min": "Минимальный приоритет",
|
||||
"action_bar_settings": "Настройки",
|
||||
"action_bar_send_test_notification": "Отправить тестовое уведомление",
|
||||
"action_bar_clear_notifications": "Удалить все уведомления",
|
||||
"action_bar_unsubscribe": "Отписаться",
|
||||
"message_bar_type_message": "Введите сообщение здесь",
|
||||
"notifications_none_for_topic_description": "Чтобы отправить уведомление на данную тему, просто отправьте PUT или POST на URL-адрес этой темы.",
|
||||
"notifications_none_for_any_description": "Чтобы отправить уведомления на тему, просто отправьте PUT или POST на URL-адрес темы. Вот пример используя одну из ваших тем.",
|
||||
"notifications_no_subscriptions_title": "Похоже у вас ещё нет подписок.",
|
||||
"notifications_none_for_topic_description": "Чтобы отправить уведомление на данную тему, просто сделаете PUT или POST-запрос на URL-адрес этой темы.",
|
||||
"notifications_none_for_any_description": "Чтобы отправить уведомление на тему, просто сделаете PUT или POST-запрос на её URL-адрес. Вот пример с использованием одной из ваших тем.",
|
||||
"notifications_no_subscriptions_title": "Похоже, что у вас ещё нет подписок.",
|
||||
"alert_grant_description": "Разрешите браузеру показывать уведомления.",
|
||||
"notifications_no_subscriptions_description": "Нажмите \"{{linktext}}\" ссылку, чтобы создать или подписаться на тему. После этого вы сможете отправлять сообщения используя PUT или POST, и вы будете получать здесь уведомления.",
|
||||
"notifications_no_subscriptions_description": "Нажмите на ссылку \"{{linktext}}\", чтобы создать или подписаться на тему. После этого Вы сможете отправлять сообщения используя PUT или POST-запросы и получать уведомления здесь.",
|
||||
"notifications_example": "Пример",
|
||||
"notifications_more_details": "Дополнительную информацию найдёте на <websiteLink>сайте</websiteLink> или в <docsLink>документации</docsLink>.",
|
||||
"notifications_loading": "Загружаются уведомления …",
|
||||
"notifications_more_details": "Для более подробной информации, посетите <websiteLink>наш сайт</websiteLink> или <docsLink>документацию</docsLink>.",
|
||||
"notifications_loading": "Идет загрузка уведомлений …",
|
||||
"publish_dialog_title_topic": "Опубликовать в {{topic}}",
|
||||
"publish_dialog_title_no_topic": "Опубликовать уведомление",
|
||||
"publish_dialog_progress_uploading": "Загружается …",
|
||||
"publish_dialog_progress_uploading": "Идет загрузка …",
|
||||
"publish_dialog_progress_uploading_detail": "Загружается {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"publish_dialog_message_published": "Уведомление опубликовано",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "превышает {{fileSizeLimit}} размер файла, {{remainingBytes}} осталось",
|
||||
"publish_dialog_attachment_limits_file_reached": "превышает {{fileSizeLimit}} размер файла",
|
||||
"publish_dialog_attachment_limits_quota_reached": "превышает квоту, {{remainingBytes}} осталось",
|
||||
"publish_dialog_attachment_limits_file_and_quota_reached": "превышает максимальный размер файла {{fileSizeLimit}} и квоту, осталось {{remainingBytes}}",
|
||||
"publish_dialog_attachment_limits_file_reached": "превышает максимальный размер файла {{fileSizeLimit}}",
|
||||
"publish_dialog_attachment_limits_quota_reached": "превышает квоту, осталось {{remainingBytes}}",
|
||||
"publish_dialog_priority_low": "Низкий приоритет",
|
||||
"publish_dialog_priority_default": "Приоритет по умолчанию",
|
||||
"publish_dialog_priority_default": "Стандартный приоритет",
|
||||
"publish_dialog_priority_high": "Высокий приоритет",
|
||||
"publish_dialog_priority_max": "Макс. приоритет",
|
||||
"publish_dialog_priority_max": "Максимальный приоритет",
|
||||
"publish_dialog_base_url_label": "URL-адрес сервиса",
|
||||
"publish_dialog_base_url_placeholder": "URL-адрес сервиса, например https://example.com",
|
||||
"publish_dialog_topic_label": "Название темы",
|
||||
@@ -32,14 +32,14 @@
|
||||
"publish_dialog_title_label": "Заголовок",
|
||||
"publish_dialog_title_placeholder": "Заголовок уведомления, например Disk space alert",
|
||||
"publish_dialog_message_label": "Сообщение",
|
||||
"publish_dialog_message_placeholder": "Текст сообщения",
|
||||
"publish_dialog_message_placeholder": "Введите сообщение здесь",
|
||||
"publish_dialog_tags_label": "Тэги",
|
||||
"publish_dialog_tags_placeholder": "Список тэгов, разделённый запятой, например warning, srv1-backup",
|
||||
"publish_dialog_tags_placeholder": "Список тэгов, разделённый запятой, например: warning, srv1-backup",
|
||||
"publish_dialog_priority_label": "Приоритет",
|
||||
"publish_dialog_click_label": "Нажмите на URL-адрес",
|
||||
"publish_dialog_click_placeholder": "URL-адрес который откроется когда будет нажато уведомление",
|
||||
"publish_dialog_email_label": "Эл. почта",
|
||||
"message_bar_error_publishing": "Ошибка отправки уведомления",
|
||||
"publish_dialog_click_label": "Ссылка при открытии",
|
||||
"publish_dialog_click_placeholder": "URL-адрес, который откроется при нажатии на уведомление",
|
||||
"publish_dialog_email_label": "Электронная почта",
|
||||
"message_bar_error_publishing": "Ошибка публикации уведомления",
|
||||
"alert_not_supported_title": "Уведомления не поддерживаются",
|
||||
"alert_not_supported_description": "Уведомления не поддерживаются вашим браузером.",
|
||||
"notifications_copied_to_clipboard": "Скопировано в буфер обмена",
|
||||
@@ -66,30 +66,30 @@
|
||||
"notifications_click_open_button": "Открыть ссылку",
|
||||
"subscribe_dialog_subscribe_title": "Подписаться на тему",
|
||||
"publish_dialog_button_cancel": "Отмена",
|
||||
"subscribe_dialog_subscribe_description": "Темы могут быть не защищены паролем, поэтому укажите сложное имя. После подписки вы можете размещать/отправлять уведомления.",
|
||||
"subscribe_dialog_subscribe_description": "Темы могут быть не защищены паролем, поэтому укажите сложное имя. После подписки Вы сможете отправлять уведомления используя PUT/POST-запросы.",
|
||||
"prefs_users_description": "Добавляйте/удаляйте пользователей для защищенных тем. Обратите внимание, что имя пользователя и пароль хранятся в локальном хранилище браузера.",
|
||||
"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>.",
|
||||
"publish_dialog_email_placeholder": "Адрес для пересылки уведомления. Например, phil@example.com",
|
||||
"publish_dialog_attach_placeholder": "Прикрепите файл по URL. Например, https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_filename_label": "Имя файла",
|
||||
"publish_dialog_delay_label": "Задержка",
|
||||
"publish_dialog_delay_placeholder": "Задержка доставки. Например, {{unixTimestamp}}, {{relativeTime}}, or \"{{naturalLanguage}}\" (English only)",
|
||||
"publish_dialog_chip_click_label": "Адрес",
|
||||
"publish_dialog_delay_placeholder": "Задержка доставки. Например, {{unixTimestamp}}, {{relativeTime}}, или \"{{naturalLanguage}}\" (только по-английски)",
|
||||
"publish_dialog_chip_click_label": "URL-адрес при нажатии",
|
||||
"publish_dialog_chip_email_label": "Переслать на электронную почту",
|
||||
"publish_dialog_chip_attach_url_label": "Прикрепить файл по URL",
|
||||
"publish_dialog_chip_attach_file_label": "Прикрепить локальный файл",
|
||||
"publish_dialog_chip_delay_label": "Задержка отправки",
|
||||
"publish_dialog_chip_delay_label": "Задержать доставку",
|
||||
"publish_dialog_chip_topic_label": "Изменить тему",
|
||||
"publish_dialog_details_examples_description": "Примеры и подробное описание всех функций см. в e <docsLink>документации</docsLink>.",
|
||||
"publish_dialog_details_examples_description": "Примеры и подробное описание всех функций смотрите в <docsLink>документации</docsLink>.",
|
||||
"publish_dialog_attach_label": "URL-адрес вложения",
|
||||
"publish_dialog_filename_placeholder": "Имя файла вложения",
|
||||
"publish_dialog_other_features": "Другие возможности:",
|
||||
"publish_dialog_button_cancel_sending": "Отменить отправку",
|
||||
"publish_dialog_button_send": "Отправить",
|
||||
"publish_dialog_checkbox_publish_another": "Опубликовать еще",
|
||||
"publish_dialog_attached_file_title": "Прикрепленный файл:",
|
||||
"publish_dialog_attached_file_title": "Прикреплённый файл:",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Имя прикреплённого файла",
|
||||
"emoji_picker_search_placeholder": "Поиск эмодзи",
|
||||
"emoji_picker_search_placeholder": "Поиск смайликов",
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "Название темы. Например, phil_alerts",
|
||||
"subscribe_dialog_subscribe_use_another_label": "Использовать другой сервер",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Отмена",
|
||||
@@ -101,23 +101,23 @@
|
||||
"subscribe_dialog_login_button_back": "Назад",
|
||||
"subscribe_dialog_login_button_login": "Войти",
|
||||
"subscribe_dialog_error_user_not_authorized": "Пользователь {{username}} не авторизован",
|
||||
"subscribe_dialog_error_user_anonymous": "аноним",
|
||||
"subscribe_dialog_error_user_anonymous": "анонимный пользователь",
|
||||
"prefs_notifications_title": "Уведомления",
|
||||
"prefs_notifications_sound_title": "Звук уведомления",
|
||||
"prefs_notifications_sound_description_none": "Уведомления не воспроизводят никаких звуков при получении",
|
||||
"prefs_notifications_sound_no_sound": "Без звука",
|
||||
"prefs_notifications_min_priority_title": "Минимальный приоритет",
|
||||
"prefs_notifications_min_priority_description_any": "Показать все уведомления, независимо от приоритета",
|
||||
"prefs_notifications_min_priority_description_any": "Показывать все уведомления, независимо от приоритета",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Показывать уведомления, если приоритет {{number}} ({{name}}) или выше",
|
||||
"prefs_notifications_min_priority_description_max": "Показывать уведомления, если приоритет равен 5 (максимум)",
|
||||
"prefs_notifications_min_priority_description_max": "Показывать уведомления, если приоритет равен 5 (максимальный)",
|
||||
"prefs_notifications_min_priority_any": "Любой приоритет",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Низкий и высокий приоритет",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Низкий приоритет и выше",
|
||||
"prefs_notifications_min_priority_max_only": "Только максимальный приоритет",
|
||||
"prefs_notifications_delete_after_title": "Удалить уведомления",
|
||||
"prefs_notifications_delete_after_never": "Никогда",
|
||||
"prefs_notifications_delete_after_three_hours": "Через три часа",
|
||||
"prefs_notifications_sound_description_some": "Уведомления воспроизводят звук {{sound}}",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Приоритет по умолчанию и высокий",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Стандартный приоритет и выше",
|
||||
"prefs_notifications_delete_after_one_day": "Через день",
|
||||
"prefs_notifications_delete_after_one_week": "Через неделю",
|
||||
"prefs_notifications_delete_after_one_month": "Через месяц",
|
||||
@@ -129,10 +129,10 @@
|
||||
"prefs_users_title": "Управление пользователями",
|
||||
"prefs_users_add_button": "Добавить пользователя",
|
||||
"prefs_users_table_user_header": "Пользователь",
|
||||
"prefs_users_table_base_url_header": "URL службы",
|
||||
"prefs_users_table_base_url_header": "URL сервера",
|
||||
"prefs_users_dialog_title_add": "Добавить пользователя",
|
||||
"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_username_label": "Имя пользователя. Например, phil",
|
||||
"prefs_users_dialog_password_label": "Пароль",
|
||||
"common_cancel": "Отмена",
|
||||
@@ -140,19 +140,217 @@
|
||||
"common_save": "Сохранить",
|
||||
"prefs_appearance_title": "Внешний вид",
|
||||
"prefs_appearance_language_title": "Язык",
|
||||
"priority_min": "минимум",
|
||||
"priority_min": "минимальный",
|
||||
"priority_low": "низкий",
|
||||
"priority_default": "по умолчанию",
|
||||
"priority_default": "стандартный",
|
||||
"priority_high": "высокий",
|
||||
"priority_max": "максимальный",
|
||||
"error_boundary_title": "О нет, Ntfy сломался",
|
||||
"error_boundary_button_copy_stack_trace": "Копирование трассировки стека",
|
||||
"error_boundary_title": "О нет, ntfy сломался",
|
||||
"error_boundary_button_copy_stack_trace": "Скопировать трассировку стека",
|
||||
"error_boundary_stack_trace": "Трассировка стека",
|
||||
"error_boundary_gathering_info": "Соберите больше информации …",
|
||||
"publish_dialog_drop_file_here": "Перетащите файл юда",
|
||||
"error_boundary_gathering_info": "Идет сбор дополнительной информации …",
|
||||
"publish_dialog_drop_file_here": "Перетащите файл сюда",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Высокий приоритет и выше",
|
||||
"action_bar_toggle_action_menu": "Открыть/закрыть меню",
|
||||
"action_bar_show_menu": "Показать меню",
|
||||
"action_bar_logo_alt": "ntfy лого",
|
||||
"emoji_picker_search_clear": "Очистить поиск"
|
||||
"action_bar_logo_alt": "Логотип ntfy",
|
||||
"emoji_picker_search_clear": "Сбросить поиск",
|
||||
"account_upgrade_dialog_cancel_warning": "Это действие <strong>отменит Вашу подписку</strong> и переведет Вашую учетную запись на бесплатное обслуживание {{date}}. При наступлении этой даты, все резервирования и сообщения в кэше <strong>будут удалены</strong>.",
|
||||
"account_tokens_table_create_token_button": "Создать токен доступа",
|
||||
"account_tokens_table_last_origin_tooltip": "с IP-адреса {{ip}}, нажмите для подробностей",
|
||||
"account_tokens_dialog_title_edit": "Изменить токен доступа",
|
||||
"account_delete_dialog_button_cancel": "Отмена",
|
||||
"account_delete_dialog_billing_warning": "Удаление учетной записи также отменяет все платные подписки. У Вас не будет доступа к порталу оплаты.",
|
||||
"account_delete_dialog_description": "Это действие безвозвратно удалит Вашу учетную запись, включая все Ваши данные хранящиеся на сервере. После удаления, Ваше имя пользователя не будет доступно для регистрации в течении 7 дней. Если Вы действительно хотите продолжить, пожалуйста введите Ваш пароль ниже.",
|
||||
"account_delete_dialog_label": "Пароль",
|
||||
"reservation_delete_dialog_action_keep_description": "Сообщения и вложения которые находятся в кэше сервера станут доступны всем, кто знает имя темы.",
|
||||
"prefs_reservations_table": "Список зарезервированных тем",
|
||||
"prefs_reservations_table_access_header": "Доступ",
|
||||
"prefs_reservations_table_everyone_write_only": "Я могу публиковать и подписываться, все остальные могут публиковать",
|
||||
"prefs_reservations_dialog_description": "Резервирование дает Вам возможность управлять темой и настраивать правила доступа к ней для пользователей.",
|
||||
"reservation_delete_dialog_action_delete_title": "Удалить сообщения в кэше и вложения",
|
||||
"reservation_delete_dialog_action_delete_description": "Сообщения в кэше и вложения будут безвозвратно удалены. Это действие невозможно отменить.",
|
||||
"prefs_reservations_table_not_subscribed": "Не подписан",
|
||||
"prefs_reservations_table_everyone_deny_all": "Только я могу публиковать и подписываться",
|
||||
"prefs_reservations_table_everyone_read_write": "Все могут публиковать и подписываться",
|
||||
"prefs_reservations_table_click_to_subscribe": "Нажмите чтобы подписаться",
|
||||
"prefs_reservations_dialog_title_add": "Зарезервировать тему",
|
||||
"prefs_reservations_dialog_title_delete": "Удалить резервирование",
|
||||
"prefs_reservations_dialog_title_edit": "Изменение резервированной темы",
|
||||
"prefs_reservations_table_topic_header": "Тема",
|
||||
"prefs_users_description_no_sync": "Пользователи и пароли не синхронизируются с Вашей учетной записью.",
|
||||
"prefs_users_delete_button": "Удалить пользователя",
|
||||
"prefs_users_table_cannot_delete_or_edit": "Невозможно удалить или редактировать залогиненного пользователя",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у Вас на данный момент. Перед сменой подписки, <strong>пожалуйста удалите хотя бы одну зарезервированную тему</strong>. Вы можете это сделать в <Link>Настройках</Link>.",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Пересчёт оплаты</strong>: при расширении подписки, разница в цене от текущей <strong>спишется сразу</strong>. При упрощении подписки, неиспользованные средства пойдут в оплату баланса по следующим счетам.",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} на файл",
|
||||
"account_tokens_table_never_expires": "Никогда",
|
||||
"account_tokens_table_copied_to_clipboard": "Токен доступа скопирован",
|
||||
"account_tokens_table_cannot_delete_or_edit": "Невозможно изменить или удалить токен текущего сеанса",
|
||||
"account_tokens_delete_dialog_description": "Перед удалением токена доступа, убедитесь что он не используется приложениями и скриптами. <strong>Это действие невозможно отменить</strong>.",
|
||||
"error_boundary_unsupported_indexeddb_title": "Работа в приватном режиме не поддерживается",
|
||||
"account_tokens_dialog_button_create": "Создать токен",
|
||||
"account_tokens_delete_dialog_submit_button": "Безвозвратно удалить токен",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у Вас на данный момент. Перед сменой подписки, <strong>пожалуйста удалите хотя бы {{count}} зарезервированных тем</strong>. Вы можете это сделать в <Link>Настройках</Link>.",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} сообщений в день",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} суммарный объем",
|
||||
"account_upgrade_dialog_tier_selected_label": "Выбранная",
|
||||
"account_tokens_table_current_session": "Текущий сеанс браузера",
|
||||
"account_tokens_dialog_button_update": "Изменить токен",
|
||||
"account_tokens_dialog_expires_label": "Токен доступа истекает",
|
||||
"account_tokens_dialog_expires_x_hours": "Токен истекает через {{hours}} часов",
|
||||
"account_tokens_dialog_expires_never": "Токен никогда не истекает",
|
||||
"prefs_notifications_sound_play": "Воспроизводить выбранный звук",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} зарезервированных тем",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} эл. сообщений в день",
|
||||
"account_basics_tier_free": "Бесплатный",
|
||||
"account_tokens_dialog_title_create": "Создать токен доступа",
|
||||
"account_tokens_dialog_title_delete": "Удалить токен доступа",
|
||||
"account_tokens_table_copy_to_clipboard": "Скопировать в буфер обмена",
|
||||
"account_tokens_dialog_button_cancel": "Отмена",
|
||||
"account_tokens_dialog_expires_unchanged": "Оставить срок истечения без изменений",
|
||||
"account_tokens_dialog_expires_x_days": "Токен истекает через {{days}} дней",
|
||||
"account_tokens_delete_dialog_title": "Удалить токен доступа",
|
||||
"prefs_users_table": "Список пользоваетелй",
|
||||
"account_upgrade_dialog_tier_current_label": "Текущая",
|
||||
"account_upgrade_dialog_button_cancel": "Отмена",
|
||||
"prefs_users_edit_button": "Редактировать пользователя",
|
||||
"account_basics_tier_upgrade_button": "Подписаться на Pro",
|
||||
"account_basics_tier_paid_until": "Подписка оплачена до {{date}} и будет продляться автоматически",
|
||||
"account_basics_tier_change_button": "Изменить",
|
||||
"account_delete_dialog_button_submit": "Безвозвратно удалить учетную запись",
|
||||
"account_upgrade_dialog_title": "Изменить уровень учетной записи",
|
||||
"account_usage_basis_ip_description": "Статистика и ограничения на использование учитываются по IP-адресу, поэтому они могут совмещаться с другими пользователями. Уровни, указанные выше, примерно соответствуют текущим ограничениям.",
|
||||
"publish_dialog_topic_reset": "Сбросить тему",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(без подписки)",
|
||||
"prefs_reservations_dialog_topic_label": "Тема",
|
||||
"signup_form_username": "Имя пользователя",
|
||||
"signup_form_password": "Пароль",
|
||||
"signup_form_confirm_password": "Подтвердите пароль",
|
||||
"signup_form_button_submit": "Зарегистрироваться",
|
||||
"signup_form_toggle_password_visibility": "Показать/скрыть пароль",
|
||||
"signup_disabled": "Регистрация недоступна",
|
||||
"signup_error_username_taken": "Имя пользователя {{username}} уже занято",
|
||||
"signup_title": "Создать учетную запись ntfy",
|
||||
"signup_already_have_account": "Уже есть учетная запись? Войдите!",
|
||||
"signup_error_creation_limit_reached": "Лимит на создание учетных записей исчерпан",
|
||||
"login_form_button_submit": "Вход",
|
||||
"login_link_signup": "Регистрация",
|
||||
"login_disabled": "Вход недоступен",
|
||||
"action_bar_reservation_add": "Зарезервировать тему",
|
||||
"action_bar_reservation_edit": "Изменить резервирование",
|
||||
"action_bar_reservation_delete": "Удалить резервирование",
|
||||
"action_bar_profile_title": "Профиль",
|
||||
"action_bar_profile_settings": "Настройки",
|
||||
"action_bar_profile_logout": "Выход",
|
||||
"action_bar_sign_in": "Вход",
|
||||
"action_bar_sign_up": "Регистрация",
|
||||
"action_bar_change_display_name": "Изменить псевдоним",
|
||||
"message_bar_publish": "Опубликовать сообщение",
|
||||
"nav_button_muted": "Уведомления заглушены",
|
||||
"nav_button_connecting": "установка соединения",
|
||||
"action_bar_account": "Учетная запись",
|
||||
"login_title": "Вход в Вашу учетную запись ntfy",
|
||||
"action_bar_reservation_limit_reached": "Лимит исчерпан",
|
||||
"action_bar_toggle_mute": "Заглушить/разрешить уведомления",
|
||||
"nav_button_account": "Учетная запись",
|
||||
"nav_upgrade_banner_label": "Подпишитесь на ntfy Pro",
|
||||
"message_bar_show_dialog": "Открыть диалог публикации",
|
||||
"notifications_list": "Список уведомлений",
|
||||
"notifications_list_item": "Уведомление",
|
||||
"notifications_mark_read": "Пометить как прочтенное",
|
||||
"notifications_priority_x": "Приоритет {{priority}}",
|
||||
"notifications_attachment_image": "Приложенное изображение",
|
||||
"notifications_attachment_file_audio": "звуковой файл",
|
||||
"notifications_attachment_file_video": "видео файл",
|
||||
"notifications_attachment_file_image": "графический файл",
|
||||
"notifications_attachment_file_app": "исполняемый файл Android",
|
||||
"notifications_attachment_file_document": "другой тип файла",
|
||||
"notifications_actions_not_supported": "Действие не поддерживается в веб-приложении",
|
||||
"display_name_dialog_title": "Изменить псевдоним",
|
||||
"display_name_dialog_description": "Создайте псевдоним для темы, который будет отображаться в списке Ваших подписок. Это помогает легче находить темы со сложными именами.",
|
||||
"reserve_dialog_checkbox_label": "Зарезервировать тему и настроить доступ",
|
||||
"publish_dialog_emoji_picker_show": "Выбрать смайлик",
|
||||
"publish_dialog_click_reset": "Удалить ссылку",
|
||||
"publish_dialog_email_reset": "Удалить адрес для пересылки",
|
||||
"publish_dialog_attach_reset": "Удалить URL-адрес вложения",
|
||||
"publish_dialog_delay_reset": "Удалить задержку доставки",
|
||||
"publish_dialog_attached_file_remove": "Удалить прикреплённый файл",
|
||||
"subscribe_dialog_subscribe_base_url_label": "URL-адрес сервера",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Сгенерировать случайное имя",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Тема уже зарезервирована",
|
||||
"account_basics_title": "Учетная запись",
|
||||
"account_basics_username_title": "Имя пользователя",
|
||||
"account_basics_username_admin_tooltip": "Вы Администратор",
|
||||
"account_basics_password_title": "Пароль",
|
||||
"account_basics_username_description": "Это Вы! :)",
|
||||
"account_basics_password_description": "Смена пароля учетной записи",
|
||||
"account_basics_password_dialog_title": "Смена пароля",
|
||||
"account_basics_password_dialog_current_password_label": "Текущий пароль",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Введен неверный пароль",
|
||||
"account_usage_title": "Использование",
|
||||
"account_usage_of_limit": "из {{limit}}",
|
||||
"account_usage_unlimited": "Неограниченно",
|
||||
"account_usage_limits_reset_daily": "Ограничения сбрасываются ежедневно в полночь (UTC)",
|
||||
"account_basics_tier_description": "Уровень Вашей учетной записи",
|
||||
"account_basics_tier_admin": "Администратор",
|
||||
"account_basics_tier_admin_suffix_with_tier": "(с {{tier}} подпиской)",
|
||||
"account_basics_tier_payment_overdue": "У Вас задолженность по оплате. Пожалуйста проверьте метод оплаты, иначе Вы скоро потеряете преимущества Вашей подписки.",
|
||||
"account_basics_tier_canceled_subscription": "Ваша подписка была отменена; учетная запись перейдет на бесплатное обслуживание {{date}}.",
|
||||
"account_basics_tier_manage_billing_button": "Управление оплатой",
|
||||
"account_usage_messages_title": "Опубликованные сообщения",
|
||||
"account_usage_emails_title": "Отправленные электронные сообщения",
|
||||
"account_usage_reservations_title": "Зарезервированные темы",
|
||||
"account_usage_reservations_none": "Нет зарезервированных тем",
|
||||
"account_usage_attachment_storage_title": "Хранение вложений",
|
||||
"account_usage_attachment_storage_description": "{{filesize}} за файл, удаляются спустя {{expiry}}",
|
||||
"account_usage_cannot_create_portal_session": "Невозможно открыть портал оплаты",
|
||||
"account_delete_title": "Удалить учетную запись",
|
||||
"account_delete_description": "Безвозвратно удалить Вашу учетную запись",
|
||||
"account_upgrade_dialog_button_redirect_signup": "Зарегистрироваться",
|
||||
"account_upgrade_dialog_button_pay_now": "Оплатить и подписаться",
|
||||
"account_upgrade_dialog_button_cancel_subscription": "Отменить подписку",
|
||||
"account_upgrade_dialog_button_update_subscription": "Изменить подписку",
|
||||
"account_tokens_title": "Токены доступа",
|
||||
"account_tokens_description": "Используйте токены доступа для публикации и подписки через ntfy API чтобы не пересылать данные Вашей учетной записи. Смотрите <Link>документацию</Link> чтобы узнать больше.",
|
||||
"account_tokens_table_token_header": "Токен",
|
||||
"account_tokens_table_label_header": "Название",
|
||||
"account_tokens_table_last_access_header": "Последний доступ",
|
||||
"account_tokens_table_expires_header": "Истекает",
|
||||
"account_tokens_dialog_label": "Название, например Radarr notifications",
|
||||
"prefs_reservations_title": "Зарезервированные темы",
|
||||
"prefs_reservations_description": "Здесь Вы можете резервировать темы для личного пользования. Резервирование дает Вам возможность управлять темой и настраивать правила доступа к ней для пользователей.",
|
||||
"prefs_reservations_limit_reached": "Вы исчерпали Ваш лимит на количество зарезервированных тем.",
|
||||
"prefs_reservations_add_button": "Добавить тему",
|
||||
"prefs_reservations_edit_button": "Настройка доступа",
|
||||
"prefs_reservations_delete_button": "Сбросить правила доступа",
|
||||
"prefs_reservations_table_everyone_read_only": "Я могу публиковать и подписываться, все остальные могут подписываться",
|
||||
"prefs_reservations_dialog_access_label": "Доступ",
|
||||
"reservation_delete_dialog_description": "Удаление резервирования дает возможность зарезервировать эту тему другим. Вы можете оставить или удалить существующие сообщения и вложения.",
|
||||
"reservation_delete_dialog_action_keep_title": "Сохранить сообщения в кэше и вложения",
|
||||
"reservation_delete_dialog_submit_button": "Удалить резервирование",
|
||||
"account_basics_tier_basic": "Базовый",
|
||||
"nav_upgrade_banner_description": "Зарезервированные темы, больше сообщений и электронных писем, а также вложения большего размера",
|
||||
"alert_not_supported_context_description": "Уведомления поддерживаются только по протоколу HTTPS. Это ограничение <mdnLink>Notifications API</mdnLink>.",
|
||||
"notifications_delete": "Удалить",
|
||||
"notifications_new_indicator": "Новое уведомление",
|
||||
"notifications_actions_http_request_title": "Сделать HTTP {{method}}-запрос на {{url}}",
|
||||
"display_name_dialog_placeholder": "Псевдоним",
|
||||
"account_basics_password_dialog_new_password_label": "Новый пароль",
|
||||
"account_basics_password_dialog_confirm_password_label": "Подтвердите пароль",
|
||||
"account_basics_password_dialog_button_submit": "Сменить пароль",
|
||||
"account_basics_tier_title": "Тип учетной записи",
|
||||
"error_boundary_unsupported_indexeddb_description": "Веб-приложение ntfy использует IndexedDB, который не поддерживается Вашим браузером в приватном режиме.<br/><br/>Хотя это и не лучший вариант, использовать веб-приложение ntfy в приватном режиме не имеет особого смысла, так как все данные храняться в локальном хранилище браузера. Вы можете узнать больше в <githubLink>этом отчете на GitHub</githubLink> или связавшись с нами через <discordLink>Discord</discordLink> или <matrixLink>Matrix</matrixLink>.",
|
||||
"account_basics_tier_interval_monthly": "ежемесячно",
|
||||
"account_basics_tier_interval_yearly": "ежегодно",
|
||||
"account_upgrade_dialog_interval_yearly": "Ежегодно",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "скидка {{discount}}%",
|
||||
"account_upgrade_dialog_interval_monthly": "Ежемесячно",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "скидка до {{discount}}%",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "Нет зарезервированных тем",
|
||||
"account_upgrade_dialog_tier_price_per_month": "в месяц",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} в год. Оплата помесячно.",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} ежегодно. Сэкономьте {{save}}.",
|
||||
"account_upgrade_dialog_billing_contact_email": "По вопросам оплаты, пожалуйста <Link>свяжитесь с нами</Link>.",
|
||||
"account_upgrade_dialog_billing_contact_website": "По вопросам оплаты, пожалуйста обратитесь к нашему <Link>сайту</Link>."
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"alert_grant_title": "Notiser är avstängda",
|
||||
"alert_grant_button": "Bevilja nu",
|
||||
"alert_not_supported_title": "Notiser stöds inte",
|
||||
"notifications_list": "Notis-lista",
|
||||
"notifications_list": "Notifieringslista",
|
||||
"notifications_list_item": "Notis",
|
||||
"notifications_delete": "Radera",
|
||||
"notifications_copied_to_clipboard": "Kopierat till urklipp",
|
||||
@@ -47,5 +47,34 @@
|
||||
"notifications_actions_open_url_title": "Gå till {{url}}",
|
||||
"notifications_none_for_any_title": "Du har inte fått några notiser.",
|
||||
"notifications_example": "Exempel",
|
||||
"notifications_loading": "Laddar notiser …"
|
||||
"notifications_loading": "Laddar notiser …",
|
||||
"signup_title": "Skapa ett nytt konto",
|
||||
"signup_form_confirm_password": "Bekräfta lösenord",
|
||||
"signup_form_button_submit": "Skapa konto",
|
||||
"login_title": "Logga in på ditt konto",
|
||||
"login_form_button_submit": "Logga in",
|
||||
"login_link_signup": "Registrera",
|
||||
"login_disabled": "Inloggning är inaktiverat",
|
||||
"action_bar_account": "Konto",
|
||||
"action_bar_change_display_name": "Ändra visningsnamn",
|
||||
"action_bar_reservation_add": "Reservera ämne",
|
||||
"action_bar_reservation_edit": "Ändra reservation",
|
||||
"action_bar_reservation_delete": "Ta bort reservation",
|
||||
"action_bar_reservation_limit_reached": "Gräns nådd",
|
||||
"action_bar_profile_title": "Profil",
|
||||
"action_bar_profile_settings": "Inställningar",
|
||||
"action_bar_profile_logout": "Logga ut",
|
||||
"action_bar_sign_in": "Logga in",
|
||||
"action_bar_sign_up": "Registrera",
|
||||
"nav_button_account": "Konto",
|
||||
"nav_upgrade_banner_label": "Uppgradera till Pro",
|
||||
"common_add": "Lägg till",
|
||||
"signup_form_password": "Lösenord",
|
||||
"signup_form_toggle_password_visibility": "Visa/dölj lösenord",
|
||||
"common_cancel": "Avbryt",
|
||||
"common_save": "Spara",
|
||||
"signup_form_username": "Användarnamn",
|
||||
"signup_already_have_account": "Har du redan ett konto? Logga in!",
|
||||
"signup_disabled": "Registrering är inaktiverad",
|
||||
"signup_error_username_taken": "Användarnamn [[username]] används redan"
|
||||
}
|
||||
|
||||
@@ -251,11 +251,11 @@
|
||||
"account_delete_dialog_button_submit": "Hesabı kalıcı olarak sil",
|
||||
"account_delete_dialog_billing_warning": "Hesabınızı silmek, faturalandırma aboneliğinizi de anında iptal eder. Artık faturalandırma sayfasına erişiminiz olmayacak.",
|
||||
"account_upgrade_dialog_title": "Hesap seviyesini değiştir",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Ödeme oranı</strong>: Ücretli planlar arasında geçiş yaparken, fiyat farkı bir sonraki faturada tahsil edilecek veya iade edilecektir. Bir sonraki fatura döneminin sonuna kadar başka bir fatura almayacaksınız.",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Fiyatlandırma</strong>: Ücretli planlar arasında yükseltme yaparken, fiyat farkı <strong>hemen tahsil edilecektir</strong>. Daha düşük bir seviyeye inildiğinde, bakiye gelecek faturalandırma dönemleri için ödeme yapmak üzere kullanılacaktır.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Seçilen seviye, geçerli seviyenizden daha az konu ayırtmaya izin veriyor. Seviyenizi değiştirmeden önce <strong>lütfen en az {{count}} ayırtmayı silin</strong>. Ayırtmaları <Link>Ayarlar</Link> sayfasından kaldırabilirsiniz.",
|
||||
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} konu ayırtıldı",
|
||||
"account_upgrade_dialog_tier_features_messages": "{{messages}} günlük mesaj",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{emails}} günlük e-posta",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} konu ayırtıldı",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} günlük mesaj",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} günlük e-posta",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "dosya başına {{filesize}}",
|
||||
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} toplam depolama",
|
||||
"account_upgrade_dialog_tier_selected_label": "Seçilen",
|
||||
@@ -340,5 +340,17 @@
|
||||
"prefs_reservations_table_everyone_read_only": "Ben yayınlayabilir ve abone olabilirim, herkes abone olabilir",
|
||||
"prefs_reservations_table_not_subscribed": "Abone olunmadı",
|
||||
"prefs_reservations_table_everyone_read_write": "Herkes yayınlayabilir ve abone olabilir",
|
||||
"reservation_delete_dialog_description": "Ayırtmanın kaldırılması, konu üzerindeki sahiplikten vazgeçer ve başkalarının onu ayırtmasına izin verir. Mevcut mesajları ve ekleri saklayabilir veya silebilirsiniz."
|
||||
"reservation_delete_dialog_description": "Ayırtmanın kaldırılması, konu üzerindeki sahiplikten vazgeçer ve başkalarının onu ayırtmasına izin verir. Mevcut mesajları ve ekleri saklayabilir veya silebilirsiniz.",
|
||||
"account_basics_tier_interval_yearly": "yıllık",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "Ayırtılan konu yok",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "Yıllık {{price}}. Aylık faturalandırılır.",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{price}} yıllık olarak faturalandırılır. {{save}} tasarruf edin.",
|
||||
"account_upgrade_dialog_interval_yearly": "Yıllık",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "%{{discount}} tasarruf edin",
|
||||
"account_upgrade_dialog_tier_price_per_month": "ay",
|
||||
"account_upgrade_dialog_billing_contact_email": "Faturalama ile ilgili sorularınız için lütfen doğrudan <Link>bizimle iletişime geçin</Link>.",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "%{{discount}} kadar tasarruf edin",
|
||||
"account_upgrade_dialog_interval_monthly": "Aylık",
|
||||
"account_basics_tier_interval_monthly": "aylık",
|
||||
"account_upgrade_dialog_billing_contact_website": "Faturalama ile ilgili sorularınız için lütfen <Link>web sitemizi ziyaret edin</Link>."
|
||||
}
|
||||
|
||||
@@ -187,5 +187,55 @@
|
||||
"priority_low": "низький",
|
||||
"error_boundary_stack_trace": "Трасування стека",
|
||||
"error_boundary_unsupported_indexeddb_title": "Приватний перегляд не підтримується",
|
||||
"error_boundary_unsupported_indexeddb_description": "Веб-програма ntfy потребує IndexedDB для роботи, а ваш браузер не підтримує IndexedDB у режимі приватного перегляду.<br/><br/>На жаль, використання ntfy web не має сенсу у режимі приватного перегляду, оскільки все зберігається в пам’яті браузера. Ви можете прочитати більше про це <githubLink>у цьому випуску GitHub</githubLink> або поспілкуватися з нами на <discordLink>Discord</discordLink> або <matrixLink>Matrix</matrixLink>."
|
||||
"error_boundary_unsupported_indexeddb_description": "Веб-програма ntfy потребує IndexedDB для роботи, а ваш браузер не підтримує IndexedDB у режимі приватного перегляду.<br/><br/>На жаль, використання ntfy web не має сенсу у режимі приватного перегляду, оскільки все зберігається в пам’яті браузера. Ви можете прочитати більше про це <githubLink>у цьому випуску GitHub</githubLink> або поспілкуватися з нами на <discordLink>Discord</discordLink> або <matrixLink>Matrix</matrixLink>.",
|
||||
"signup_title": "Створення облікового запису ntfy",
|
||||
"signup_form_username": "Ім'я користувача",
|
||||
"signup_form_password": "Пароль",
|
||||
"signup_form_confirm_password": "Підтвердіть пароль",
|
||||
"signup_form_button_submit": "Зареєструватися",
|
||||
"signup_form_toggle_password_visibility": "Перемкнути видимість пароля",
|
||||
"signup_already_have_account": "Вже маєте обліковий запис? Увійдіть!",
|
||||
"signup_disabled": "Реєстрацію вимкнено",
|
||||
"signup_error_username_taken": "Ім'я користувача {{username}} вже зайнято",
|
||||
"signup_error_creation_limit_reached": "Досягнуто обмеження на створення облікового запису",
|
||||
"login_title": "Увійдіть до свого облікового запису ntfy",
|
||||
"login_form_button_submit": "Увійти",
|
||||
"login_link_signup": "Зареєструватися",
|
||||
"login_disabled": "Вхід вимкнено",
|
||||
"action_bar_account": "Обліковий запис",
|
||||
"action_bar_reservation_add": "Зарезервувати тему",
|
||||
"action_bar_reservation_edit": "Змінити резервування",
|
||||
"action_bar_reservation_delete": "Видалити резервування",
|
||||
"action_bar_reservation_limit_reached": "Досягнуто ліміту",
|
||||
"action_bar_change_display_name": "Змінити відображувану назву",
|
||||
"action_bar_profile_title": "Профіль",
|
||||
"action_bar_profile_settings": "Налаштування",
|
||||
"action_bar_sign_up": "Зареєструватися",
|
||||
"nav_button_account": "Обліковий запис",
|
||||
"nav_upgrade_banner_description": "Резервування тем, більше повідомлень та імейлів, більші вкладення",
|
||||
"alert_not_supported_context_description": "Сповіщення підтримуються лише через HTTPS. Це обмеження <mdnLink>Notifications API</mdnLink>.",
|
||||
"display_name_dialog_title": "Змінити відображувану назву",
|
||||
"reserve_dialog_checkbox_label": "Зарезервувати тему та налаштувати доступ",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Згенерувати назву",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Тема вже зарезервована",
|
||||
"account_basics_title": "Обліковий запис",
|
||||
"account_basics_username_title": "Ім'я користувача",
|
||||
"account_basics_username_description": "Привіт, це ти ❤",
|
||||
"account_basics_password_dialog_title": "Змінити пароль",
|
||||
"account_basics_password_dialog_current_password_label": "Поточний пароль",
|
||||
"account_basics_password_dialog_new_password_label": "Новий пароль",
|
||||
"account_basics_password_dialog_confirm_password_label": "Підтвердіть пароль",
|
||||
"account_basics_password_dialog_button_submit": "Змінити пароль",
|
||||
"account_basics_password_dialog_current_password_incorrect": "Неправильний пароль",
|
||||
"account_usage_title": "Використання",
|
||||
"account_usage_limits_reset_daily": "Ліміти використання скидаються щодня опівночі (UTC)",
|
||||
"account_basics_tier_title": "Тип облікового запису",
|
||||
"account_basics_tier_admin": "Адміністратор",
|
||||
"action_bar_sign_in": "Увійти",
|
||||
"action_bar_profile_logout": "Вийти",
|
||||
"nav_upgrade_banner_label": "Оновлення до ntfy Pro",
|
||||
"display_name_dialog_description": "Задайте альтернативну назву для теми, яка відображатиметься у списку підписок. Це допоможе легше ідентифікувати теми зі складними назвами.",
|
||||
"display_name_dialog_placeholder": "Відображуване ім'я",
|
||||
"account_basics_password_title": "Пароль",
|
||||
"account_basics_username_admin_tooltip": "Ви адміністратор"
|
||||
}
|
||||
|
||||
@@ -293,12 +293,12 @@
|
||||
"account_delete_dialog_billing_warning": "删除您的帐户也会立即取消您的计费订阅。您将无法再访问计费仪表板。",
|
||||
"account_upgrade_dialog_title": "更改帐户等级",
|
||||
"account_upgrade_dialog_cancel_warning": "这将<strong>取消您的订阅</strong>,并在 {{date}} 降级您的帐户。在那一天,主题保留以及缓存在服务器上的消息<strong>将被删除</strong>。",
|
||||
"account_upgrade_dialog_proration_info": "<strong>按比例分配</strong>:在付费计划之间切换时,差价将在下一次计费时收取或退还。在下一个计费周期结束之前,您不会收到另一张收据。",
|
||||
"account_upgrade_dialog_proration_info": "<strong>按比例分配</strong>:在付费计划之间升级时,差价将被<strong>立刻收取</strong>。在降级到较低级别时,余额将被用于支付未来的账单周期。",
|
||||
"account_upgrade_dialog_reservations_warning_one": "所选等级允许的保留主题少于当前等级。在更改您的等级之前,<strong>请至少删除 1 项保留</strong>。您可以在<Link>设置</Link>中删除保留。",
|
||||
"account_upgrade_dialog_reservations_warning_other": "所选等级允许的保留主题少于当前等级。在更改您的等级之前,<strong>请至少删除 {{count}} 项保留</strong>。您可以在<Link>设置</Link>中删除保留。",
|
||||
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} 条保留主题",
|
||||
"account_upgrade_dialog_tier_features_messages": "{{messages}} 条每日消息",
|
||||
"account_upgrade_dialog_tier_features_emails": "{{emails}} 条每日邮件",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} 条保留主题",
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} 条每日消息",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} 条每日邮件",
|
||||
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} 每个文件",
|
||||
"signup_form_confirm_password": "确认密码",
|
||||
"signup_form_button_submit": "注册",
|
||||
@@ -340,5 +340,17 @@
|
||||
"account_tokens_table_last_origin_tooltip": "于IP地址 {{ip}},点击查找",
|
||||
"account_tokens_dialog_label": "标签,例如:Radarr 通知",
|
||||
"account_tokens_dialog_button_create": "创建令牌",
|
||||
"account_tokens_dialog_button_update": "更新令牌"
|
||||
"account_tokens_dialog_button_update": "更新令牌",
|
||||
"account_basics_tier_interval_monthly": "每月",
|
||||
"account_basics_tier_interval_yearly": "每年",
|
||||
"account_upgrade_dialog_interval_monthly": "每月",
|
||||
"account_upgrade_dialog_interval_yearly": "每年",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "节省 {{discount}}%",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "节省高达 {{discount}}%",
|
||||
"account_upgrade_dialog_tier_features_no_reservations": "无保留主题",
|
||||
"account_upgrade_dialog_tier_price_per_month": "月",
|
||||
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} 每年。按月计费。",
|
||||
"account_upgrade_dialog_tier_price_billed_yearly": "{{价格}} 按年计费。节省 {{save}}。",
|
||||
"account_upgrade_dialog_billing_contact_email": "有关账单问题,请直接<Link>联系我们 </Link>。",
|
||||
"account_upgrade_dialog_billing_contact_website": "有关账单问题,请参考我们的<Link>网站 </Link>。"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user