Compare commits
117 Commits
remove-rat
...
v2.10.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ee62033b5 | ||
|
|
3e02d7b0bb | ||
|
|
290ed1124e | ||
|
|
fc62682334 | ||
|
|
28404565d2 | ||
|
|
f8548e9d46 | ||
|
|
d90b290cd2 | ||
|
|
21c6776269 | ||
|
|
7fed392e0c | ||
|
|
913b59b5e3 | ||
|
|
4692ca7b7f | ||
|
|
af16542d02 | ||
|
|
5511812e30 | ||
|
|
547b09a7e5 | ||
|
|
b9c176ddba | ||
|
|
f971377cbb | ||
|
|
a04f2f9c9a | ||
|
|
763eafd5dd | ||
|
|
9247dac50d | ||
|
|
de65d07518 | ||
|
|
1966f80855 | ||
|
|
4b2e38320d | ||
|
|
83356f565e | ||
|
|
7fd5f0b29d | ||
|
|
03737dbf5c | ||
|
|
867cf28080 | ||
|
|
b2eb5b94bd | ||
|
|
df7d6baec5 | ||
|
|
a4f5c8dee7 | ||
|
|
4c0ec3f75b | ||
|
|
12bbe9a1ae | ||
|
|
0a589f6242 | ||
|
|
ab2dd6136e | ||
|
|
4d64515e45 | ||
|
|
411597ecc2 | ||
|
|
1a426da913 | ||
|
|
7936c38feb | ||
|
|
d0beaa900f | ||
|
|
f4bf8fd9bb | ||
|
|
d866cb2fd9 | ||
|
|
0ab6171962 | ||
|
|
b7c2898b9c | ||
|
|
d155ebb3a4 | ||
|
|
3d5bb92774 | ||
|
|
a2f1a45097 | ||
|
|
b43fff7c7e | ||
|
|
b186c7a324 | ||
|
|
e50b6f4075 | ||
|
|
e5342d5eca | ||
|
|
a25fc1daaa | ||
|
|
6e75108aa9 | ||
|
|
0735a5cb48 | ||
|
|
088aede833 | ||
|
|
2119954f67 | ||
|
|
220b012ae3 | ||
|
|
e1278a5e92 | ||
|
|
0ae62d36d2 | ||
|
|
70729edb2b | ||
|
|
de2f7d3e9b | ||
|
|
8c69234e28 | ||
|
|
a9b7c4530b | ||
|
|
76fc015775 | ||
|
|
ef302d22a9 | ||
|
|
7ab8ef73a3 | ||
|
|
7616b24e30 | ||
|
|
05c1b264f2 | ||
|
|
35ccfdb8d8 | ||
|
|
d744204052 | ||
|
|
6c3aca4cd6 | ||
|
|
d54db74f5a | ||
|
|
4e1980c2cc | ||
|
|
40f990fffe | ||
|
|
8931f25ac5 | ||
|
|
94f60fb5b8 | ||
|
|
01b397a31a | ||
|
|
f2cd1edc57 | ||
|
|
243123fd7e | ||
|
|
36b33030f3 | ||
|
|
17709f2fb7 | ||
|
|
a8c17c1856 | ||
|
|
8ac920d28c | ||
|
|
239c620707 | ||
|
|
f2e600d681 | ||
|
|
6486db99fa | ||
|
|
766ca05e15 | ||
|
|
6c41f69db7 | ||
|
|
cae696c323 | ||
|
|
42dc8bc3f5 | ||
|
|
896a1b007f | ||
|
|
fc5973751a | ||
|
|
9ca5133c76 | ||
|
|
509f59ac3c | ||
|
|
9d8482a119 | ||
|
|
34343fae02 | ||
|
|
1ff6ecf5d8 | ||
|
|
f2f7ad8253 | ||
|
|
78de5d4866 | ||
|
|
9449b0b875 | ||
|
|
b86c50c60a | ||
|
|
34f90facb2 | ||
|
|
99a0c72d49 | ||
|
|
c4d9e397ab | ||
|
|
3f49f51847 | ||
|
|
7dfcda9306 | ||
|
|
4cfc64e528 | ||
|
|
f2d6f09671 | ||
|
|
0f44d20da5 | ||
|
|
aaf53d5d3f | ||
|
|
4b1468cfd8 | ||
|
|
00fe639a95 | ||
|
|
94781c89f3 | ||
|
|
a3312f69fb | ||
|
|
6a10bac017 | ||
|
|
f565302a0f | ||
|
|
6a3f169a47 | ||
|
|
6bd8875375 | ||
|
|
3691e59af1 |
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.21-bullseye as builder
|
||||
FROM golang:1.22-bullseye as builder
|
||||
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
|
||||
27
README.md
27
README.md
@@ -9,7 +9,6 @@
|
||||
[](https://discord.gg/cT7ECsZj9w)
|
||||
[](https://matrix.to/#/#ntfy:matrix.org)
|
||||
[](https://matrix.to/#/#ntfy-space:matrix.org)
|
||||
[](https://discuss.ntfy.sh/c/ntfy)
|
||||
[](https://ntfy.statuspage.io/)
|
||||
[](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
|
||||
|
||||
@@ -50,7 +49,6 @@ works best for you:
|
||||
|
||||
* [Discord server](https://discord.gg/cT7ECsZj9w) - direct chat with the community
|
||||
* [Matrix room #ntfy](https://matrix.to/#/#ntfy:matrix.org) (+ [Matrix space](https://matrix.to/#/#ntfy-space:matrix.org)) - same chat, bridged from Discord
|
||||
* [Lemmy discussion board](https://discuss.ntfy.sh/c/ntfy) - asynchronous forum (_new as of June 2023_)
|
||||
* [GitHub issues](https://github.com/binwiederhier/ntfy/issues) - questions, features, bugs
|
||||
|
||||
## Announcements/beta testers
|
||||
@@ -71,7 +69,7 @@ for the server and the Android app. Or, if you'd like to help translate 🇩🇪
|
||||
## Sponsors
|
||||
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier),
|
||||
and [Liberapay](https://liberapay.com/ntfy). I would be humbled if you helped me carry the server and developer
|
||||
account costs. Even small donations are very much appreciated. A big fat **Thank You** to the folks already sponsoring ntfy:
|
||||
account costs. Even small donations are very much appreciated. A big fat **Thank You** to the folks who have sponsored ntfy in the past, or are still sponsoring ntfy:
|
||||
|
||||
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
|
||||
<a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a>
|
||||
@@ -168,6 +166,29 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
|
||||
<a href="https://github.com/ubipo"><img src="https://github.com/ubipo.png" width="40px" /></a>
|
||||
<a href="https://github.com/tka85"><img src="https://github.com/tka85.png" width="40px" /></a>
|
||||
<a href="https://github.com/beekeeb"><img src="https://github.com/beekeeb.png" width="40px" /></a>
|
||||
<a href="https://github.com/Emiliaaah"><img src="https://github.com/Emiliaaah.png" width="40px" /></a>
|
||||
<a href="https://github.com/zark0s"><img src="https://github.com/zark0s.png" width="40px" /></a>
|
||||
<a href="https://github.com/tomershvueli"><img src="https://github.com/tomershvueli.png" width="40px" /></a>
|
||||
<a href="https://github.com/CataIana"><img src="https://github.com/CataIana.png" width="40px" /></a>
|
||||
<a href="https://github.com/ajay-actuary"><img src="https://github.com/ajay-actuary.png" width="40px" /></a>
|
||||
<a href="https://github.com/mursec"><img src="https://github.com/mursec.png" width="40px" /></a>
|
||||
<a href="https://github.com/FrameXX"><img src="https://github.com/FrameXX.png" width="40px" /></a>
|
||||
<a href="https://github.com/vovayartsev"><img src="https://github.com/vovayartsev.png" width="40px" /></a>
|
||||
<a href="https://github.com/dwain-lab"><img src="https://github.com/dwain-lab.png" width="40px" /></a>
|
||||
<a href="https://github.com/brookmg"><img src="https://github.com/brookmg.png" width="40px" /></a>
|
||||
<a href="https://github.com/siebej"><img src="https://github.com/siebej.png" width="40px" /></a>
|
||||
<a href="https://github.com/rxsantos"><img src="https://github.com/rxsantos.png" width="40px" /></a>
|
||||
<a href="https://github.com/hermannx5"><img src="https://github.com/hermannx5.png" width="40px" /></a>
|
||||
<a href="https://github.com/rwxd"><img src="https://github.com/rwxd.png" width="40px" /></a>
|
||||
<a href="https://github.com/Integral-Tech"><img src="https://github.com/Integral-Tech.png" width="40px" /></a>
|
||||
<a href="https://github.com/TheTomik1"><img src="https://github.com/TheTomik1.png" width="40px" /></a>
|
||||
<a href="https://github.com/dav23r"><img src="https://github.com/dav23r.png" width="40px" /></a>
|
||||
<a href="https://github.com/stannynuytkens"><img src="https://github.com/stannynuytkens.png" width="40px" /></a>
|
||||
<a href="https://github.com/danbartram"><img src="https://github.com/danbartram.png" width="40px" /></a>
|
||||
<a href="https://github.com/arthurgleckler"><img src="https://github.com/arthurgleckler.png" width="40px" /></a>
|
||||
<a href="https://github.com/tomroth04"><img src="https://github.com/tomroth04.png" width="40px" /></a>
|
||||
<a href="https://github.com/Circenn5130"><img src="https://github.com/Circenn5130.png" width="40px" /></a>
|
||||
<a href="https://github.com/jceloria"><img src="https://github.com/jceloria.png" width="40px" /></a>
|
||||
|
||||
I'd also like to thank JetBrains for their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/),
|
||||
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
|
||||
|
||||
@@ -2,6 +2,7 @@ package client
|
||||
|
||||
import (
|
||||
"gopkg.in/yaml.v2"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"os"
|
||||
)
|
||||
|
||||
@@ -44,6 +45,7 @@ func NewConfig() *Config {
|
||||
|
||||
// LoadConfig loads the Client config from a yaml file
|
||||
func LoadConfig(filename string) (*Config, error) {
|
||||
log.Debug("Loading client config from %s", filename)
|
||||
b, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
164
cmd/serve.go
164
cmd/serve.go
@@ -6,23 +6,22 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io/fs"
|
||||
"math"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -35,7 +34,7 @@ const (
|
||||
|
||||
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"},
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: 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 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"}),
|
||||
@@ -45,19 +44,19 @@ var flagsServe = append(
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"cert_file", "E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: util.FormatDuration(server.DefaultCacheDuration), Usage: "buffer messages for this time to allow `since` requests"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "cache-batch-size", Aliases: []string{"cache_batch_size"}, EnvVars: []string{"NTFY_BATCH_SIZE"}, Usage: "max size of messages to batch together when writing to message cache (if zero, writes are synchronous)"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Value: util.FormatDuration(server.DefaultCacheBatchTimeout), Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: util.FormatDuration(server.DefaultKeepaliveInterval), Usage: "interval of keepalive messages"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: util.FormatDuration(server.DefaultManagerInterval), Usage: "interval of for message pruning and stats printing"}),
|
||||
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "disallowed-topics", Aliases: []string{"disallowed_topics"}, EnvVars: []string{"NTFY_DISALLOWED_TOPICS"}, Usage: "topics that are not allowed to be used"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "/", Usage: "sets root of the web app (e.g. /, or /app), or disables it (disable)"}),
|
||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
|
||||
@@ -76,16 +75,18 @@ var flagsServe = append(
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: "total storage limit used for attachments per visitor"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorRequestLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(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"}),
|
||||
@@ -126,7 +127,7 @@ func execServe(c *cli.Context) error {
|
||||
|
||||
// Read all the options
|
||||
config := c.String("config")
|
||||
baseURL := c.String("base-url")
|
||||
baseURL := strings.TrimSuffix(c.String("base-url"), "/")
|
||||
listenHTTP := c.String("listen-http")
|
||||
listenHTTPS := c.String("listen-https")
|
||||
listenUnix := c.String("listen-unix")
|
||||
@@ -140,19 +141,19 @@ func execServe(c *cli.Context) error {
|
||||
webPushEmailAddress := c.String("web-push-email-address")
|
||||
webPushStartupQueries := c.String("web-push-startup-queries")
|
||||
cacheFile := c.String("cache-file")
|
||||
cacheDuration := c.Duration("cache-duration")
|
||||
cacheDurationStr := c.String("cache-duration")
|
||||
cacheStartupQueries := c.String("cache-startup-queries")
|
||||
cacheBatchSize := c.Int("cache-batch-size")
|
||||
cacheBatchTimeout := c.Duration("cache-batch-timeout")
|
||||
cacheBatchTimeoutStr := c.String("cache-batch-timeout")
|
||||
authFile := c.String("auth-file")
|
||||
authStartupQueries := c.String("auth-startup-queries")
|
||||
authDefaultAccess := c.String("auth-default-access")
|
||||
attachmentCacheDir := c.String("attachment-cache-dir")
|
||||
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
||||
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
|
||||
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
|
||||
keepaliveInterval := c.Duration("keepalive-interval")
|
||||
managerInterval := c.Duration("manager-interval")
|
||||
attachmentExpiryDurationStr := c.String("attachment-expiry-duration")
|
||||
keepaliveIntervalStr := c.String("keepalive-interval")
|
||||
managerIntervalStr := c.String("manager-interval")
|
||||
disallowedTopics := c.StringSlice("disallowed-topics")
|
||||
webRoot := c.String("web-root")
|
||||
enableSignup := c.Bool("enable-signup")
|
||||
@@ -171,17 +172,19 @@ func execServe(c *cli.Context) error {
|
||||
twilioAuthToken := c.String("twilio-auth-token")
|
||||
twilioPhoneNumber := c.String("twilio-phone-number")
|
||||
twilioVerifyService := c.String("twilio-verify-service")
|
||||
messageSizeLimitStr := c.String("message-size-limit")
|
||||
messageDelayLimitStr := c.String("message-delay-limit")
|
||||
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")
|
||||
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
|
||||
visitorRequestLimitReplenishStr := c.String("visitor-request-limit-replenish")
|
||||
visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",")
|
||||
visitorMessageDailyLimit := c.Int("visitor-message-daily-limit")
|
||||
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
||||
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
|
||||
visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish")
|
||||
behindProxy := c.Bool("behind-proxy")
|
||||
stripeSecretKey := c.String("stripe-secret-key")
|
||||
stripeWebhookKey := c.String("stripe-webhook-key")
|
||||
@@ -190,6 +193,64 @@ func execServe(c *cli.Context) error {
|
||||
enableMetrics := c.Bool("enable-metrics") || metricsListenHTTP != ""
|
||||
profileListenHTTP := c.String("profile-listen-http")
|
||||
|
||||
// Convert durations
|
||||
cacheDuration, err := util.ParseDuration(cacheDurationStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid cache duration: %s", cacheDurationStr)
|
||||
}
|
||||
cacheBatchTimeout, err := util.ParseDuration(cacheBatchTimeoutStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid cache batch timeout: %s", cacheBatchTimeoutStr)
|
||||
}
|
||||
attachmentExpiryDuration, err := util.ParseDuration(attachmentExpiryDurationStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid attachment expiry duration: %s", attachmentExpiryDurationStr)
|
||||
}
|
||||
keepaliveInterval, err := util.ParseDuration(keepaliveIntervalStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid keepalive interval: %s", keepaliveIntervalStr)
|
||||
}
|
||||
managerInterval, err := util.ParseDuration(managerIntervalStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid manager interval: %s", managerIntervalStr)
|
||||
}
|
||||
messageDelayLimit, err := util.ParseDuration(messageDelayLimitStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid message delay limit: %s", messageDelayLimitStr)
|
||||
}
|
||||
visitorRequestLimitReplenish, err := util.ParseDuration(visitorRequestLimitReplenishStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid visitor request limit replenish: %s", visitorRequestLimitReplenishStr)
|
||||
}
|
||||
visitorEmailLimitReplenish, err := util.ParseDuration(visitorEmailLimitReplenishStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid visitor email limit replenish: %s", visitorEmailLimitReplenishStr)
|
||||
}
|
||||
|
||||
// Convert sizes to bytes
|
||||
messageSizeLimit, err := util.ParseSize(messageSizeLimitStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid message size limit: %s", messageSizeLimitStr)
|
||||
}
|
||||
attachmentTotalSizeLimit, err := util.ParseSize(attachmentTotalSizeLimitStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid attachment total size limit: %s", attachmentTotalSizeLimitStr)
|
||||
}
|
||||
attachmentFileSizeLimit, err := util.ParseSize(attachmentFileSizeLimitStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid attachment file size limit: %s", attachmentFileSizeLimitStr)
|
||||
}
|
||||
visitorAttachmentTotalSizeLimit, err := util.ParseSize(visitorAttachmentTotalSizeLimitStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid visitor attachment total size limit: %s", visitorAttachmentTotalSizeLimitStr)
|
||||
}
|
||||
visitorAttachmentDailyBandwidthLimit, err := util.ParseSize(visitorAttachmentDailyBandwidthLimitStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid visitor attachment daily bandwidth limit: %s", visitorAttachmentDailyBandwidthLimitStr)
|
||||
} else if visitorAttachmentDailyBandwidthLimit > math.MaxInt {
|
||||
return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
|
||||
}
|
||||
|
||||
// Check values
|
||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||
return errors.New("if set, FCM key file must exist")
|
||||
@@ -213,10 +274,15 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
|
||||
} else if attachmentCacheDir != "" && baseURL == "" {
|
||||
return errors.New("if attachment-cache-dir is set, base-url must also be set")
|
||||
} else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||
return errors.New("if set, base-url must start with http:// or https://")
|
||||
} else if baseURL != "" && strings.HasSuffix(baseURL, "/") {
|
||||
return errors.New("if set, base-url must not end with a slash (/)")
|
||||
} else if baseURL != "" {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("if set, base-url must be a valid URL, e.g. https://ntfy.mydomain.com: %v", err)
|
||||
} else if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return errors.New("if set, base-url must be a valid URL starting with http:// or https://, e.g. https://ntfy.mydomain.com")
|
||||
} else if u.Path != "" {
|
||||
return fmt.Errorf("if set, base-url must not have a path (%s), as hosting ntfy on a sub-path is not supported, e.g. https://ntfy.mydomain.com", u.Path)
|
||||
}
|
||||
} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
|
||||
return errors.New("if set, upstream-base-url must start with http:// or https://")
|
||||
} else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") {
|
||||
@@ -233,6 +299,11 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
||||
} else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") {
|
||||
return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file must also be set")
|
||||
} else if messageSizeLimit > server.DefaultMessageSizeLimit {
|
||||
log.Warn("message-size-limit is greater than 4K, this is not recommended and largely untested, and may lead to issues with some clients")
|
||||
if messageSizeLimit > 5*1024*1024 {
|
||||
return errors.New("message-size-limit cannot be higher than 5M")
|
||||
}
|
||||
}
|
||||
|
||||
// Backwards compatibility
|
||||
@@ -257,26 +328,6 @@ func execServe(c *cli.Context) error {
|
||||
listenHTTP = ""
|
||||
}
|
||||
|
||||
// Convert sizes to bytes
|
||||
attachmentTotalSizeLimit, err := parseSize(attachmentTotalSizeLimitStr, server.DefaultAttachmentTotalSizeLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachmentFileSizeLimit, err := parseSize(attachmentFileSizeLimitStr, server.DefaultAttachmentFileSizeLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
visitorAttachmentTotalSizeLimit, err := parseSize(visitorAttachmentTotalSizeLimitStr, server.DefaultVisitorAttachmentTotalSizeLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
visitorAttachmentDailyBandwidthLimit, err := parseSize(visitorAttachmentDailyBandwidthLimitStr, server.DefaultVisitorAttachmentDailyBandwidthLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if visitorAttachmentDailyBandwidthLimit > math.MaxInt {
|
||||
return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
|
||||
}
|
||||
|
||||
// Resolve hosts
|
||||
visitorRequestLimitExemptIPs := make([]netip.Prefix, 0)
|
||||
for _, host := range visitorRequestLimitExemptHosts {
|
||||
@@ -337,6 +388,8 @@ func execServe(c *cli.Context) error {
|
||||
conf.TwilioAuthToken = twilioAuthToken
|
||||
conf.TwilioPhoneNumber = twilioPhoneNumber
|
||||
conf.TwilioVerifyService = twilioVerifyService
|
||||
conf.MessageSizeLimit = int(messageSizeLimit)
|
||||
conf.MessageDelayMax = messageDelayLimit
|
||||
conf.TotalTopicLimit = totalTopicLimit
|
||||
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
||||
@@ -379,17 +432,6 @@ func execServe(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseSize(s string, defaultValue int64) (v int64, err error) {
|
||||
if s == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
v, err = util.ParseSize(s)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func sigHandlerConfigReload(config string) {
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGHUP)
|
||||
|
||||
@@ -310,28 +310,43 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
|
||||
if filename != "" {
|
||||
return client.LoadConfig(filename)
|
||||
}
|
||||
configFile := defaultClientConfigFile()
|
||||
if s, _ := os.Stat(configFile); s != nil {
|
||||
return client.LoadConfig(configFile)
|
||||
configFile, err := defaultClientConfigFile()
|
||||
if err != nil {
|
||||
log.Warn("Could not determine default client config file: %s", err.Error())
|
||||
} else {
|
||||
if s, _ := os.Stat(configFile); s != nil {
|
||||
return client.LoadConfig(configFile)
|
||||
}
|
||||
log.Debug("Config file %s not found", configFile)
|
||||
}
|
||||
log.Debug("Loading default config")
|
||||
return client.NewConfig(), nil
|
||||
}
|
||||
|
||||
//lint:ignore U1000 Conditionally used in different builds
|
||||
func defaultClientConfigFileUnix() string {
|
||||
u, _ := user.Current()
|
||||
func defaultClientConfigFileUnix() (string, error) {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine current user: %w", err)
|
||||
}
|
||||
configFile := clientRootConfigFileUnixAbsolute
|
||||
if u.Uid != "0" {
|
||||
homeDir, _ := os.UserConfigDir()
|
||||
return filepath.Join(homeDir, clientUserConfigFileUnixRelative)
|
||||
homeDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDir, clientUserConfigFileUnixRelative), nil
|
||||
}
|
||||
return configFile
|
||||
return configFile, nil
|
||||
}
|
||||
|
||||
//lint:ignore U1000 Conditionally used in different builds
|
||||
func defaultClientConfigFileWindows() string {
|
||||
homeDir, _ := os.UserConfigDir()
|
||||
return filepath.Join(homeDir, clientUserConfigFileWindowsRelative)
|
||||
func defaultClientConfigFileWindows() (string, error) {
|
||||
homeDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDir, clientUserConfigFileWindowsRelative), nil
|
||||
}
|
||||
|
||||
func logMessagePrefix(m *client.Message) string {
|
||||
|
||||
@@ -11,6 +11,6 @@ var (
|
||||
scriptLauncher = []string{"sh", "-c"}
|
||||
)
|
||||
|
||||
func defaultClientConfigFile() string {
|
||||
func defaultClientConfigFile() (string, error) {
|
||||
return defaultClientConfigFileUnix()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,6 @@ var (
|
||||
scriptLauncher = []string{"sh", "-c"}
|
||||
)
|
||||
|
||||
func defaultClientConfigFile() string {
|
||||
func defaultClientConfigFile() (string, error) {
|
||||
return defaultClientConfigFileUnix()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,6 @@ var (
|
||||
scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
|
||||
)
|
||||
|
||||
func defaultClientConfigFile() string {
|
||||
func defaultClientConfigFile() (string, error) {
|
||||
return defaultClientConfigFileWindows()
|
||||
}
|
||||
|
||||
@@ -366,9 +366,9 @@ func printTier(c *cli.Context, tier *user.Tier) {
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSize(tier.AttachmentBandwidthLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))
|
||||
fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices)
|
||||
}
|
||||
|
||||
@@ -404,10 +404,10 @@ with the given username/password. Be sure to use HTTPS to avoid eavesdropping an
|
||||
```
|
||||
|
||||
### Example: UnifiedPush
|
||||
[UnifiedPush](https://unifiedpush.org) requires that the [application server](https://unifiedpush.org/spec/definitions/#application-server) (e.g. Synapse, Fediverse Server, …)
|
||||
has anonymous write access to the [topic](https://unifiedpush.org/spec/definitions/#endpoint) used for push messages.
|
||||
[UnifiedPush](https://unifiedpush.org) requires that the [application server](https://unifiedpush.org/developers/spec/definitions/#application-server) (e.g. Synapse, Fediverse Server, …)
|
||||
has anonymous write access to the [topic](https://unifiedpush.org/developers/spec/definitions/#endpoint) used for push messages.
|
||||
The topic names used by UnifiedPush all start with the `up*` prefix. Please refer to the
|
||||
**[UnifiedPush documentation](https://unifiedpush.org/users/distributors/ntfy/#limit-access-to-some-users)** for more details.
|
||||
**[UnifiedPush documentation](https://unifiedpush.org/users/distributors/ntfy/#limit-access-to-some-users-acl)** for more details.
|
||||
|
||||
To enable support for UnifiedPush for private servers (i.e. `auth-default-access: "deny-all"`), you should either
|
||||
allow anonymous write access for the entire prefix or explicitly per topic:
|
||||
@@ -995,6 +995,15 @@ are the easiest), and then configure the following options:
|
||||
After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`),
|
||||
and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message.
|
||||
|
||||
## Message limits
|
||||
There are a few message limits that you can configure:
|
||||
|
||||
* `message-size-limit` defines the max size of a message body. Please note message sizes >4K are **not recommended,
|
||||
and largely untested**. The Android/iOS and other clients may not work, or work properly. If FCM and/or APNS is used,
|
||||
the limit should stay 4K, because their limits are around that size. If you increase this size limit regardless,
|
||||
FCM and APNS will NOT work for large messages.
|
||||
* `message-delay-limit` defines the max delay of a message when using the "Delay" header and [scheduled delivery](publish.md#scheduled-delivery).
|
||||
|
||||
## Rate limiting
|
||||
!!! info
|
||||
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
||||
@@ -1092,8 +1101,8 @@ response if no "rate visitor" has been previously registered. This is to avoid b
|
||||
To enable subscriber-based rate limiting, set `visitor-subscriber-rate-limiting: true`.
|
||||
|
||||
!!! info
|
||||
Due to a denial-of-service issue, support for the `Rate-Topics` header was removed entirely. This is unfortunate,
|
||||
but subscriber-based rate limiting will still work for `up*` topics.
|
||||
Due to a [denial-of-service issue](https://github.com/binwiederhier/ntfy/issues/1048), support for the `Rate-Topics`
|
||||
header was removed entirely. This is unfortunate, but subscriber-based rate limiting will still work for `up*` topics.
|
||||
|
||||
## 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,
|
||||
@@ -1391,6 +1400,8 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 |
|
||||
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
|
||||
| `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
||||
| `message-size-limit` | `NTFY_MESSAGE_SIZE_LIMIT` | *size* | 4K | The size limit for the message body. Please note that this is largely untested, and that FCM/APNS have limits around 4KB. If you increase this size limit, FCM and APNS will NOT work for large messages. |
|
||||
| `message-delay-limit` | `NTFY_MESSAGE_DELAY_LIMIT` | *duration* | 3d | Amount of time a message can be [scheduled](publish.md#scheduled-delivery) into the future when using the `Delay` header |
|
||||
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
||||
| `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers |
|
||||
| `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth |
|
||||
@@ -1417,7 +1428,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address |
|
||||
| `web-push-startup-queries` | `NTFY_WEB_PUSH_STARTUP_QUERIES` | *string* | - | Web Push: SQL queries to run against subscription database at startup |
|
||||
|
||||
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
||||
The format for a *duration* is: `<number>(smhd)`, e.g. 30s, 20m, 1h or 3d.
|
||||
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
||||
|
||||
## Command line options
|
||||
@@ -1449,7 +1460,7 @@ OPTIONS:
|
||||
--log-level-overrides value, --log_level_overrides value [ --log-level-overrides value, --log_level_overrides value ] set log level overrides [$NTFY_LOG_LEVEL_OVERRIDES]
|
||||
--log-format value, --log_format value set log format (default: "text") [$NTFY_LOG_FORMAT]
|
||||
--log-file value, --log_file value set log file, default is STDOUT [$NTFY_LOG_FILE]
|
||||
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
||||
--config value, -c value config file (default: "/etc/ntfy/server.yml") [$NTFY_CONFIG_FILE]
|
||||
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
|
||||
--listen-http value, --listen_http value, -l value ip:port used as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
||||
--listen-https value, --listen_https value, -L value ip:port used as HTTPS listen address [$NTFY_LISTEN_HTTPS]
|
||||
@@ -1459,19 +1470,19 @@ OPTIONS:
|
||||
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
||||
--firebase-key-file value, --firebase_key_file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
|
||||
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
||||
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
||||
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: "12h") [$NTFY_CACHE_DURATION]
|
||||
--cache-batch-size value, --cache_batch_size value max size of messages to batch together when writing to message cache (if zero, writes are synchronous) (default: 0) [$NTFY_BATCH_SIZE]
|
||||
--cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: 0s) [$NTFY_CACHE_BATCH_TIMEOUT]
|
||||
--cache-batch-timeout value, --cache_batch_timeout value timeout for batched async writes to the message cache (if zero, writes are synchronous) (default: "0s") [$NTFY_CACHE_BATCH_TIMEOUT]
|
||||
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
|
||||
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
|
||||
--auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
|
||||
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
|
||||
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
|
||||
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
||||
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
||||
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
||||
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
||||
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: "5G") [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: "15M") [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
|
||||
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: "3h") [$NTFY_ATTACHMENT_EXPIRY_DURATION]
|
||||
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: "45s") [$NTFY_KEEPALIVE_INTERVAL]
|
||||
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: "1m") [$NTFY_MANAGER_INTERVAL]
|
||||
--disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ] topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS]
|
||||
--web-root value, --web_root value sets root of the web app (e.g. /, or /app), or disables it (disable) (default: "/") [$NTFY_WEB_ROOT]
|
||||
--enable-signup, --enable_signup allows users to sign up via the web app, or API (default: false) [$NTFY_ENABLE_SIGNUP]
|
||||
@@ -1490,16 +1501,18 @@ OPTIONS:
|
||||
--twilio-auth-token value, --twilio_auth_token value Twilio auth token [$NTFY_TWILIO_AUTH_TOKEN]
|
||||
--twilio-phone-number value, --twilio_phone_number value Twilio number to use for outgoing calls [$NTFY_TWILIO_PHONE_NUMBER]
|
||||
--twilio-verify-service value, --twilio_verify_service value Twilio Verify service ID, used for phone number verification [$NTFY_TWILIO_VERIFY_SERVICE]
|
||||
--message-size-limit value, --message_size_limit value size limit for the message (see docs for limitations) (default: "4K") [$NTFY_MESSAGE_SIZE_LIMIT]
|
||||
--message-delay-limit value, --message_delay_limit value max duration a message can be scheduled into the future (default: "3d") [$NTFY_MESSAGE_DELAY_LIMIT]
|
||||
--global-topic-limit value, --global_topic_limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
||||
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
||||
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
||||
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
||||
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
||||
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: "5s") [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
||||
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
|
||||
--visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT]
|
||||
--visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
||||
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
||||
--visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: "1h") [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
||||
--visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING]
|
||||
--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]
|
||||
@@ -1512,6 +1525,6 @@ OPTIONS:
|
||||
--web-push-private-key value, --web_push_private_key value private key used for web push notifications [$NTFY_WEB_PUSH_PRIVATE_KEY]
|
||||
--web-push-file value, --web_push_file value file used to store web push subscriptions [$NTFY_WEB_PUSH_FILE]
|
||||
--web-push-email-address value, --web_push_email_address value e-mail address of sender, required to use browser push services [$NTFY_WEB_PUSH_EMAIL_ADDRESS]
|
||||
--web-push-startup-queries value, --web_push_startup-queries value queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES]
|
||||
--web-push-startup-queries value, --web_push_startup_queries value queries run when the web push database is initialized [$NTFY_WEB_PUSH_STARTUP_QUERIES]
|
||||
--help, -h show help
|
||||
```
|
||||
|
||||
@@ -363,7 +363,7 @@ To build your own version with Firebase, you must:
|
||||
* And change `app_base_url` in [values.xml](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/res/values/values.xml)
|
||||
* Then run:
|
||||
```
|
||||
# To build an unsigned .apk (app/build/outputs/apk/play/*.apk)
|
||||
# To build an unsigned .apk (app/build/outputs/apk/play/release/*.apk)
|
||||
./gradlew assemblePlayRelease
|
||||
|
||||
# To build a bundle .aab (app/play/release/*.aab)
|
||||
|
||||
@@ -162,14 +162,23 @@ services:
|
||||
image: containrrr/watchtower
|
||||
environment:
|
||||
- WATCHTOWER_NOTIFICATIONS=shoutrrr
|
||||
- WATCHTOWER_NOTIFICATION_SKIP_TITLE=True
|
||||
- WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates
|
||||
```
|
||||
|
||||
The environment variable `WATCHTOWER_NOTIFICATION_SKIP_TITLE` is required to prevent Watchtower from [replacing the `title` query parameter](https://containrrr.dev/watchtower/notifications/#settings). If omitted, the provided notification title will not be used.
|
||||
|
||||
Or, if you only want to send notifications using shoutrrr:
|
||||
```
|
||||
shoutrrr send -u "ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
|
||||
```
|
||||
|
||||
Authentication tokens are also supported via the generic webhook and authorization header using this url format (replace the domain, topic and token with your own):
|
||||
|
||||
```
|
||||
generic+https://DOMAIN/TOPIC?@authorization=Bearer+TOKEN`
|
||||
```
|
||||
|
||||
## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd
|
||||
|
||||
<!-- Sonarr v4 is in beta as of May 2023, should be updated to remove v3 reference when stable -->
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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'))
|
||||
|
||||
def on_post_build(config, **kwargs):
|
||||
site_dir = config["site_dir"]
|
||||
shutil.copytree("docs/static/fonts", os.path.join(site_dir, "get"))
|
||||
|
||||
@@ -30,37 +30,37 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.8.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.8.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.10.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.10.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.10.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.8.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.8.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.10.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.10.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.10.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.8.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.8.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.10.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.10.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.10.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.8.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.8.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.8.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.10.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.10.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.10.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -110,7 +110,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -118,7 +118,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -126,7 +126,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -134,7 +134,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -144,28 +144,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_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.8.0/ntfy_2.8.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -195,18 +195,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.8.0/ntfy_2.8.0_darwin_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_darwin_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.8.0/ntfy_2.8.0_darwin_all.tar.gz > ntfy_2.8.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.8.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.8.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_darwin_all.tar.gz > ntfy_2.10.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.10.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.10.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.8.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.10.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -224,7 +224,7 @@ brew install ntfy
|
||||
|
||||
## Windows
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.8.0/ntfy_2.8.0_windows_amd64.zip),
|
||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.10.0/ntfy_2.10.0_windows_amd64.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).
|
||||
|
||||
@@ -6,6 +6,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
|
||||
## Official integrations
|
||||
|
||||
- [changedetection.io](https://changedetection.io) ⭐ - Website change detection and notification
|
||||
- [Healthchecks.io](https://healthchecks.io/) ⭐ - Online service for monitoring regularly running tasks such as cron jobs
|
||||
- [Apprise](https://github.com/caronc/apprise/wiki/Notify_ntfy) ⭐ - Push notifications that work with just about every platform
|
||||
- [Uptime Kuma](https://uptime.kuma.pet/) ⭐ - A self-hosted monitoring tool
|
||||
@@ -16,7 +17,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard
|
||||
- [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool
|
||||
- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media
|
||||
- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.7/services/ntfy/) ⭐ - Notification library for gophers and their furry friends.
|
||||
- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.8/services/ntfy/) ⭐ - Notification library for gophers and their furry friends.
|
||||
- [Netdata](https://learn.netdata.cloud/docs/alerts-and-notifications/notifications/agent-alert-notifications/ntfy) ⭐ - Real-time performance monitoring
|
||||
- [Deployer](https://github.com/deployphp/deployer) ⭐ - PHP deployment tool
|
||||
- [Scrt.link](https://scrt.link/) - Share a secret
|
||||
@@ -24,7 +25,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [diun](https://crazymax.dev/diun/) - Docker Image Update Notifier
|
||||
- [Cloudron](https://www.cloudron.io/store/sh.ntfy.cloudronapp.html) - Platform that makes it easy to manage web apps on your server
|
||||
- [Xitoring](https://xitoring.com/docs/notifications/notification-roles/ntfy/) - Server and Uptime monitoring
|
||||
- [changedetection.io](https://changedetection.io) ⭐ - Website change detection and notification
|
||||
- [HetrixTools](https://docs.hetrixtools.com/ntfy-sh-notifications/) - Uptime monitoring
|
||||
|
||||
## Integration via HTTP/SMTP/etc.
|
||||
|
||||
@@ -138,9 +139,24 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [jetspotter](https://github.com/vvanouytsel/jetspotter) - send notifications when planes are spotted near you (Go)
|
||||
- [monitoring_ntfy](https://www.drupal.org/project/monitoring_ntfy) - Drupal monitoring Ntfy.sh integration (PHP/Drupal)
|
||||
- [Notify](https://flathub.org/apps/com.ranfdev.Notify) - Native GTK4 client for ntfy (Rust)
|
||||
- [notify-via-ntfy](https://exchange.checkmk.com/p/notify-via-ntfy) - Checkmk plugin to send notifications via ntfy (Python)
|
||||
- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java)
|
||||
|
||||
## Blog + forum posts
|
||||
|
||||
- [ntfy / Emacs Lisp](https://speechcode.com/blog/ntfy/) - speechcode.com - 3/2024
|
||||
- [Boost Your Productivity with ntfy.sh: The Ultimate Notification Tool for Command-Line Users](https://dev.to/archetypal/boost-your-productivity-with-ntfysh-the-ultimate-notification-tool-for-command-line-users-iil) - dev.to - 3/2024
|
||||
- [Nextcloud Talk (F-Droid version) notifications using ntfy (ntfy.sh)](https://www.youtube.com/watch?v=0a6PpfN5PD8) - youtube.com - 2/2024
|
||||
- [ZFS and SMART Warnings via Ntfy](https://rair.dev/zfs-smart-ntfy/) - rair.dev - 2/2024
|
||||
- [Automating Security Camera Notifications With Home Assistant and Ntfy](https://runtimeterror.dev/automating-camera-notifications-home-assistant-ntfy/) ⭐ - runtimeterror.dev - 2/2024
|
||||
- [Ntfy: self-hosted notification service](https://medium.com/@williamdonze/ntfy-self-hosted-notification-service-0f3eada6e657) ⭐ - williamdonze.medium.com - 1/2024
|
||||
- [Let’s Supercharge Snowflake Alerts with Cool ntfy Open-source Notifications!](https://sarathi-data-ml-cloud.medium.com/lets-supercharge-snowflake-alerts-with-cool-ntfy-open-source-notifications-296da442c331) - sarathi-data-ml-cloud.medium.com - 1/2024
|
||||
- [Setting up NTFY with Ngnix-Proxy-Manager, authentication and Ansible notifications](https://random-it-blog.de/rocky-linux/setting-up-ntfy-with-ngnix-proxy-manager-authentication-and-ansible-notifications/) - random-it-blog.de - 12/2023
|
||||
- [Introducing the Monitoring Ntfy.sh Integration Module: Real-time Notifications for Drupal Monitoring](https://cyberschorsch.dev/drupal/introducing-monitoring-ntfysh-integration-module-real-time-notifications-drupal-monitoring) - cyberschorsch.dev - 11/2023
|
||||
- [How to install Ntfy.sh on CasaOS using BigBearCasaOS](https://www.youtube.com/watch?v=wSWhtSNwTd8) - youtube.com - 10/2023
|
||||
- [Podman Update Notifications via Ntfy](https://rair.dev/podman-update-notifications-ntfy/) - rair.dev - 9/2023
|
||||
- [Easy Push Notifications With ntfy.sh](https://runtimeterror.dev/easy-push-notifications-with-ntfy/) ⭐ - runtimeterror.dev - 9/2023
|
||||
- [Ntfy: Your Ultimate Push Notification Powerhouse!](https://kkamalesh117.medium.com/ntfy-your-ultimate-push-notification-powerhouse-1968c070f1d1) - kkamalesh117.medium.com - 9/2023
|
||||
- [Installing Self Host NTFY On Linux Using Docker Container](https://www.pinoylinux.org/topicsplus/containers/installing-self-host-ntfy-on-linux-using-docker-container/) - pinoylinux.org - 9/2023
|
||||
- [Homelab Notifications with ntfy](https://blog.alexsguardian.net/posts/2023/09/12/selfhosting-ntfy/) ⭐ - alexsguardian.net - 9/2023
|
||||
- [Why NTFY is the Ultimate Push Notification Tool for Your Needs](https://osintph.medium.com/why-ntfy-is-the-ultimate-push-notification-tool-for-your-needs-e767421c84c5) - osintph.medium.com - 9/2023
|
||||
@@ -226,7 +242,6 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [Show HN: A tool to send push notifications to your phone, written in Go](https://news.ycombinator.com/item?id=29715464) ⭐ - news.ycombinator.com - 12/2021
|
||||
- [Reddit selfhostable post](https://www.reddit.com/r/selfhosted/comments/qxlsm9/my_open_source_notification_android_app_and/) ⭐ - reddit.com - 11/2021
|
||||
|
||||
|
||||
## Alternative ntfy servers
|
||||
|
||||
Here's a list of public ntfy servers. As of right now, there is only one official server. The others are provided by the
|
||||
|
||||
160
docs/publish.md
160
docs/publish.md
File diff suppressed because one or more lines are too long
@@ -2,10 +2,52 @@
|
||||
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.10.0
|
||||
Released Mar 27, 2024
|
||||
|
||||
This release adds support for **message templating** in the ntfy server, which allows you to include a message and/or
|
||||
title template that will be filled with values from a JSON body (e.g. `curl -gd '{"alert":"Disk space low"}' "ntfy.sh/mytopic?tpl=1&m={{.alert}}"`).
|
||||
This is great for services that let you specify a webhook URL but do not let you change the webhook body (such as GitHub, or Grafana).
|
||||
|
||||
**Features:**
|
||||
|
||||
* [Message templating](publish.md#message-templating): You can now include a message and/or title template that will be filled with values from a JSON body ([#724](https://github.com/binwiederhier/ntfy/issues/724), thanks to [@wunter8](https://github.com/wunter8) for implementing)
|
||||
|
||||
### ntfy server v2.9.0
|
||||
Released Mar 7, 2024
|
||||
|
||||
A small release after a long pause (lots of day job work). This release adds for **larger messages** and **longer
|
||||
message delays** in scheduled delivery messages. The web app also now supports pasting images from the clipboard. Other
|
||||
than that, only a few bug fixes and documentation updates, and a teeny tiny breaking change 😬.
|
||||
|
||||
!!! info
|
||||
⚠️ **Breaking change**: The `Rate-Topics` header was removed due to a [DoS issue](https://github.com/binwiederhier/ntfy/issues/1048). This only affects
|
||||
installations with `visitor-subscriber-rate-limiting: true`, which is not the default and likely very rarely used.
|
||||
Normally I'd never remove a feature, but this is a security issue, and likely affects almost nobody.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support for larger message delays with `message-delay-limit` (see [message limits](config.md#message-limits), [#1050](https://github.com/binwiederhier/ntfy/pull/1050)/[#1019](https://github.com/binwiederhier/ntfy/issues/1019), thanks to [@MrChadMWood](https://github.com/MrChadMWood) for reporting)
|
||||
* Support for larger message body sizes with `message-size-limit` (use at your own risk, see [message limits](config.md#message-limits), [#836](https://github.com/binwiederhier/ntfy/pull/836)/[#1050](https://github.com/binwiederhier/ntfy/pull/1050), thanks to [@zhzy0077](https://github.com/zhzy0077) for implementing this, and to [@nkjshlsqja7331](https://github.com/nkjshlsqja7331) for reporting)
|
||||
* Web app: You can now paste images into the message bar or publish dialog ([#963](https://github.com/binwiederhier/ntfy/pull/963)/[#572](https://github.com/binwiederhier/ntfy/issues/572), thanks to [@cmj2002](https://github.com/cmj2002) for implementing, and [@rounakdatta](https://github.com/rounakdatta) for reporting)
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* ⚠️ Remove `Rate-Topics` header due to DoS security issue if `visitor-subscriber-rate-limiting: true` ([#1048](https://github.com/binwiederhier/ntfy/issues/1048))
|
||||
|
||||
**Documentation:**
|
||||
|
||||
* Remove `mkdocs-simple-hooks` ([#1016](https://github.com/binwiederhier/ntfy/pull/1016), thanks to [@Tom-Hubrecht](https://github.com/Tom-Hubrecht))
|
||||
* Update Watchtower example ([#1014](https://github.com/binwiederhier/ntfy/pull/1014), thanks to [@lennart-m](https://github.com/lennart-m))
|
||||
* Fix dead links ([#1022](https://github.com/binwiederhier/ntfy/pull/1022), thanks to [@DerRockWolf](https://github.com/DerRockWolf))
|
||||
* PowerShell file upload example ([#1004](https://github.com/binwiederhier/ntfy/pull/1004), thanks to [@YMan84](https://github.com/YMan84))
|
||||
|
||||
## ntfy iOS app v1.3
|
||||
Released Nov 26, 2023
|
||||
|
||||
This release (hopefully) fixes the issues with the iOS UI not updating properly when new notifications arrive, as well as notifications not being received (anymore) after previously working. Both issues have been annoying and known bugs for a long time, and I hope that they are finally fixed.
|
||||
This release (hopefully) fixes the issues with the iOS UI not updating properly when new notifications arrive, as well
|
||||
as notifications not being received (anymore) after previously working. Both issues have been annoying and known bugs
|
||||
for a long time, and I hope that they are finally fixed.
|
||||
|
||||
Many thanks to [@tcaputi](https://github.com/tcaputi) for fixing the issues, and to the anonymous donor for sponsoring these fixes.
|
||||
|
||||
@@ -13,10 +55,13 @@ Many thanks to [@tcaputi](https://github.com/tcaputi) for fixing the issues, and
|
||||
|
||||
* UI not updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267)/[#402](https://github.com/binwiederhier/ntfy/issues/402), thanks to [@tcaputi](https://github.com/tcaputi))
|
||||
|
||||
### ntfy server v2.8.0
|
||||
## ntfy server v2.8.0
|
||||
Released November 19, 2023
|
||||
|
||||
This release brings a handful of random bug fixes: two unrelated access control list fixes, a fix around web app crashes for languages with underscores in the language code (e.g. `zh_Hant`, `zh_Hans`, `pt_BR`, ...), a workaround for the `Priority` header (often used in Cloudflare setups), and support among others support for HTML-only emails (finally), web app crash fixes
|
||||
This release brings a handful of random bug fixes: two unrelated access control list fixes, a fix around web app crashes
|
||||
for languages with underscores in the language code (e.g. `zh_Hant`, `zh_Hans`, `pt_BR`, ...), a workaround for the
|
||||
`Priority` header (often used in Cloudflare setups), and support among others support for HTML-only emails (finally),
|
||||
web app crash fixes
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
@@ -1313,12 +1358,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
### ntfy server v2.9.0
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Remove `Rate-Topics` header due to DoS security issue if `visitor-subscriber-rate-limiting: true` ([#1048](https://github.com/binwiederhier/ntfy/issues/1048))
|
||||
|
||||
### ntfy Android app v1.16.1 (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
BIN
docs/static/img/android-screenshot-template.jpg
vendored
Normal file
BIN
docs/static/img/android-screenshot-template.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
130
docs/static/js/extra.js
vendored
130
docs/static/js/extra.js
vendored
@@ -1,99 +1,103 @@
|
||||
// Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
|
||||
|
||||
const savedCodeTab = localStorage.getItem('savedTab')
|
||||
const codeTabs = document.querySelectorAll(".tabbed-set > input")
|
||||
const savedCodeTab = localStorage.getItem("savedTab");
|
||||
const codeTabs = document.querySelectorAll(".tabbed-set > input");
|
||||
for (const tab of codeTabs) {
|
||||
tab.addEventListener("click", () => {
|
||||
const current = document.querySelector(`label[for=${tab.id}]`)
|
||||
const pos = current.getBoundingClientRect().top
|
||||
const labelContent = current.innerHTML
|
||||
const labels = document.querySelectorAll('.tabbed-set > label, .tabbed-alternate > .tabbed-labels > label')
|
||||
for (const label of labels) {
|
||||
if (label.innerHTML === labelContent) {
|
||||
document.querySelector(`input[id=${label.getAttribute('for')}]`).checked = true
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve scroll position
|
||||
const delta = (current.getBoundingClientRect().top) - pos
|
||||
window.scrollBy(0, delta)
|
||||
|
||||
// Save
|
||||
localStorage.setItem('savedTab', labelContent)
|
||||
})
|
||||
|
||||
// Select saved tab
|
||||
const current = document.querySelector(`label[for=${tab.id}]`)
|
||||
const labelContent = current.innerHTML
|
||||
if (savedCodeTab === labelContent) {
|
||||
tab.checked = true
|
||||
tab.addEventListener("click", () => {
|
||||
const current = document.querySelector(`label[for=${tab.id}]`);
|
||||
const pos = current.getBoundingClientRect().top;
|
||||
const labelContent = current.innerHTML;
|
||||
const labels = document.querySelectorAll(".tabbed-set > label, .tabbed-alternate > .tabbed-labels > label");
|
||||
for (const label of labels) {
|
||||
if (label.innerHTML === labelContent) {
|
||||
document.querySelector(`input[id=${label.getAttribute("for")}]`).checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve scroll position
|
||||
const delta = (current.getBoundingClientRect().top) - pos;
|
||||
window.scrollBy(0, delta);
|
||||
|
||||
// Save
|
||||
localStorage.setItem("savedTab", labelContent);
|
||||
});
|
||||
|
||||
// Select saved tab
|
||||
const current = document.querySelector(`label[for=${tab.id}]`);
|
||||
const labelContent = current.innerHTML;
|
||||
if (savedCodeTab === labelContent) {
|
||||
tab.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Lightbox for screenshot
|
||||
|
||||
const lightbox = document.createElement('div');
|
||||
lightbox.classList.add('lightbox');
|
||||
const lightbox = document.createElement("div");
|
||||
lightbox.classList.add("lightbox");
|
||||
document.body.appendChild(lightbox);
|
||||
|
||||
const showScreenshotOverlay = (e, el, group, index) => {
|
||||
lightbox.classList.add('show');
|
||||
document.addEventListener('keydown', nextScreenshotKeyboardListener);
|
||||
return showScreenshot(e, group, index);
|
||||
lightbox.classList.add("show");
|
||||
document.addEventListener("keydown", nextScreenshotKeyboardListener);
|
||||
return showScreenshot(e, group, index);
|
||||
};
|
||||
|
||||
const showScreenshot = (e, group, index) => {
|
||||
const actualIndex = resolveScreenshotIndex(group, index);
|
||||
lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[group][actualIndex].innerHTML;
|
||||
lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e, group, actualIndex+1); };
|
||||
currentScreenshotGroup = group;
|
||||
currentScreenshotIndex = actualIndex;
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
const actualIndex = resolveScreenshotIndex(group, index);
|
||||
lightbox.innerHTML = "<div class=\"close-lightbox\"></div>" + screenshots[group][actualIndex].innerHTML;
|
||||
lightbox.querySelector("img").onclick = (e) => {
|
||||
return showScreenshot(e, group, actualIndex + 1);
|
||||
};
|
||||
currentScreenshotGroup = group;
|
||||
currentScreenshotIndex = actualIndex;
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
};
|
||||
|
||||
const nextScreenshot = (e) => {
|
||||
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex+1);
|
||||
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex + 1);
|
||||
};
|
||||
|
||||
const previousScreenshot = (e) => {
|
||||
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex-1);
|
||||
return showScreenshot(e, currentScreenshotGroup, currentScreenshotIndex - 1);
|
||||
};
|
||||
|
||||
const resolveScreenshotIndex = (group, index) => {
|
||||
if (index < 0) {
|
||||
return screenshots[group].length - 1;
|
||||
} else if (index > screenshots[group].length - 1) {
|
||||
return 0;
|
||||
}
|
||||
return index;
|
||||
if (index < 0) {
|
||||
return screenshots[group].length - 1;
|
||||
} else if (index > screenshots[group].length - 1) {
|
||||
return 0;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const hideScreenshotOverlay = (e) => {
|
||||
lightbox.classList.remove('show');
|
||||
document.removeEventListener('keydown', nextScreenshotKeyboardListener);
|
||||
lightbox.classList.remove("show");
|
||||
document.removeEventListener("keydown", nextScreenshotKeyboardListener);
|
||||
};
|
||||
|
||||
const nextScreenshotKeyboardListener = (e) => {
|
||||
switch (e.keyCode) {
|
||||
case 37:
|
||||
previousScreenshot(e);
|
||||
break;
|
||||
case 39:
|
||||
nextScreenshot(e);
|
||||
break;
|
||||
}
|
||||
switch (e.keyCode) {
|
||||
case 37:
|
||||
previousScreenshot(e);
|
||||
break;
|
||||
case 39:
|
||||
nextScreenshot(e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let currentScreenshotGroup = '';
|
||||
let currentScreenshotGroup = "";
|
||||
let currentScreenshotIndex = 0;
|
||||
let screenshots = {};
|
||||
Array.from(document.getElementsByClassName('screenshots')).forEach((sg) => {
|
||||
const group = sg.id;
|
||||
screenshots[group] = [...sg.querySelectorAll('a')];
|
||||
screenshots[group].forEach((el, index) => {
|
||||
el.onclick = (e) => { return showScreenshotOverlay(e, el, group, index); };
|
||||
});
|
||||
Array.from(document.getElementsByClassName("screenshots")).forEach((sg) => {
|
||||
const group = sg.id;
|
||||
screenshots[group] = [...sg.querySelectorAll("a")];
|
||||
screenshots[group].forEach((el, index) => {
|
||||
el.onclick = (e) => {
|
||||
return showScreenshotOverlay(e, el, group, index);
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
lightbox.onclick = hideScreenshotOverlay;
|
||||
|
||||
24
go.mod
24
go.mod
@@ -6,9 +6,9 @@ toolchain go1.21.3
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.15.0 // indirect
|
||||
cloud.google.com/go/storage v1.39.0 // indirect
|
||||
cloud.google.com/go/storage v1.39.1 // indirect
|
||||
github.com/BurntSushi/toml v1.3.2 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
|
||||
github.com/emersion/go-smtp v0.18.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.3
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
@@ -21,7 +21,7 @@ require (
|
||||
golang.org/x/sync v0.6.0
|
||||
golang.org/x/term v0.18.0
|
||||
golang.org/x/time v0.5.0
|
||||
google.golang.org/api v0.168.0
|
||||
google.golang.org/api v0.171.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -39,10 +39,10 @@ require (
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.112.1 // indirect
|
||||
cloud.google.com/go/compute v1.25.0 // indirect
|
||||
cloud.google.com/go/compute v1.25.1 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
||||
cloud.google.com/go/iam v1.1.6 // indirect
|
||||
cloud.google.com/go/longrunning v0.5.5 // indirect
|
||||
cloud.google.com/go/iam v1.1.7 // indirect
|
||||
cloud.google.com/go/longrunning v0.5.6 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
@@ -60,16 +60,16 @@ require (
|
||||
github.com/google/s2a-go v0.1.7 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.12.2 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.12.3 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.0 // indirect
|
||||
github.com/prometheus/common v0.50.0 // indirect
|
||||
github.com/prometheus/common v0.51.1 // indirect
|
||||
github.com/prometheus/procfs v0.13.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-20231213231151-1d8dd44e695e // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
||||
@@ -81,9 +81,9 @@ require (
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.5 // indirect
|
||||
google.golang.org/genproto v0.0.0-20240304212257-790db918fca8 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8 // indirect
|
||||
google.golang.org/genproto v0.0.0-20240325203815-454cdb8f5daa // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240325203815-454cdb8f5daa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240325203815-454cdb8f5daa // indirect
|
||||
google.golang.org/grpc v1.62.1 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
54
go.sum
54
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.112.1 h1:uJSeirPke5UNZHIb4SxfZklVSiWWVqW4oXlETwZziwM=
|
||||
cloud.google.com/go v0.112.1/go.mod h1:+Vbu+Y1UU+I1rjmzeMOb/8RfkKJK2Gyxi1X6jJCZLo4=
|
||||
cloud.google.com/go/compute v1.25.0 h1:H1/4SqSUhjPFE7L5ddzHOfY2bCAvjwNRZPNl6Ni5oYU=
|
||||
cloud.google.com/go/compute v1.25.0/go.mod h1:GR7F0ZPZH8EhChlMo9FkLd7eUTwEymjqQagxzilIxIE=
|
||||
cloud.google.com/go/compute v1.25.1 h1:ZRpHJedLtTpKgr3RV1Fx23NuaAEN1Zfx9hw1u4aJdjU=
|
||||
cloud.google.com/go/compute v1.25.1/go.mod h1:oopOIR53ly6viBYxaDhBfJwzUAxf1zE//uf3IB011ls=
|
||||
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.15.0 h1:/k8ppuWOtNuDHt2tsRV42yI21uaGnKDEQnRFeBpbFF8=
|
||||
cloud.google.com/go/firestore v1.15.0/go.mod h1:GWOxFXcv8GZUtYpWHw/w6IuYNux/BtmeVTMmjrm4yhk=
|
||||
cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc=
|
||||
cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI=
|
||||
cloud.google.com/go/longrunning v0.5.5 h1:GOE6pZFdSrTb4KAiKnXsJBtlE6mEyaW44oKyMILWnOg=
|
||||
cloud.google.com/go/longrunning v0.5.5/go.mod h1:WV2LAxD8/rg5Z1cNW6FJ/ZpX4E4VnDnoTk0yawPBB7s=
|
||||
cloud.google.com/go/storage v1.39.0 h1:brbjUa4hbDHhpQf48tjqMaXEV+f1OGoaTmQau9tmCsA=
|
||||
cloud.google.com/go/storage v1.39.0/go.mod h1:OAEj/WZwUYjA3YHQ10/YcN9ttGuEpLwvaoyBXIPikEk=
|
||||
cloud.google.com/go/iam v1.1.7 h1:z4VHOhwKLF/+UYXAJDFwGtNF0b6gjsW1Pk9Ml0U/IoM=
|
||||
cloud.google.com/go/iam v1.1.7/go.mod h1:J4PMPg8TtyurAUvSmPj8FF3EDgY1SPRZxcUGrn7WXGA=
|
||||
cloud.google.com/go/longrunning v0.5.6 h1:xAe8+0YaWoCKr9t1+aWe+OeQgN/iJK1fEgZSXmjuEaE=
|
||||
cloud.google.com/go/longrunning v0.5.6/go.mod h1:vUaDrWYOMKRuhiv6JBnn49YxCPz2Ayn9GqyjaBT8/mA=
|
||||
cloud.google.com/go/storage v1.39.1 h1:MvraqHKhogCOTXTlct/9C3K3+Uy2jBmFYb3/Sp6dVtY=
|
||||
cloud.google.com/go/storage v1.39.1/go.mod h1:xK6xZmxZmo+fyP7+DEF6FhNc24/JAe95OLyOHCXFH1o=
|
||||
firebase.google.com/go/v4 v4.13.0 h1:meFz9nvDNh/FDyrEykoAzSfComcQbmnQSjoHrePRqeI=
|
||||
firebase.google.com/go/v4 v4.13.0/go.mod h1:e1/gaR6EnbQfsmTnAMx1hnz+ninJIrrr/RAh59Tpfn8=
|
||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||
@@ -33,8 +33,8 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj
|
||||
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.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/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=
|
||||
@@ -98,8 +98,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||
github.com/googleapis/gax-go/v2 v2.12.2 h1:mhN09QQW1jEWeMF74zGR81R30z4VJzjZsfkUhuHF+DA=
|
||||
github.com/googleapis/gax-go/v2 v2.12.2/go.mod h1:61M8vcyyXR2kqKFxKrfA22jaA8JGF7Dc8App1U3H6jc=
|
||||
github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA=
|
||||
github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
@@ -125,6 +125,8 @@ github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZ
|
||||
github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8=
|
||||
github.com/prometheus/common v0.50.0 h1:YSZE6aa9+luNa2da6/Tik0q0A5AbR+U003TItK57CPQ=
|
||||
github.com/prometheus/common v0.50.0/go.mod h1:wHFBCEVWVmHMUpg7pYcOm2QUR/ocQdYSJVQJKnHc3xQ=
|
||||
github.com/prometheus/common v0.51.1 h1:eIjN50Bwglz6a/c3hAgSMcofL3nD+nFQkV6Dd4DsQCw=
|
||||
github.com/prometheus/common v0.51.1/go.mod h1:lrWtQx+iDfn2mbH5GUzlH9TSHyfZpHkSiG1W7y3sF2Q=
|
||||
github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o=
|
||||
github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
@@ -145,8 +147,8 @@ github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ5
|
||||
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
|
||||
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
|
||||
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI=
|
||||
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw=
|
||||
github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
@@ -240,8 +242,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
|
||||
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
google.golang.org/api v0.168.0 h1:MBRe+Ki4mMN93jhDDbpuRLjRddooArz4FeSObvUMmjY=
|
||||
google.golang.org/api v0.168.0/go.mod h1:gpNOiMA2tZ4mf5R9Iwf4rK/Dcz0fbdIgWYWVoxmsyLg=
|
||||
google.golang.org/api v0.170.0 h1:zMaruDePM88zxZBG+NG8+reALO2rfLhe/JShitLyT48=
|
||||
google.golang.org/api v0.170.0/go.mod h1:/xql9M2btF85xac/VAm4PsLMTLVGUOpq4BE9R8jyNy8=
|
||||
google.golang.org/api v0.171.0 h1:w174hnBPqut76FzW5Qaupt7zY8Kql6fiVjgys4f58sU=
|
||||
google.golang.org/api v0.171.0/go.mod h1:Hnq5AHm4OTMt2BUVjael2CWZFD6vksJdWCWiUAmjC9o=
|
||||
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.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
@@ -251,12 +255,18 @@ google.golang.org/appengine/v2 v2.0.5/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7
|
||||
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-20240304212257-790db918fca8 h1:Fe8QycXyEd9mJgnwB9kmw00WgB43eQ/xYO5C6gceybQ=
|
||||
google.golang.org/genproto v0.0.0-20240304212257-790db918fca8/go.mod h1:yA7a1bW1kwl459Ol0m0lV4hLTfrL/7Bkk4Mj2Ir1mWI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8 h1:8eadJkXbwDEMNwcB5O0s5Y5eCfyuCLdvaiOIaGTrWmQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240304212257-790db918fca8/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8 h1:IR+hp6ypxjH24bkMfEJ0yHR21+gwPWdV+/IBrPQyn3k=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8/go.mod h1:UCOku4NytXMJuLQE5VuqA5lX3PcHCBo8pxNyvkf4xBs=
|
||||
google.golang.org/genproto v0.0.0-20240318140521-94a12d6c2237 h1:PgNlNSx2Nq2/j4juYzQBG0/Zdr+WP4z5N01Vk4VYBCY=
|
||||
google.golang.org/genproto v0.0.0-20240318140521-94a12d6c2237/go.mod h1:9sVD8c25Af3p0rGs7S7LLsxWKFiJt/65LdSyqXBkX/Y=
|
||||
google.golang.org/genproto v0.0.0-20240325203815-454cdb8f5daa h1:ePqxpG3LVx+feAUOx8YmR5T7rc0rdzK8DyxM8cQ9zq0=
|
||||
google.golang.org/genproto v0.0.0-20240325203815-454cdb8f5daa/go.mod h1:CnZenrTdRJb7jc+jOm0Rkywq+9wh0QC4U8tyiRbEPPM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 h1:RFiFrvy37/mpSpdySBDrUdipW/dHwsRwh3J3+A9VgT4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240325203815-454cdb8f5daa h1:Jt1XW5PaLXF1/ePZrznsh/aAUvI7Adfc3LY1dAKlzRs=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240325203815-454cdb8f5daa/go.mod h1:K4kfzHtI0kqWA79gecJarFtDn/Mls+GxQcg3Zox91Ac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240325203815-454cdb8f5daa h1:RBgMaUMP+6soRkik4VoN8ojR2nex2TqZwjSSogic+eo=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240325203815-454cdb8f5daa/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
|
||||
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=
|
||||
|
||||
@@ -65,15 +65,15 @@ markdown_extensions:
|
||||
- md_in_html
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||
|
||||
hooks:
|
||||
- docs/hooks.py
|
||||
|
||||
plugins:
|
||||
- search
|
||||
- minify:
|
||||
minify_html: true
|
||||
- mkdocs-simple-hooks:
|
||||
hooks:
|
||||
on_post_build: "docs.hooks:copy_fonts"
|
||||
|
||||
nav:
|
||||
- "Getting started": index.md
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# The documentation uses 'mkdocs', which is written in Python
|
||||
mkdocs-material
|
||||
mkdocs-minify-plugin
|
||||
mkdocs-simple-hooks
|
||||
|
||||
@@ -12,11 +12,12 @@ import (
|
||||
const (
|
||||
DefaultListenHTTP = ":80"
|
||||
DefaultCacheDuration = 12 * time.Hour
|
||||
DefaultCacheBatchTimeout = time.Duration(0)
|
||||
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
|
||||
DefaultManagerInterval = time.Minute
|
||||
DefaultDelayedSenderInterval = 10 * time.Second
|
||||
DefaultMinDelay = 10 * time.Second
|
||||
DefaultMaxDelay = 3 * 24 * time.Hour
|
||||
DefaultMessageDelayMin = 10 * time.Second
|
||||
DefaultMessageDelayMax = 3 * 24 * time.Hour
|
||||
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery
|
||||
DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
|
||||
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
|
||||
@@ -34,7 +35,7 @@ const (
|
||||
// - total topic limit: max number of topics overall
|
||||
// - various attachment limits
|
||||
const (
|
||||
DefaultMessageLengthLimit = 4096 // Bytes
|
||||
DefaultMessageSizeLimit = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message
|
||||
DefaultTotalTopicLimit = 15000
|
||||
DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
|
||||
DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB
|
||||
@@ -122,9 +123,9 @@ type Config struct {
|
||||
MetricsEnable bool
|
||||
MetricsListenHTTP string
|
||||
ProfileListenHTTP string
|
||||
MessageLimit int
|
||||
MinDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
MessageDelayMin time.Duration
|
||||
MessageDelayMax time.Duration
|
||||
MessageSizeLimit int
|
||||
TotalTopicLimit int
|
||||
TotalAttachmentSizeLimit int64
|
||||
VisitorSubscriptionLimit int
|
||||
@@ -211,9 +212,9 @@ func NewConfig() *Config {
|
||||
TwilioPhoneNumber: "",
|
||||
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
|
||||
TwilioVerifyService: "",
|
||||
MessageLimit: DefaultMessageLengthLimit,
|
||||
MinDelay: DefaultMinDelay,
|
||||
MaxDelay: DefaultMaxDelay,
|
||||
MessageSizeLimit: DefaultMessageSizeLimit,
|
||||
MessageDelayMin: DefaultMessageDelayMin,
|
||||
MessageDelayMax: DefaultMessageDelayMax,
|
||||
TotalTopicLimit: DefaultTotalTopicLimit,
|
||||
TotalAttachmentSizeLimit: 0,
|
||||
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
||||
|
||||
@@ -89,7 +89,7 @@ var (
|
||||
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages", nil}
|
||||
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", "", nil}
|
||||
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is not allowed", "", nil}
|
||||
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", "", nil}
|
||||
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid request: message must be UTF-8 encoded", "", nil}
|
||||
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments", nil}
|
||||
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments", nil}
|
||||
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery", nil}
|
||||
@@ -113,10 +113,15 @@ var (
|
||||
errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||
errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||
errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil}
|
||||
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil}
|
||||
errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "invalid request: delayed call notifications are not supported", "", nil}
|
||||
errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil}
|
||||
errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil}
|
||||
errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", nil}
|
||||
errHTTPBadRequestTemplateMessageTooLarge = &errHTTP{40041, http.StatusBadRequest, "invalid request: message or title is too large after replacing template", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateMessageNotJSON = &errHTTP{40042, http.StatusBadRequest, "invalid request: message body must be JSON if templating is enabled", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateInvalid = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
|
||||
120
server/server.go
120
server/server.go
@@ -23,6 +23,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -123,15 +124,22 @@ var (
|
||||
|
||||
const (
|
||||
firebaseControlTopic = "~control" // See Android if changed
|
||||
firebasePollTopic = "~poll" // See iOS if changed
|
||||
firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now)
|
||||
emptyMessageBody = "triggered" // Used if message body is empty
|
||||
newMessageBody = "New message" // Used in poll requests as generic message
|
||||
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
||||
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
|
||||
jsonBodyBytesLimit = 16384 // Max number of bytes for a JSON request body
|
||||
jsonBodyBytesLimit = 32768 // Max number of bytes for a request bodys (unless MessageLimit is higher)
|
||||
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
||||
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
||||
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
||||
templateMaxExecutionTime = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
var (
|
||||
// templateDisallowedRegex tests a template for disallowed expressions. While not really dangerous, they
|
||||
// are not useful, and seem potentially troublesome.
|
||||
templateDisallowedRegex = regexp.MustCompile(`(?m)\{\{-?\s*(call|template|define)\b`)
|
||||
)
|
||||
|
||||
// WebSocket constants
|
||||
@@ -673,7 +681,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
||||
// - avoid abuse (e.g. 1 uploader, 1k downloaders)
|
||||
// - and also uses the higher bandwidth limits of a paying user
|
||||
m, err := s.messageCache.Message(messageID)
|
||||
if err == errMessageNotFound {
|
||||
if errors.Is(err, errMessageNotFound) {
|
||||
if s.config.CacheBatchTimeout > 0 {
|
||||
// Strange edge case: If we immediately after upload request the file (the web app does this for images),
|
||||
// and messages are persisted asynchronously, retry fetching from the database
|
||||
@@ -733,12 +741,12 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := util.Peek(r.Body, s.config.MessageLimit)
|
||||
body, err := util.Peek(r.Body, s.config.MessageSizeLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := newDefaultMessage(t.ID, "")
|
||||
cache, firebase, email, call, unifiedpush, e := s.parsePublishParams(r, m)
|
||||
cache, firebase, email, call, template, unifiedpush, e := s.parsePublishParams(r, m)
|
||||
if e != nil {
|
||||
return nil, e.With(t)
|
||||
}
|
||||
@@ -769,7 +777,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||
if cache {
|
||||
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
|
||||
}
|
||||
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
|
||||
if err := s.handlePublishBody(r, v, m, body, template, unifiedpush); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Message == "" {
|
||||
@@ -872,7 +880,7 @@ 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 {
|
||||
if errors.Is(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())
|
||||
@@ -924,7 +932,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) {
|
||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template bool, unifiedpush bool, err *errHTTP) {
|
||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||
m.Title = readParam(r, "x-title", "title", "t")
|
||||
@@ -940,7 +948,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
}
|
||||
if attach != "" {
|
||||
if !urlRegex.MatchString(attach) {
|
||||
return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid
|
||||
return false, false, "", "", false, false, errHTTPBadRequestAttachmentURLInvalid
|
||||
}
|
||||
m.Attachment.URL = attach
|
||||
if m.Attachment.Name == "" {
|
||||
@@ -958,19 +966,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
}
|
||||
if icon != "" {
|
||||
if !urlRegex.MatchString(icon) {
|
||||
return false, false, "", "", false, errHTTPBadRequestIconURLInvalid
|
||||
return false, false, "", "", false, false, errHTTPBadRequestIconURLInvalid
|
||||
}
|
||||
m.Icon = icon
|
||||
}
|
||||
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
|
||||
if s.smtpSender == nil && email != "" {
|
||||
return false, false, "", "", false, errHTTPBadRequestEmailDisabled
|
||||
return false, false, "", "", false, false, errHTTPBadRequestEmailDisabled
|
||||
}
|
||||
call = readParam(r, "x-call", "call")
|
||||
if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) {
|
||||
return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled
|
||||
return false, false, "", "", false, false, errHTTPBadRequestPhoneCallsDisabled
|
||||
} else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) {
|
||||
return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid
|
||||
return false, false, "", "", false, false, errHTTPBadRequestPhoneNumberInvalid
|
||||
}
|
||||
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
|
||||
if messageStr != "" {
|
||||
@@ -979,27 +987,27 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
var e error
|
||||
m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
|
||||
if e != nil {
|
||||
return false, false, "", "", false, errHTTPBadRequestPriorityInvalid
|
||||
return false, false, "", "", false, false, errHTTPBadRequestPriorityInvalid
|
||||
}
|
||||
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
|
||||
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
|
||||
if delayStr != "" {
|
||||
if !cache {
|
||||
return false, false, "", "", false, errHTTPBadRequestDelayNoCache
|
||||
return false, false, "", "", false, false, errHTTPBadRequestDelayNoCache
|
||||
}
|
||||
if email != "" {
|
||||
return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||
return false, false, "", "", false, false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
|
||||
}
|
||||
if call != "" {
|
||||
return false, false, "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
|
||||
return false, false, "", "", false, false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet)
|
||||
}
|
||||
delay, err := util.ParseFutureTime(delayStr, time.Now())
|
||||
if err != nil {
|
||||
return false, false, "", "", false, errHTTPBadRequestDelayCannotParse
|
||||
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
|
||||
return false, false, "", "", false, errHTTPBadRequestDelayTooSmall
|
||||
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
|
||||
return false, false, "", "", false, errHTTPBadRequestDelayTooLarge
|
||||
return false, false, "", "", false, false, errHTTPBadRequestDelayCannotParse
|
||||
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestDelayTooSmall
|
||||
} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
|
||||
return false, false, "", "", false, false, errHTTPBadRequestDelayTooLarge
|
||||
}
|
||||
m.Time = delay.Unix()
|
||||
}
|
||||
@@ -1007,13 +1015,14 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
if actionsStr != "" {
|
||||
m.Actions, e = parseActions(actionsStr)
|
||||
if e != nil {
|
||||
return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
|
||||
return false, false, "", "", false, false, errHTTPBadRequestActionsInvalid.Wrap(e.Error())
|
||||
}
|
||||
}
|
||||
contentType, markdown := readParam(r, "content-type", "content_type"), readBoolParam(r, false, "x-markdown", "markdown", "md")
|
||||
if markdown || strings.ToLower(contentType) == "text/markdown" {
|
||||
m.ContentType = "text/markdown"
|
||||
}
|
||||
template = readBoolParam(r, false, "x-template", "template", "tpl")
|
||||
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
|
||||
if unifiedpush {
|
||||
firebase = false
|
||||
@@ -1025,7 +1034,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
cache = false
|
||||
email = ""
|
||||
}
|
||||
return cache, firebase, email, call, unifiedpush, nil
|
||||
return cache, firebase, email, call, template, unifiedpush, nil
|
||||
}
|
||||
|
||||
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
|
||||
@@ -1033,16 +1042,18 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
|
||||
// 1. curl -X POST -H "Poll: 1234" ntfy.sh/...
|
||||
// If a message is flagged as poll request, the body does not matter and is discarded
|
||||
// 2. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
|
||||
// If body is binary, encode as base64, if not do not encode
|
||||
// If UnifiedPush is enabled, encode as base64 if body is binary, and do not trim
|
||||
// 3. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
|
||||
// Body must be a message, because we attached an external URL
|
||||
// 4. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
|
||||
// Body must be attachment, because we passed a filename
|
||||
// 5. curl -T file.txt ntfy.sh/mytopic
|
||||
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
||||
// 5. curl -H "Template: yes" -T file.txt ntfy.sh/mytopic
|
||||
// If templating is enabled, read up to 32k and treat message body as JSON
|
||||
// 6. curl -T file.txt ntfy.sh/mytopic
|
||||
// If file.txt is > message limit, treat it as an attachment
|
||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, unifiedpush bool) error {
|
||||
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
|
||||
// 7. curl -T file.txt ntfy.sh/mytopic
|
||||
// In all other cases, mostly if file.txt is > message limit, treat it as an attachment
|
||||
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser, template, unifiedpush bool) error {
|
||||
if m.Event == pollRequestEvent { // Case 1
|
||||
return s.handleBodyDiscard(body)
|
||||
} else if unifiedpush {
|
||||
@@ -1051,10 +1062,12 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body
|
||||
return s.handleBodyAsTextMessage(m, body) // Case 3
|
||||
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
||||
} else if template {
|
||||
return s.handleBodyAsTemplatedTextMessage(m, body) // Case 5
|
||||
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
||||
return s.handleBodyAsTextMessage(m, body) // Case 5
|
||||
return s.handleBodyAsTextMessage(m, body) // Case 6
|
||||
}
|
||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 6
|
||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 7
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyDiscard(body *util.PeekedReadCloser) error {
|
||||
@@ -1086,6 +1099,45 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsTemplatedTextMessage(m *message, body *util.PeekedReadCloser) error {
|
||||
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
|
||||
if err != nil {
|
||||
return err
|
||||
} else if body.LimitReached {
|
||||
return errHTTPEntityTooLargeJSONBody
|
||||
}
|
||||
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
||||
if m.Message, err = replaceTemplate(m.Message, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Title, err = replaceTemplate(m.Title, peekedBody); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(m.Message) > s.config.MessageSizeLimit {
|
||||
return errHTTPBadRequestTemplateMessageTooLarge
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceTemplate(tpl string, source string) (string, error) {
|
||||
if templateDisallowedRegex.MatchString(tpl) {
|
||||
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
||||
}
|
||||
var data any
|
||||
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||
}
|
||||
t, err := template.New("").Parse(tpl)
|
||||
if err != nil {
|
||||
return "", errHTTPBadRequestTemplateInvalid
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil {
|
||||
return "", errHTTPBadRequestTemplateExecuteFailed
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
|
||||
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
|
||||
return errHTTPBadRequestAttachmentsDisallowed.With(m)
|
||||
@@ -1128,7 +1180,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||
util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining),
|
||||
}
|
||||
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)
|
||||
if err == util.ErrLimitReached {
|
||||
if errors.Is(err, util.ErrLimitReached) {
|
||||
return errHTTPEntityTooLargeAttachment.With(m)
|
||||
} else if err != nil {
|
||||
return err
|
||||
@@ -1754,7 +1806,7 @@ func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
|
||||
// before passing it on to the next handler. This is meant to be used in combination with handlePublish.
|
||||
func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageLimit*2, false) // 2x to account for JSON format overhead
|
||||
m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageSizeLimit*2, false) // 2x to account for JSON format overhead
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1812,7 +1864,7 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
||||
|
||||
func (s *Server) transformMatrixJSON(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageLimit)
|
||||
newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageSizeLimit)
|
||||
if err != nil {
|
||||
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Invalid Matrix request")
|
||||
if e, ok := err.(*errMatrixPushkeyRejected); ok {
|
||||
|
||||
@@ -236,6 +236,16 @@
|
||||
# upstream-base-url:
|
||||
# upstream-access-token:
|
||||
|
||||
# Configures message-specific limits
|
||||
#
|
||||
# - message-size-limit defines the max size of a message body. Please note message sizes >4K are NOT RECOMMENDED,
|
||||
# and largely untested. If FCM and/or APNS is used, the limit should stay 4K, because their limits are around that size.
|
||||
# If you increase this size limit regardless, FCM and APNS will NOT work for large messages.
|
||||
# - message-delay-limit defines the max delay of a message when using the "Delay" header.
|
||||
#
|
||||
# message-size-limit: "4k"
|
||||
# message-delay-limit: "3d"
|
||||
|
||||
# Rate limiting: Total number of topics before the server rejects new topics.
|
||||
#
|
||||
# global-topic-limit: 15000
|
||||
|
||||
@@ -718,11 +718,11 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "starter",
|
||||
MessageLimit: 10,
|
||||
MessageSizeLimit: 10,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 20,
|
||||
MessageSizeLimit: 20,
|
||||
}))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "starter"))
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"net/http"
|
||||
)
|
||||
@@ -45,7 +46,7 @@ func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return errHTTPBadRequest.Wrap("username invalid, or password missing")
|
||||
}
|
||||
u, err := s.userManager.User(req.Username)
|
||||
if err != nil && err != user.ErrUserNotFound {
|
||||
if err != nil && !errors.Is(err, user.ErrUserNotFound) {
|
||||
return err
|
||||
} else if u != nil {
|
||||
return errHTTPConflictUserExists
|
||||
@@ -53,7 +54,7 @@ func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visit
|
||||
var tier *user.Tier
|
||||
if req.Tier != "" {
|
||||
tier, err = s.userManager.Tier(req.Tier)
|
||||
if err == user.ErrTierNotFound {
|
||||
if errors.Is(err, user.ErrTierNotFound) {
|
||||
return errHTTPBadRequestTierInvalid
|
||||
} else if err != nil {
|
||||
return err
|
||||
@@ -76,7 +77,7 @@ func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *vi
|
||||
return err
|
||||
}
|
||||
u, err := s.userManager.User(req.Username)
|
||||
if err == user.ErrUserNotFound {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return errHTTPBadRequestUserNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
@@ -98,7 +99,7 @@ func (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *vi
|
||||
return err
|
||||
}
|
||||
_, err = s.userManager.User(req.Username)
|
||||
if err == user.ErrUserNotFound {
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
return errHTTPBadRequestUserNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -150,8 +150,8 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
return err
|
||||
}
|
||||
body = strings.TrimSpace(body)
|
||||
if len(body) > conf.MessageLimit {
|
||||
body = body[:conf.MessageLimit]
|
||||
if len(body) > conf.MessageSizeLimit {
|
||||
body = body[:conf.MessageSizeLimit]
|
||||
}
|
||||
m := newDefaultMessage(s.topic, body)
|
||||
subject := strings.TrimSpace(msg.Header.Get("Subject"))
|
||||
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
@@ -104,9 +105,9 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
|
||||
|
||||
func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
|
||||
obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty)
|
||||
if err == util.ErrUnmarshalJSON {
|
||||
if errors.Is(err, util.ErrUnmarshalJSON) {
|
||||
return nil, errHTTPBadRequestJSONInvalid
|
||||
} else if err == util.ErrTooLargeJSON {
|
||||
} else if errors.Is(err, util.ErrTooLargeJSON) {
|
||||
return nil, errHTTPEntityTooLargeJSONBody
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -30,10 +30,10 @@ const (
|
||||
visitorDefaultCallsLimit = int64(0)
|
||||
)
|
||||
|
||||
// Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter
|
||||
// Constants used to convert a tier-user's MessageSizeLimit (see user.Tier) into adequate request limiter
|
||||
// values (token bucket). This is only used to increase the values in server.yml, never decrease them.
|
||||
//
|
||||
// Example: Assuming a user.Tier's MessageLimit is 10,000:
|
||||
// Example: Assuming a user.Tier's MessageSizeLimit is 10,000:
|
||||
// - the allowed burst is 500 (= 10,000 * 5%), which is < 1000 (the max)
|
||||
// - the replenish rate is 2 * 10,000 / 24 hours
|
||||
const (
|
||||
|
||||
@@ -16,7 +16,7 @@ func StartServer(t *testing.T) (*server.Server, int) {
|
||||
|
||||
// StartServerWithConfig starts a server.Server with a random port and waits for the server to be up
|
||||
func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) {
|
||||
port := 10000 + rand.Intn(20000)
|
||||
port := 10000 + rand.Intn(30000)
|
||||
conf.ListenHTTP = fmt.Sprintf(":%d", port)
|
||||
conf.AttachmentCacheDir = t.TempDir()
|
||||
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
||||
|
||||
@@ -2,6 +2,7 @@ package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
@@ -26,7 +27,7 @@ func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
|
||||
}
|
||||
peeked := make([]byte, limit)
|
||||
read, err := io.ReadFull(underlying, peeked)
|
||||
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
return &PeekedReadCloser{
|
||||
@@ -44,7 +45,7 @@ func (r *PeekedReadCloser) Read(p []byte) (n int, err error) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n, err = r.peeked.Read(p)
|
||||
if err == io.EOF {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return r.underlying.Read(p)
|
||||
} else if err != nil {
|
||||
return 0, err
|
||||
|
||||
40
util/time.go
40
util/time.go
@@ -10,8 +10,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
errUnparsableTime = errors.New("unable to parse time")
|
||||
durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
|
||||
errInvalidDuration = errors.New("unable to parse duration")
|
||||
durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -51,7 +51,7 @@ func ParseFutureTime(s string, now time.Time) (time.Time, error) {
|
||||
if err == nil {
|
||||
return t, nil
|
||||
}
|
||||
return time.Time{}, errUnparsableTime
|
||||
return time.Time{}, errInvalidDuration
|
||||
}
|
||||
|
||||
// ParseDuration is like time.ParseDuration, except that it also understands days (d), which
|
||||
@@ -65,7 +65,7 @@ func ParseDuration(s string) (time.Duration, error) {
|
||||
if matches != nil {
|
||||
number, err := strconv.Atoi(matches[1])
|
||||
if err != nil {
|
||||
return 0, errUnparsableTime
|
||||
return 0, errInvalidDuration
|
||||
}
|
||||
switch unit := matches[2][0:1]; unit {
|
||||
case "d":
|
||||
@@ -77,10 +77,28 @@ func ParseDuration(s string) (time.Duration, error) {
|
||||
case "s":
|
||||
return time.Duration(number) * time.Second, nil
|
||||
default:
|
||||
return 0, errUnparsableTime
|
||||
return 0, errInvalidDuration
|
||||
}
|
||||
}
|
||||
return 0, errUnparsableTime
|
||||
return 0, errInvalidDuration
|
||||
}
|
||||
|
||||
// FormatDuration formats a time.Duration into a human-readable string, e.g. "2d", "20h", "30m", "40s".
|
||||
// It rounds to the largest unit that is not zero, thereby effectively rounding down.
|
||||
func FormatDuration(d time.Duration) string {
|
||||
if d >= 24*time.Hour {
|
||||
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
|
||||
}
|
||||
if d >= time.Hour {
|
||||
return strconv.Itoa(int(d/time.Hour)) + "h"
|
||||
}
|
||||
if d >= time.Minute {
|
||||
return strconv.Itoa(int(d/time.Minute)) + "m"
|
||||
}
|
||||
if d >= time.Second {
|
||||
return strconv.Itoa(int(d/time.Second)) + "s"
|
||||
}
|
||||
return "0s"
|
||||
}
|
||||
|
||||
func parseFromDuration(s string, now time.Time) (time.Time, error) {
|
||||
@@ -88,7 +106,7 @@ func parseFromDuration(s string, now time.Time) (time.Time, error) {
|
||||
if err == nil {
|
||||
return now.Add(d), nil
|
||||
}
|
||||
return time.Time{}, errUnparsableTime
|
||||
return time.Time{}, errInvalidDuration
|
||||
}
|
||||
|
||||
func parseUnixTime(s string, now time.Time) (time.Time, error) {
|
||||
@@ -96,7 +114,7 @@ func parseUnixTime(s string, now time.Time) (time.Time, error) {
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
} else if int64(t) < now.Unix() {
|
||||
return time.Time{}, errUnparsableTime
|
||||
return time.Time{}, errInvalidDuration
|
||||
}
|
||||
return time.Unix(int64(t), 0).UTC(), nil
|
||||
}
|
||||
@@ -104,7 +122,7 @@ func parseUnixTime(s string, now time.Time) (time.Time, error) {
|
||||
func parseNaturalTime(s string, now time.Time) (time.Time, error) {
|
||||
r, err := when.EN.Parse(s, now) // returns "nil, nil" if no matches!
|
||||
if err != nil || r == nil {
|
||||
return time.Time{}, errUnparsableTime
|
||||
return time.Time{}, errInvalidDuration
|
||||
} else if r.Time.After(now) {
|
||||
return r.Time, nil
|
||||
}
|
||||
@@ -112,9 +130,9 @@ func parseNaturalTime(s string, now time.Time) (time.Time, error) {
|
||||
// simply append "tomorrow, " to it.
|
||||
r, err = when.EN.Parse("tomorrow, "+s, now) // returns "nil, nil" if no matches!
|
||||
if err != nil || r == nil {
|
||||
return time.Time{}, errUnparsableTime
|
||||
return time.Time{}, errInvalidDuration
|
||||
} else if r.Time.After(now) {
|
||||
return r.Time, nil
|
||||
}
|
||||
return time.Time{}, errUnparsableTime
|
||||
return time.Time{}, errInvalidDuration
|
||||
}
|
||||
|
||||
@@ -92,3 +92,27 @@ func TestParseDuration(t *testing.T) {
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, time.Duration(0), d)
|
||||
}
|
||||
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
values := []struct {
|
||||
duration time.Duration
|
||||
expected string
|
||||
}{
|
||||
{24 * time.Second, "24s"},
|
||||
{56 * time.Minute, "56m"},
|
||||
{time.Hour, "1h"},
|
||||
{2 * time.Hour, "2h"},
|
||||
{24 * time.Hour, "1d"},
|
||||
{3 * 24 * time.Hour, "3d"},
|
||||
}
|
||||
for _, value := range values {
|
||||
require.Equal(t, value.expected, FormatDuration(value.duration))
|
||||
d, err := ParseDuration(FormatDuration(value.duration))
|
||||
require.Nil(t, err)
|
||||
require.Equalf(t, value.duration, d, "duration does not match: %v != %v", value.duration, d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDuration_Rounded(t *testing.T) {
|
||||
require.Equal(t, "1d", FormatDuration(47*time.Hour))
|
||||
}
|
||||
|
||||
34
util/timeout_writer.go
Normal file
34
util/timeout_writer.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrWriteTimeout is returned when a write timed out
|
||||
var ErrWriteTimeout = errors.New("write operation failed due to timeout since creation")
|
||||
|
||||
// TimeoutWriter wraps an io.Writer that will time out after the given timeout
|
||||
type TimeoutWriter struct {
|
||||
writer io.Writer
|
||||
timeout time.Duration
|
||||
start time.Time
|
||||
}
|
||||
|
||||
// NewTimeoutWriter creates a new TimeoutWriter
|
||||
func NewTimeoutWriter(w io.Writer, timeout time.Duration) *TimeoutWriter {
|
||||
return &TimeoutWriter{
|
||||
writer: w,
|
||||
timeout: timeout,
|
||||
start: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Write implements the io.Writer interface, failing if called after the timeout period from creation.
|
||||
func (tw *TimeoutWriter) Write(p []byte) (n int, err error) {
|
||||
if time.Since(tw.start) > tw.timeout {
|
||||
return 0, errors.New("write operation failed due to timeout since creation")
|
||||
}
|
||||
return tw.writer.Write(p)
|
||||
}
|
||||
22
util/util.go
22
util/util.go
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net/netip"
|
||||
"os"
|
||||
@@ -215,6 +216,8 @@ func ParseSize(s string) (int64, error) {
|
||||
return -1, fmt.Errorf("cannot convert number %s", matches[1])
|
||||
}
|
||||
switch strings.ToUpper(matches[2]) {
|
||||
case "T":
|
||||
return int64(value) * 1024 * 1024 * 1024 * 1024, nil
|
||||
case "G":
|
||||
return int64(value) * 1024 * 1024 * 1024, nil
|
||||
case "M":
|
||||
@@ -226,8 +229,23 @@ func ParseSize(s string) (int64, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// FormatSize formats bytes into a human-readable notation, e.g. 2.1 MB
|
||||
// FormatSize formats the size in a way that it can be parsed by ParseSize.
|
||||
// It does not include decimal places. Uneven sizes are rounded down.
|
||||
func FormatSize(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%d%c", int(math.Floor(float64(b)/float64(div))), "KMGT"[exp])
|
||||
}
|
||||
|
||||
// FormatSizeHuman formats bytes into a human-readable notation, e.g. 2.1 MB
|
||||
func FormatSizeHuman(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d bytes", b)
|
||||
@@ -237,7 +255,7 @@ func FormatSize(b int64) string {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGT"[exp])
|
||||
}
|
||||
|
||||
// ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the
|
||||
|
||||
@@ -110,33 +110,47 @@ func TestShortTopicURL(t *testing.T) {
|
||||
|
||||
func TestParseSize_10GSuccess(t *testing.T) {
|
||||
s, err := ParseSize("10G")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(10*1024*1024*1024), s)
|
||||
}
|
||||
|
||||
func TestParseSize_10MUpperCaseSuccess(t *testing.T) {
|
||||
s, err := ParseSize("10M")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(10*1024*1024), s)
|
||||
}
|
||||
|
||||
func TestParseSize_10kLowerCaseSuccess(t *testing.T) {
|
||||
s, err := ParseSize("10k")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(10*1024), s)
|
||||
}
|
||||
|
||||
func TestParseSize_FailureInvalid(t *testing.T) {
|
||||
_, err := ParseSize("not a size")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, but got none")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFormatSize(t *testing.T) {
|
||||
values := []struct {
|
||||
size int64
|
||||
expected string
|
||||
}{
|
||||
{10, "10"},
|
||||
{10 * 1024, "10K"},
|
||||
{10 * 1024 * 1024, "10M"},
|
||||
{10 * 1024 * 1024 * 1024, "10G"},
|
||||
}
|
||||
for _, value := range values {
|
||||
require.Equal(t, value.expected, FormatSize(value.size))
|
||||
s, err := ParseSize(FormatSize(value.size))
|
||||
require.Nil(t, err)
|
||||
require.Equalf(t, value.size, s, "size does not match: %d != %d", value.size, s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSize_Rounded(t *testing.T) {
|
||||
require.Equal(t, "10K", FormatSize(10*1024+999))
|
||||
}
|
||||
|
||||
func TestSplitKV(t *testing.T) {
|
||||
|
||||
2526
web/package-lock.json
generated
2526
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"action_bar_clear_notifications": "Премахване на известия",
|
||||
"alert_notification_permission_required_description": "Разрешете на мрежовия четец да показва известия.",
|
||||
"alert_notification_permission_required_description": "Разрешете на мрежовия четец да показва известия",
|
||||
"notifications_attachment_copy_url_title": "Копиране на адреса на прикачения файл",
|
||||
"notifications_example": "Пример",
|
||||
"notifications_no_subscriptions_title": "Липсват абонаменти.",
|
||||
"notifications_no_subscriptions_title": "Липсват абонаменти",
|
||||
"nav_topics_title": "Абонаменти",
|
||||
"action_bar_send_test_notification": "Пробно известие",
|
||||
"action_bar_unsubscribe": "Отписване",
|
||||
@@ -22,7 +22,7 @@
|
||||
"publish_dialog_chip_email_label": "Препращане към ел. поща",
|
||||
"publish_dialog_chip_attach_url_label": "Прикачване на файл от адрес",
|
||||
"publish_dialog_chip_attach_file_label": "Прикачване местен файл",
|
||||
"publish_dialog_chip_delay_label": "Забавяне на изпращането",
|
||||
"publish_dialog_chip_delay_label": "Отлагане на изпращането",
|
||||
"publish_dialog_chip_topic_label": "Промяна на темата",
|
||||
"publish_dialog_button_cancel_sending": "Отменяне на изпращането",
|
||||
"publish_dialog_button_cancel": "Отказ",
|
||||
@@ -39,7 +39,7 @@
|
||||
"prefs_notifications_delete_after_never": "Никога",
|
||||
"prefs_users_add_button": "Добавяне",
|
||||
"prefs_users_dialog_password_label": "Парола",
|
||||
"alert_not_supported_description": "Мрежовият четец не поддържа известия.",
|
||||
"alert_not_supported_description": "Мрежовият четец не поддържа известия",
|
||||
"message_bar_type_message": "Въведете съобщение",
|
||||
"message_bar_error_publishing": "Грешка при изпращане на известието",
|
||||
"notifications_copied_to_clipboard": "Копирано в междинната памет",
|
||||
@@ -55,16 +55,16 @@
|
||||
"notifications_attachment_open_title": "Към {{url}}",
|
||||
"notifications_attachment_copy_url_button": "Копиране на адреса",
|
||||
"notifications_attachment_open_button": "Отваряне на прикачения файл",
|
||||
"notifications_attachment_link_expires": "препратката изтича на {{date}}",
|
||||
"notifications_attachment_link_expires": "давността на препратката изтича на {{date}}",
|
||||
"notifications_actions_open_url_title": "Към {{url}}",
|
||||
"notifications_click_copy_url_button": "Копиране на препратка",
|
||||
"notifications_click_open_button": "Отваряне",
|
||||
"notifications_click_copy_url_title": "Копиране на препратката в междинната памет",
|
||||
"notifications_none_for_topic_title": "Темата е все още празна",
|
||||
"notifications_none_for_any_title": "Липсват известия.",
|
||||
"notifications_none_for_any_title": "Липсват известия",
|
||||
"notifications_none_for_topic_description": "За да изпратите известия в тази тема направете заявка чрез методите PUT или POST към адреса ѝ.",
|
||||
"notifications_none_for_any_description": "За да изпратите известия в тема направете заявка чрез методите PUT или POST към адреса ѝ. Ето пример с една от вашите теми.",
|
||||
"notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете тема или да се абонирате. След това като направите заявка чрез методите PUT или POST ще ги получите тук.",
|
||||
"notifications_no_subscriptions_description": "Щракнете върху „{{linktext}}“, за да създадете или да се абонирате за тема. След това като изпратите съобщение с методите PUT или POST ще го получите тук.",
|
||||
"notifications_more_details": "За допълнителна информация посетете <websiteLink>страницата</websiteLink> или <docsLink>документацията</docsLink>.",
|
||||
"publish_dialog_priority_min": "Най-нисък приоритет",
|
||||
"publish_dialog_attachment_limits_file_reached": "надвишава ограничението от {{fileSizeLimit}} за размер на файл",
|
||||
@@ -84,14 +84,14 @@
|
||||
"publish_dialog_topic_label": "Име на темата",
|
||||
"publish_dialog_title_label": "Заглавие",
|
||||
"publish_dialog_priority_label": "Приоритет",
|
||||
"publish_dialog_click_placeholder": "Адрес, който се отваря при щракване върху известието",
|
||||
"publish_dialog_email_placeholder": "Поща, на която да се препрати известието, напр. phil@example.com",
|
||||
"publish_dialog_click_placeholder": "Адрес, който се отваря при докосване на известието",
|
||||
"publish_dialog_email_placeholder": "Адрес, към който да бъдат препращани известия, напр. phil@example.com",
|
||||
"publish_dialog_attach_label": "Адрес на прикачения файл",
|
||||
"publish_dialog_filename_placeholder": "Име на прикачения файл",
|
||||
"publish_dialog_attach_placeholder": "Прикачете файл от адрес, напр. https://f-droid.org/F-Droid.apk",
|
||||
"prefs_notifications_delete_after_three_hours": "След три часа",
|
||||
"publish_dialog_filename_label": "Име на файла",
|
||||
"publish_dialog_delay_label": "Забавяне",
|
||||
"publish_dialog_delay_label": "Отлагане",
|
||||
"publish_dialog_details_examples_description": "За примери и подробно описание на всички възможности при изпращане, вижте <docsLink>документацията</docsLink>.",
|
||||
"publish_dialog_button_send": "Изпращане",
|
||||
"publish_dialog_checkbox_publish_another": "Изпращане на повече",
|
||||
@@ -121,7 +121,7 @@
|
||||
"subscribe_dialog_login_button_login": "Вход",
|
||||
"subscribe_dialog_error_user_not_authorized": "Потребителят {{username}} няма достъп",
|
||||
"prefs_appearance_title": "Външен вид",
|
||||
"publish_dialog_delay_placeholder": "Забавяне на изпращането, {{unixTimestamp}}, {{relativeTime}} или „{{naturalLanguage}}“ (на английски)",
|
||||
"publish_dialog_delay_placeholder": "Отлагане на изпращането, {{unixTimestamp}}, {{relativeTime}} или „{{naturalLanguage}}“ (на английски)",
|
||||
"prefs_notifications_delete_after_one_week": "След една седмица",
|
||||
"prefs_users_title": "Управление на потребители",
|
||||
"prefs_users_table_base_url_header": "Адрес на услугата",
|
||||
@@ -177,7 +177,7 @@
|
||||
"publish_dialog_topic_reset": "Нулиране на тема",
|
||||
"publish_dialog_click_reset": "Премахване на адрес",
|
||||
"publish_dialog_email_reset": "Премахване на препращането към ел. поща",
|
||||
"publish_dialog_delay_reset": "Премахва забавянето на изпращането",
|
||||
"publish_dialog_delay_reset": "Премахва отлагането на изпращането",
|
||||
"publish_dialog_attached_file_remove": "Премахване на прикачения файл",
|
||||
"emoji_picker_search_clear": "Изчистване на търсенето",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Адрес на услугата",
|
||||
@@ -220,7 +220,7 @@
|
||||
"alert_not_supported_context_description": "Известията се поддържат само през HTTPS. Това е ограничение на <mdnLink>Notifications API</mdnLink>.",
|
||||
"display_name_dialog_description": "Изберете друго име за темата, което да се показва в списъка с абонаменти. Помага за по-лесното разпознаване на теми със сложни имена.",
|
||||
"subscribe_dialog_error_topic_already_reserved": "Темата вече е резервирана",
|
||||
"nav_upgrade_banner_description": "Резервиране на теми, повече съобщения и имейли и по-големи прикачени файлове",
|
||||
"nav_upgrade_banner_description": "Резервиране на теми, повече съобщения и писма, по-големи прикачени файлове",
|
||||
"display_name_dialog_placeholder": "Наименование",
|
||||
"reserve_dialog_checkbox_label": "Резервиране на тема и настройки за достъп",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Произволно име",
|
||||
@@ -257,7 +257,7 @@
|
||||
"account_tokens_dialog_button_cancel": "Отказ",
|
||||
"account_delete_title": "Премахване на профила",
|
||||
"account_upgrade_dialog_title": "Промяна нивото на профила",
|
||||
"account_usage_emails_title": "Изпратени съобщения",
|
||||
"account_usage_emails_title": "Изпратени електронни писма",
|
||||
"account_usage_reservations_title": "Резервирани теми",
|
||||
"account_usage_reservations_none": "Няма резервирани теми",
|
||||
"account_usage_cannot_create_portal_session": "Порталът за разплащане не може да бъде отворен",
|
||||
@@ -332,8 +332,76 @@
|
||||
"account_upgrade_dialog_tier_price_per_month": "на месец",
|
||||
"account_upgrade_dialog_button_pay_now": "Плащане и абониране",
|
||||
"account_upgrade_dialog_tier_selected_label": "Избрано",
|
||||
"account_upgrade_dialog_button_update_subscription": "Премяна на абонамент",
|
||||
"account_upgrade_dialog_button_update_subscription": "Промяна на абонамент",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Избраното ниво разрешава по-малко резервирани теми, от колкото текущото. Преди промяна на нивото <strong>изтрийте най-малко {{count}} резервирани теми</strong>. Можете да премахвате теми в <Link>Настройки</Link>.",
|
||||
"account_tokens_table_expires_header": "Изтича",
|
||||
"account_tokens_table_never_expires": "Никога"
|
||||
"account_tokens_table_never_expires": "Никога",
|
||||
"prefs_reservations_title": "Резервирани теми",
|
||||
"prefs_reservations_table_click_to_subscribe": "Докоснете, за да се абонирате",
|
||||
"prefs_reservations_dialog_title_delete": "Премахване на резервирането",
|
||||
"prefs_reservations_table_everyone_read_only": "Аз мога да публикувам и да се абонирам, всички останали могат да се абонират",
|
||||
"prefs_reservations_table_not_subscribed": "Без абонамент",
|
||||
"account_tokens_table_token_header": "Код за достъп",
|
||||
"account_tokens_table_create_token_button": "Създаване на код за достъп",
|
||||
"account_tokens_dialog_expires_x_days": "Кодът за достъп изтича след {{days}} дена",
|
||||
"account_tokens_dialog_expires_never": "Кодът за достъп не изтича",
|
||||
"account_tokens_delete_dialog_title": "Премахване на код за достъп",
|
||||
"prefs_reservations_limit_reached": "Достигнахте ограничението за брой резервирани теми.",
|
||||
"prefs_reservations_add_button": "Добавяне на тема",
|
||||
"prefs_reservations_delete_button": "Нулиране на правата за достъп",
|
||||
"prefs_reservations_table": "Списък с резервирани теми",
|
||||
"prefs_reservations_dialog_title_add": "Резервиране на тема",
|
||||
"prefs_reservations_dialog_title_edit": "Променяне на резервирана тема",
|
||||
"account_tokens_table_current_session": "Текущ сеанс на четеца",
|
||||
"account_tokens_table_copied_to_clipboard": "Кодът за достъп е копиран",
|
||||
"account_tokens_table_cannot_delete_or_edit": "Не можете да променяте или премахвате кода за достъп на текущия сеанс",
|
||||
"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_expires_label": "Кодът за достъп изтича след",
|
||||
"account_tokens_dialog_expires_x_hours": "Кодът за достъп изтича след {{hours}} часа",
|
||||
"account_tokens_dialog_expires_unchanged": "Без промяна на давността",
|
||||
"account_tokens_delete_dialog_submit_button": "Безвъзвратно премахване на код за достъп",
|
||||
"prefs_users_description_no_sync": "Потребителите и паролите не се синхронизират заедно с профила.",
|
||||
"prefs_users_table_cannot_delete_or_edit": "Влезлият потребител не може да бъде премахнат",
|
||||
"prefs_reservations_table_everyone_deny_all": "Само аз мога да публикувам и да се абонирам",
|
||||
"prefs_reservations_table_everyone_write_only": "Аз мога да публикувам и да се абонирам, всички останали могат да публикуват",
|
||||
"prefs_reservations_table_everyone_read_write": "Всички могат да публикуват и да се абонират",
|
||||
"reservation_delete_dialog_submit_button": "Премахване на резервирането",
|
||||
"account_tokens_description": "Използвайте код за достъп когато публикувате или се абонирате през ППИ на ntfy, за да не се налага да изпращате потребителско име и парола. Прочетете <Link>документацията</Link> за повече информация.",
|
||||
"account_tokens_delete_dialog_description": "Преди да премахвате код за достъп се уверете, че не се използва от приложения или скриптове. <strong>Действието е необратимо.</strong>",
|
||||
"prefs_reservations_dialog_description": "Резервирането ви осигурява собственост върху темата и ви дава възможност да определяте права за достъп от други потребители.",
|
||||
"reservation_delete_dialog_action_keep_title": "Пазене на съобщения и прикачени файлове",
|
||||
"reservation_delete_dialog_action_keep_description": "Съобщенията и прикачените файлове, които са във временната памет на сървъра ще бъдат достъпни за всеки, който знае името на темата.",
|
||||
"reservation_delete_dialog_action_delete_title": "Премахване на съобщения и прикачени файлове",
|
||||
"reservation_delete_dialog_action_delete_description": "Съобщенията и прикачените файлове, които са във временната памет ще бъдат премахнати. Действието е необратимо.",
|
||||
"prefs_reservations_description": "Тук можете да резервирате тема за собствено ползване. Резервирането ви осигурява собственост върху темата и ви дава възможност да определяте права за достъп от други потребители.",
|
||||
"reservation_delete_dialog_description": "С премахването на резервирането вие се отказвате от собствеността върху темата и давате възможност друг потребител да я резервира. Можете да оставите или да премахнете съществуващите съобщения и прикачени файлове.",
|
||||
"alert_notification_permission_denied_description": "Включете ги от мрежовия четец",
|
||||
"alert_notification_permission_denied_title": "Известията са изключени",
|
||||
"notifications_actions_failed_notification": "Действието е неуспешно",
|
||||
"publish_dialog_checkbox_markdown": "Съобщението е Markdown",
|
||||
"prefs_notifications_web_push_disabled_description": "Известията ще бъдат получавани докато приложението за уеб работи (чрез WebSocket)",
|
||||
"prefs_notifications_web_push_enabled": "Включено за {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Изключено",
|
||||
"prefs_appearance_theme_dark": "Тъмна",
|
||||
"prefs_appearance_theme_light": "Светла",
|
||||
"error_boundary_button_reload_ntfy": "Презареждне на ntfy",
|
||||
"web_push_unknown_notification_title": "Получено е неочаквано известие",
|
||||
"web_push_unknown_notification_body": "Вероятно ще трябва да обновите ntfy като отворите приложението за уеб",
|
||||
"alert_notification_ios_install_required_title": "Необходимо е инсталиране за iOS",
|
||||
"alert_notification_ios_install_required_description": "Докоснете бутона Споделяне и Добавяне към началния екран, за да включите известията под iOS",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "Известията от други сървъри няма да бъдат получавани ако приложението за уеб не е отворено",
|
||||
"action_bar_mute_notifications": "Заглушаване на известия",
|
||||
"prefs_notifications_web_push_title": "Известия във фонов режим",
|
||||
"prefs_notifications_web_push_enabled_description": "Известията ще бъдат получавани даже и ако приложението за уеб не работи (чрез Web Push)",
|
||||
"prefs_appearance_theme_title": "Цветова тема",
|
||||
"prefs_appearance_theme_system": "Системна (подразбирана)",
|
||||
"web_push_subscription_expiring_title": "Известията временно ще бъдат спрени",
|
||||
"web_push_subscription_expiring_body": "За да продължите да получавате известия, отворете ntfy",
|
||||
"action_bar_unmute_notifications": "Включване звука на известията"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"common_save": "Gem",
|
||||
"common_add": "Tilføj",
|
||||
"signup_title": "Opret en ntfy konto",
|
||||
"signup_title": "Opret en ntfy-konto",
|
||||
"signup_form_username": "Brugernavn",
|
||||
"signup_form_password": "Kodeord",
|
||||
"signup_form_confirm_password": "Bekræft kodeord",
|
||||
@@ -10,24 +10,24 @@
|
||||
"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_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_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_send_test_notification": "Send testnotifikation",
|
||||
"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_toggle_action_menu": "Åben/luk handlingsmenu",
|
||||
"action_bar_profile_title": "Profil",
|
||||
"action_bar_profile_settings": "Indstillinger",
|
||||
"action_bar_profile_logout": "Log ud",
|
||||
@@ -58,9 +58,9 @@
|
||||
"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_link_expired": "download-link er udløbet",
|
||||
"notifications_attachment_file_image": "billedfil",
|
||||
"notifications_attachment_file_app": "Android app fil",
|
||||
"notifications_attachment_file_app": "Android-appfil",
|
||||
"notifications_attachment_file_document": "andet dokument",
|
||||
"notifications_click_copy_url_title": "Kopier linkets URL til udklipsholderen",
|
||||
"notifications_click_copy_url_button": "Kopier link",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"message_bar_type_message": "Escriba un mensaje aquí",
|
||||
"message_bar_error_publishing": "Error al publicar la notificación",
|
||||
"alert_notification_permission_required_title": "Las notificaciones están deshabilitadas",
|
||||
"alert_notification_permission_required_description": "Concede a tu navegador permiso para mostrar notificaciones en el escritorio.",
|
||||
"alert_notification_permission_required_description": "Concede a tu navegador permiso para mostrar notificaciones de escritorio",
|
||||
"nav_button_all_notifications": "Todas las notificaciones",
|
||||
"nav_button_settings": "Ajustes",
|
||||
"nav_button_subscribe": "Suscribirse al tópico",
|
||||
@@ -16,7 +16,7 @@
|
||||
"nav_button_publish_message": "Publicar notificación",
|
||||
"notifications_copied_to_clipboard": "Copiado al portapapeles",
|
||||
"alert_not_supported_title": "Notificaciones no soportadas",
|
||||
"alert_not_supported_description": "Las notificaciones no están soportadas por tu navegador.",
|
||||
"alert_not_supported_description": "Su navegador no admite notificaciones",
|
||||
"notifications_tags": "Etiquetas",
|
||||
"notifications_attachment_copy_url_title": "Copiar la URL del archivo adjunto en el portapapeles",
|
||||
"notifications_attachment_copy_url_button": "Copiar URL",
|
||||
@@ -381,5 +381,28 @@
|
||||
"account_basics_phone_numbers_dialog_title": "Agregar número de teléfono",
|
||||
"account_basics_phone_numbers_dialog_code_placeholder": "p.ej. 123456",
|
||||
"publish_dialog_call_item": "Llamar al número de teléfono {{number}}",
|
||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "No hay números de teléfono verificados"
|
||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "No hay números de teléfono verificados",
|
||||
"action_bar_mute_notifications": "Silenciar Notificaciones",
|
||||
"action_bar_unmute_notifications": "Reactivar notificaciones",
|
||||
"alert_notification_permission_denied_title": "Notificaciones bloqueadas",
|
||||
"alert_notification_permission_denied_description": "Porfavor, reactivelas en su navegador",
|
||||
"alert_notification_ios_install_required_title": "Requiere instalacion de iOS",
|
||||
"alert_notification_ios_install_required_description": "Haz click en el icono de compartir y Añadir a pantalla de inicio para activar las notificaciones de iOS",
|
||||
"notifications_actions_failed_notification": "Acción fallida",
|
||||
"publish_dialog_checkbox_markdown": "Formatear como Markdown",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "Las notificaciones de otros servidores no se recibirán cuando la aplicación web no esté abierta",
|
||||
"prefs_notifications_web_push_title": "Notificaciones en segundo plano",
|
||||
"prefs_notifications_web_push_enabled_description": "Las notificaciones se reciben incluso cuando la aplicación web no se está ejecutando (a través de Web Push)",
|
||||
"prefs_notifications_web_push_disabled": "Desactivado",
|
||||
"prefs_appearance_theme_title": "Tema",
|
||||
"prefs_appearance_theme_system": "Sistema (por defecto)",
|
||||
"error_boundary_button_reload_ntfy": "Volver a cargar ntfy",
|
||||
"web_push_subscription_expiring_title": "Las notificaciones se pausarán",
|
||||
"prefs_notifications_web_push_disabled_description": "Las notificaciones se reciben cuando la aplicación web se está ejecutando (a través de WebSocket)",
|
||||
"prefs_notifications_web_push_enabled": "Activado para {{server}}",
|
||||
"prefs_appearance_theme_light": "Claro",
|
||||
"prefs_appearance_theme_dark": "Oscuro",
|
||||
"web_push_subscription_expiring_body": "Abrir ntfy para seguir recibiendo notificaciones",
|
||||
"web_push_unknown_notification_title": "Notificación desconocida recibida del servidor",
|
||||
"web_push_unknown_notification_body": "Puede que necesites actualizar ntfy abriendo la aplicación web"
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"prefs_users_description": "Lisää/poista käyttäjiä suojatuista topikeista täällä. Huomaa, että käyttäjätunnus ja salasana on tallennettu selaimen paikalliseen tallennustilaan.",
|
||||
"account_basics_phone_numbers_dialog_number_label": "Puhelinnumero",
|
||||
"subscribe_dialog_subscribe_description": "Aiheet eivät välttämättä ole salasanasuojattuja, joten valitse nimi, jota ei ole helposti arvatavissa. Kun olet tilannut, voit käyttää PUT/POST ilmoituksia.",
|
||||
"action_bar_logo_alt": "ntfy logo",
|
||||
"action_bar_logo_alt": "ntfy-logo",
|
||||
"account_basics_password_dialog_button_submit": "Vaihda salasana",
|
||||
"publish_dialog_emoji_picker_show": "Valitse emoji",
|
||||
"account_basics_username_title": "Käyttäjätunnus",
|
||||
@@ -30,7 +30,7 @@
|
||||
"account_tokens_dialog_label": "Etiketti, esim. Tutka-ilmoitukset",
|
||||
"common_add": "Lisää",
|
||||
"account_tokens_table_expires_header": "Vanhenee",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Osuus suhde</strong>: Kun päivität maksullisten pakettien välillä, hintaero <strong>veloitetaan välittömästi</strong>. Kun siirryt alemmalle tasolle, saldoa käytetään tulevien laskutuskausien maksamiseen.",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Osuussuhde</strong>: Kun päivität maksullisten pakettien välillä, hintaero <strong>veloitetaan välittömästi</strong>. Kun siirryt alemmalle tasolle, saldoa käytetään tulevien laskutuskausien maksamiseen.",
|
||||
"prefs_reservations_dialog_access_label": "Oikeudet",
|
||||
"account_usage_attachment_storage_title": "Liiteiden säilytys",
|
||||
"prefs_users_dialog_username_label": "Username, esim pena",
|
||||
@@ -42,9 +42,9 @@
|
||||
"prefs_reservations_table_not_subscribed": "Ei tilattu",
|
||||
"publish_dialog_topic_placeholder": "Topikin nimi, esim. erkin_hälyt",
|
||||
"account_upgrade_dialog_tier_features_emails_other": "{{emails}} päivittäisiä emaileja",
|
||||
"prefs_notifications_min_priority_max_only": "Vain maksimi prioriteetti",
|
||||
"prefs_notifications_min_priority_max_only": "Vain maksimiprioriteetti",
|
||||
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} päivittäisiä puheluja",
|
||||
"prefs_notifications_sound_description_some": "Ilmoitukset soittavat {{sound}} äänen saapuessaan",
|
||||
"prefs_notifications_sound_description_some": "Ilmoitukset soittavat {{sound}}-äänen saapuessaan",
|
||||
"prefs_reservations_edit_button": "Muokkaa topikin oikeuksia",
|
||||
"account_basics_phone_numbers_dialog_verify_button_sms": "Lähetä SMS",
|
||||
"account_basics_tier_change_button": "Vaihda",
|
||||
@@ -84,7 +84,7 @@
|
||||
"subscribe_dialog_error_user_not_authorized": "Käyttäjää {{username}} ei ole valtuutettu",
|
||||
"prefs_reservations_table_everyone_read_write": "Jokainen voi julkaista ja tilata",
|
||||
"prefs_reservations_dialog_title_delete": "Poista topikin varaus",
|
||||
"prefs_users_table": "Käyttäjä taulukko",
|
||||
"prefs_users_table": "Käyttäjätaulukko",
|
||||
"prefs_reservations_table_topic_header": "Topikki",
|
||||
"action_bar_toggle_mute": "Hiljennä/poista hiljennys",
|
||||
"reservation_delete_dialog_submit_button": "Poista varaus",
|
||||
@@ -96,7 +96,7 @@
|
||||
"account_upgrade_dialog_tier_features_messages_other": "{{messages}} päivittäisiä viestejä",
|
||||
"publish_dialog_delay_reset": "Poista viivästetty toimitus",
|
||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Ei puhelinnumeroita vielä",
|
||||
"action_bar_toggle_action_menu": "Avaa/sulje toiminto valikko",
|
||||
"action_bar_toggle_action_menu": "Avaa/sulje toimintovalikko",
|
||||
"subscribe_dialog_subscribe_button_generate_topic_name": "Luo nimi",
|
||||
"notifications_list_item": "Ilmoitus",
|
||||
"prefs_appearance_language_title": "Kieli",
|
||||
@@ -116,10 +116,10 @@
|
||||
"account_tokens_table_label_header": "Merkki",
|
||||
"notifications_attachment_file_document": "muu asiakirja",
|
||||
"publish_dialog_button_cancel": "Peruuta",
|
||||
"account_upgrade_dialog_billing_contact_website": "Laskutukseen liittyvissä kysymyksissä käy <Link>website</Link>.",
|
||||
"account_upgrade_dialog_billing_contact_website": "Laskutukseen liittyvissä kysymyksissä käy sivulla <Link>website</Link>.",
|
||||
"signup_form_button_submit": "Kirjaudu linkki",
|
||||
"account_basics_username_admin_tooltip": "Olet pääkäyttäjä",
|
||||
"prefs_notifications_delete_after_never_description": "Ilmoituksia eivät koskaan poisteta automaattisesti",
|
||||
"prefs_notifications_delete_after_never_description": "Ilmoituksia ei koskaan poisteta automaattisesti",
|
||||
"account_delete_dialog_description": "Tämä poistaa pysyvästi tilisi, mukaan lukien kaikki palvelimelle tallennetut tiedot. Poistamisen jälkeen käyttäjätunnuksesi on poissa käytöstä 7 päivään. Jos todella haluat jatkaa, vahvista salasanasi alla olevaan kenttään.",
|
||||
"publish_dialog_email_reset": "Poista sähköpostin edelleenlähetys",
|
||||
"account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} varatut topikit",
|
||||
@@ -139,11 +139,11 @@
|
||||
"prefs_reservations_description": "Voit varata topikien nimiä henkilökohtaiseen käyttöön täältä. Aiheen varaaminen antaa sinulle topikin omistajuuden ja voit määrittää topikkiin liittyviä käyttöoikeuksia muille käyttäjille.",
|
||||
"notifications_attachment_copy_url_title": "Kopioi liitteen URL-osoite leikepöydälle",
|
||||
"account_usage_title": "Käytössä",
|
||||
"account_basics_tier_upgrade_button": "Päivitä Pro versioon",
|
||||
"account_basics_tier_upgrade_button": "Päivitä Pro-versioon",
|
||||
"prefs_users_description_no_sync": "Käyttäjiä ja salasanoja ei ole synkronoitu tiliisi.",
|
||||
"account_tokens_dialog_title_edit": "Muokkaa käyttöoikeustunnusta",
|
||||
"nav_button_publish_message": "Julkaise ilmoitus",
|
||||
"prefs_users_table_base_url_header": "Palvelin URL",
|
||||
"prefs_users_table_base_url_header": "Palvelin-URL",
|
||||
"notifications_click_copy_url_title": "Kopioi linkin URL-osoite leikepöydälle",
|
||||
"publish_dialog_attach_reset": "Poista liitteen URL-osoite",
|
||||
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} päivittäisiä viestejä",
|
||||
@@ -160,7 +160,7 @@
|
||||
"publish_dialog_priority_low": "Matala tärkeys",
|
||||
"publish_dialog_priority_label": "Prioriteetti",
|
||||
"prefs_reservations_delete_button": "Poista topikin oikeudet",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(e tasoa)",
|
||||
"account_basics_tier_admin_suffix_no_tier": "(ei tasoa)",
|
||||
"prefs_notifications_delete_after_one_week_description": "Ilmoitukset poistetaan automaattisesti viikon kuluttua",
|
||||
"error_boundary_unsupported_indexeddb_description": "Ntfy-verkkosovellus tarvitsee IndexedDB:n toimiakseen, eikä selaimesi tue IndexedDB:tä yksityisessä selaustilassa.<br/><br/>Vaikka tämä on valitettavaa, ntfy-verkon käyttäminen ei myöskään ole kovin järkevää yksityisessä selaustilassa, koska kaikki on tallennettu selaimen tallennustilaan. Voit lukea siitä lisää <githubLink>tästä GitHub-numerosta</githubLink> tai puhua meille <discordLink>Discordissa</discordLink> tai <matrixLink>Matrixissa</matrixLink>.",
|
||||
"subscribe_dialog_subscribe_button_cancel": "Peruuta",
|
||||
@@ -197,7 +197,7 @@
|
||||
"account_usage_calls_title": "Soitetut puhelut",
|
||||
"error_boundary_description": "Näin ei selvästikään pitäisi tapahtua. Pahoittelut tästä.<br/>Jos sinulla on hetki aikaa, <githubLink>ilmoita tästä GitHubissa</githubLink> tai ilmoita meille <discordLink>Discordin</discordLink> tai <matrixLink>Matrix</matrixLink> kautta.",
|
||||
"signup_form_toggle_password_visibility": "Vaihda salasanan näkyvyys",
|
||||
"login_link_signup": "Kirjaudu linkki",
|
||||
"login_link_signup": "Kirjautumislinkki",
|
||||
"publish_dialog_message_label": "Viesti",
|
||||
"publish_dialog_attached_file_title": "Liitetiedosto:",
|
||||
"priority_min": "min",
|
||||
@@ -254,7 +254,7 @@
|
||||
"publish_dialog_attach_placeholder": "Liitä tiedosto URL-osoitteen mukaan, esim. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_email_placeholder": "Osoite, johon ilmoitus välitetään, esim. urpo@example.com",
|
||||
"notifications_attachment_link_expires": "linkki vanhenee {{date}}",
|
||||
"action_bar_send_test_notification": "Lähetä testi ilmoitus",
|
||||
"action_bar_send_test_notification": "Lähetä testi-ilmoitus",
|
||||
"reservation_delete_dialog_action_keep_title": "Säilytä välimuistissa olevat viestit ja liitteet",
|
||||
"prefs_notifications_sound_no_sound": "Ei ääntä",
|
||||
"account_upgrade_dialog_interval_yearly": "Vuosittain",
|
||||
@@ -300,15 +300,15 @@
|
||||
"account_tokens_table_cannot_delete_or_edit": "Nykyistä istuntotunnusta ei voi muokata tai poistaa",
|
||||
"notifications_tags": "Tagit",
|
||||
"prefs_notifications_sound_play": "Toista valittu ääni",
|
||||
"account_tokens_table_last_access_header": "Viimeinen käyty",
|
||||
"account_tokens_table_last_access_header": "Viimeinen käynti",
|
||||
"action_bar_profile_logout": "Kirjaudu ulos",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Liitetiedoston nimi",
|
||||
"publish_dialog_priority_default": "Oletusprioriteetti",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Palvelimen URL",
|
||||
"account_tokens_table_last_origin_tooltip": "Napsauta IP-osoitteesta {{ip}}, etsiäksesi",
|
||||
"account_tokens_table_last_origin_tooltip": "Napsauta IP-osoitteesta {{ip}} etsiäksesi",
|
||||
"account_usage_reservations_title": "Varatut topikit",
|
||||
"account_upgrade_dialog_tier_price_per_month": "Kuukausi",
|
||||
"message_bar_show_dialog": "Näytä julkaisu dialogi",
|
||||
"message_bar_show_dialog": "Näytä julkaisudialogi",
|
||||
"publish_dialog_chip_attach_url_label": "Liitä tiedosto URL-osoitteen mukaan",
|
||||
"account_usage_calls_none": "Tällä tilillä ei voi soittaa puheluita",
|
||||
"notifications_click_open_button": "Avaa linkki",
|
||||
@@ -331,7 +331,7 @@
|
||||
"prefs_notifications_delete_after_one_month_description": "Ilmoitukset poistetaan automaattisesti kuukauden kuluttua",
|
||||
"common_cancel": "Peruuta",
|
||||
"account_basics_phone_numbers_dialog_verify_button_call": "Soita minulle",
|
||||
"signup_already_have_account": "Onko sinulla jo tili ? Kirjaudu sisään !",
|
||||
"signup_already_have_account": "Onko sinulla jo tili? Kirjaudu sisään!",
|
||||
"publish_dialog_call_item": "Soita puhelinnumeroon {{number}}",
|
||||
"nav_button_account": "Tili",
|
||||
"publish_dialog_click_reset": "Poista napsautettava URL-osoite",
|
||||
@@ -349,7 +349,7 @@
|
||||
"notifications_priority_x": "Prioriteetti {{priority}}",
|
||||
"account_delete_dialog_billing_warning": "Tilin poistaminen peruuttaa myös laskutustilauksesi välittömästi. Et voi enää käyttää laskutuksen hallintapaneelia.",
|
||||
"prefs_notifications_min_priority_description_max": "Näytä ilmoitukset, jos prioriteetti on 5 (max)",
|
||||
"subscribe_dialog_login_description": "Tämä Topikki on suojattu salasanalla. Anna käyttäjätunnus ja salasana.",
|
||||
"subscribe_dialog_login_description": "Tämä topikki on suojattu salasanalla. Anna käyttäjätunnus ja salasana.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Valittu taso sallii vähemmän varattuja topikkeja kuin nykyinen tasosi. Ennen kuin muutat tasosi, <strong>poista vähintään {{count}} varausta</strong>. Voit poistaa varauksia <Link>Asetuksista</Link>.",
|
||||
"prefs_users_dialog_title_add": "Lisää käyttäjä",
|
||||
"account_tokens_dialog_button_create": "Luo tunnus",
|
||||
@@ -360,11 +360,11 @@
|
||||
"notifications_actions_not_supported": "Toimintoa ei tueta verkkosovelluksessa",
|
||||
"notifications_actions_open_url_title": "Siirry osoitteeseen {{url}}",
|
||||
"notifications_none_for_any_title": "Et ole saanut ilmoituksia.",
|
||||
"notifications_none_for_topic_description": "Jos haluat lähettää ilmoituksia tähän topikkiin, PUT tai POST topikin URL-osoitteeseen.",
|
||||
"notifications_none_for_topic_description": "Jos haluat lähettää ilmoituksia tähän topikkiin, lähetä PUT tai POST topikin URL-osoitteeseen.",
|
||||
"notifications_none_for_any_description": "Jos haluat lähettää ilmoituksia topikkiin, PUT tai POST topikin URL-osoitteeseen. Tässä on esimerkki yhden topikin käyttämisestä.",
|
||||
"notifications_no_subscriptions_title": "Näyttää siltä, että sinulla ei ole vielä tilauksia.",
|
||||
"notifications_none_for_topic_title": "Et ole vielä saanut ilmoituksia tästä aiheesta.",
|
||||
"notifications_actions_http_request_title": "Lähetä HTTP {{method}} to {{url}}",
|
||||
"notifications_actions_http_request_title": "Lähetä HTTP {{method}} osoitteeseen {{url}}",
|
||||
"reserve_dialog_checkbox_label": "Käänteinen aihe ja aseta pääsy",
|
||||
"publish_dialog_progress_uploading": "Lähetetään …",
|
||||
"publish_dialog_title_no_topic": "Julkaise ilmoitus",
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"nav_button_muted": "Notificacións acaladas",
|
||||
"nav_button_connecting": "conectando",
|
||||
"nav_upgrade_banner_label": "Mellorar a ntfy Pro",
|
||||
"alert_not_supported_description": "O teu navegador non ten soporte para notificacións.",
|
||||
"alert_not_supported_description": "O teu navegador non ten soporte para notificacións",
|
||||
"notifications_priority_x": "Prioridade {{priority}}",
|
||||
"notifications_attachment_link_expires": "a ligazón caduca o {{date}}",
|
||||
"notifications_attachment_link_expired": "a ligazón de descarga caducou",
|
||||
@@ -380,5 +380,31 @@
|
||||
"account_basics_phone_numbers_dialog_verify_button_call": "Chámame",
|
||||
"account_usage_emails_title": "Emails enviados",
|
||||
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
|
||||
"subscribe_dialog_login_description": "Este tema está protexido por contrasinal. Por favor, introduza o usuario e contrasinal para subscribirse."
|
||||
"subscribe_dialog_login_description": "Este tema está protexido por contrasinal. Por favor, introduza o usuario e contrasinal para subscribirse.",
|
||||
"action_bar_mute_notifications": "Acalar notificacións",
|
||||
"action_bar_unmute_notifications": "Reactivar notificacións",
|
||||
"alert_notification_permission_required_title": "Notificacións desactivadas",
|
||||
"alert_notification_permission_required_description": "Concederlle permisos ao navegador para mostrar notificacións de escritorio",
|
||||
"alert_notification_permission_required_button": "Conceder",
|
||||
"alert_notification_permission_denied_title": "Notificacións bloqueadas",
|
||||
"alert_notification_permission_denied_description": "Por favor reactívaas no navegador",
|
||||
"alert_notification_ios_install_required_title": "Require instalación iOS",
|
||||
"alert_notification_ios_install_required_description": "Preme na icona Compartir e Engadir a Pantalla de Inicio para activar as notificacións en iOS",
|
||||
"notifications_actions_failed_notification": "Non se puido realizar a acción",
|
||||
"publish_dialog_checkbox_markdown": "Dar formato Markdow",
|
||||
"prefs_notifications_web_push_title": "Notificacións en segundo plano",
|
||||
"prefs_notifications_web_push_enabled_description": "Recíbense notificacións incluso se a app web non está en execución (vía Web Push)",
|
||||
"prefs_notifications_web_push_disabled_description": "Recíbense as notificacións cando a app web está en execución (vía WebSocket)",
|
||||
"prefs_notifications_web_push_enabled": "Activadas para {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Desactivadas",
|
||||
"prefs_appearance_theme_title": "Decorado",
|
||||
"prefs_appearance_theme_system": "Sistema (por defecto)",
|
||||
"prefs_appearance_theme_dark": "Modo escuro",
|
||||
"prefs_appearance_theme_light": "Modo claro",
|
||||
"error_boundary_button_reload_ntfy": "Recargar ntfy",
|
||||
"web_push_subscription_expiring_title": "Vanse pausar as notificacións",
|
||||
"web_push_subscription_expiring_body": "Abrir ntfy para seguir recibindo notificacións",
|
||||
"web_push_unknown_notification_title": "Recibida unha notificación descoñecida desde o servidor",
|
||||
"web_push_unknown_notification_body": "Poderías ter que actualizar ntfy abrindo a app web",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "As notificacións procedentes doutros servidores non se van recibir cando a app web estea pechada"
|
||||
}
|
||||
|
||||
@@ -381,5 +381,28 @@
|
||||
"account_upgrade_dialog_tier_features_no_calls": "Tidak ada panggilan telepon",
|
||||
"account_basics_phone_numbers_dialog_code_label": "Kode verifikasi",
|
||||
"publish_dialog_call_item": "Panggil nomor telepon {{number}}",
|
||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Tidak ada nomor telepon terverifikasi"
|
||||
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Tidak ada nomor telepon terverifikasi",
|
||||
"action_bar_unmute_notifications": "Nyalakan notifikasi",
|
||||
"alert_notification_permission_denied_title": "Notifikasi sedang diblokir",
|
||||
"alert_notification_permission_denied_description": "Silakan aktifkan lagi dalam peramban Anda",
|
||||
"alert_notification_ios_install_required_title": "Pemasangan iOS diperlukan",
|
||||
"alert_notification_ios_install_required_description": "Klik ikon Bagikan dan Tambahkan ke Layar Beranda untuk mengaktifkan notifikasi di iOS",
|
||||
"notifications_actions_failed_notification": "Tindakan tidak berhasil",
|
||||
"publish_dialog_checkbox_markdown": "Format sebagai Markdown",
|
||||
"prefs_notifications_web_push_title": "Notifikasi latar belakang",
|
||||
"prefs_notifications_web_push_enabled_description": "Notifikasi diterima bahkan ketika aplikasi web tidak berjalan (melalui Web Push)",
|
||||
"prefs_notifications_web_push_disabled_description": "Notifikasi diterima ketika aplikasi web berjalan (melalui WebSocket)",
|
||||
"prefs_appearance_theme_title": "Tema",
|
||||
"error_boundary_button_reload_ntfy": "Muat ulang ntfy",
|
||||
"action_bar_mute_notifications": "Matikan notifikasi",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "Notifikasi dari server lain tidak akan diterima ketika aplikasi web tidak buka",
|
||||
"prefs_notifications_web_push_enabled": "Diaktifkan untuk {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Dinonaktifkan",
|
||||
"prefs_appearance_theme_dark": "Mode gelap",
|
||||
"prefs_appearance_theme_system": "Sistem (bawaan)",
|
||||
"prefs_appearance_theme_light": "Mode terang",
|
||||
"web_push_subscription_expiring_title": "Notifikasi akan dijeda",
|
||||
"web_push_subscription_expiring_body": "Buka ntfy untuk terus menerima notifikasi",
|
||||
"web_push_unknown_notification_title": "Notifikasi yang tidak diketahui diterima dari server",
|
||||
"web_push_unknown_notification_body": "Anda mungkin harus memperbarui ntfy dengan membuka aplikasi web"
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"publish_dialog_email_placeholder": "Adresse å videresende merknaden til, f.eks. phil@example.com",
|
||||
"error_boundary_gathering_info": "Hent mer info …",
|
||||
"prefs_notifications_sound_description_some": "Merknader spiller {{sound}}-lyd når de mottas",
|
||||
"prefs_notifications_min_priority_description_any": "Viser aller merknader, uavhengig av prioritet",
|
||||
"prefs_notifications_min_priority_description_any": "Viser alle merknader, uavhengig av prioritet",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Vis merknader hvis prioritet er {{number}} ({{name}}) eller høyere",
|
||||
"prefs_notifications_min_priority_high_and_higher": "Høy prioritet og høyere",
|
||||
"prefs_notifications_min_priority_max_only": "Kun maks. prioritet",
|
||||
@@ -158,7 +158,7 @@
|
||||
"prefs_notifications_min_priority_low_and_higher": "Lav prioritet og høyere",
|
||||
"prefs_users_description": "Legg til/fjern brukere for dine beskyttede emner her. Vær oppmerksom på at brukernavn og passord er lagret i nettleserens lokale lagring.",
|
||||
"error_boundary_description": "Dette skal åpenbart ikke skje. Beklager dette.<br/>Hvis du har et minutt, vennligst <githubLink>rapporter dette på GitHub</githubLink>, eller gi oss beskjed via <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.",
|
||||
"action_bar_logo_alt": "ntfy logo",
|
||||
"action_bar_logo_alt": "ntfy-logo",
|
||||
"message_bar_publish": "Publiser melding",
|
||||
"action_bar_toggle_action_menu": "Åpne/lukk handlingsmeny",
|
||||
"message_bar_show_dialog": "Vis publiseringsdialog",
|
||||
@@ -181,7 +181,7 @@
|
||||
"publish_dialog_topic_reset": "Tilbakestill emne",
|
||||
"publish_dialog_click_label": "Klikk URL",
|
||||
"publish_dialog_email_reset": "Fjern videresending av e-post",
|
||||
"publish_dialog_attach_reset": "Fjern URL vedlegg",
|
||||
"publish_dialog_attach_reset": "Fjern URL-vedlegg",
|
||||
"publish_dialog_delay_reset": "Fjern forsinket levering",
|
||||
"publish_dialog_attached_file_remove": "Fjern vedlagt fil",
|
||||
"subscribe_dialog_subscribe_base_url_label": "Tjeneste-URL",
|
||||
@@ -191,7 +191,7 @@
|
||||
"action_bar_account": "Konto",
|
||||
"action_bar_profile_settings": "Innstillinger",
|
||||
"nav_button_account": "Konto",
|
||||
"signup_title": "Opprett en ntfy konto",
|
||||
"signup_title": "Opprett en ntfy-konto",
|
||||
"signup_form_username": "Brukernavn",
|
||||
"signup_form_password": "Passord",
|
||||
"signup_form_button_submit": "Meld deg på",
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
"alert_grant_title": "Oznámenia sú vypnuté",
|
||||
"alert_grant_button": "Prideliť teraz",
|
||||
"alert_not_supported_title": "Oznámenia nie sú podporované",
|
||||
"alert_not_supported_description": "Oznámenia nie sú vo vašom prehliadači podporované.",
|
||||
"alert_not_supported_description": "Oznámenia nie sú vo vašom prehliadači podporované",
|
||||
"notifications_attachment_copy_url_title": "Kopírovať URL adresu prílohy do schránky",
|
||||
"notifications_attachment_copy_url_button": "Kopírovať adresu URL",
|
||||
"notifications_attachment_open_title": "Prejsť na {{url}}",
|
||||
@@ -380,5 +380,31 @@
|
||||
"account_upgrade_dialog_reservations_warning_other": "Vybraná úroveň umožňuje menej rezervovaných tém ako vaša aktuálna úroveň. Pred zmenou úrovne <strong>vymažte aspoň {{count}} rezervácií</strong>. Rezervácie môžete odstrániť v <Link>Nastaveniach</Link>.",
|
||||
"prefs_users_dialog_title_add": "Pridať používateľa",
|
||||
"account_tokens_dialog_button_create": "Vytvoriť token",
|
||||
"account_tokens_table_create_token_button": "Vytvoriť prístupový token"
|
||||
"account_tokens_table_create_token_button": "Vytvoriť prístupový token",
|
||||
"action_bar_mute_notifications": "Stlmiť oznámenia",
|
||||
"action_bar_unmute_notifications": "Zrušiť stlmenie oznámení",
|
||||
"alert_notification_permission_required_description": "Udeliť povolenie prehliadaču na zobrazovanie oznámení na ploche",
|
||||
"alert_notification_permission_required_button": "Udeliť teraz",
|
||||
"alert_notification_permission_denied_title": "Oznámenia sú zablokované",
|
||||
"alert_notification_permission_denied_description": "Opätovne ich povoľte vo svojom prehliadači",
|
||||
"alert_notification_ios_install_required_title": "Vyžaduje sa inštalácia iOS",
|
||||
"notifications_actions_failed_notification": "Neúspešná akcia",
|
||||
"publish_dialog_checkbox_markdown": "Formátovať ako Markdown",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "Oznámenia z iných serverov sa nebudú prijímať, keď webová aplikácia nie je otvorená",
|
||||
"prefs_notifications_web_push_title": "Oznámenia na pozadí",
|
||||
"prefs_notifications_web_push_enabled_description": "Oznámenia sa prijímajú, aj keď webová aplikácia nie je spustená (prostredníctvom Web Push)",
|
||||
"prefs_notifications_web_push_disabled_description": "Oznámenia sa prijímajú, keď je webová aplikácia spustená (cez WebSocket)",
|
||||
"prefs_notifications_web_push_enabled": "Povolené pre {{server}}",
|
||||
"prefs_notifications_web_push_disabled": "Zakázané",
|
||||
"prefs_appearance_theme_title": "Téma",
|
||||
"prefs_appearance_theme_system": "Systémové (predvolené)",
|
||||
"prefs_appearance_theme_dark": "Tmavý režim",
|
||||
"prefs_appearance_theme_light": "Svetlý režim",
|
||||
"error_boundary_button_reload_ntfy": "Obnoviť ntfy",
|
||||
"web_push_subscription_expiring_title": "Oznámenia budú pozastavené",
|
||||
"web_push_subscription_expiring_body": "Ak chcete pokračovať v prijímaní upozornení, otvorte ntfy",
|
||||
"web_push_unknown_notification_title": "Neznáme oznámenie prijaté zo servera",
|
||||
"web_push_unknown_notification_body": "Možno budete musieť aktualizovať ntfy otvorením webovej aplikácie",
|
||||
"alert_notification_permission_required_title": "Oznámenia sú vypnuté",
|
||||
"alert_notification_ios_install_required_description": "Kliknutím na Zdieľať a Pridať na domovskú obrazovku povolíte oznámenia v systéme iOS"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"action_bar_settings": "Inställningar",
|
||||
"action_bar_send_test_notification": "Skicka test notis",
|
||||
"action_bar_send_test_notification": "Skicka testnotis",
|
||||
"action_bar_toggle_action_menu": "Öppna/stäng åtgärdsmeny",
|
||||
"message_bar_type_message": "Skriv ett meddelande här",
|
||||
"message_bar_error_publishing": "Fel vid publicering av notis",
|
||||
"message_bar_show_dialog": "Visa publicerings dialog",
|
||||
"message_bar_show_dialog": "Visa publiceringsdialog",
|
||||
"message_bar_publish": "Publicera meddelande",
|
||||
"nav_topics_title": "Prenumererade kategorier",
|
||||
"nav_button_all_notifications": "Alla notiser",
|
||||
@@ -27,7 +27,7 @@
|
||||
"notifications_attachment_link_expired": "Nedladdningslänk utgått",
|
||||
"notifications_priority_x": "Prioritet {{priority}}",
|
||||
"action_bar_show_menu": "Visa meny",
|
||||
"action_bar_logo_alt": "ntfy logga",
|
||||
"action_bar_logo_alt": "ntfy-logga",
|
||||
"action_bar_unsubscribe": "Avprenumerera",
|
||||
"action_bar_toggle_mute": "Tysta/aktivera notiser",
|
||||
"action_bar_clear_notifications": "Rensa alla notiser",
|
||||
@@ -36,12 +36,12 @@
|
||||
"nav_button_settings": "Inställningar",
|
||||
"nav_button_muted": "Notiser tystade",
|
||||
"notifications_attachment_link_expires": "länken utgår {{date}}",
|
||||
"notifications_attachment_file_image": "bild fil",
|
||||
"notifications_attachment_file_audio": "ljud fil",
|
||||
"notifications_attachment_file_image": "bildfil",
|
||||
"notifications_attachment_file_audio": "ljudfil",
|
||||
"alert_notification_permission_required_description": "Ge din webbläsare behörighet att visa skrivbordsnotiser.",
|
||||
"alert_not_supported_description": "Notiser stöds inte i din webbläsare.",
|
||||
"notifications_mark_read": "Markera som läst",
|
||||
"notifications_attachment_file_video": "video fil",
|
||||
"notifications_attachment_file_video": "videofil",
|
||||
"notifications_click_copy_url_button": "Kopiera länk",
|
||||
"notifications_click_open_button": "Öppna länk",
|
||||
"notifications_actions_open_url_title": "Gå till {{url}}",
|
||||
@@ -78,10 +78,10 @@
|
||||
"signup_disabled": "Registrering är inaktiverad",
|
||||
"signup_error_username_taken": "Användarnamn [[username]] används redan",
|
||||
"notifications_attachment_file_document": "annat dokument",
|
||||
"notifications_attachment_file_app": "Android app fil",
|
||||
"notifications_attachment_file_app": "Android-appfil",
|
||||
"notifications_click_copy_url_title": "Kopiera länk till urklipp",
|
||||
"notifications_none_for_topic_title": "Du har inte fått några notiser för detta ämnet ännu.",
|
||||
"notifications_none_for_topic_description": "För att kunna skicka notiser till detta ämnet, använd PUT eller POST till ämnets URL.",
|
||||
"notifications_none_for_topic_description": "För att kunna skicka notiser till detta ämne, använd PUT eller POST till ämnets URL.",
|
||||
"notifications_actions_http_request_title": "Skicka HTTP {{method}} till {{url}}",
|
||||
"publish_dialog_progress_uploading": "Laddar upp …",
|
||||
"nav_upgrade_banner_description": "Reservera ämnen, fler meddelanden och e-postmeddelanden och större bilagor",
|
||||
@@ -103,7 +103,7 @@
|
||||
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagligt e-postmeddelande",
|
||||
"account_upgrade_dialog_button_cancel": "Avbryt",
|
||||
"common_copy_to_clipboard": "Kopiera till urklipp",
|
||||
"account_tokens_table_copied_to_clipboard": "Åtkomsttoken kopierat",
|
||||
"account_tokens_table_copied_to_clipboard": "Åtkomsttoken kopierad",
|
||||
"account_tokens_description": "Använd åtkomsttoken när du publicerar och prenumererar via ntfy API, så att du inte behöver skicka dina kontouppgifter. Läs mer i <Link>dokumentationen</Link>.",
|
||||
"account_tokens_table_create_token_button": "Skapa åtkomsttoken",
|
||||
"prefs_users_description_no_sync": "Användare och lösenord synkroniseras inte till ditt konto.",
|
||||
@@ -129,7 +129,7 @@
|
||||
"account_upgrade_dialog_interval_yearly": "Årligen",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save": "spara {{discount}}%",
|
||||
"account_upgrade_dialog_interval_yearly_discount_save_up_to": "spara upp till {{discount}}%",
|
||||
"account_upgrade_dialog_cancel_warning": "Detta kommer att <strong>säga upp din prenumeration</strong> och nedgradera ditt konto på {{date}}. På det datumet kommer ämnesreservationer och meddelanden som ligger i cacheminnet på servern <strong>att raderas</strong>.",
|
||||
"account_upgrade_dialog_cancel_warning": "Detta kommer att <strong>säga upp din prenumeration</strong> och nedgradera ditt konto {{date}}. På det datumet kommer ämnesreservationer och meddelanden som ligger i cacheminnet på servern <strong>att raderas</strong>.",
|
||||
"account_upgrade_dialog_proration_info": "<strong>Deklaration</strong>: När du uppgraderar mellan betalda planer kommer prisskillnaden att <strong>debiteras omedelbart</strong>. Vid nedgradering till en lägre nivå kommer saldot att användas för att betala för framtida faktureringsperioder.",
|
||||
"account_upgrade_dialog_reservations_warning_one": "Den valda nivån tillåter färre reserverade ämnen än din nuvarande nivå. Innan du ändrar nivå, <strong>bör du ta bort minst en reservation</strong>. Du kan ta bort reservationer i <Link>Inställningar</Link>.",
|
||||
"account_upgrade_dialog_reservations_warning_other": "Den valda nivån tillåter färre reserverade ämnen än din nuvarande nivå. Innan du ändrar nivå, <strong>ta bort minst {{count}} reservationer</strong>. Du kan ta bort reservationer i <Link>Inställningar</Link>.",
|
||||
@@ -363,7 +363,7 @@
|
||||
"account_basics_phone_numbers_description": "För notifieringar via telefonsamtal",
|
||||
"account_basics_phone_numbers_no_phone_numbers_yet": "Inga telefonnummer ännu",
|
||||
"account_basics_phone_numbers_copied_to_clipboard": "Telefonnummer kopierat till urklipp",
|
||||
"account_basics_phone_numbers_dialog_title": "Lägga till telefonnummer",
|
||||
"account_basics_phone_numbers_dialog_title": "Lägg till telefonnummer",
|
||||
"account_basics_phone_numbers_dialog_number_label": "Telefonnummer",
|
||||
"account_basics_phone_numbers_dialog_number_placeholder": "t.ex. +1222333444",
|
||||
"account_basics_phone_numbers_dialog_verify_button_sms": "Skicka SMS",
|
||||
|
||||
@@ -60,8 +60,8 @@
|
||||
"nav_button_documentation": "Belgelendirme",
|
||||
"nav_button_publish_message": "Bildirim yayınla",
|
||||
"alert_notification_permission_required_title": "Bildirimler devre dışı",
|
||||
"alert_notification_permission_required_description": "Tarayıcınıza masaüstü bildirimlerini görüntüleme izni verin.",
|
||||
"alert_not_supported_description": "Tarayıcınızda bildirimler desteklenmiyor.",
|
||||
"alert_notification_permission_required_description": "Tarayıcınıza masaüstü bildirimlerini görüntüleme izni verin",
|
||||
"alert_not_supported_description": "Tarayıcınızda bildirimler desteklenmiyor",
|
||||
"notifications_copied_to_clipboard": "Panoya kopyalandı",
|
||||
"notifications_tags": "Etiketler",
|
||||
"notifications_attachment_copy_url_title": "Ek URL'sini panoya kopyala",
|
||||
@@ -380,5 +380,28 @@
|
||||
"account_basics_phone_numbers_dialog_code_label": "Doğrulama kodu",
|
||||
"account_basics_phone_numbers_dialog_code_placeholder": "örn. 123456",
|
||||
"account_usage_calls_title": "Yapılan telefon aramaları",
|
||||
"account_upgrade_dialog_tier_features_no_calls": "Telefon araması yok"
|
||||
"account_upgrade_dialog_tier_features_no_calls": "Telefon araması yok",
|
||||
"action_bar_mute_notifications": "Bildirimleri sessize al",
|
||||
"action_bar_unmute_notifications": "Bildirimlerin sesini aç",
|
||||
"alert_notification_permission_denied_title": "Bildirimler engellendi",
|
||||
"alert_notification_permission_denied_description": "Lütfen tarayıcınızda yeniden etkinleştirin",
|
||||
"alert_notification_ios_install_required_title": "iOS kurulumu gerekli",
|
||||
"alert_notification_ios_install_required_description": "iOS'ta bildirimleri etkinleştirmek için Paylaş simgesine ve Ana Ekrana Ekle'ye tıklayın",
|
||||
"notifications_actions_failed_notification": "Başarısız eylem",
|
||||
"publish_dialog_checkbox_markdown": "Markdown olarak biçimlendir",
|
||||
"prefs_notifications_web_push_title": "Arka plan bildirimleri",
|
||||
"prefs_notifications_web_push_enabled_description": "Web uygulaması çalışmadığında bile bildirimler alınır (Web Push aracılığıyla)",
|
||||
"prefs_notifications_web_push_disabled_description": "Web uygulaması çalışırken bildirim alınır (WebSocket aracılığıyla)",
|
||||
"prefs_notifications_web_push_enabled": "{{server}} için etkinleştirildi",
|
||||
"prefs_notifications_web_push_disabled": "Devre dışı",
|
||||
"prefs_appearance_theme_title": "Tema",
|
||||
"prefs_appearance_theme_system": "Sistem (öntanımlı)",
|
||||
"prefs_appearance_theme_dark": "Koyu mod",
|
||||
"prefs_appearance_theme_light": "Açık mod",
|
||||
"error_boundary_button_reload_ntfy": "ntfy'yi yeniden yükle",
|
||||
"web_push_subscription_expiring_title": "Bildirimler duraklatılacak",
|
||||
"web_push_subscription_expiring_body": "Bildirimleri almaya devam etmek için ntfy'yi açın",
|
||||
"web_push_unknown_notification_title": "Sunucudan bilinmeyen bildirim alındı",
|
||||
"web_push_unknown_notification_body": "Web uygulamasını açarak ntfy'yi güncellemeniz gerekebilir",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "Web uygulaması açık değilken diğer sunuculardan gelen bildirimler alınmayacaktır"
|
||||
}
|
||||
|
||||
27
web/public/static/langs/uz.json
Normal file
27
web/public/static/langs/uz.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"signup_title": "ntfy hisobini yaratish",
|
||||
"signup_form_password": "Parol",
|
||||
"signup_form_confirm_password": "Parolni tasdiqlang",
|
||||
"signup_error_username_taken": "Foydalanuvchi nomi {{username}} allaqachon foydalanilmoqda",
|
||||
"signup_error_creation_limit_reached": "Boshqa hisob raqam ocha olmaysiz",
|
||||
"login_title": "Ntfy hisobingizga kiring",
|
||||
"login_form_button_submit": "Kirish",
|
||||
"login_link_signup": "Ro'yxatdan o'tish",
|
||||
"login_disabled": "Kirish o'chirilgan",
|
||||
"action_bar_show_menu": "Menyuni ko'rsatish",
|
||||
"action_bar_logo_alt": "ntfy logotipi",
|
||||
"action_bar_settings": "Sozlamalar",
|
||||
"action_bar_change_display_name": "Ko'rsatilgan nomni o'zgartiring",
|
||||
"action_bar_reservation_add": "Zaxira mavzusi",
|
||||
"common_cancel": "Bekor qilish",
|
||||
"common_save": "Saqlash",
|
||||
"common_add": "Qo‘shish",
|
||||
"common_back": "Orqaga",
|
||||
"common_copy_to_clipboard": "Xotiraga nusxalash",
|
||||
"signup_form_username": "Foydalanuvchi nomi",
|
||||
"signup_form_button_submit": "Ro‘yxatdan o‘tish",
|
||||
"signup_form_toggle_password_visibility": "Parol ko‘rinishini o‘zgartirish",
|
||||
"signup_already_have_account": "Hisobingiz bormi? Tizimga kiring!",
|
||||
"signup_disabled": "Ro‘yxatdan o‘tish o‘chirilgan",
|
||||
"action_bar_account": "Hisob"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { Box, Link } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import fileDocument from "../img/file-document.svg";
|
||||
import fileImage from "../img/file-image.svg";
|
||||
@@ -32,16 +32,18 @@ const AttachmentIcon = (props) => {
|
||||
imageLabel = t("notifications_attachment_file_document");
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
component="img"
|
||||
src={imageFile}
|
||||
alt={imageLabel}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
}}
|
||||
/>
|
||||
<Link href={props.href} target="_blank">
|
||||
<Box
|
||||
component="img"
|
||||
src={imageFile}
|
||||
alt={imageLabel}
|
||||
loading="lazy"
|
||||
sx={{
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import Navigation from "./Navigation";
|
||||
|
||||
const Messaging = (props) => {
|
||||
const [message, setMessage] = useState("");
|
||||
const [attachFile, setAttachFile] = useState(null);
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
|
||||
const { dialogOpenMode } = props;
|
||||
@@ -22,12 +23,30 @@ const Messaging = (props) => {
|
||||
const handleDialogClose = () => {
|
||||
props.onDialogOpenModeChange("");
|
||||
setDialogKey((prev) => prev + 1);
|
||||
setAttachFile(null);
|
||||
};
|
||||
|
||||
const getPastedImage = (ev) => {
|
||||
const { items } = ev.clipboardData;
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
if (items[i].type.indexOf("image") !== -1) {
|
||||
return items[i].getAsFile();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{subscription && (
|
||||
<MessageBar subscription={subscription} message={message} onMessageChange={setMessage} onOpenDialogClick={handleOpenDialogClick} />
|
||||
<MessageBar
|
||||
subscription={subscription}
|
||||
message={message}
|
||||
onMessageChange={setMessage}
|
||||
onFilePasted={setAttachFile}
|
||||
onOpenDialogClick={handleOpenDialogClick}
|
||||
getPastedImage={getPastedImage}
|
||||
/>
|
||||
)}
|
||||
<PublishDialog
|
||||
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
|
||||
@@ -35,6 +54,8 @@ const Messaging = (props) => {
|
||||
baseUrl={subscription?.baseUrl ?? config.base_url}
|
||||
topic={subscription?.topic ?? ""}
|
||||
message={message}
|
||||
attachFile={attachFile}
|
||||
getPastedImage={getPastedImage}
|
||||
onClose={handleDialogClose}
|
||||
onDragEnter={() => props.onDialogOpenModeChange((prev) => prev || PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open
|
||||
onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)}
|
||||
@@ -56,6 +77,15 @@ const MessageBar = (props) => {
|
||||
}
|
||||
props.onMessageChange("");
|
||||
};
|
||||
|
||||
const handlePaste = (ev) => {
|
||||
const blob = props.getPastedImage(ev);
|
||||
if (blob) {
|
||||
props.onFilePasted(blob);
|
||||
props.onOpenDialogClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={3}
|
||||
@@ -89,6 +119,7 @@ const MessageBar = (props) => {
|
||||
handleSendClick();
|
||||
}
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={handleSendClick} aria-label={t("message_bar_publish")}>
|
||||
<SendIcon />
|
||||
|
||||
@@ -235,6 +235,19 @@ const PublishDialog = (props) => {
|
||||
await checkAttachmentLimits(file);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.attachFile) {
|
||||
updateAttachFile(props.attachFile);
|
||||
}
|
||||
}, [props.attachFile]);
|
||||
|
||||
const handlePaste = (ev) => {
|
||||
const blob = props.getPastedImage(ev);
|
||||
if (blob) {
|
||||
updateAttachFile(blob);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAttachFileChanged = async (ev) => {
|
||||
await updateAttachFile(ev.target.files[0]);
|
||||
};
|
||||
@@ -357,6 +370,7 @@ const PublishDialog = (props) => {
|
||||
inputProps={{
|
||||
"aria-label": t("publish_dialog_message_label"),
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
<FormControlLabel
|
||||
label={t("publish_dialog_checkbox_markdown")}
|
||||
@@ -791,7 +805,7 @@ const AttachmentBox = (props) => {
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<AttachmentIcon type={file.type} />
|
||||
<AttachmentIcon type={file.type} href={URL.createObjectURL(file)} />
|
||||
<Box sx={{ marginLeft: 1, textAlign: "left" }}>
|
||||
<ExpandingTextField
|
||||
minWidth={140}
|
||||
|
||||
Reference in New Issue
Block a user