1
0
Fork 0
mirror of https://github.com/binwiederhier/ntfy.git synced 2024-11-01 01:21:15 +01:00

Merge branch 'main' into markdown

This commit is contained in:
binwiederhier 2023-07-04 21:15:08 -04:00
commit 43981bb675
129 changed files with 6741 additions and 1183 deletions

View file

@ -11,7 +11,7 @@ jobs:
name: Install Go
uses: actions/setup-go@v4
with:
go-version: '1.19.x'
go-version: '1.20.x'
-
name: Install node
uses: actions/setup-node@v3

View file

@ -14,7 +14,7 @@ jobs:
name: Install Go
uses: actions/setup-go@v4
with:
go-version: '1.19.x'
go-version: '1.20.x'
-
name: Install node
uses: actions/setup-node@v3

View file

@ -11,7 +11,7 @@ jobs:
name: Install Go
uses: actions/setup-go@v4
with:
go-version: '1.19.x'
go-version: '1.20.x'
-
name: Install node
uses: actions/setup-node@v3

2
.gitignore vendored
View file

@ -1,4 +1,5 @@
dist/
dev-dist/
build/
.idea/
.vscode/
@ -12,3 +13,4 @@ secrets/
node_modules/
.DS_Store
__pycache__
web/dev-dist/

View file

@ -119,8 +119,6 @@ archives:
- server/ntfy.service
- client/client.yml
- client/ntfy-client.service
replacements:
amd64: x86_64
-
id: ntfy_windows
builds:
@ -131,8 +129,6 @@ archives:
- LICENSE
- README.md
- client/client.yml
replacements:
amd64: x86_64
-
id: ntfy_darwin
builds:
@ -142,8 +138,6 @@ archives:
- LICENSE
- README.md
- client/client.yml
replacements:
darwin: macOS
universal_binaries:
-
id: ntfy_darwin_all

View file

@ -9,7 +9,7 @@
[![Discord](https://img.shields.io/discord/874398661709295626?label=Discord)](https://discord.gg/cT7ECsZj9w)
[![Matrix](https://img.shields.io/matrix/ntfy:matrix.org?label=Matrix)](https://matrix.to/#/#ntfy:matrix.org)
[![Matrix space](https://img.shields.io/matrix/ntfy-space:matrix.org?label=Matrix+space)](https://matrix.to/#/#ntfy-space:matrix.org)
[![Reddit](https://img.shields.io/reddit/subreddit-subscribers/ntfy?color=%23317f6f&label=-%20r%2Fntfy&style=social)](https://www.reddit.com/r/ntfy/)
[![Lemmy](https://img.shields.io/badge/Lemmy-discuss-green)](https://discuss.ntfy.sh/c/ntfy)
[![Healthcheck](https://healthchecks.io/badge/68b65976-b3b0-4102-aec9-980921/kcoEgrLY.svg)](https://ntfy.statuspage.io/)
[![Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/binwiederhier/ntfy)
@ -47,9 +47,8 @@ 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
* [Reddit r/ntfy](https://www.reddit.com/r/ntfy/) - asynchronous forum (_new as of October 2022_)
* [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
* [Email](https://heckel.io/about) - reach me directly (_I usually prefer the other methods_)
## Announcements / beta testers
For announcements of new releases and cutting-edge beta versions, please subscribe to the [ntfy.sh/announcements](https://ntfy.sh/announcements)
@ -141,6 +140,7 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
<a href="https://github.com/CreativeWarlock"><img src="https://github.com/CreativeWarlock.png" width="40px" /></a>
<a href="https://github.com/darkdragon-001"><img src="https://github.com/darkdragon-001.png" width="40px" /></a>
<a href="https://github.com/jonathan-kosgei"><img src="https://github.com/jonathan-kosgei.png" width="40px" /></a>
<a href="https://github.com/KevinWang15"><img src="https://github.com/KevinWang15.png" width="40px" /></a>
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
@ -178,3 +178,4 @@ Third party libraries and resources:
* [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used)
* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html)
* [Linked tabs in mkdocs](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs)
* [webpush-go](https://github.com/SherClockHolmes/webpush-go) (MIT) is used to send web push notifications

View file

@ -11,23 +11,25 @@ import (
"heckel.io/ntfy/util"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
// Event type constants
const (
MessageEvent = "message"
KeepaliveEvent = "keepalive"
OpenEvent = "open"
PollRequestEvent = "poll_request"
// MessageEvent identifies a message event
MessageEvent = "message"
)
const (
maxResponseBytes = 4096
)
var (
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // Same as in server/server.go
)
// Client is the ntfy client that can be used to publish and subscribe to ntfy topics
type Client struct {
Messages chan *Message
@ -96,8 +98,14 @@ func (c *Client) Publish(topic, message string, options ...PublishOption) (*Mess
// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache,
// WithNoFirebase, and the generic WithHeader.
func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) {
topicURL := c.expandTopicURL(topic)
req, _ := http.NewRequest("POST", topicURL, body)
topicURL, err := c.expandTopicURL(topic)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", topicURL, body)
if err != nil {
return nil, err
}
for _, option := range options {
if err := option(req); err != nil {
return nil, err
@ -133,11 +141,14 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO
// By default, all messages will be returned, but you can change this behavior using a SubscribeOption.
// See WithSince, WithSinceAll, WithSinceUnixTime, WithScheduled, and the generic WithQueryParam.
func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, error) {
topicURL, err := c.expandTopicURL(topic)
if err != nil {
return nil, err
}
ctx := context.Background()
messages := make([]*Message, 0)
msgChan := make(chan *Message)
errChan := make(chan error)
topicURL := c.expandTopicURL(topic)
log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL))
options = append(options, WithPoll())
go func() {
@ -166,15 +177,18 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err
// Example:
//
// c := client.New(client.NewConfig())
// subscriptionID := c.Subscribe("mytopic")
// subscriptionID, _ := c.Subscribe("mytopic")
// for m := range c.Messages {
// fmt.Printf("New message: %s", m.Message)
// }
func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
func (c *Client) Subscribe(topic string, options ...SubscribeOption) (string, error) {
topicURL, err := c.expandTopicURL(topic)
if err != nil {
return "", err
}
c.mu.Lock()
defer c.mu.Unlock()
subscriptionID := util.RandomString(10)
topicURL := c.expandTopicURL(topic)
log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL))
ctx, cancel := context.WithCancel(context.Background())
c.subscriptions[subscriptionID] = &subscription{
@ -183,7 +197,7 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
cancel: cancel,
}
go handleSubscribeConnLoop(ctx, c.Messages, topicURL, subscriptionID, options...)
return subscriptionID
return subscriptionID, nil
}
// Unsubscribe unsubscribes from a topic that has been previously subscribed to using the unique
@ -199,31 +213,16 @@ func (c *Client) Unsubscribe(subscriptionID string) {
sub.cancel()
}
// UnsubscribeAll unsubscribes from a topic that has been previously subscribed with Subscribe.
// If there are multiple subscriptions matching the topic, all of them are unsubscribed from.
//
// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://
// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the
// config (e.g. mytopic -> https://ntfy.sh/mytopic).
func (c *Client) UnsubscribeAll(topic string) {
c.mu.Lock()
defer c.mu.Unlock()
topicURL := c.expandTopicURL(topic)
for _, sub := range c.subscriptions {
if sub.topicURL == topicURL {
delete(c.subscriptions, sub.ID)
sub.cancel()
}
}
}
func (c *Client) expandTopicURL(topic string) string {
func (c *Client) expandTopicURL(topic string) (string, error) {
if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") {
return topic
return topic, nil
} else if strings.Contains(topic, "/") {
return fmt.Sprintf("https://%s", topic)
return fmt.Sprintf("https://%s", topic), nil
}
return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic)
if !topicRegex.MatchString(topic) {
return "", fmt.Errorf("invalid topic name: %s", topic)
}
return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic), nil
}
func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) {

View file

@ -21,7 +21,7 @@ func TestClient_Publish_Subscribe(t *testing.T) {
defer test.StopServer(t, s, port)
c := client.New(newTestConfig(port))
subscriptionID := c.Subscribe("mytopic")
subscriptionID, _ := c.Subscribe("mytopic")
time.Sleep(time.Second)
msg, err := c.Publish("mytopic", "some message")

View file

@ -94,6 +94,11 @@ var flagsServe = append(
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-metrics", Aliases: []string{"enable_metrics"}, EnvVars: []string{"NTFY_ENABLE_METRICS"}, Value: false, Usage: "if set, Prometheus metrics are exposed via the /metrics endpoint"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "metrics-listen-http", Aliases: []string{"metrics_listen_http"}, EnvVars: []string{"NTFY_METRICS_LISTEN_HTTP"}, Usage: "ip:port used to expose the metrics endpoint (implicitly enables metrics)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "profile-listen-http", Aliases: []string{"profile_listen_http"}, EnvVars: []string{"NTFY_PROFILE_LISTEN_HTTP"}, Usage: "ip:port used to expose the profiling endpoints (implicitly enables profiling)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-public-key", Aliases: []string{"web_push_public_key"}, EnvVars: []string{"NTFY_WEB_PUSH_PUBLIC_KEY"}, Usage: "public key used for web push notifications"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-private-key", Aliases: []string{"web_push_private_key"}, EnvVars: []string{"NTFY_WEB_PUSH_PRIVATE_KEY"}, Usage: "private key used for web push notifications"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-file", Aliases: []string{"web_push_file"}, EnvVars: []string{"NTFY_WEB_PUSH_FILE"}, Usage: "file used to store web push subscriptions"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-email-address", Aliases: []string{"web_push_email_address"}, EnvVars: []string{"NTFY_WEB_PUSH_EMAIL_ADDRESS"}, Usage: "e-mail address of sender, required to use browser push services"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-startup-queries", Aliases: []string{"web_push_startup_queries"}, EnvVars: []string{"NTFY_WEB_PUSH_STARTUP_QUERIES"}, Usage: "queries run when the web push database is initialized"}),
)
var cmdServe = &cli.Command{
@ -129,6 +134,11 @@ func execServe(c *cli.Context) error {
keyFile := c.String("key-file")
certFile := c.String("cert-file")
firebaseKeyFile := c.String("firebase-key-file")
webPushPrivateKey := c.String("web-push-private-key")
webPushPublicKey := c.String("web-push-public-key")
webPushFile := c.String("web-push-file")
webPushEmailAddress := c.String("web-push-email-address")
webPushStartupQueries := c.String("web-push-startup-queries")
cacheFile := c.String("cache-file")
cacheDuration := c.Duration("cache-duration")
cacheStartupQueries := c.String("cache-startup-queries")
@ -183,6 +193,8 @@ func execServe(c *cli.Context) error {
// Check values
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
return errors.New("if set, FCM key file must exist")
} else if webPushPublicKey != "" && (webPushPrivateKey == "" || webPushFile == "" || webPushEmailAddress == "" || baseURL == "") {
return errors.New("if web push is enabled, web-push-private-key, web-push-public-key, web-push-file, web-push-email-address, and base-url should be set. run 'ntfy webpush keys' to generate keys")
} else if keepaliveInterval < 5*time.Second {
return errors.New("keepalive interval cannot be lower than five seconds")
} else if managerInterval < 5*time.Second {
@ -347,6 +359,11 @@ func execServe(c *cli.Context) error {
conf.MetricsListenHTTP = metricsListenHTTP
conf.ProfileListenHTTP = profileListenHTTP
conf.Version = c.App.Version
conf.WebPushPrivateKey = webPushPrivateKey
conf.WebPushPublicKey = webPushPublicKey
conf.WebPushFile = webPushFile
conf.WebPushEmailAddress = webPushEmailAddress
conf.WebPushStartupQueries = webPushStartupQueries
// Set up hot-reloading of config
go sigHandlerConfigReload(config)

View file

@ -72,7 +72,7 @@ ntfy subscribe TOPIC COMMAND
$NTFY_TITLE $title, $t Message title
$NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max)
$NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list)
$NTFY_RAW $raw Raw JSON message
$NTFY_RAW $raw Raw JSON message
Examples:
ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
@ -194,7 +194,10 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
topicOptions = append(topicOptions, auth)
}
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
subscriptionID, err := cl.Subscribe(s.Topic, topicOptions...)
if err != nil {
return err
}
if s.Command != "" {
cmds[subscriptionID] = s.Command
} else if conf.DefaultCommand != "" {
@ -204,7 +207,10 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
}
}
if topic != "" {
subscriptionID := cl.Subscribe(topic, options...)
subscriptionID, err := cl.Subscribe(topic, options...)
if err != nil {
return err
}
cmds[subscriptionID] = command
}
for m := range cl.Messages {

48
cmd/webpush.go Normal file
View file

@ -0,0 +1,48 @@
//go:build !noserver
package cmd
import (
"fmt"
"github.com/SherClockHolmes/webpush-go"
"github.com/urfave/cli/v2"
)
func init() {
commands = append(commands, cmdWebPush)
}
var cmdWebPush = &cli.Command{
Name: "webpush",
Usage: "Generate keys, in the future manage web push subscriptions",
UsageText: "ntfy webpush [keys]",
Category: categoryServer,
Subcommands: []*cli.Command{
{
Action: generateWebPushKeys,
Name: "keys",
Usage: "Generate VAPID keys to enable browser background push notifications",
UsageText: "ntfy webpush keys",
Category: categoryServer,
},
},
}
func generateWebPushKeys(c *cli.Context) error {
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
if err != nil {
return err
}
_, err = fmt.Fprintf(c.App.ErrWriter, `Web Push keys generated. Add the following lines to your config file:
web-push-public-key: %s
web-push-private-key: %s
web-push-file: /var/cache/ntfy/webpush.db # or similar
web-push-email-address: <email address>
See https://ntfy.sh/docs/config/#web-push for details.
`, publicKey, privateKey)
return err
}

24
cmd/webpush_test.go Normal file
View file

@ -0,0 +1,24 @@
package cmd
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/server"
)
func TestCLI_WebPush_GenerateKeys(t *testing.T) {
app, _, _, stderr := newTestApp()
require.Nil(t, runWebPushCommand(app, server.NewConfig(), "keys"))
require.Contains(t, stderr.String(), "Web Push keys generated.")
}
func runWebPushCommand(app *cli.App, conf *server.Config, args ...string) error {
webPushArgs := []string{
"ntfy",
"--log-level=ERROR",
"webpush",
}
return app.Run(append(webPushArgs, args...))
}

View file

@ -789,6 +789,57 @@ Note that the self-hosted server literally sends the message `New message` for e
may be `Some other message`. This is so that if iOS cannot talk to the self-hosted server (in time, or at all),
it'll show `New message` as a popup.
## Web Push
[Web Push](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) ([RFC8030](https://datatracker.ietf.org/doc/html/rfc8030))
allows ntfy to receive push notifications, even when the ntfy web app (or even the browser, depending on the platform) is closed.
When enabled, the user can enable **background notifications** for their topics in the wep app under Settings. Once enabled by the
user, ntfy will forward published messages to the push endpoint (browser-provided, e.g. fcm.googleapis.com), which will then
forward it to the browser.
To configure Web Push, you need to generate and configure a [VAPID](https://datatracker.ietf.org/doc/html/draft-thomson-webpush-vapid) keypair (via `ntfy webpush keys`),
a database to keep track of the browser's subscriptions, and an admin email address (you):
- `web-push-public-key` is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
- `web-push-private-key` is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
- `web-push-file` is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db`
- `web-push-email-address` is the admin email address send to the push provider, e.g. `sysadmin@example.com`
- `web-push-startup-queries` is an optional list of queries to run on startup`
Limitations:
- Like foreground browser notifications, background push notifications require the web app to be served over HTTPS. A _valid_
certificate is required, as service workers will not run on origins with untrusted certificates.
- Web Push is only supported for the same server. You cannot use subscribe to web push on a topic on another server. This
is due to a limitation of the Push API, which doesn't allow multiple push servers for the same origin.
To configure VAPID keys, first generate them:
```sh
$ ntfy webpush keys
Web Push keys generated.
...
```
Then copy the generated values into your `server.yml` or use the corresponding environment variables or command line arguments:
```yaml
web-push-public-key: AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
web-push-private-key: AA2BB1234567890abcdefzxcvbnm1234567890
web-push-file: /var/cache/ntfy/webpush.db
web-push-email-address: sysadmin@example.com
```
The `web-push-file` is used to store the push subscriptions. Unused subscriptions will send out a warning after 7 days,
and will automatically expire after 9 days (not configurable). If the gateway returns an error (e.g. 410 Gone when a user has unsubscribed),
subscriptions are also removed automatically.
The web app refreshes subscriptions on start and regularly on an interval, but this file should be persisted across restarts. If the subscription
file is deleted or lost, any web apps that aren't open will not receive new web push notifications until you open then.
Changing your public/private keypair is **not recommended**. Browsers only allow one server identity (public key) per origin, and
if you change them the clients will not be able to subscribe via web push until the user manually clears the notification permission.
## Tiers
ntfy supports associating users to pre-defined tiers. Tiers can be used to grant users higher limits, such as
daily message limits, attachment size, or make it possible for users to reserve topics. If [payments are enabled](#payments),
@ -1285,13 +1336,17 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact |
| `web-push-public-key` | `NTFY_WEB_PUSH_PUBLIC_KEY` | *string* | - | Web Push: Public Key. Run `ntfy webpush keys` to generate |
| `web-push-private-key` | `NTFY_WEB_PUSH_PRIVATE_KEY` | *string* | - | Web Push: Private Key. Run `ntfy webpush keys` to generate |
| `web-push-file` | `NTFY_WEB_PUSH_FILE` | *string* | - | Web Push: Database file that stores subscriptions |
| `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 *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
## Command line options
```
$ ntfy serve --help
NAME:
ntfy serve - Run the ntfy server
@ -1321,8 +1376,8 @@ OPTIONS:
--log-file value, --log_file value set log file, default is STDOUT [$NTFY_LOG_FILE]
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
--base-url value, --base_url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
--listen-http value, --listen_http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
--listen-https value, --listen_https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
--listen-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]
--listen-unix value, --listen_unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
--listen-unix-mode value, --listen_unix_mode value file permissions of unix socket, e.g. 0700 (default: system default) [$NTFY_LISTEN_UNIX_MODE]
--key-file value, --key_file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
@ -1343,11 +1398,12 @@ OPTIONS:
--keepalive-interval value, --keepalive_interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
--manager-interval value, --manager_interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
--disallowed-topics value, --disallowed_topics value [ --disallowed-topics value, --disallowed_topics value ] topics that are not allowed to be used [$NTFY_DISALLOWED_TOPICS]
--web-root value, --web_root value sets web root to landing page (home), web app (app) or disabled (disable) (default: "app") [$NTFY_WEB_ROOT]
--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]
--enable-login, --enable_login allows users to log in via the web app, or API (default: false) [$NTFY_ENABLE_LOGIN]
--enable-reservations, --enable_reservations allows users to reserve topics (if their tier allows it) (default: false) [$NTFY_ENABLE_RESERVATIONS]
--upstream-base-url value, --upstream_base_url value forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers [$NTFY_UPSTREAM_BASE_URL]
--upstream-access-token value, --upstream_access_token value access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth [$NTFY_UPSTREAM_ACCESS_TOKEN]
--smtp-sender-addr value, --smtp_sender_addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
--smtp-sender-user value, --smtp_sender_user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]
--smtp-sender-pass value, --smtp_sender_pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
@ -1355,6 +1411,10 @@ OPTIONS:
--smtp-server-listen value, --smtp_server_listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
--smtp-server-domain value, --smtp_server_domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
--smtp-server-addr-prefix value, --smtp_server_addr_prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
--twilio-account value, --twilio_account value Twilio account SID, used for phone calls, e.g. AC123... [$NTFY_TWILIO_ACCOUNT]
--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]
--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]
@ -1365,10 +1425,18 @@ OPTIONS:
--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-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]
--stripe-webhook-key value, --stripe_webhook_key value key required to validate the authenticity of incoming webhooks from Stripe [$NTFY_STRIPE_WEBHOOK_KEY]
--billing-contact value, --billing_contact value e-mail or website to display in upgrade dialog (only if payments are enabled) [$NTFY_BILLING_CONTACT]
--help, -h show help (default: false)
--enable-metrics, --enable_metrics if set, Prometheus metrics are exposed via the /metrics endpoint (default: false) [$NTFY_ENABLE_METRICS]
--metrics-listen-http value, --metrics_listen_http value ip:port used to expose the metrics endpoint (implicitly enables metrics) [$NTFY_METRICS_LISTEN_HTTP]
--profile-listen-http value, --profile_listen_http value ip:port used to expose the profiling endpoints (implicitly enables profiling) [$NTFY_PROFILE_LISTEN_HTTP]
--web-push-public-key value, --web_push_public_key value public key used for web push notifications [$NTFY_WEB_PUSH_PUBLIC_KEY]
--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]
--help, -h show help
```

View file

@ -16,7 +16,7 @@ server consists of three components:
* **The documentation** is generated by [MkDocs](https://www.mkdocs.org/) and [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/),
which is written in [Python](https://www.python.org/). You'll need Python and MkDocs (via `pip`) only if you want to
build the docs.
* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Create React App](https://create-react-app.dev/)
* **The web app** is written in [React](https://reactjs.org/), using [MUI](https://mui.com/). It uses [Vite](https://vitejs.dev/)
to build the production build. If you want to modify the web app, you need [nodejs](https://nodejs.org/en/) (for `npm`)
and install all the 100,000 dependencies (*sigh*).
@ -241,6 +241,41 @@ $ cd web
$ npm start
```
### Testing Web Push locally
Reference: <https://stackoverflow.com/questions/34160509/options-for-testing-service-workers-via-http>
#### With the dev servers
1. Get web push keys `go run main.go webpush keys`
2. Run the server with web push enabled
```sh
go run main.go \
--log-level debug \
serve \
--web-push-public-key KEY \
--web-push-private-key KEY \
--web-push-email-address <email> \
--web-push-file=/tmp/webpush.db
```
3. In `web/public/config.js`:
- Set `base_url` to `http://localhost`, This is required as web push can only be used with the server matching the `base_url`.
- Set the `web_push_public_key` correctly.
4. Run `npm run start`
#### With a built package
1. Run `make web-build`
2. Run the server (step 2 above)
3. Open <http://localhost/>
### Build the docs
The sources for the docs live in `docs/`. Similarly to the web app, you can simply run `make docs` to build the
documentation. As long as you have `mkdocs` installed (see above), this should work fine:

View file

@ -80,3 +80,13 @@ a proper backend. So as long as you secure your backend with ACLs, exposing the
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier).
I would be humbled if you helped me carry the server and developer account costs. Even small donations are very much
appreciated.
## Can I email you? Can I DM you on Discord/Matrix?
While I love chatting on [Discord](https://discord.gg/cT7ECsZj9w), [Matrix](https://matrix.to/#/#ntfy-space:matrix.org),
[Lemmy](https://discuss.ntfy.sh/c/ntfy), or [GitHub](https://github.com/binwiederhier/ntfy/issues), I generally
**do not respond to emails about ntfy or direct messages** about ntfy, unless you are paying for a
[ntfy Pro](https://ntfy.sh/#pricing) plan, or you are inquiring about business opportunities.
I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions
in the above-mentioned forums benefits others, since I can link to the discussion at a later point in time, or other users
may be able to help out. I hope you understand.

View file

@ -29,37 +29,37 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_x86_64.tar.gz
tar zxvf ntfy_2.5.0_linux_x86_64.tar.gz
sudo cp -a ntfy_2.5.0_linux_x86_64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_amd64.tar.gz
tar zxvf ntfy_2.6.2_linux_amd64.tar.gz
sudo cp -a ntfy_2.6.2_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.tar.gz
tar zxvf ntfy_2.5.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.5.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv6.tar.gz
tar zxvf ntfy_2.6.2_linux_armv6.tar.gz
sudo cp -a ntfy_2.6.2_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.tar.gz
tar zxvf ntfy_2.5.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.5.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv7.tar.gz
tar zxvf ntfy_2.6.2_linux_armv7.tar.gz
sudo cp -a ntfy_2.6.2_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.tar.gz
tar zxvf ntfy_2.5.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.5.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_arm64.tar.gz
tar zxvf ntfy_2.6.2_linux_arm64.tar.gz
sudo cp -a ntfy_2.6.2_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.6.2_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@ -109,7 +109,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -117,7 +117,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -125,7 +125,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -133,7 +133,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@ -143,34 +143,36 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_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.5.0/ntfy_2.5.0_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
## Arch Linux
ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/). You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download, build and install ntfy and keep it up to date.
ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/).
You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download,
build and install ntfy and keep it up to date.
```
paru -S ntfysh-bin
```
@ -192,18 +194,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.5.0/ntfy_2.5.0_macOS_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_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.5.0/ntfy_2.5.0_macOS_all.tar.gz > ntfy_2.5.0_macOS_all.tar.gz
tar zxvf ntfy_2.5.0_macOS_all.tar.gz
sudo cp -a ntfy_2.5.0_macOS_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_darwin_all.tar.gz > ntfy_2.6.2_darwin_all.tar.gz
tar zxvf ntfy_2.6.2_darwin_all.tar.gz
sudo cp -a ntfy_2.6.2_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.5.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.6.2_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@ -221,7 +223,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.5.0/ntfy_2.5.0_windows_x86_64.zip),
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.6.2/ntfy_2.6.2_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).
@ -277,7 +279,7 @@ docker run \
Using docker-compose with non-root user and healthchecks enabled:
```yaml
version: "2.1"
version: "2.3"
services:
ntfy:

View file

@ -55,6 +55,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [ntfy-for-delphi](https://github.com/hazzelnuts/ntfy-for-delphi) - A friendly library to push instant notifications ntfy (Delphi)
- [ntfy](https://github.com/ffflorian/ntfy) - Send notifications over ntfy (JS)
- [ntfy_dart](https://github.com/jr1221/ntfy_dart) - Dart wrapper around the ntfy API (Dart)
- [gotfy](https://github.com/AnthonyHewins/gotfy) - A Go wrapper for the ntfy API (Go)
## CLIs + GUIs
@ -121,9 +122,14 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [ntfyd](https://github.com/joachimschmidt557/ntfyd) - ntfy desktop daemon (Zig)
- [ntfy-browser](https://github.com/johman10/ntfy-browser) - browser extension to receive notifications without having the page open (TypeScript)
- [ntfy-electron](https://github.com/xdpirate/ntfy-electron) - Electron wrapper for the ntfy web app (JS)
- [systemd-ntfy-poweronoff](https://github.com/stendler/systemd-ntfy-poweronoff) - Systemd services to send notifications on system startup and shutdown (Go)
- [msgdrop](https://github.com/jbrubake/msgdrop) - Send and receive encrypted messages (Bash)
- [vigilant](https://github.com/VerifiedJoseph/vigilant) - Monitor RSS/ATOM and JSON feeds, and send push notifications on new entries (PHP)
## Blog + forum posts
- [Pingdom alternative in one line of curl through ntfy.sh](https://piqoni.bearblog.dev/uptime-monitoring-in-one-line-of-curl/) - bearblog.dev - 6/2023
- [#OpenSourceDiscovery 78: ntfy.sh](https://opensourcedisc.substack.com/p/opensourcediscovery-78-ntfysh) - opensourcedisc.substack.com - 6/2023
- [ntfy: des notifications instantanées](https://blogmotion.fr/diy/ntfy-notification-push-domotique-20708) - blogmotion.fr - 5/2023
- [桌面通知ntfy](https://www.cnblogs.com/xueweihan/archive/2023/05/04/17370060.html) - cnblogs.com - 5/2023
- [ntfy.sh - Open source push notifications via PUT/POST](https://lobste.rs/s/5drapz/ntfy_sh_open_source_push_notifications) - lobste.rs - 5/2023

View file

@ -1,5 +1,5 @@
# Known issues
This is an incomplete list of known issues with the ntfy server, Android app, and iOS app. You can find a complete
This is an incomplete list of known issues with the ntfy server, web app, Android app, and iOS app. You can find a complete
list [on GitHub](https://github.com/binwiederhier/ntfy/labels/%F0%9F%AA%B2%20bug), but I thought it may be helpful
to have the prominent ones here to link to.
@ -26,3 +26,17 @@ Be sure that in your selfhosted server:
* Set `upstream-base-url: "https://ntfy.sh"` (**not your own hostname!**)
* Ensure that the URL you set in `base-url` **matches exactly** what you set the Default Server in iOS to
## Firefox on Android not automatically subscribing to web push (see [#789](https://github.com/binwiederhier/ntfy/issues/789))
ntfy defaults to web-push based subscriptions when installed as a [progressive web app](./subscribe/pwa.md). Firefox
Android has an [open bug](https://bugzilla.mozilla.org/show_bug.cgi?id=1796434) where it reports the PWA mode incorrectly.
This causes ntfy to not automatically subscribe to web push, and requires you to go to the ntfy Settings page to enable
it manually.
## Safari does not play sounds for web push notifications
Safari does not support playing sounds for web push notifications, and treats them all as silent. This will be fixed with
iOS 17 / Safari 17, which will be released later in 2023.
## PWA on iOS sometimes crashes with an IndexedDB error (see [#787](https://github.com/binwiederhier/ntfy/issues/787))
When resuming the installed PWA from the background, it sometimes crashes with an error from IndexedDB/Dexie, due to a
[WebKit bug]( https://bugs.webkit.org/show_bug.cgi?id=197050). A reload will fix it until a permanent fix is found.

View file

@ -2,6 +2,53 @@
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.6.2
Released June 30, 2023
With this release, the ntfy web app now contains a **[progressive web app](subscribe/pwa.md) (PWA)
with Web Push support**, which means you'll be able to **install the ntfy web app on your desktop or phone** similar
to a native app (__even on iOS!__ 🥳). Installing the PWA gives ntfy web its own launcher, a standalone window,
push notifications, and an app badge with the unread notification count. Note that for self-hosted servers,
[Web Push](config.md#web-push) must be configured.
On top of that, this release also brings **dark mode** 🧛🌙 to the web app.
🙏 A huge thanks for this release goes to [@nimbleghost](https://github.com/nimbleghost), for basically implementing the
Web Push / PWA and dark mode feature by himself. I'm really grateful for your contributions.
❤️ If you like ntfy, **please consider sponsoring us** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier)
and [Liberapay](https://en.liberapay.com/ntfy/), or buying a [paid plan via the web app](https://ntfy.sh/app) (20% off
if you use promo code `MYTOPIC`). ntfy will always remain open source.
**Features:**
* The web app now supports Web Push, and is installable as a [progressive web app (PWA)](https://docs.ntfy.sh/subscribe/pwa/) on Chrome, Edge, Android, and iOS ([#751](https://github.com/binwiederhier/ntfy/pull/751), thanks to [@nimbleghost](https://github.com/nimbleghost))
* Support for dark mode in the web app ([#206](https://github.com/binwiederhier/ntfy/issues/206), thanks to [@nimbleghost](https://github.com/nimbleghost))
**Bug fixes:**
* Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting)
* Do not forward poll requests for UnifiedPush messages (no ticket, thanks to NoName for reporting)
* Fix `ntfy pub %` segfaulting ([#760](https://github.com/binwiederhier/ntfy/issues/760), thanks to [@clesmian](https://github.com/clesmian) for reporting)
* Newly created access tokens are now lowercase only to fully support `<topic>+<token>@<domain>` email syntax ([#773](https://github.com/binwiederhier/ntfy/issues/773), thanks to gingervitiz for reporting)
* The .1 release fixes a few visual issues with dark mode, and other web app updates ([#791](https://github.com/binwiederhier/ntfy/pull/791), [#793](https://github.com/binwiederhier/ntfy/pull/793), [#792](https://github.com/binwiederhier/ntfy/pull/792), thanks to [@nimbleghost](https://github.com/nimbleghost))
* The .2 release fixes issues with the service worker in Firefox and adds automatic service worker updates ([#795](https://github.com/binwiederhier/ntfy/pull/795), thanks to [@nimbleghost](https://github.com/nimbleghost))
**Maintenance:**
* Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost))
* Web: Add JS formatter "prettier" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost))
* Web: Add eslint with eslint-config-airbnb ([#748](https://github.com/binwiederhier/ntfy/pull/748), thanks to [@nimbleghost](https://github.com/nimbleghost))
* Web: Switch to Vite ([#749](https://github.com/binwiederhier/ntfy/pull/749), thanks to [@nimbleghost](https://github.com/nimbleghost))
**Changes in tarball/zip naming:**
Due to a [change in GoReleaser](https://goreleaser.com/deprecations/#archivesreplacements), some of the binary release
archives now have slightly different names. My apologies if this causes issues in the downstream projects that use ntfy:
- `ntfy_v${VERSION}_windows_x86_64.zip` -> `ntfy_v${VERSION}_windows_amd64.zip`
- `ntfy_v${VERSION}_linux_x86_64.tar.gz` -> `ntfy_v${VERSION}_linux_amd64.tar.gz`
- `ntfy_v${VERSION}_macOS_all.tar.gz` -> `ntfy_v${VERSION}_darwin_all.tar.gz`
## ntfy server v2.5.0
Released May 18, 2023
@ -1204,6 +1251,16 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy server v2.7.0 (UNRELEASED)
**Features:**
* Add support for right-to-left languages (RTL) in the web app ([#663](https://github.com/binwiederhier/ntfy/issues/663), thanks to [@nimbleghost](https://github.com/nimbleghost))
**Bug fixes + maintenance:**
* Fix issues with date/time with different locales ([#700](https://github.com/binwiederhier/ntfy/issues/700), thanks to [@nimbleghost](https://github.com/nimbleghost))
### ntfy Android app v1.16.1 (UNRELEASED)
**Features:**
@ -1219,16 +1276,3 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
**Additional languages:**
* Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/))
### ntfy server v2.6.0 (UNRELEASED)
**Bug fixes:**
* Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting)
**Maintenance:**
* Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost))
* Web: Add JS formatter "prettier" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost))
* Web: Add eslint with eslint-config-airbnb ([#748](https://github.com/binwiederhier/ntfy/pull/748), thanks to [@nimbleghost](https://github.com/nimbleghost))
* Web: Switch to Vite ([#749](https://github.com/binwiederhier/ntfy/pull/749), thanks to [@nimbleghost](https://github.com/nimbleghost))

BIN
docs/static/img/pwa-badge.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

BIN
docs/static/img/pwa-install.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
docs/static/img/pwa.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View file

@ -12,6 +12,9 @@ You can get the Android app from both [Google Play](https://play.google.com/stor
from [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/). Both are largely identical, with the one exception that
the F-Droid flavor does not use Firebase. The iOS app can be downloaded from the [App Store](https://apps.apple.com/us/app/ntfy/id1625396347).
Alternatively, you may also want to consider using the **[progressive web app (PWA)](pwa.md)** instead of the native app.
The PWA is a website that you can add to your home screen, and it will behave just like a native app.
## Overview
A picture is worth a thousand words. Here are a few screenshots showing what the app looks like. It's all pretty
straight forward. You can add topics and as soon as you add them, you can [publish messages](../publish.md) to them.

62
docs/subscribe/pwa.md Normal file
View file

@ -0,0 +1,62 @@
# Using the progressive web app (PWA)
While ntfy doesn't have a native desktop app, it is built as a [progressive web app](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps) (PWA)
and thus can be **installed on both desktop and mobile devices**.
This gives it its own launcher (e.g. shortcut on Windows, app on macOS, launcher shortcut on Linux, home screen icon on iOS, and
launcher icon on Android), a standalone window, push notifications, and an app badge with the unread notification count.
Web app installation is **supported on** (see [compatibility table](https://caniuse.com/web-app-manifest) for details):
- **Chrome:** Android, Windows, Linux, macOS
- **Safari:** iOS 16.4+, macOS 14+
- **Firefox:** Android, as well as on Windows/Linux [via an extension](https://addons.mozilla.org/en-US/firefox/addon/pwas-for-firefox/)
- **Edge:** Windows
Note that for self-hosted servers, [Web Push](../config.md#web-push) must be configured for the PWA to work.
## Installation
### Chrome on Desktop
To install and register the web app via Chrome, click the "install app" icon. After installation, you can find the app in your
app drawer:
<div id="pwa-screenshots-chrome-safari-desktop" class="screenshots">
<a href="../../static/img/pwa-install.png"><img src="../../static/img/pwa-install.png"/></a>
<a href="../../static/img/pwa.png"><img src="../../static/img/pwa.png"/></a>
<a href="../../static/img/pwa-badge.png"><img src="../../static/img/pwa-badge.png"/></a>
</div>
### Chrome/Firefox on Android
For Chrome on Android, either click the "Add to Home Screen" banner at the bottom of the screen, or select "Install app"
in the menu, and then click "Install" in the popup menu. After installation, you can find the app in your app drawer,
and on your home screen.
<div id="pwa-screenshots-chrome-android" class="screenshots">
<a href="../../static/img/pwa-install-chrome-android.jpg"><img src="../../static/img/pwa-install-chrome-android.jpg"/></a>
<a href="../../static/img/pwa-install-chrome-android-menu.jpg"><img src="../../static/img/pwa-install-chrome-android-menu.jpg"/></a>
<a href="../../static/img/pwa-install-chrome-android-popup.jpg"><img src="../../static/img/pwa-install-chrome-android-popup.jpg"/></a>
</div>
For Firefox, select "Install" in the menu, and then click "Add" to add an icon to your home screen:
<div id="pwa-screenshots-firefox-android" class="screenshots">
<a href="../../static/img/pwa-install-firefox-android-menu.jpg"><img src="../../static/img/pwa-install-firefox-android-menu.jpg"/></a>
<a href="../../static/img/pwa-install-firefox-android-popup.jpg"><img src="../../static/img/pwa-install-firefox-android-popup.jpg"/></a>
</div>
### Safari on iOS
On iOS Safari, tap on the Share menu, then tap "Add to Home Screen":
<div id="pwa-screenshots-safari-ios" class="screenshots">
<a href="../../static/img/pwa-install-safari-ios-button.jpg"><img src="../../static/img/pwa-install-safari-ios-button.jpg"/></a>
<a href="../../static/img/pwa-install-safari-ios-menu.jpg"><img src="../../static/img/pwa-install-safari-ios-menu.jpg"/></a>
<a href="../../static/img/pwa-install-safari-ios-add-icon.jpg"><img src="../../static/img/pwa-install-safari-ios-add-icon.jpg"/></a>
</div>
## Background notifications
Background notifications via web push are enabled by default and cannot be turned off when the app is installed, as notifications would
not be delivered reliably otherwise. You can mute topics you don't want to receive notifications for.
On desktop, you generally need either your browser or the web app open to receive notifications, though the ntfy tab doesn't need to be
open. On mobile, you don't need to have the web app open to receive notifications. Look at the [web docs](./web.md#background-notifications)
for a detailed breakdown.

View file

@ -1,27 +1,75 @@
# Subscribe from the Web UI
You can use the Web UI to subscribe to topics as well. If you do, and you keep the website open, **notifications will
pop up as desktop notifications**. Simply type in the topic name and click the *Subscribe* button. The browser will
keep a connection open and listen for incoming notifications.
# Subscribe from the web app
The web app lets you subscribe and publish messages to ntfy topics. For ntfy.sh, the web app is available at [ntfy.sh/app](https://ntfy.sh/app).
To subscribe, simply type in the topic name and click the *Subscribe* button. **After subscribing, messages published to the topic
will appear in the web app, and pop up as a notification.**
<div id="subscribe-screenshots" class="screenshots">
<a href="../../static/img/web-subscribe.png"><img src="../../static/img/web-subscribe.png"/></a>
</div>
## Publish messages
To learn how to send messages, check out the [publishing page](../publish.md).
<div id="web-screenshots" class="screenshots">
<a href="../../static/img/web-detail.png"><img src="../../static/img/web-detail.png"/></a>
<a href="../../static/img/web-notification.png"><img src="../../static/img/web-notification.png"/></a>
<a href="../../static/img/web-subscribe.png"><img src="../../static/img/web-subscribe.png"/></a>
</div>
To keep receiving desktop notifications from ntfy, you need to keep the website open. What I do, and what I highly recommend,
is to pin the tab so that it's always open, but sort of out of the way:
<figure markdown>
![pinned](../static/img/web-pin.png){ width=500 }
<figcaption>Pin web app to move it out of the way</figcaption>
</figure>
## Topic reservations
If topic reservations are enabled, you can claim ownership over topics and define access to it:
<div id="reserve-screenshots" class="screenshots">
<a href="../../static/img/web-reserve-topic.png"><img src="../../static/img/web-reserve-topic.png"/></a>
<a href="../../static/img/web-reserve-topic-dialog.png"><img src="../../static/img/web-reserve-topic-dialog.png"/></a>
</div>
## Notification features and browser support
- Emoji tags are supported in all browsers
- [Click](../publish.md#click-action) actions are supported in all browsers
- Only Chrome, Edge, and Opera support displaying view and http [actions](../publish.md#action-buttons) in notifications.
Their presentation is platform specific.
Note that HTTP actions are performed using fetch and thus are limited to the [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
rules, which means that any URL you include needs to respond to a [preflight request](https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request)
with headers allowing the origin of the ntfy web app (`Access-Control-Allow-Origin: https://ntfy.sh`) or `*`.
- Only Chrome, Edge, and Opera support displaying [images](../publish.md#attachments) in notifications.
Look at the [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API#browser_compatibility)
for more info.
## Background notifications
While subscribing, you have the option to enable background notifications on supported browsers (see "Settings" tab).
Note: If you add the web app to your homescreen (as a progressive web app, more info in the [installed web app](pwa.md)
docs), you cannot turn these off, as notifications would not be delivered reliably otherwise. You can mute topics you don't want to receive
notifications for.
**If background notifications are off:** This requires an active ntfy tab to be open to receive notifications.
These are typically instantaneous, and will appear as a system notification. If you don't see these, check that your browser
is allowed to show notifications (for example in System Settings on macOS). If you don't want to enable background notifications,
**pinning the ntfy tab on your browser** is a good solution to leave it running.
**If background notifications are on:** This uses the [Web Push API](https://caniuse.com/push-api). You don't need an active
ntfy tab open, but in some cases you may need to keep your browser open. Background notifications are only supported on the
same server hosting the web app. You cannot use another server, but can instead subscribe on the other server itself.
If the ntfy app is not opened for more than a week, background notifications will be paused. You can resume them
by opening the app again, and will get a warning notification before they are paused.
| Browser | Platform | Browser Running | Browser Not Running | Restrictions |
|---------|----------|-----------------|---------------------|---------------------------------------------------------|
| Chrome | Desktop | ✅ | ❌ | |
| Firefox | Desktop | ✅ | ❌ | |
| Edge | Desktop | ✅ | ❌ | |
| Opera | Desktop | ✅ | ❌ | |
| Safari | Desktop | ✅ | ✅ | requires Safari 16.1, macOS 13 Ventura |
| Chrome | Android | ✅ | ✅ | |
| Firefox | Android | ✅ | ✅ | |
| Safari | iOS | ⚠️ | ⚠️ | requires iOS 16.4, only when app is added to homescreen |
(Browsers below 1% usage not shown, look at the [Push API](https://caniuse.com/push-api) for more info)

60
go.mod
View file

@ -3,23 +3,23 @@ module heckel.io/ntfy
go 1.18
require (
cloud.google.com/go/firestore v1.9.0 // indirect
cloud.google.com/go/storage v1.30.1 // indirect
github.com/BurntSushi/toml v1.2.1 // indirect
cloud.google.com/go/firestore v1.11.0 // indirect
cloud.google.com/go/storage v1.31.0 // indirect
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/emersion/go-smtp v0.16.0
github.com/gabriel-vasile/mimetype v1.4.2
github.com/gorilla/websocket v1.5.0
github.com/mattn/go-sqlite3 v1.14.16
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
github.com/mattn/go-sqlite3 v1.14.17
github.com/olebedev/when v1.0.0
github.com/stretchr/testify v1.8.1
github.com/urfave/cli/v2 v2.25.3
golang.org/x/crypto v0.9.0
golang.org/x/oauth2 v0.8.0 // indirect
golang.org/x/sync v0.2.0
golang.org/x/term v0.8.0
github.com/urfave/cli/v2 v2.25.7
golang.org/x/crypto v0.10.0
golang.org/x/oauth2 v0.9.0 // indirect
golang.org/x/sync v0.3.0
golang.org/x/term v0.9.0
golang.org/x/time v0.3.0
google.golang.org/api v0.122.0
google.golang.org/api v0.129.0
gopkg.in/yaml.v2 v2.4.0
)
@ -27,48 +27,52 @@ require github.com/pkg/errors v0.9.1 // indirect
require (
firebase.google.com/go/v4 v4.11.0
github.com/prometheus/client_golang v1.15.1
github.com/stripe/stripe-go/v74 v74.18.0
github.com/SherClockHolmes/webpush-go v1.2.0
github.com/prometheus/client_golang v1.16.0
github.com/stripe/stripe-go/v74 v74.24.0
)
require (
cloud.google.com/go v0.110.2 // indirect
cloud.google.com/go/compute v1.19.3 // indirect
cloud.google.com/go v0.110.3 // indirect
cloud.google.com/go/compute v1.20.1 // indirect
cloud.google.com/go/compute/metadata v0.2.3 // indirect
cloud.google.com/go/iam v1.0.1 // indirect
cloud.google.com/go/longrunning v0.4.2 // indirect
cloud.google.com/go/iam v1.1.1 // indirect
cloud.google.com/go/longrunning v0.5.1 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/s2a-go v0.1.3 // indirect
github.com/google/s2a-go v0.1.4 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
github.com/googleapis/gax-go/v2 v2.8.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.5 // indirect
github.com/googleapis/gax-go/v2 v2.11.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.4.0 // indirect
github.com/prometheus/common v0.43.0 // indirect
github.com/prometheus/procfs v0.9.0 // indirect
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.11.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/net v0.11.0 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/text v0.11.0 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/appengine/v2 v2.0.3 // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
google.golang.org/grpc v1.55.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
google.golang.org/genproto v0.0.0-20230629202037-9506855d4529 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529 // indirect
google.golang.org/grpc v1.56.1 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

125
go.sum
View file

@ -1,28 +1,30 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA=
cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw=
cloud.google.com/go/compute v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds=
cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI=
cloud.google.com/go v0.110.3 h1:wwearW+L7sAPSomPIgJ3bVn6Ck00HGQnn5HMLwf0azo=
cloud.google.com/go v0.110.3/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI=
cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZNbg=
cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
cloud.google.com/go/iam v1.0.1 h1:lyeCAU6jpnVNrE9zGQkTl3WgNgK/X+uWwaw0kynZJMU=
cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8=
cloud.google.com/go/longrunning v0.4.2 h1:WDKiiNXFTaQ6qz/G8FCOkuY9kJmOJGY67wPUC1M2RbE=
cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ=
cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM=
cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E=
cloud.google.com/go/firestore v1.11.0 h1:PPgtwcYUOXV2jFe1bV3nda3RCrOa8cvBjTOn2MQVfW8=
cloud.google.com/go/firestore v1.11.0/go.mod h1:b38dKhgzlmNNGTNZZwe7ZRFEuRab1Hay3/DBsIGKKy4=
cloud.google.com/go/iam v1.1.1 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y=
cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU=
cloud.google.com/go/longrunning v0.5.1 h1:Fr7TXftcqTudoyRJa113hyaqlGdiBQkp0Gq7tErFDWI=
cloud.google.com/go/longrunning v0.5.1/go.mod h1:spvimkwdz6SPWKEt/XBij79E9fiTkHSQl/fRUUQJYJc=
cloud.google.com/go/storage v1.31.0 h1:+S3LjjEN2zZ+L5hOwj4+1OkGCsLVe0NzpXKQ1pSdTCI=
cloud.google.com/go/storage v1.31.0/go.mod h1:81ams1PrhW16L4kF7qg+4mTq7SRs5HsbDTM0bWvrwJ0=
firebase.google.com/go/v4 v4.11.0 h1:szjBoiF33A2FavRLIDZjW1mw+OsW/XAtHoYNIqWOjRk=
firebase.google.com/go/v4 v4.11.0/go.mod h1:60c36dWLK4+j05Vw5XMllek3b3PCynU3BfI46OSwsUE=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/SherClockHolmes/webpush-go v1.2.0 h1:sGv0/ZWCvb1HUH+izLqrb2i68HuqD/0Y+AmGQfyqKJA=
github.com/SherClockHolmes/webpush-go v1.2.0/go.mod h1:w6X47YApe/B9wUz2Wh8xukxlyupaxSSEbu6yKJcHN2w=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
@ -57,6 +59,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@ -91,40 +95,40 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
github.com/google/s2a-go v0.1.3 h1:FAgZmpLl/SXurPEZyCMPBIiiYeTbqfjlbdnCNTAkbGE=
github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A=
github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc=
github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k=
github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
github.com/googleapis/gax-go/v2 v2.8.0 h1:UBtEZqx1bjXtOQ5BVTkuYghXrr3N4V123VKJK67vJZc=
github.com/googleapis/gax-go/v2 v2.8.0/go.mod h1:4orTrqY6hXxxaUL4LHIPl6lGo8vAE38/qKbhSAKP6QI=
github.com/googleapis/enterprise-certificate-proxy v0.2.5 h1:UR4rDjcgpgEnqpIEvkiqTYKBCKLNmlge2eVjoZfySzM=
github.com/googleapis/enterprise-certificate-proxy v0.2.5/go.mod h1:RxW0N9901Cko1VOCW3SXCpWP+mlIEkk2tP7jnHy9a3w=
github.com/googleapis/gax-go/v2 v2.11.0 h1:9V9PWXEsWnPpQhu/PeQIkS4eGzMlTLGgt80cUUI8Ki4=
github.com/googleapis/gax-go/v2 v2.11.0/go.mod h1:DxmR61SGKkGLa2xigwuZIQpkCI2S5iydzRfb3peWZJI=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8 h1:0uFGkScHef2Xd8g74BMHU1jFcnKEm0PzrPn4CluQ9FI=
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
github.com/olebedev/when v1.0.0 h1:T2DZCj8HxUhOVxcqaLOmzuTr+iZLtMHsZEim7mjIA2w=
github.com/olebedev/when v1.0.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.15.1 h1:8tXpTmJbyH5lydzFPoxSIJ0J46jdh3tylbvM1xCv0LI=
github.com/prometheus/client_golang v1.15.1/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8=
github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY=
github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.43.0 h1:iq+BVjvYLei5f27wiuNiB1DN6DYQkp1c8Bx0Vykh5us=
github.com/prometheus/common v0.43.0/go.mod h1:NCvr5cQIh3Y/gy73/RdVtC9r8xxrxwJnB+2lB3BxrFc=
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/procfs v0.11.0 h1:5EAgkfkMl659uZPbe9AS2N68a7Cc1TJbPEuGzFuRbyk=
github.com/prometheus/procfs v0.11.0/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@ -139,22 +143,23 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stripe/stripe-go/v74 v74.18.0 h1:ImSIoaVkTUozHxa21AhwHYBjwc8fVSJJJB1Q7oaXzIw=
github.com/stripe/stripe-go/v74 v74.18.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY=
github.com/urfave/cli/v2 v2.25.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
github.com/stripe/stripe-go/v74 v74.24.0 h1:h+hXEI5avC5moAh2YLtphMFTBnp11TfXTcP4suuWDLk=
github.com/stripe/stripe-go/v74 v74.24.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@ -174,19 +179,19 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8=
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
golang.org/x/oauth2 v0.9.0 h1:BPpt2kU7oMRq3kCHAA1tbSEshXRw1LpG2ztgDwrzuAs=
golang.org/x/oauth2 v0.9.0/go.mod h1:qYgFZaFiu6Wg24azG8bdV52QJXJGbZzIIsRCdVKzbLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI=
golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -197,20 +202,24 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28=
golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -225,8 +234,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/api v0.122.0 h1:zDobeejm3E7pEG1mNHvdxvjs5XJoCMzyNH+CmwL94Es=
google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms=
google.golang.org/api v0.129.0 h1:2XbdjjNfFPXQyufzQVwPf1RRnHH8Den2pfNE2jw7L8w=
google.golang.org/api v0.129.0/go.mod h1:dFjiXlanKwWE3612X97llhsoI36FAoIiRj3aTl5b/zE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
@ -237,8 +246,12 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
google.golang.org/genproto v0.0.0-20230629202037-9506855d4529 h1:9JucMWR7sPvCxUFd6UsOUNmA5kCcWOfORaT3tpAsKQs=
google.golang.org/genproto v0.0.0-20230629202037-9506855d4529/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64=
google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529 h1:s5YSX+ZH5b5vS9rnpGymvIyMpLRJizowqDlOuyjXnTk=
google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529 h1:DEH99RbiLZhMxrpEJCZ0A+wdTe0EOgou/poSLx9vWf4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20230629202037-9506855d4529/go.mod h1:66JfowdXAEgad5O9NnYcsNPLCPZJD++2L9X0PCMODrA=
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=
@ -247,8 +260,8 @@ google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTp
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
google.golang.org/grpc v1.55.0 h1:3Oj82/tFSCeUrRTg/5E/7d/W5A1tj6Ky1ABAuZuv5ag=
google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
google.golang.org/grpc v1.56.1 h1:z0dNfjIl0VpaZ9iSVjA6daGatAYwPGstTjt5vkRMFkQ=
google.golang.org/grpc v1.56.1/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@ -260,8 +273,8 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View file

@ -82,6 +82,7 @@ nav:
- "Subscribing":
- "From your phone": subscribe/phone.md
- "From the Web app": subscribe/web.md
- "From the Desktop": subscribe/pwa.md
- "From the CLI": subscribe/cli.md
- "Using the API": subscribe/api.md
- "Self-hosting":

View file

@ -1,10 +1,11 @@
package server
import (
"heckel.io/ntfy/user"
"io/fs"
"net/netip"
"time"
"heckel.io/ntfy/user"
)
// Defines default config settings (excluding limits, see below)
@ -22,6 +23,12 @@ const (
DefaultStripePriceCacheDuration = 3 * time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed
)
// Defines default Web Push settings
const (
DefaultWebPushExpiryWarningDuration = 7 * 24 * time.Hour
DefaultWebPushExpiryDuration = 9 * 24 * time.Hour
)
// Defines all global and per-visitor limits
// - message size limit: the max number of bytes for a message
// - total topic limit: max number of topics overall
@ -146,6 +153,13 @@ type Config struct {
EnableMetrics bool
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
Version string // injected by App
WebPushPrivateKey string
WebPushPublicKey string
WebPushFile string
WebPushEmailAddress string
WebPushStartupQueries string
WebPushExpiryDuration time.Duration
WebPushExpiryWarningDuration time.Duration
}
// NewConfig instantiates a default new server config
@ -227,5 +241,11 @@ func NewConfig() *Config {
EnableReservations: false,
AccessControlAllowOrigin: "*",
Version: "",
WebPushPrivateKey: "",
WebPushPublicKey: "",
WebPushFile: "",
WebPushEmailAddress: "",
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
}
}

View file

@ -114,6 +114,9 @@ var (
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}
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}
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}
@ -138,5 +141,6 @@ var (
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
errHTTPInternalErrorWebPushUnableToPublish = &errHTTP{50004, http.StatusInternalServerError, "internal server error: unable to publish web push message", "", nil}
errHTTPInsufficientStorageUnifiedPush = &errHTTP{50701, http.StatusInsufficientStorage, "cannot publish to UnifiedPush topic without previously active subscriber", "", nil}
)

View file

@ -29,6 +29,7 @@ const (
tagResetter = "resetter"
tagWebsocket = "websocket"
tagMatrix = "matrix"
tagWebPush = "webpush"
)
var (

View file

@ -270,7 +270,7 @@ func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration
if err != nil {
return nil, err
}
if err := setupDB(db, startupQueries, cacheDuration); err != nil {
if err := setupMessagesDB(db, startupQueries, cacheDuration); err != nil {
return nil, err
}
var queue *util.BatchingQueue[*message]
@ -749,7 +749,7 @@ func (c *messageCache) Close() error {
return c.db.Close()
}
func setupDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
func setupMessagesDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
// Run startup queries
if startupQueries != "" {
if _, err := db.Exec(startupQueries); err != nil {

View file

@ -9,13 +9,6 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/emersion/go-smtp"
"github.com/gorilla/websocket"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/sync/errgroup"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"io"
"net"
"net/http"
@ -32,6 +25,14 @@ import (
"sync"
"time"
"unicode/utf8"
"github.com/emersion/go-smtp"
"github.com/gorilla/websocket"
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/sync/errgroup"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
)
// Server is the main server, providing the UI and API for ntfy
@ -52,6 +53,7 @@ type Server struct {
messagesHistory []int64 // Last n values of the messages counter, used to determine rate
userManager *user.Manager // Might be nil!
messageCache *messageCache // Database that stores the messages
webPush *webPushStore // Database that stores web push subscriptions
fileCache *fileCache // File system based cache that stores attachments
stripe stripeAPI // Stripe API, can be replaced with a mock
priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
@ -76,11 +78,15 @@ var (
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
webConfigPath = "/config.js"
webManifestPath = "/manifest.webmanifest"
webRootHTMLPath = "/app.html"
webServiceWorkerPath = "/sw.js"
accountPath = "/account"
matrixPushPath = "/_matrix/push/v1/notify"
metricsPath = "/metrics"
apiHealthPath = "/v1/health"
apiStatsPath = "/v1/stats"
apiWebPushPath = "/v1/webpush"
apiTiersPath = "/v1/tiers"
apiUsersPath = "/v1/users"
apiUsersAccessPath = "/v1/users/access"
@ -151,6 +157,13 @@ func New(conf *Config) (*Server, error) {
if err != nil {
return nil, err
}
var webPush *webPushStore
if conf.WebPushPublicKey != "" {
webPush, err = newWebPushStore(conf.WebPushFile, conf.WebPushStartupQueries)
if err != nil {
return nil, err
}
}
topics, err := messageCache.Topics()
if err != nil {
return nil, err
@ -190,6 +203,7 @@ func New(conf *Config) (*Server, error) {
s := &Server{
config: conf,
messageCache: messageCache,
webPush: webPush,
fileCache: fileCache,
firebaseClient: firebaseClient,
smtpSender: mailer,
@ -342,6 +356,9 @@ func (s *Server) closeDatabases() {
s.userManager.Close()
}
s.messageCache.Close()
if s.webPush != nil {
s.webPush.Close()
}
}
// handle is the main entry point for all HTTP requests
@ -416,6 +433,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.handleHealth(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
return s.ensureWebPushEnabled(s.handleWebManifest)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiUsersPath {
return s.ensureAdmin(s.handleUsersGet)(w, r, v)
} else if r.Method == http.MethodPut && r.URL.Path == apiUsersPath {
@ -470,6 +489,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v)
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath {
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v)
} else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path {
return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v)
} else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path {
return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushDelete))(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath {
return s.handleStats(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
@ -478,7 +501,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.handleMatrixDiscovery(w)
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
return s.handleMetrics(w, r, v)
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
} else if r.Method == http.MethodGet && (staticRegex.MatchString(r.URL.Path) || r.URL.Path == webServiceWorkerPath || r.URL.Path == webRootHTMLPath) {
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
return s.ensureWebEnabled(s.handleDocs)(w, r, v)
@ -552,7 +575,9 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
EnableCalls: s.config.TwilioAccount != "",
EnableEmails: s.config.SMTPSenderFrom != "",
EnableReservations: s.config.EnableReservations,
EnableWebPush: s.config.WebPushPublicKey != "",
BillingContact: s.config.BillingContact,
WebPushPublicKey: s.config.WebPushPublicKey,
DisallowedTopics: s.config.DisallowedTopics,
}
b, err := json.MarshalIndent(response, "", " ")
@ -564,6 +589,25 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
return err
}
// handleWebManifest serves the web app manifest for the progressive web app (PWA)
func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
response := &webManifestResponse{
Name: "ntfy web",
Description: "ntfy lets you send push notifications via scripts from any computer or phone",
ShortName: "ntfy",
Scope: "/",
StartURL: s.config.WebRoot,
Display: "standalone",
BackgroundColor: "#ffffff",
ThemeColor: "#317f6f",
Icons: []*webManifestIcon{
{SRC: "/static/images/pwa-192x192.png", Sizes: "192x192", Type: "image/png"},
{SRC: "/static/images/pwa-512x512.png", Sizes: "512x512", Type: "image/png"},
},
}
return s.writeJSONWithContentType(w, response, "application/manifest+json")
}
// handleMetrics returns Prometheus metrics. This endpoint is only called if enable-metrics is set,
// and listen-metrics-http is not set.
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request, _ *visitor) error {
@ -760,9 +804,12 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
if s.config.TwilioAccount != "" && call != "" {
go s.callPhone(v, r, m, call)
}
if s.config.UpstreamBaseURL != "" {
if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream
go s.forwardPollRequest(v, m)
}
if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m)
}
} else {
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
}
@ -1696,6 +1743,9 @@ func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
if s.config.UpstreamBaseURL != "" {
go s.forwardPollRequest(v, m)
}
if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m)
}
if err := s.messageCache.MarkPublished(m); err != nil {
return err
}
@ -1916,7 +1966,11 @@ func (s *Server) visitor(ip netip.Addr, user *user.User) *visitor {
}
func (s *Server) writeJSON(w http.ResponseWriter, v any) error {
w.Header().Set("Content-Type", "application/json")
return s.writeJSONWithContentType(w, v, "application/json")
}
func (s *Server) writeJSONWithContentType(w http.ResponseWriter, v any, contentType string) error {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
if err := json.NewEncoder(w).Encode(v); err != nil {
return err

View file

@ -144,6 +144,27 @@
# smtp-server-domain:
# smtp-server-addr-prefix:
# Web Push support (background notifications for browsers)
#
# If enabled, allows ntfy to receive push notifications, even when the ntfy web app is closed. When enabled, users
# can enable background notifications in the web app. Once enabled, ntfy will forward published messages to the push
# endpoint, which will then forward it to the browser.
#
# You must configure web-push-public/private key, web-push-file, and web-push-email-address below to enable Web Push.
# Run "ntfy webpush keys" to generate the keys.
#
# - web-push-public-key is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
# - web-push-private-key is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
# - web-push-file is a database file to keep track of browser subscription endpoints, e.g. `/var/cache/ntfy/webpush.db`
# - web-push-email-address is the admin email address send to the push provider, e.g. `sysadmin@example.com`
# - web-push-startup-queries is an optional list of queries to run on startup`
#
# web-push-public-key:
# web-push-private-key:
# web-push-file:
# web-push-email-address:
# web-push-startup-queries:
# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header.
#
# - twilio-account is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586

View file

@ -170,6 +170,11 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *
if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil {
return errHTTPBadRequestIncorrectPasswordConfirmation
}
if s.webPush != nil && u.ID != "" {
if err := s.webPush.RemoveSubscriptionsByUserID(u.ID); err != nil {
logvr(v, r).Err(err).Warn("Error removing web push subscriptions for %s", u.Name)
}
}
if u.Billing.StripeSubscriptionID != "" {
logvr(v, r).Tag(tagStripe).Info("Canceling billing subscription for user %s", u.Name)
if _, err := s.stripe.CancelSubscription(u.Billing.StripeSubscriptionID); err != nil {

View file

@ -15,6 +15,7 @@ func (s *Server) execManager() {
s.pruneTokens()
s.pruneAttachments()
s.pruneMessages()
s.pruneAndNotifyWebPushSubscriptions()
// Message count per topic
var messagesCached int

View file

@ -58,6 +58,15 @@ func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
}
}
func (s *Server) ensureWebPushEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.config.WebRoot == "" || s.config.WebPushPublicKey == "" {
return errHTTPNotFound
}
return next(w, r, v)
}
}
func (s *Server) ensureUserManager(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.userManager == nil {

View file

@ -22,6 +22,7 @@ import (
"testing"
"time"
"github.com/SherClockHolmes/webpush-go"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
@ -238,6 +239,12 @@ func TestServer_WebEnabled(t *testing.T) {
rr = request(t, s, "GET", "/config.js", "", nil)
require.Equal(t, 404, rr.Code)
rr = request(t, s, "GET", "/sw.js", "", nil)
require.Equal(t, 404, rr.Code)
rr = request(t, s, "GET", "/app.html", "", nil)
require.Equal(t, 404, rr.Code)
rr = request(t, s, "GET", "/static/css/home.css", "", nil)
require.Equal(t, 404, rr.Code)
@ -250,6 +257,35 @@ func TestServer_WebEnabled(t *testing.T) {
rr = request(t, s2, "GET", "/config.js", "", nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s2, "GET", "/sw.js", "", nil)
require.Equal(t, 200, rr.Code)
rr = request(t, s2, "GET", "/app.html", "", nil)
require.Equal(t, 200, rr.Code)
}
func TestServer_WebPushEnabled(t *testing.T) {
conf := newTestConfig(t)
conf.WebRoot = "" // Disable web app
s := newTestServer(t, conf)
rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
require.Equal(t, 404, rr.Code)
conf2 := newTestConfig(t)
s2 := newTestServer(t, conf2)
rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
require.Equal(t, 404, rr.Code)
conf3 := newTestConfigWithWebPush(t)
s3 := newTestServer(t, conf3)
rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
require.Equal(t, 200, rr.Code)
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
}
func TestServer_PublishLargeMessage(t *testing.T) {
@ -2559,6 +2595,29 @@ func TestServer_UpstreamBaseURL_With_Access_Token_Success(t *testing.T) {
})
}
func TestServer_UpstreamBaseURL_DoNotForwardUnifiedPush(t *testing.T) {
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("UnifiedPush messages should not be forwarded")
}))
defer upstreamServer.Close()
c := newTestConfigWithAuthFile(t)
c.BaseURL = "http://myserver.internal"
c.UpstreamBaseURL = upstreamServer.URL
s := newTestServer(t, c)
// Send UP message, this should not forward to upstream server
response := request(t, s, "PUT", "/mytopic?up=1", `hi there`, nil)
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.NotEmpty(t, m.ID)
require.Equal(t, "hi there", m.Message)
// Forwarding is done asynchronously, so wait a bit.
// This ensures that the t.Fatal above is actually not triggered.
time.Sleep(500 * time.Millisecond)
}
func newTestConfig(t *testing.T) *Config {
conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345"
@ -2568,19 +2627,33 @@ func newTestConfig(t *testing.T) *Config {
return conf
}
func newTestConfigWithAuthFile(t *testing.T) *Config {
conf := newTestConfig(t)
func configureAuth(t *testing.T, conf *Config) *Config {
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
conf.AuthStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
conf.AuthBcryptCost = bcrypt.MinCost // This speeds up tests a lot
return conf
}
func newTestConfigWithAuthFile(t *testing.T) *Config {
conf := newTestConfig(t)
conf = configureAuth(t, conf)
return conf
}
func newTestConfigWithWebPush(t *testing.T) *Config {
conf := newTestConfig(t)
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
require.Nil(t, err)
conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
conf.WebPushEmailAddress = "testing@example.com"
conf.WebPushPrivateKey = privateKey
conf.WebPushPublicKey = publicKey
return conf
}
func newTestServer(t *testing.T, config *Config) *Server {
server, err := New(config)
if err != nil {
t.Fatal(err)
}
require.Nil(t, err)
return server
}

171
server/server_webpush.go Normal file
View file

@ -0,0 +1,171 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"github.com/SherClockHolmes/webpush-go"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
)
const (
webPushTopicSubscribeLimit = 50
)
var (
webPushAllowedEndpointsPatterns = []string{
"https://*.google.com/",
"https://*.googleapis.com/",
"https://*.mozilla.com/",
"https://*.mozaws.net/",
"https://*.windows.com/",
"https://*.microsoft.com/",
"https://*.apple.com/",
}
webPushAllowedEndpointsRegex *regexp.Regexp
)
func init() {
for i, pattern := range webPushAllowedEndpointsPatterns {
webPushAllowedEndpointsPatterns[i] = strings.ReplaceAll(strings.ReplaceAll(pattern, ".", "\\."), "*", ".+")
}
allPatterns := fmt.Sprintf("^(%s)", strings.Join(webPushAllowedEndpointsPatterns, "|"))
webPushAllowedEndpointsRegex = regexp.MustCompile(allPatterns)
}
func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil || req.Endpoint == "" || req.P256dh == "" || req.Auth == "" {
return errHTTPBadRequestWebPushSubscriptionInvalid
} else if !webPushAllowedEndpointsRegex.MatchString(req.Endpoint) {
return errHTTPBadRequestWebPushEndpointUnknown
} else if len(req.Topics) > webPushTopicSubscribeLimit {
return errHTTPBadRequestWebPushTopicCountTooHigh
}
topics, err := s.topicsFromIDs(req.Topics...)
if err != nil {
return err
}
if s.userManager != nil {
u := v.User()
for _, t := range topics {
if err := s.userManager.Authorize(u, t.ID, user.PermissionRead); err != nil {
logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID)
return errHTTPForbidden.With(t)
}
}
}
if err := s.webPush.UpsertSubscription(req.Endpoint, req.Auth, req.P256dh, v.MaybeUserID(), v.IP(), req.Topics); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *visitor) error {
req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil || req.Endpoint == "" {
return errHTTPBadRequestWebPushSubscriptionInvalid
}
if err := s.webPush.RemoveSubscriptionsByEndpoint(req.Endpoint); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
subscriptions, err := s.webPush.SubscriptionsForTopic(m.Topic)
if err != nil {
logvm(v, m).Err(err).With(v, m).Warn("Unable to publish web push messages")
return
}
log.Tag(tagWebPush).With(v, m).Debug("Publishing web push message to %d subscribers", len(subscriptions))
payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m))
if err != nil {
log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload")
return
}
for _, subscription := range subscriptions {
if err := s.sendWebPushNotification(subscription, payload, v, m); err != nil {
log.Tag(tagWebPush).Err(err).With(v, m, subscription).Warn("Unable to publish web push message")
}
}
}
func (s *Server) pruneAndNotifyWebPushSubscriptions() {
if s.config.WebPushPublicKey == "" {
return
}
go func() {
if err := s.pruneAndNotifyWebPushSubscriptionsInternal(); err != nil {
log.Tag(tagWebPush).Err(err).Warn("Unable to prune or notify web push subscriptions")
}
}()
}
func (s *Server) pruneAndNotifyWebPushSubscriptionsInternal() error {
// Expire old subscriptions
if err := s.webPush.RemoveExpiredSubscriptions(s.config.WebPushExpiryDuration); err != nil {
return err
}
// Notify subscriptions that will expire soon
subscriptions, err := s.webPush.SubscriptionsExpiring(s.config.WebPushExpiryWarningDuration)
if err != nil {
return err
} else if len(subscriptions) == 0 {
return nil
}
payload, err := json.Marshal(newWebPushSubscriptionExpiringPayload())
if err != nil {
return err
}
warningSent := make([]*webPushSubscription, 0)
for _, subscription := range subscriptions {
if err := s.sendWebPushNotification(subscription, payload); err != nil {
log.Tag(tagWebPush).Err(err).With(subscription).Warn("Unable to publish expiry imminent warning")
continue
}
warningSent = append(warningSent, subscription)
}
if err := s.webPush.MarkExpiryWarningSent(warningSent); err != nil {
return err
}
log.Tag(tagWebPush).Debug("Expired old subscriptions and published %d expiry imminent warnings", len(subscriptions))
return nil
}
func (s *Server) sendWebPushNotification(sub *webPushSubscription, message []byte, contexters ...log.Contexter) error {
log.Tag(tagWebPush).With(sub).With(contexters...).Debug("Sending web push message")
payload := &webpush.Subscription{
Endpoint: sub.Endpoint,
Keys: webpush.Keys{
Auth: sub.Auth,
P256dh: sub.P256dh,
},
}
resp, err := webpush.SendNotification(message, payload, &webpush.Options{
Subscriber: s.config.WebPushEmailAddress,
VAPIDPublicKey: s.config.WebPushPublicKey,
VAPIDPrivateKey: s.config.WebPushPrivateKey,
Urgency: webpush.UrgencyHigh, // iOS requires this to ensure delivery
TTL: int(s.config.CacheDuration.Seconds()),
})
if err != nil {
log.Tag(tagWebPush).With(sub).With(contexters...).Err(err).Debug("Unable to publish web push message, removing endpoint")
if err := s.webPush.RemoveSubscriptionsByEndpoint(sub.Endpoint); err != nil {
return err
}
return err
}
if (resp.StatusCode < 200 || resp.StatusCode > 299) && resp.StatusCode != 429 {
log.Tag(tagWebPush).With(sub).With(contexters...).Field("response_code", resp.StatusCode).Debug("Unable to publish web push message, unexpected response")
if err := s.webPush.RemoveSubscriptionsByEndpoint(sub.Endpoint); err != nil {
return err
}
return errHTTPInternalErrorWebPushUnableToPublish.With(sub).With(contexters...)
}
return nil
}

View file

@ -0,0 +1,256 @@
package server
import (
"encoding/json"
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"io"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"sync/atomic"
"testing"
"time"
)
const (
testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
)
func TestServer_WebPush_Disabled(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
require.Equal(t, 404, response.Code)
}
func TestServer_WebPush_TopicAdd(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
require.Equal(t, subs[0].P256dh, "p256dh-key")
require.Equal(t, subs[0].Auth, "auth-key")
require.Equal(t, subs[0].UserID, "")
}
func TestServer_WebPush_TopicAdd_InvalidEndpoint(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://ddos-target.example.com/webpush"), nil)
require.Equal(t, 400, response.Code)
require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String())
}
func TestServer_WebPush_TopicAdd_TooManyTopics(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
topicList := make([]string, 51)
for i := range topicList {
topicList[i] = util.RandomString(5)
}
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, topicList, testWebPushEndpoint), nil)
require.Equal(t, 400, response.Code)
require.Equal(t, `{"code":40040,"http":400,"error":"invalid request: too many web push topic subscriptions"}`+"\n", response.Body.String())
}
func TestServer_WebPush_TopicUnsubscribe(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
addSubscription(t, s, testWebPushEndpoint, "test-topic")
requireSubscriptionCount(t, s, "test-topic", 1)
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{}, testWebPushEndpoint), nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
requireSubscriptionCount(t, s, "test-topic", 0)
}
func TestServer_WebPush_Delete(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
addSubscription(t, s, testWebPushEndpoint, "test-topic")
requireSubscriptionCount(t, s, "test-topic", 1)
response := request(t, s, "DELETE", "/v1/webpush", fmt.Sprintf(`{"endpoint":"%s"}`, testWebPushEndpoint), nil)
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
requireSubscriptionCount(t, s, "test-topic", 0)
}
func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) {
config := configureAuth(t, newTestConfigWithWebPush(t))
config.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, config)
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
require.Nil(t, err)
require.Len(t, subs, 1)
require.True(t, strings.HasPrefix(subs[0].UserID, "u_"))
}
func TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) {
config := configureAuth(t, newTestConfigWithWebPush(t))
config.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, config)
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
require.Equal(t, 403, response.Code)
requireSubscriptionCount(t, s, "test-topic", 0)
}
func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) {
config := configureAuth(t, newTestConfigWithWebPush(t))
s := newTestServer(t, config)
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
requireSubscriptionCount(t, s, "test-topic", 1)
request(t, s, "DELETE", "/v1/account", `{"password":"ben"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
// should've been deleted with the account
requireSubscriptionCount(t, s, "test-topic", 0)
}
func TestServer_WebPush_Publish(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
var received atomic.Bool
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.ReadAll(r.Body)
require.Nil(t, err)
require.Equal(t, "/push-receive", r.URL.Path)
require.Equal(t, "high", r.Header.Get("Urgency"))
require.Equal(t, "", r.Header.Get("Topic"))
received.Store(true)
}))
defer pushService.Close()
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
request(t, s, "POST", "/test-topic", "web push test", nil)
waitFor(t, func() bool {
return received.Load()
})
}
func TestServer_WebPush_Publish_RemoveOnError(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
var received atomic.Bool
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.ReadAll(r.Body)
require.Nil(t, err)
w.WriteHeader(http.StatusGone)
received.Store(true)
}))
defer pushService.Close()
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic", "test-topic-abc")
requireSubscriptionCount(t, s, "test-topic", 1)
requireSubscriptionCount(t, s, "test-topic-abc", 1)
request(t, s, "POST", "/test-topic", "web push test", nil)
waitFor(t, func() bool {
return received.Load()
})
// Receiving the 410 should've caused the publisher to expire all subscriptions on the endpoint
requireSubscriptionCount(t, s, "test-topic", 0)
requireSubscriptionCount(t, s, "test-topic-abc", 0)
}
func TestServer_WebPush_Expiry(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(t))
var received atomic.Bool
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := io.ReadAll(r.Body)
require.Nil(t, err)
w.WriteHeader(200)
w.Write([]byte(``))
received.Store(true)
}))
defer pushService.Close()
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
requireSubscriptionCount(t, s, "test-topic", 1)
_, err := s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-7*24*time.Hour).Unix())
require.Nil(t, err)
s.pruneAndNotifyWebPushSubscriptions()
requireSubscriptionCount(t, s, "test-topic", 1)
waitFor(t, func() bool {
return received.Load()
})
_, err = s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-9*24*time.Hour).Unix())
require.Nil(t, err)
s.pruneAndNotifyWebPushSubscriptions()
waitFor(t, func() bool {
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
require.Nil(t, err)
return len(subs) == 0
})
}
func payloadForTopics(t *testing.T, topics []string, endpoint string) string {
topicsJSON, err := json.Marshal(topics)
require.Nil(t, err)
return fmt.Sprintf(`{
"topics": %s,
"endpoint": "%s",
"p256dh": "p256dh-key",
"auth": "auth-key"
}`, topicsJSON, endpoint)
}
func addSubscription(t *testing.T, s *Server, endpoint string, topics ...string) {
require.Nil(t, s.webPush.UpsertSubscription(endpoint, "kSC3T8aN1JCQxxPdrFLrZg", "BMKKbxdUU_xLS7G1Wh5AN8PvWOjCzkCuKZYb8apcqYrDxjOF_2piggBnoJLQYx9IeSD70fNuwawI3e9Y8m3S3PE", "u_123", netip.MustParseAddr("1.2.3.4"), topics)) // Test auth and p256dh
}
func requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLength int) {
subs, err := s.webPush.SubscriptionsForTopic(topic)
require.Nil(t, err)
require.Len(t, subs, expectedLength)
}

View file

@ -1,12 +1,13 @@
package server
import (
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"net/http"
"net/netip"
"time"
"heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
)
@ -41,7 +42,7 @@ type message struct {
ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
User string `json:"-"` // Username of the uploader, used to associated attachments
User string `json:"-"` // UserID of the uploader, used to associated attachments
}
func (m *message) Context() log.Context {
@ -398,7 +399,9 @@ type apiConfigResponse struct {
EnableCalls bool `json:"enable_calls"`
EnableEmails bool `json:"enable_emails"`
EnableReservations bool `json:"enable_reservations"`
EnableWebPush bool `json:"enable_web_push"`
BillingContact string `json:"billing_contact"`
WebPushPublicKey string `json:"web_push_public_key"`
DisallowedTopics []string `json:"disallowed_topics"`
}
@ -463,3 +466,75 @@ type apiStripeSubscriptionDeletedEvent struct {
ID string `json:"id"`
Customer string `json:"customer"`
}
type apiWebPushUpdateSubscriptionRequest struct {
Endpoint string `json:"endpoint"`
Auth string `json:"auth"`
P256dh string `json:"p256dh"`
Topics []string `json:"topics"`
}
// List of possible Web Push events (see sw.js)
const (
webPushMessageEvent = "message"
webPushExpiringEvent = "subscription_expiring"
)
type webPushPayload struct {
Event string `json:"event"`
SubscriptionID string `json:"subscription_id"`
Message *message `json:"message"`
}
func newWebPushPayload(subscriptionID string, message *message) *webPushPayload {
return &webPushPayload{
Event: webPushMessageEvent,
SubscriptionID: subscriptionID,
Message: message,
}
}
type webPushControlMessagePayload struct {
Event string `json:"event"`
}
func newWebPushSubscriptionExpiringPayload() *webPushControlMessagePayload {
return &webPushControlMessagePayload{
Event: webPushExpiringEvent,
}
}
type webPushSubscription struct {
ID string
Endpoint string
Auth string
P256dh string
UserID string
}
func (w *webPushSubscription) Context() log.Context {
return map[string]any{
"web_push_subscription_id": w.ID,
"web_push_subscription_user_id": w.UserID,
"web_push_subscription_endpoint": w.Endpoint,
}
}
// https://developer.mozilla.org/en-US/docs/Web/Manifest
type webManifestResponse struct {
Name string `json:"name"`
Description string `json:"description"`
ShortName string `json:"short_name"`
Scope string `json:"scope"`
StartURL string `json:"start_url"`
Display string `json:"display"`
BackgroundColor string `json:"background_color"`
ThemeColor string `json:"theme_color"`
Icons []*webManifestIcon `json:"icons"`
}
type webManifestIcon struct {
SRC string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
}

280
server/webpush_store.go Normal file
View file

@ -0,0 +1,280 @@
package server
import (
"database/sql"
"errors"
"heckel.io/ntfy/util"
"net/netip"
"time"
_ "github.com/mattn/go-sqlite3" // SQLite driver
)
const (
subscriptionIDPrefix = "wps_"
subscriptionIDLength = 10
subscriptionEndpointLimitPerSubscriberIP = 10
)
var (
errWebPushNoRows = errors.New("no rows found")
errWebPushTooManySubscriptions = errors.New("too many subscriptions")
errWebPushUserIDCannotBeEmpty = errors.New("user ID cannot be empty")
)
const (
createWebPushSubscriptionsTableQuery = `
BEGIN;
CREATE TABLE IF NOT EXISTS subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at INT NOT NULL,
warned_at INT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_endpoint ON subscription (endpoint);
CREATE INDEX IF NOT EXISTS idx_subscriber_ip ON subscription (subscriber_ip);
CREATE TABLE IF NOT EXISTS subscription_topic (
subscription_id TEXT NOT NULL,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic),
FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_topic ON subscription_topic (topic);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
COMMIT;
`
builtinStartupQueries = `
PRAGMA foreign_keys = ON;
`
selectWebPushSubscriptionIDByEndpoint = `SELECT id FROM subscription WHERE endpoint = ?`
selectWebPushSubscriptionCountBySubscriberIP = `SELECT COUNT(*) FROM subscription WHERE subscriber_ip = ?`
selectWebPushSubscriptionsForTopicQuery = `
SELECT id, endpoint, key_auth, key_p256dh, user_id
FROM subscription_topic st
JOIN subscription s ON s.id = st.subscription_id
WHERE st.topic = ?
ORDER BY endpoint
`
selectWebPushSubscriptionsExpiringSoonQuery = `
SELECT id, endpoint, key_auth, key_p256dh, user_id
FROM subscription
WHERE warned_at = 0 AND updated_at <= ?
`
insertWebPushSubscriptionQuery = `
INSERT INTO subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (endpoint)
DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at
`
updateWebPushSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?`
deleteWebPushSubscriptionByEndpointQuery = `DELETE FROM subscription WHERE endpoint = ?`
deleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?`
deleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan!
insertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)`
deleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?`
)
// Schema management queries
const (
currentWebPushSchemaVersion = 1
insertWebPushSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
selectWebPushSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
)
type webPushStore struct {
db *sql.DB
}
func newWebPushStore(filename, startupQueries string) (*webPushStore, error) {
db, err := sql.Open("sqlite3", filename)
if err != nil {
return nil, err
}
if err := setupWebPushDB(db); err != nil {
return nil, err
}
if err := runWebPushStartupQueries(db, startupQueries); err != nil {
return nil, err
}
return &webPushStore{
db: db,
}, nil
}
func setupWebPushDB(db *sql.DB) error {
// If 'schemaVersion' table does not exist, this must be a new database
rows, err := db.Query(selectWebPushSchemaVersionQuery)
if err != nil {
return setupNewWebPushDB(db)
}
return rows.Close()
}
func setupNewWebPushDB(db *sql.DB) error {
if _, err := db.Exec(createWebPushSubscriptionsTableQuery); err != nil {
return err
}
if _, err := db.Exec(insertWebPushSchemaVersion, currentWebPushSchemaVersion); err != nil {
return err
}
return nil
}
func runWebPushStartupQueries(db *sql.DB, startupQueries string) error {
if _, err := db.Exec(startupQueries); err != nil {
return err
}
if _, err := db.Exec(builtinStartupQueries); err != nil {
return err
}
return nil
}
// UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. It always first deletes all
// existing entries for a given endpoint.
func (c *webPushStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error {
tx, err := c.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
// Read number of subscriptions for subscriber IP address
rowsCount, err := tx.Query(selectWebPushSubscriptionCountBySubscriberIP, subscriberIP.String())
if err != nil {
return err
}
defer rowsCount.Close()
var subscriptionCount int
if !rowsCount.Next() {
return errWebPushNoRows
}
if err := rowsCount.Scan(&subscriptionCount); err != nil {
return err
}
if err := rowsCount.Close(); err != nil {
return err
}
// Read existing subscription ID for endpoint (or create new ID)
rows, err := tx.Query(selectWebPushSubscriptionIDByEndpoint, endpoint)
if err != nil {
return err
}
defer rows.Close()
var subscriptionID string
if rows.Next() {
if err := rows.Scan(&subscriptionID); err != nil {
return err
}
} else {
if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP {
return errWebPushTooManySubscriptions
}
subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength)
}
if err := rows.Close(); err != nil {
return err
}
// Insert or update subscription
updatedAt, warnedAt := time.Now().Unix(), 0
if _, err = tx.Exec(insertWebPushSubscriptionQuery, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil {
return err
}
// Replace all subscription topics
if _, err := tx.Exec(deleteWebPushSubscriptionTopicAllQuery, subscriptionID); err != nil {
return err
}
for _, topic := range topics {
if _, err = tx.Exec(insertWebPushSubscriptionTopicQuery, subscriptionID, topic); err != nil {
return err
}
}
return tx.Commit()
}
// SubscriptionsForTopic returns all subscriptions for the given topic
func (c *webPushStore) SubscriptionsForTopic(topic string) ([]*webPushSubscription, error) {
rows, err := c.db.Query(selectWebPushSubscriptionsForTopicQuery, topic)
if err != nil {
return nil, err
}
defer rows.Close()
return c.subscriptionsFromRows(rows)
}
// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period
func (c *webPushStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*webPushSubscription, error) {
rows, err := c.db.Query(selectWebPushSubscriptionsExpiringSoonQuery, time.Now().Add(-warnAfter).Unix())
if err != nil {
return nil, err
}
defer rows.Close()
return c.subscriptionsFromRows(rows)
}
// MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon
func (c *webPushStore) MarkExpiryWarningSent(subscriptions []*webPushSubscription) error {
tx, err := c.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, subscription := range subscriptions {
if _, err := tx.Exec(updateWebPushSubscriptionWarningSentQuery, time.Now().Unix(), subscription.ID); err != nil {
return err
}
}
return tx.Commit()
}
func (c *webPushStore) subscriptionsFromRows(rows *sql.Rows) ([]*webPushSubscription, error) {
subscriptions := make([]*webPushSubscription, 0)
for rows.Next() {
var id, endpoint, auth, p256dh, userID string
if err := rows.Scan(&id, &endpoint, &auth, &p256dh, &userID); err != nil {
return nil, err
}
subscriptions = append(subscriptions, &webPushSubscription{
ID: id,
Endpoint: endpoint,
Auth: auth,
P256dh: p256dh,
UserID: userID,
})
}
return subscriptions, nil
}
// RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint
func (c *webPushStore) RemoveSubscriptionsByEndpoint(endpoint string) error {
_, err := c.db.Exec(deleteWebPushSubscriptionByEndpointQuery, endpoint)
return err
}
// RemoveSubscriptionsByUserID removes all subscriptions for the given user ID
func (c *webPushStore) RemoveSubscriptionsByUserID(userID string) error {
if userID == "" {
return errWebPushUserIDCannotBeEmpty
}
_, err := c.db.Exec(deleteWebPushSubscriptionByUserIDQuery, userID)
return err
}
// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period
func (c *webPushStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error {
_, err := c.db.Exec(deleteWebPushSubscriptionByAgeQuery, time.Now().Add(-expireAfter).Unix())
return err
}
// Close closes the underlying database connection
func (c *webPushStore) Close() error {
return c.db.Close()
}

View file

@ -0,0 +1,199 @@
package server
import (
"fmt"
"github.com/stretchr/testify/require"
"net/netip"
"path/filepath"
"testing"
"time"
)
func TestWebPushStore_UpsertSubscription_SubscriptionsForTopic(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
subs, err := webPush.SubscriptionsForTopic("test-topic")
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
require.Equal(t, subs[0].P256dh, "p256dh-key")
require.Equal(t, subs[0].Auth, "auth-key")
require.Equal(t, subs[0].UserID, "u_1234")
subs2, err := webPush.SubscriptionsForTopic("mytopic")
require.Nil(t, err)
require.Len(t, subs2, 1)
require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint)
}
func TestWebPushStore_UpsertSubscription_SubscriberIPLimitReached(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert 10 subscriptions with the same IP address
for i := 0; i < 10; i++ {
endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i)
require.Nil(t, webPush.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
}
// Another one for the same endpoint should be fine
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
// But with a different endpoint it should fail
require.Equal(t, errWebPushTooManySubscriptions, webPush.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
// But with a different IP address it should be fine again
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"}))
}
func TestWebPushStore_UpsertSubscription_UpdateTopics(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics, and another with one topic
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 2)
require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint)
subs, err = webPush.SubscriptionsForTopic("topic2")
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
// Update the first subscription to have only one topic
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"}))
subs, err = webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 2)
require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
subs, err = webPush.SubscriptionsForTopic("topic2")
require.Nil(t, err)
require.Len(t, subs, 0)
}
func TestWebPushStore_RemoveSubscriptionsByEndpoint(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// And remove it again
require.Nil(t, webPush.RemoveSubscriptionsByEndpoint(testWebPushEndpoint))
subs, err = webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 0)
}
func TestWebPushStore_RemoveSubscriptionsByUserID(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// And remove it again
require.Nil(t, webPush.RemoveSubscriptionsByUserID("u_1234"))
subs, err = webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 0)
}
func TestWebPushStore_RemoveSubscriptionsByUserID_Empty(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
require.Equal(t, errWebPushUserIDCannotBeEmpty, webPush.RemoveSubscriptionsByUserID(""))
}
func TestWebPushStore_MarkExpiryWarningSent(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// Mark them as warning sent
require.Nil(t, webPush.MarkExpiryWarningSent(subs))
rows, err := webPush.db.Query("SELECT endpoint FROM subscription WHERE warned_at > 0")
require.Nil(t, err)
defer rows.Close()
var endpoint string
require.True(t, rows.Next())
require.Nil(t, rows.Scan(&endpoint))
require.Nil(t, err)
require.Equal(t, testWebPushEndpoint, endpoint)
require.False(t, rows.Next())
}
func TestWebPushStore_SubscriptionsExpiring(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// Fake-mark them as soon-to-expire
_, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-8*24*time.Hour).Unix(), testWebPushEndpoint)
require.Nil(t, err)
// Should not be cleaned up yet
require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour))
// Run expiration
subs, err = webPush.SubscriptionsExpiring(7 * 24 * time.Hour)
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, testWebPushEndpoint, subs[0].Endpoint)
}
func TestWebPushStore_RemoveExpiredSubscriptions(t *testing.T) {
webPush := newTestWebPushStore(t)
defer webPush.Close()
// Insert subscription with two topics
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
subs, err := webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 1)
// Fake-mark them as expired
_, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-10*24*time.Hour).Unix(), testWebPushEndpoint)
require.Nil(t, err)
// Run expiration
require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour))
// List again, should be 0
subs, err = webPush.SubscriptionsForTopic("topic1")
require.Nil(t, err)
require.Len(t, subs, 0)
}
func newTestWebPushStore(t *testing.T) *webPushStore {
webPush, err := newWebPushStore(filepath.Join(t.TempDir(), "webpush.db"), "")
require.Nil(t, err)
return webPush
}

View file

@ -126,6 +126,7 @@ const (
ON CONFLICT (id) DO NOTHING;
COMMIT;
`
builtinStartupQueries = `
PRAGMA foreign_keys = ON;
`
@ -508,7 +509,7 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) {
// after a fixed duration unless ChangeToken is called. This function also prunes tokens for the
// given user, if there are too many of them.
func (a *Manager) CreateToken(userID, label string, expires time.Time, origin netip.Addr) (*Token, error) {
token := util.RandomStringPrefix(tokenPrefix, tokenLength)
token := util.RandomLowerStringPrefix(tokenPrefix, tokenLength) // Lowercase only to support "<topic>+<token>@<domain>" email addresses
tx, err := a.db.Begin()
if err != nil {
return nil, err

View file

@ -183,6 +183,19 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
require.Equal(t, ErrUserNotFound, err)
}
func TestManager_CreateToken_Only_Lower(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
// Create user, add reservations and token
require.Nil(t, a.AddUser("user", "pass", RoleAdmin))
u, err := a.User("user")
require.Nil(t, err)
token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour), netip.IPv4Unspecified())
require.Nil(t, err)
require.Equal(t, token.Value, strings.ToLower(token.Value))
}
func TestManager_UserManagement(t *testing.T) {
a := newTestManager(t, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin))

View file

@ -23,7 +23,8 @@ import (
)
const (
randomStringCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
randomStringCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
randomStringLowerCaseCharset = "abcdefghijklmnopqrstuvwxyz0123456789"
)
var (
@ -112,11 +113,20 @@ func RandomString(length int) string {
// RandomStringPrefix returns a random string with a given length, with a prefix
func RandomStringPrefix(prefix string, length int) string {
return randomStringPrefixWithCharset(prefix, length, randomStringCharset)
}
// RandomLowerStringPrefix returns a random lowercase-only string with a given length, with a prefix
func RandomLowerStringPrefix(prefix string, length int) string {
return randomStringPrefixWithCharset(prefix, length, randomStringLowerCaseCharset)
}
func randomStringPrefixWithCharset(prefix string, length int, charset string) string {
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
defer randomMutex.Unlock()
b := make([]byte, length-len(prefix))
for i := range b {
b[i] = randomStringCharset[random.Intn(len(randomStringCharset))]
b[i] = charset[random.Intn(len(charset))]
}
return prefix + string(b)
}

View file

@ -33,5 +33,6 @@
"unnamedComponents": "arrow-function"
}
]
}
},
"overrides": [{ "files": ["./public/sw.js"], "rules": { "no-restricted-globals": "off" } }]
}

View file

@ -13,11 +13,17 @@
<meta name="theme-color" content="#317f6f" />
<meta name="msapplication-navbutton-color" content="#317f6f" />
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f" />
<link rel="apple-touch-icon" href="/static/images/apple-touch-icon.png" sizes="180x180" />
<link rel="mask-icon" href="/static/images/mask-icon.svg" color="#317f6f" />
<!-- Favicon, see favicon.io -->
<link rel="icon" type="image/png" href="/static/images/favicon.ico" />
<!-- Previews in Google, Slack, WhatsApp, etc. -->
<meta
name="description"
content="ntfy lets you send push notifications via scripts from any computer or phone. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy."
/>
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta property="og:site_name" content="ntfy web" />
@ -35,6 +41,9 @@
<!-- Style overrides & fonts -->
<link rel="stylesheet" href="/static/css/app.css" type="text/css" />
<link rel="stylesheet" href="/static/css/fonts.css" type="text/css" />
<!-- PWA -->
<link rel="manifest" href="/manifest.webmanifest" />
</head>
<body>
<noscript>

3430
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@
"lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/"
},
"dependencies": {
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.4.2",
@ -28,7 +29,9 @@
"react-infinite-scroll-component": "^6.1.0",
"react-router-dom": "^6.2.2",
"stacktrace-gps": "^3.0.4",
"stacktrace-js": "^2.0.2"
"stacktrace-js": "^2.0.2",
"stylis": "^4.3.0",
"stylis-plugin-rtl": "^2.1.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
@ -40,7 +43,8 @@
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"prettier": "^2.8.8",
"vite": "^4.3.8"
"vite": "^4.3.9",
"vite-plugin-pwa": "^0.15.0"
},
"browserslist": {
"production": [

View file

@ -7,13 +7,15 @@
var config = {
base_url: window.location.origin, // Change to test against a different server
app_root: "/app",
app_root: "/",
enable_login: true,
enable_signup: true,
enable_payments: false,
enable_reservations: true,
enable_emails: true,
enable_calls: true,
enable_web_push: true,
billing_contact: "",
web_push_public_key: "",
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"],
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,20 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M1546 6263 c-1 -1 -132 -3 -292 -4 -301 -1 -353 -7 -484 -50 -265
-88 -483 -296 -578 -550 -52 -140 -54 -172 -53 -784 2 -2183 1 -3783 -3 -3802
-2 -12 -7 -49 -11 -82 -3 -33 -7 -68 -9 -78 -2 -10 -7 -45 -12 -78 -4 -33 -8
-62 -9 -65 0 -3 -5 -36 -10 -75 -5 -38 -9 -72 -10 -75 -1 -3 -5 -34 -10 -70
-12 -98 -12 -96 -30 -225 -9 -66 -19 -123 -21 -127 -15 -24 16 -17 686 162
107 29 200 53 205 54 6 2 30 8 55 15 25 7 140 37 255 68 116 30 282 75 370 98
l160 43 2175 0 c1196 0 2201 3 2234 7 210 21 414 120 572 279 118 119 188 237
236 403 l23 78 2 2025 2 2025 -25 99 c-23 94 -87 247 -116 277 -7 8 -26 33
-41 56 -97 142 -326 296 -512 342 -27 7 -59 15 -70 18 -11 3 -94 7 -185 10
-165 4 -4490 10 -4494 6z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -12,8 +12,8 @@
"nav_button_publish_message": "نشر الإشعار",
"nav_button_subscribe": "اشترك في الموضوع",
"nav_button_connecting": "جارٍ الاتصال",
"alert_grant_title": "تم تعطيل الإشعارات",
"alert_grant_description": "امنح متصفحك الإذن لعرض إشعارات سطح المكتب.",
"alert_notification_permission_required_title": "تم تعطيل الإشعارات",
"alert_notification_permission_required_description": "امنح متصفحك الإذن لعرض إشعارات سطح المكتب.",
"notifications_list": "قائمة الإشعارات",
"notifications_list_item": "إشعار",
"notifications_mark_read": "وضع علامة كمقروء",
@ -44,7 +44,7 @@
"notifications_copied_to_clipboard": "تم نسخه إلى الحافظة",
"action_bar_toggle_mute": "كتم / إلغاء كتم الإشعارات",
"action_bar_toggle_action_menu": "فتح/إغلاق قائمة الإجراءات",
"alert_grant_button": "امنح الآن",
"alert_notification_permission_required_button": "امنح الآن",
"notifications_attachment_open_button": "فتح المرفق",
"notifications_attachment_copy_url_title": "نسخ عنوان URL للمرفق إلى الحافظة",
"notifications_click_copy_url_title": "انسخ رابط URL إلى الحافظة",

View file

@ -1,6 +1,6 @@
{
"action_bar_clear_notifications": "Премахване на известия",
"alert_grant_description": "Разрешете на мрежовия четец да показва известия.",
"alert_notification_permission_required_description": "Разрешете на мрежовия четец да показва известия.",
"notifications_attachment_copy_url_title": "Копиране на адреса на прикачения файл",
"notifications_example": "Пример",
"notifications_no_subscriptions_title": "Липсват абонаменти",
@ -47,8 +47,8 @@
"nav_button_settings": "Настройки",
"nav_button_documentation": "Ръководство",
"nav_button_subscribe": "Абониране за тема",
"alert_grant_title": "Известията са изключени",
"alert_grant_button": "Разрешаване",
"alert_notification_permission_required_title": "Известията са изключени",
"alert_notification_permission_required_button": "Разрешаване",
"notifications_tags": "Етикети",
"nav_button_publish_message": "Изпращане",
"alert_not_supported_title": "Не се поддържат известия",

View file

@ -11,9 +11,9 @@
"nav_button_documentation": "Dokumentace",
"nav_button_publish_message": "Odeslat oznámení",
"nav_button_subscribe": "Přihlásit se k odběru tématu",
"alert_grant_title": "Oznámení jsou zakázána",
"alert_grant_description": "Udělte prohlížeči oprávnění k zobrazování oznámení na ploše.",
"alert_grant_button": "Udělit nyní",
"alert_notification_permission_required_title": "Oznámení jsou zakázána",
"alert_notification_permission_required_description": "Udělte prohlížeči oprávnění k zobrazování oznámení na ploše.",
"alert_notification_permission_required_button": "Udělit nyní",
"alert_not_supported_title": "Oznámení nejsou podporována",
"alert_not_supported_description": "Oznámení nejsou ve vašem prohlížeči podporována.",
"notifications_copied_to_clipboard": "Zkopírováno do schránky",

View file

@ -40,8 +40,8 @@
"nav_button_all_notifications": "Alle notifikationer",
"nav_button_connecting": "forbinder",
"nav_upgrade_banner_label": "Opgrader til ntfy Pro",
"alert_grant_title": "Notifikationer er deaktiveret",
"alert_grant_description": "Giv din browser tilladelse til at vise skrivebordsnotifikationer.",
"alert_notification_permission_required_title": "Notifikationer er deaktiveret",
"alert_notification_permission_required_description": "Giv din browser tilladelse til at vise skrivebordsnotifikationer.",
"alert_not_supported_title": "Notifikationer understøttes ikke",
"alert_not_supported_description": "Notifikationer understøttes ikke i din browser.",
"alert_not_supported_context_description": "Notifikationer understøttes kun via HTTPS. Dette skyldes en begrænsning i <mdnLink>Notifications API</mdnLink>.",

View file

@ -5,7 +5,7 @@
"nav_button_documentation": "Dokumentation",
"nav_button_publish_message": "Benachrichtigung senden",
"nav_button_subscribe": "Thema abonnieren",
"alert_grant_title": "Benachrichtigungen sind deaktiviert",
"alert_notification_permission_required_title": "Benachrichtigungen sind deaktiviert",
"publish_dialog_base_url_label": "Service-URL",
"publish_dialog_details_examples_description": "Beispiele und ausführliche Informationen zu allen Optionen findest Du in der <docsLink>Dokumentation</docsLink>.",
"publish_dialog_attached_file_filename_placeholder": "Dateiname des Anhangs",
@ -31,7 +31,7 @@
"notifications_attachment_open_title": "Gehe zu {{url}}",
"notifications_none_for_any_title": "Du hast keine Benachrichtigungen empfangen.",
"action_bar_send_test_notification": "Test-Benachrichtigung senden",
"alert_grant_description": "Dem Browser erlauben, Desktop-Benachrichtigungen anzuzeigen.",
"alert_notification_permission_required_description": "Dem Browser erlauben, Desktop-Benachrichtigungen anzuzeigen.",
"notifications_tags": "Tags",
"message_bar_type_message": "Gib hier eine Nachricht ein",
"message_bar_error_publishing": "Fehler beim Senden der Benachrichtigung",
@ -39,7 +39,7 @@
"alert_not_supported_description": "Benachrichtigungen werden von Deinem Browser nicht unterstützt.",
"action_bar_settings": "Einstellungen",
"action_bar_clear_notifications": "Alle Benachrichtigungen löschen",
"alert_grant_button": "Jetzt erlauben",
"alert_notification_permission_required_button": "Jetzt erlauben",
"notifications_none_for_topic_title": "Du hast für dieses Thema noch keine Benachrichtigungen empfangen.",
"notifications_click_open_button": "Link öffnen",
"notifications_more_details": "Ausführlichere Informationen findest Du auf der <websiteLink>Website</websiteLink> und in der <docsLink>Dokumentation</docsLink>.",

View file

@ -29,6 +29,8 @@
"action_bar_reservation_limit_reached": "Limit reached",
"action_bar_send_test_notification": "Send test notification",
"action_bar_clear_notifications": "Clear all notifications",
"action_bar_mute_notifications": "Mute notifications",
"action_bar_unmute_notifications": "Unmute notifications",
"action_bar_unsubscribe": "Unsubscribe",
"action_bar_toggle_mute": "Mute/unmute notifications",
"action_bar_toggle_action_menu": "Open/close action menu",
@ -52,11 +54,15 @@
"nav_button_connecting": "connecting",
"nav_upgrade_banner_label": "Upgrade to ntfy Pro",
"nav_upgrade_banner_description": "Reserve topics, more messages & emails, and larger attachments",
"alert_grant_title": "Notifications are disabled",
"alert_grant_description": "Grant your browser permission to display desktop notifications.",
"alert_grant_button": "Grant now",
"alert_notification_permission_required_title": "Notifications are disabled",
"alert_notification_permission_required_description": "Grant your browser permission to display desktop notifications",
"alert_notification_permission_required_button": "Grant now",
"alert_notification_permission_denied_title": "Notifications are blocked",
"alert_notification_permission_denied_description": "Please re-enable them in your browser",
"alert_notification_ios_install_required_title": "iOS install required",
"alert_notification_ios_install_required_description": "Click on the Share icon and Add to Home Screen to enable notifications on iOS",
"alert_not_supported_title": "Notifications not supported",
"alert_not_supported_description": "Notifications are not supported in your browser.",
"alert_not_supported_description": "Notifications are not supported in your browser",
"alert_not_supported_context_description": "Notifications are only supported over HTTPS. This is a limitation of the <mdnLink>Notifications API</mdnLink>.",
"notifications_list": "Notifications list",
"notifications_list_item": "Notification",
@ -84,6 +90,7 @@
"notifications_actions_open_url_title": "Go to {{url}}",
"notifications_actions_not_supported": "Action not supported in web app",
"notifications_actions_http_request_title": "Send HTTP {{method}} to {{url}}",
"notifications_actions_failed_notification": "Unsuccessful action",
"notifications_none_for_topic_title": "You haven't received any notifications for this topic yet.",
"notifications_none_for_topic_description": "To send notifications to this topic, simply PUT or POST to the topic URL.",
"notifications_none_for_any_title": "You haven't received any notifications.",
@ -164,6 +171,7 @@
"subscribe_dialog_subscribe_description": "Topics may not be password-protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications.",
"subscribe_dialog_subscribe_topic_placeholder": "Topic name, e.g. phil_alerts",
"subscribe_dialog_subscribe_use_another_label": "Use another server",
"subscribe_dialog_subscribe_use_another_background_info": "Notifications from other servers will not be received when the web app is not open",
"subscribe_dialog_subscribe_base_url_label": "Service URL",
"subscribe_dialog_subscribe_button_generate_topic_name": "Generate name",
"subscribe_dialog_subscribe_button_cancel": "Cancel",
@ -325,6 +333,11 @@
"prefs_notifications_delete_after_one_day_description": "Notifications are auto-deleted after one day",
"prefs_notifications_delete_after_one_week_description": "Notifications are auto-deleted after one week",
"prefs_notifications_delete_after_one_month_description": "Notifications are auto-deleted after one month",
"prefs_notifications_web_push_title": "Background notifications",
"prefs_notifications_web_push_enabled_description": "Notifications are received even when the web app is not running (via Web Push)",
"prefs_notifications_web_push_disabled_description": "Notification are received when the web app is running (via WebSocket)",
"prefs_notifications_web_push_enabled": "Enabled for {{server}}",
"prefs_notifications_web_push_disabled": "Disabled",
"prefs_users_title": "Manage users",
"prefs_users_description": "Add/remove users for your protected topics here. Please note that username and password are stored in the browser's local storage.",
"prefs_users_description_no_sync": "Users and passwords are not synchronized to your account.",
@ -342,6 +355,10 @@
"prefs_users_dialog_password_label": "Password",
"prefs_appearance_title": "Appearance",
"prefs_appearance_language_title": "Language",
"prefs_appearance_theme_title": "Theme",
"prefs_appearance_theme_system": "System (default)",
"prefs_appearance_theme_dark": "Dark mode",
"prefs_appearance_theme_light": "Light mode",
"prefs_reservations_title": "Reserved topics",
"prefs_reservations_description": "You can reserve topic names for personal use here. Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.",
"prefs_reservations_limit_reached": "You reached your reserved topics limit.",
@ -377,8 +394,13 @@
"error_boundary_title": "Oh no, ntfy crashed",
"error_boundary_description": "This should obviously not happen. Very sorry about this.<br/>If you have a minute, please <githubLink>report this on GitHub</githubLink>, or let us know via <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.",
"error_boundary_button_copy_stack_trace": "Copy stack trace",
"error_boundary_button_reload_ntfy": "Reload ntfy",
"error_boundary_stack_trace": "Stack trace",
"error_boundary_gathering_info": "Gather more info …",
"error_boundary_unsupported_indexeddb_title": "Private browsing not supported",
"error_boundary_unsupported_indexeddb_description": "The ntfy web app needs IndexedDB to function, and your browser does not support IndexedDB in private browsing mode.<br/><br/>While this is unfortunate, it also doesn't really make a lot of sense to use the ntfy web app in private browsing mode anyway, because everything is stored in the browser storage. You can read more about it <githubLink>in this GitHub issue</githubLink>, or talk to us on <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>."
"error_boundary_unsupported_indexeddb_description": "The ntfy web app needs IndexedDB to function, and your browser does not support IndexedDB in private browsing mode.<br/><br/>While this is unfortunate, it also doesn't really make a lot of sense to use the ntfy web app in private browsing mode anyway, because everything is stored in the browser storage. You can read more about it <githubLink>in this GitHub issue</githubLink>, or talk to us on <discordLink>Discord</discordLink> or <matrixLink>Matrix</matrixLink>.",
"web_push_subscription_expiring_title": "Notifications will be paused",
"web_push_subscription_expiring_body": "Open ntfy to continue receiving notifications",
"web_push_unknown_notification_title": "Unknown notification received from server",
"web_push_unknown_notification_body": "You may need to update ntfy by opening the web app"
}

View file

@ -3,12 +3,12 @@
"action_bar_send_test_notification": "Enviar notificación de prueba",
"action_bar_clear_notifications": "Borrar todas las notificaciones",
"nav_topics_title": "Tópicos suscritos",
"alert_grant_button": "Conceder ahora",
"alert_notification_permission_required_button": "Conceder ahora",
"action_bar_unsubscribe": "Cancelar la suscripción",
"message_bar_type_message": "Escriba un mensaje aquí",
"message_bar_error_publishing": "Error al publicar la notificación",
"alert_grant_title": "Las notificaciones están deshabilitadas",
"alert_grant_description": "Concede a tu navegador permiso para mostrar notificaciones en el escritorio.",
"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.",
"nav_button_all_notifications": "Todas las notificaciones",
"nav_button_settings": "Ajustes",
"nav_button_subscribe": "Suscribirse al tópico",
@ -379,5 +379,7 @@
"account_basics_phone_numbers_copied_to_clipboard": "Número de teléfono copiado al portapapeles",
"account_basics_phone_numbers_dialog_check_verification_button": "Confirmar código",
"account_basics_phone_numbers_dialog_title": "Agregar número de teléfono",
"account_basics_phone_numbers_dialog_code_placeholder": "p.ej. 123456"
"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"
}

View file

@ -50,9 +50,9 @@
"publish_dialog_attachment_limits_file_reached": "Dépasse la limite du fichier {{fileSizeLimit}}",
"nav_button_subscribe": "S'abonner au sujet",
"notifications_no_subscriptions_description": "Cliquez sur le lien « {{linktext}} » pour créer ou vous abonner à un sujet. Après cela, vous pouvez envoyer des messages via PUT ou POST et vous recevrez des notifications ici.",
"alert_grant_title": "Les notifications sont désactivées",
"alert_grant_description": "Autorisez votre navigateur à afficher les notifications du bureau.",
"alert_grant_button": "Accorder maintenant",
"alert_notification_permission_required_title": "Les notifications sont désactivées",
"alert_notification_permission_required_description": "Autorisez votre navigateur à afficher les notifications du bureau.",
"alert_notification_permission_required_button": "Accorder maintenant",
"notifications_none_for_any_title": "Vous n'avez reçu aucune notification.",
"publish_dialog_title_topic": "Publier vers {{topic}}",
"publish_dialog_title_no_topic": "Publier la notification",

View file

@ -8,12 +8,12 @@
"message_bar_error_publishing": "Hiba történt az értesítés elküldése közben",
"nav_button_all_notifications": "Összes értesítés",
"nav_topics_title": "Feliratkozott témák",
"alert_grant_title": "Az értesítések le vannak tiltva",
"alert_grant_description": "Engedélyezd a böngészőnek, hogy asztali értesítéseket jeleníttessen meg.",
"alert_notification_permission_required_title": "Az értesítések le vannak tiltva",
"alert_notification_permission_required_description": "Engedélyezd a böngészőnek, hogy asztali értesítéseket jeleníttessen meg.",
"nav_button_settings": "Beállítások",
"nav_button_documentation": "Dokumentáció",
"nav_button_publish_message": "Értesítés küldése",
"alert_grant_button": "Engedélyezés",
"alert_notification_permission_required_button": "Engedélyezés",
"alert_not_supported_title": "Nem támogatott funkció",
"notifications_copied_to_clipboard": "Másolva a vágólapra",
"notifications_tags": "Címkék",

View file

@ -22,8 +22,8 @@
"common_add": "Tambahkan",
"nav_topics_title": "Topik yang dilanggani",
"nav_button_subscribe": "Berlangganan ke topik",
"alert_grant_title": "Notifikasi dinonaktifkan",
"alert_grant_description": "Berikan izin ke peramban untuk menampilkan notifikasi desktop.",
"alert_notification_permission_required_title": "Notifikasi dinonaktifkan",
"alert_notification_permission_required_description": "Berikan izin ke peramban untuk menampilkan notifikasi desktop.",
"alert_not_supported_description": "Notifikasi tidak didukung dalam peramban Anda.",
"notifications_attachment_open_title": "Pergi ke {{url}}",
"notifications_attachment_open_button": "Buka lampiran",
@ -33,7 +33,7 @@
"notifications_click_open_button": "Buka tautan",
"publish_dialog_topic_placeholder": "Nama topik, mis. pemberitahuan_andi",
"nav_button_publish_message": "Publikasikan notifikasi",
"alert_grant_button": "Berikan sekarang",
"alert_notification_permission_required_button": "Berikan sekarang",
"notifications_copied_to_clipboard": "Disalin ke papan klip",
"notifications_tags": "Tanda",
"notifications_attachment_copy_url_title": "Salin URL lampiran ke papan klip",

View file

@ -15,8 +15,8 @@
"nav_button_subscribe": "Iscriviti al topic",
"nav_button_muted": "Notifiche disattivate",
"nav_button_connecting": "connessione",
"alert_grant_title": "Le notifiche sono disabilitate",
"alert_grant_button": "Concedi ora",
"alert_notification_permission_required_title": "Le notifiche sono disabilitate",
"alert_notification_permission_required_button": "Concedi ora",
"notifications_list": "Elenco notifiche",
"notifications_list_item": "Notifiche",
"notifications_mark_read": "Segna come letto",
@ -155,7 +155,7 @@
"alert_not_supported_description": "Le notifiche non sono supportate nel tuo browser.",
"nav_button_documentation": "Documentazione",
"notifications_actions_http_request_title": "Invia HTTP {{method}} a {{url}}",
"alert_grant_description": "Concedi al tuo browser l'autorizzazione a visualizzare le notifiche sul desktop.",
"alert_notification_permission_required_description": "Concedi al tuo browser l'autorizzazione a visualizzare le notifiche sul desktop.",
"alert_not_supported_title": "Notifiche non supportate",
"notifications_attachment_file_app": "file app Android",
"notifications_no_subscriptions_description": "Fai clic sul link \"{{linktext}}\" per creare o iscriverti a un topic. Successivamente, puoi inviare messaggi tramite PUT o POST e riceverai le notifiche qui.",

View file

@ -28,13 +28,13 @@
"message_bar_type_message": "メッセージを入力してください",
"nav_topics_title": "購読しているトピック",
"nav_button_subscribe": "トピックを購読",
"alert_grant_description": "ブラウザのデスクトップ通知を許可してください。",
"alert_grant_button": "許可する",
"alert_notification_permission_required_description": "ブラウザのデスクトップ通知を許可してください。",
"alert_notification_permission_required_button": "許可する",
"notifications_attachment_link_expires": "リンクは {{date}} に失効します",
"notifications_click_copy_url_button": "リンクをコピー",
"notifications_none_for_topic_description": "トピックに通知を送信するには、トピックのURLにPUTかPOSTしてください。",
"nav_button_publish_message": "通知を送信",
"alert_grant_title": "通知は無効化されています",
"alert_notification_permission_required_title": "通知は無効化されています",
"alert_not_supported_title": "通知機能はサポートされていません",
"notifications_tags": "タグ",
"notifications_attachment_copy_url_button": "URLをコピー",
@ -356,5 +356,29 @@
"account_upgrade_dialog_tier_features_messages_one": "毎日 {{messages}} メッセージ",
"account_upgrade_dialog_tier_features_reservations_one": "予約済みトピック {{reservations}} 件",
"account_upgrade_dialog_tier_features_emails_one": "毎日メール {{emails}} 件",
"publish_dialog_call_label": "電話"
"publish_dialog_call_label": "電話",
"publish_dialog_call_item": "電話番号 {{number}}",
"account_basics_phone_numbers_title": "電話番号",
"account_usage_calls_none": "このアカウントからは電話を発信できません",
"account_usage_calls_title": "電話を発信しました",
"account_upgrade_dialog_tier_features_calls_one": "電話 1日 {{calls}} 回",
"account_upgrade_dialog_tier_features_no_calls": "電話なし",
"publish_dialog_call_reset": "電話番号を削除",
"publish_dialog_chip_call_label": "電話番号",
"account_basics_phone_numbers_dialog_description": "電話通知機能を使うには、最低ひとつの電話番号を追加して認証する必要があります。認証はSMSまたは電話で実施できます。",
"account_basics_phone_numbers_description": "電話通知",
"account_basics_phone_numbers_dialog_title": "電話番号を追加",
"account_basics_phone_numbers_no_phone_numbers_yet": "電話番号はまだありません",
"account_basics_phone_numbers_copied_to_clipboard": "電話番号がクリップボードにコピーされました",
"account_basics_phone_numbers_dialog_number_label": "電話番号",
"account_basics_phone_numbers_dialog_number_placeholder": "例 +1222333444",
"account_basics_phone_numbers_dialog_verify_button_sms": "SMSを送信",
"account_basics_phone_numbers_dialog_verify_button_call": "自分に電話する",
"account_basics_phone_numbers_dialog_code_label": "確認コード",
"account_basics_phone_numbers_dialog_code_placeholder": "例 123456",
"account_basics_phone_numbers_dialog_check_verification_button": "確認コード",
"account_upgrade_dialog_tier_features_calls_other": "電話 1日 {{calls}} 回",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "認証済み電話番号がありません",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_channel_call": "電話する"
}

View file

@ -17,9 +17,9 @@
"nav_button_subscribe": "주제 구독하기",
"nav_button_muted": "알림 음소거됨",
"nav_button_connecting": "연결중",
"alert_grant_title": "알림이 비활성화되어 있습니다",
"alert_grant_description": "데스크톱 알림을 받기 위해서는 브라우저에서 권한을 부여해야 합니다.",
"alert_grant_button": "권한 부여하기",
"alert_notification_permission_required_title": "알림이 비활성화되어 있습니다",
"alert_notification_permission_required_description": "데스크톱 알림을 받기 위해서는 브라우저에서 권한을 부여해야 합니다.",
"alert_notification_permission_required_button": "권한 부여하기",
"alert_not_supported_title": "알림이 지원되지 않습니다",
"notifications_list_item": "알림",
"notifications_mark_read": "읽음으로 표시",

View file

@ -9,7 +9,7 @@
"nav_button_settings": "Innstillinger",
"nav_button_documentation": "Dokumentasjon",
"nav_topics_title": "Abonnerte emner",
"alert_grant_title": "Merknader er avskrudd",
"alert_notification_permission_required_title": "Merknader er avskrudd",
"alert_not_supported_title": "Merknader støttes ikke",
"notifications_copied_to_clipboard": "Kopiert til utklippstavlen",
"notifications_attachment_copy_url_title": "Kopier vedleggsnettadresse til utklippstavlen",
@ -98,7 +98,7 @@
"priority_default": "forvalg",
"priority_high": "høy",
"priority_max": "maks.",
"alert_grant_button": "Innvilg nå",
"alert_notification_permission_required_button": "Innvilg nå",
"publish_dialog_topic_label": "Emnenavn",
"prefs_notifications_delete_after_one_day_description": "Merknader slettes automatisk etter én dag",
"notifications_click_copy_url_button": "Kopier lenke",
@ -133,7 +133,7 @@
"publish_dialog_chip_delay_label": "Forsink leveringen",
"publish_dialog_details_examples_description": "For eksempler og en detaljert beskrivelse av alle sendefunksjoner, se <docsLink>dokumentasjonen</docsLink>.",
"publish_dialog_base_url_placeholder": "Tjeneste-URL, f.eks. https://example.com",
"alert_grant_description": "Gi nettleseren din tillatelse til å vise skrivebordsvarsler.",
"alert_notification_permission_required_description": "Gi nettleseren din tillatelse til å vise skrivebordsvarsler.",
"alert_not_supported_description": "Varsler støttes ikke i nettleseren din.",
"notifications_attachment_file_app": "Android-app-fil",
"notifications_no_subscriptions_description": "Klikk på \"{{linktext}}\"-koblingen for å opprette eller abonnere på et emne. Etter det kan du sende meldinger via PUT eller POST, og du vil motta varsler her.",

View file

@ -35,9 +35,9 @@
"nav_button_subscribe": "Abonneer op onderwerp",
"nav_button_muted": "Notificaties gedempt",
"nav_button_connecting": "verbinden",
"alert_grant_title": "Notificaties zijn uitgeschakeld",
"alert_grant_description": "Verleen je browser toestemming voor het weergeven van notificaties.",
"alert_grant_button": "Nu toestaan",
"alert_notification_permission_required_title": "Notificaties zijn uitgeschakeld",
"alert_notification_permission_required_description": "Verleen je browser toestemming voor het weergeven van notificaties.",
"alert_notification_permission_required_button": "Nu toestaan",
"alert_not_supported_title": "Notificaties zijn niet ondersteund",
"notifications_list": "Notificatielijst",
"notifications_list_item": "Notificatie",
@ -355,5 +355,30 @@
"prefs_reservations_table_topic_header": "Onderwerp",
"prefs_reservations_table_access_header": "Toegang",
"prefs_reservations_table_everyone_read_write": "Iedereen kan publiceren en abonneren",
"prefs_reservations_table_not_subscribed": "Niet geabonneerd"
"prefs_reservations_table_not_subscribed": "Niet geabonneerd",
"publish_dialog_call_label": "Telefoongesprek",
"publish_dialog_call_reset": "Telefoongesprek verwijderen",
"publish_dialog_chip_call_label": "Telefoongesprek",
"account_basics_phone_numbers_title": "Telefoonnummers",
"account_basics_phone_numbers_description": "Voor meldingen via telefoongesprekken",
"account_basics_phone_numbers_no_phone_numbers_yet": "Nog geen telefoonnummers",
"account_basics_phone_numbers_dialog_verify_button_call": "Bel me",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} dagelijkse telefoontjes",
"account_basics_phone_numbers_copied_to_clipboard": "Telefoonnummer gekopieerd naar klembord",
"publish_dialog_call_item": "Bel telefoonnummer {{nummer}}",
"account_basics_phone_numbers_dialog_check_verification_button": "Bevestig code",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Geen geverifieerde telefoonnummers",
"account_basics_phone_numbers_dialog_channel_call": "Telefoongesprek",
"account_basics_phone_numbers_dialog_number_label": "Telefoonnummer",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_code_placeholder": "bijv. 123456",
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} dagelijkse telefoontjes",
"account_upgrade_dialog_tier_features_no_calls": "Geen telefoontjes",
"account_basics_phone_numbers_dialog_description": "Als u de functie voor oproepmeldingen wilt gebruiken, moet u ten minste één telefoonnummer toevoegen en verifiëren. Verificatie kan worden gedaan via sms of een telefoontje.",
"account_basics_phone_numbers_dialog_title": "Telefoonnummer toevoegen",
"account_basics_phone_numbers_dialog_number_placeholder": "bijv. +1222333444",
"account_basics_phone_numbers_dialog_verify_button_sms": "Stuur SMS",
"account_basics_phone_numbers_dialog_code_label": "Verificatiecode",
"account_usage_calls_title": "Aantal telefoontjes",
"account_usage_calls_none": "Met dit account kan niet worden gebeld"
}

View file

@ -9,9 +9,9 @@
"nav_button_all_notifications": "Wszystkie powiadomienia",
"nav_button_documentation": "Dokumentacja",
"nav_button_muted": "Powiadomienia wyciszone",
"alert_grant_title": "Powiadomienia są wyłączone",
"alert_grant_description": "Udziel przeglądarce pozwolenia na wyświetlanie powiadomień na pulpicie.",
"alert_grant_button": "Pozwól teraz",
"alert_notification_permission_required_title": "Powiadomienia są wyłączone",
"alert_notification_permission_required_description": "Udziel przeglądarce pozwolenia na wyświetlanie powiadomień na pulpicie.",
"alert_notification_permission_required_button": "Pozwól teraz",
"alert_not_supported_title": "Powiadomienia nie są obsługiwane",
"alert_not_supported_description": "Powiadomienia nie są obsługiwane przez Twoją przeglądarkę.",
"notifications_list": "Lista powiadomień",

View file

@ -15,8 +15,8 @@
"nav_button_subscribe": "Subscrever tópico",
"nav_button_muted": "Notificações desativadas",
"nav_button_connecting": "A ligar",
"alert_grant_title": "As notificações estão desativadas",
"alert_grant_description": "Conceder permissão ao seu navegador para mostrar notificações.",
"alert_notification_permission_required_title": "As notificações estão desativadas",
"alert_notification_permission_required_description": "Conceder permissão ao seu navegador para mostrar notificações.",
"alert_not_supported_title": "Notificações não suportadas",
"notifications_list": "Lista de notificações",
"alert_not_supported_description": "As notificações não são suportadas pelo seu navegador.",
@ -119,7 +119,7 @@
"action_bar_logo_alt": "logótipo do ntfy",
"action_bar_settings": "Configurações",
"message_bar_show_dialog": "Mostrar caixa de publicação",
"alert_grant_button": "Conceder agora",
"alert_notification_permission_required_button": "Conceder agora",
"publish_dialog_attachment_limits_file_reached": "excede o limite de ficheiro de {{fileSizeLimit}}",
"publish_dialog_emoji_picker_show": "Escolher emoji",
"publish_dialog_priority_max": "Prioridade máxima",

View file

@ -7,9 +7,9 @@
"nav_button_all_notifications": "Todas notificações",
"nav_button_settings": "Configurações",
"nav_button_subscribe": "Inscrever no tópico",
"alert_grant_title": "Notificações estão desativadas",
"alert_grant_description": "Conceder ao navegador permissão para mostrar notificações.",
"alert_grant_button": "Conceder agora",
"alert_notification_permission_required_title": "Notificações estão desativadas",
"alert_notification_permission_required_description": "Conceder ao navegador permissão para mostrar notificações.",
"alert_notification_permission_required_button": "Conceder agora",
"alert_not_supported_title": "Notificações não são suportadas",
"alert_not_supported_description": "Notificações não são suportadas pelo seu navagador.",
"notifications_copied_to_clipboard": "Copiado para a área de transferência",
@ -188,5 +188,13 @@
"prefs_users_edit_button": "Editar usuário",
"prefs_users_delete_button": "Excluir usuário",
"error_boundary_unsupported_indexeddb_title": "Navegação anônima não suportada",
"error_boundary_unsupported_indexeddb_description": "O ntfy web app precisa do IndexedDB para funcionar, e seu navegador não suporta IndexedDB no modo de navegação privada.<br/><br/>Embora isso seja lamentável, também não faz muito sentido usar o ntfy web app no modo de navegação privada de qualquer maneira, porque tudo é armazenado no armazenamento do navegador. Você pode ler mais sobre isso <githubLink>nesta edição do GitHub</githubLink>, ou falar conosco em <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>."
"error_boundary_unsupported_indexeddb_description": "O ntfy web app precisa do IndexedDB para funcionar, e seu navegador não suporta IndexedDB no modo de navegação privada.<br/><br/>Embora isso seja lamentável, também não faz muito sentido usar o ntfy web app no modo de navegação privada de qualquer maneira, porque tudo é armazenado no armazenamento do navegador. Você pode ler mais sobre isso <githubLink>nesta edição do GitHub</githubLink>, ou falar conosco em <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
"action_bar_reservation_add": "Reserve topic",
"action_bar_reservation_edit": "Change reservation",
"signup_disabled": "Signup is disabled",
"signup_error_username_taken": "Username {{username}} is already taken",
"signup_error_creation_limit_reached": "Account creation limit reached",
"action_bar_reservation_delete": "N",
"action_bar_account": "Account",
"action_bar_change_display_name": "Change display name"
}

View file

@ -9,5 +9,97 @@
"message_bar_type_message": "Scrie un mesaj aici",
"message_bar_error_publishing": "Eroare la publicarea notificării",
"action_bar_profile_title": "Profil",
"action_bar_profile_settings": "Setări"
"action_bar_profile_settings": "Setări",
"nav_button_settings": "Setări",
"nav_button_connecting": "conectare",
"notifications_attachment_file_video": "fișier video",
"publish_dialog_priority_default": "Prioritate default",
"publish_dialog_priority_high": "Prioritate înaltă",
"publish_dialog_priority_max": "Max. prioritate",
"publish_dialog_message_placeholder": "Introdu un mesaj aici",
"nav_button_subscribe": "Abonează-te la topic",
"nav_upgrade_banner_label": "Upgrade la ntfy Pro",
"nav_upgrade_banner_description": "Rezervă topic-uri, mai multe mesaje și email-uri, și atașamente mai mari",
"common_back": "Înapoi",
"nav_button_account": "Cont",
"nav_button_documentation": "Documentație",
"nav_button_publish_message": "Publică notificarea",
"alert_notification_permission_required_title": "Notificările sunt dezactivate",
"alert_notification_permission_required_button": "Permite acum",
"alert_not_supported_title": "Notificările nu sunt acceptate",
"alert_not_supported_description": "Notificările nu sunt acceptate în browser.",
"alert_notification_permission_required_description": "Permite browser-ului să afișeze notificări.",
"notifications_list": "Lista de notificări",
"notifications_list_item": "Notificare",
"notifications_mark_read": "Marchează ca citit",
"notifications_delete": "Șterge",
"notifications_copied_to_clipboard": "Copiat în clipboard",
"notifications_tags": "Tag-uri",
"notifications_new_indicator": "Notificare nouă",
"notifications_attachment_image": "Imagine atașament",
"notifications_attachment_copy_url_title": "Copiază URL-ul atașamentului în clipboard",
"notifications_attachment_copy_url_button": "Copiază URL",
"notifications_attachment_open_title": "Mergi la {{url}}",
"notifications_attachment_link_expires": "link-ul expiră {{date}}",
"notifications_actions_not_supported": "Acțiune neacceptată în aplicația web",
"notifications_actions_http_request_title": "Trimite {{method}} HTTP la {{url}}",
"notifications_none_for_topic_title": "N-ați primit încă notificări pe acest subiect.",
"notifications_none_for_topic_description": "Pentru a trimite notificări pe acest subiect, setați PUT sau POST pe URL-ul subiectului.",
"notifications_none_for_any_title": "N-ați primit nici o notificare.",
"notifications_none_for_any_description": "Pentru a trimite notificări pe acest subiect, setează PUT sau POST pe URL-ul subiectului. Uite un exemplu cu unul dintre subiectele tale.",
"notifications_no_subscriptions_title": "Se pare că nu ai nici o înscriere.",
"notifications_no_subscriptions_description": "Click pe link-ul \"{{linktext}}\" ca sa creezi o înscriere la un subiect. După aceea, poți trimite mesaje via PUT sau POST și vei primi notificări aici.",
"notifications_example": "Exemplu",
"notifications_more_details": "Pentru mai multe informații, vezi <websiteLink>site-ul web</websiteLink> sau <docsLink>documentația</docsLink>.",
"display_name_dialog_title": "Schimbă numele afișat",
"display_name_dialog_description": "Setează un nume alternativ pentru subiect care este afișat în lista de înscrieri. Va ajuta la ușurarea identificării subiectelor cu nume complexe.",
"display_name_dialog_placeholder": "Nume afișat",
"reserve_dialog_checkbox_label": "Rezervă subiectul și configurează accesul",
"publish_dialog_progress_uploading": "Încărcare…",
"publish_dialog_progress_uploading_detail": "Încărcare {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_message_published": "Notificare publicată",
"publish_dialog_attachment_limits_file_and_quota_reached": "depășește {{fileSizeLimit}} limita fișierului și cota, {{remainingBytes}} mai rămân",
"publish_dialog_attachment_limits_file_reached": "depășește {{fileSizeLimit}} limita fișierului",
"publish_dialog_attachment_limits_quota_reached": "depășește cota, {{remainingBytes}} mai rămân",
"publish_dialog_priority_min": "Min. prioritate",
"publish_dialog_base_url_label": "URL serviciu",
"publish_dialog_base_url_placeholder": "URL serviciu, ex: https://example.com",
"publish_dialog_topic_label": "Nume subiect",
"publish_dialog_topic_placeholder": "Nume subiect, ex: alerte_phil",
"publish_dialog_topic_reset": "Resetare subiect",
"publish_dialog_title_label": "Titlu",
"publish_dialog_title_placeholder": "Titlu notificare, ex: Alerta spațiu disc",
"publish_dialog_message_label": "Mesaj",
"publish_dialog_tags_label": "Tag-uri",
"publish_dialog_tags_placeholder": "Lista de tag-uri separate prin virgula, ex: avertizare,srv1-backup",
"publish_dialog_priority_label": "Prioritate",
"publish_dialog_click_label": "Click URL",
"publish_dialog_click_placeholder": "URL deschis când notificarea este selectată",
"publish_dialog_click_reset": "Șterge URL selecție",
"publish_dialog_email_label": "E-mail",
"signup_form_confirm_password": "Confirmă parola",
"action_bar_account": "Cont",
"action_bar_change_display_name": "Schimbă numele afișat",
"action_bar_reservation_limit_reached": "Limita atinsă",
"common_cancel": "Anulează",
"common_save": "Salvează",
"common_add": "Adaugă",
"signup_form_password": "Parolă",
"publish_dialog_title_topic": "Publică în {{topic}}",
"publish_dialog_title_no_topic": "Publică notificare",
"nav_button_all_notifications": "Toate notificările",
"notifications_priority_x": "Prioritate {{priority}}",
"notifications_attachment_file_image": "fișier imagine",
"notifications_attachment_open_button": "Deschide atașament",
"notifications_attachment_file_audio": "fișier audio",
"notifications_actions_open_url_title": "Mergi la {{url}}",
"notifications_attachment_file_document": "alt document",
"notifications_attachment_link_expired": "link-ul de descărcare expirat",
"notifications_attachment_file_app": "fișier aplicație Android",
"notifications_click_copy_url_title": "Copiază URL-ul în clipboard",
"notifications_click_copy_url_button": "Copiază link",
"notifications_click_open_button": "Deschide link",
"publish_dialog_emoji_picker_show": "Alege un emoji",
"notifications_loading": "Încărcare notificări…",
"publish_dialog_priority_low": "Prioritate joasă"
}

View file

@ -8,7 +8,7 @@
"notifications_none_for_topic_description": "Чтобы отправить уведомление на данную тему, просто сделаете PUT или POST-запрос на URL-адрес этой темы.",
"notifications_none_for_any_description": "Чтобы отправить уведомление на тему, просто сделаете PUT или POST-запрос на её URL-адрес. Вот пример с использованием одной из ваших тем.",
"notifications_no_subscriptions_title": "Похоже, что у вас ещё нет подписок.",
"alert_grant_description": "Разрешите браузеру показывать уведомления.",
"alert_notification_permission_required_description": "Разрешите браузеру показывать уведомления.",
"notifications_no_subscriptions_description": "Нажмите на ссылку \"{{linktext}}\", чтобы создать или подписаться на тему. После этого Вы сможете отправлять сообщения используя PUT или POST-запросы и получать уведомления здесь.",
"notifications_example": "Пример",
"notifications_more_details": "Для более подробной информации, посетите <websiteLink>наш сайт</websiteLink> или <docsLink>документацию</docsLink>.",
@ -51,13 +51,13 @@
"nav_button_documentation": "Документация",
"nav_button_publish_message": "Опубликовать уведомление",
"nav_button_subscribe": "Подписаться на тему",
"alert_grant_button": "Разрешить",
"alert_notification_permission_required_button": "Разрешить",
"notifications_attachment_copy_url_button": "Скопировать URL-адрес",
"notifications_attachment_open_title": "Перейти на {{url}}",
"notifications_attachment_link_expired": "срок действия ссылки для скачивания истёк",
"notifications_click_copy_url_button": "Скопировать ссылку",
"notifications_none_for_any_title": "Вы ещё не получали никаких уведомлений.",
"alert_grant_title": "Уведомления отключены",
"alert_notification_permission_required_title": "Уведомления отключены",
"notifications_attachment_copy_url_title": "Скопировать URL-адрес вложения",
"notifications_actions_open_url_title": "Перейти на {{url}}",
"notifications_tags": "Тэги",

View file

@ -11,8 +11,8 @@
"nav_button_documentation": "Dokumentation",
"nav_button_publish_message": "Publicera notis",
"nav_button_subscribe": "Prenumerera på kategori",
"alert_grant_title": "Notiser är avstängda",
"alert_grant_button": "Bevilja nu",
"alert_notification_permission_required_title": "Notiser är avstängda",
"alert_notification_permission_required_button": "Bevilja nu",
"alert_not_supported_title": "Notiser stöds inte",
"notifications_list": "Notifieringslista",
"notifications_list_item": "Notis",
@ -38,7 +38,7 @@
"notifications_attachment_link_expires": "länken utgår {{date}}",
"notifications_attachment_file_image": "bild fil",
"notifications_attachment_file_audio": "ljud fil",
"alert_grant_description": "Ge din webbläsare behörighet att visa skrivbordsnotiser.",
"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",
@ -355,5 +355,30 @@
"reservation_delete_dialog_action_keep_title": "Behåll cachade meddelanden och bilagor",
"reservation_delete_dialog_action_keep_description": "Meddelanden och bilagor som lagras på servern blir offentligt synliga för personer som känner till ämnesnamnet.",
"reservation_delete_dialog_action_delete_title": "Ta bort meddelanden och bilagor som sparats i cacheminnet",
"reservation_delete_dialog_description": "Om du tar bort en reservation ger du upp äganderätten till ämnet och låter andra reservera det. Du kan behålla eller radera befintliga meddelanden och bilagor."
"reservation_delete_dialog_description": "Om du tar bort en reservation ger du upp äganderätten till ämnet och låter andra reservera det. Du kan behålla eller radera befintliga meddelanden och bilagor.",
"publish_dialog_call_label": "Telefonsamtal",
"publish_dialog_call_reset": "Ta bort telefonsamtal",
"publish_dialog_chip_call_label": "Telefonsamtal",
"account_basics_phone_numbers_title": "Telefonnummer",
"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_number_label": "Telefonnummer",
"account_basics_phone_numbers_dialog_number_placeholder": "t.ex. +1222333444",
"account_basics_phone_numbers_dialog_verify_button_sms": "Skicka SMS",
"account_basics_phone_numbers_dialog_verify_button_call": "Ring mig",
"account_basics_phone_numbers_dialog_code_label": "Verifieringskod",
"account_basics_phone_numbers_dialog_channel_call": "Ring",
"account_usage_calls_title": "Telefonsamtal som gjorts",
"account_usage_calls_none": "Inga telefonsamtal kan göras med detta konto",
"publish_dialog_call_item": "Ring telefonnummer {{number}}",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Inga verifierade telefonnummer",
"account_basics_phone_numbers_dialog_description": "För att använda funktionen för samtalsavisering måste du lägga till och verifiera minst ett telefonnummer. Verifieringen kan göras via SMS eller ett telefonsamtal.",
"account_basics_phone_numbers_dialog_code_placeholder": "t.ex. 123456",
"account_basics_phone_numbers_dialog_check_verification_button": "Bekräfta kod",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} dagliga telefonsamtal",
"account_upgrade_dialog_tier_features_no_calls": "Inga telefonsamtal",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} dagliga telefonsamtal"
}

View file

@ -44,7 +44,7 @@
"prefs_notifications_min_priority_title": "En düşük öncelik",
"prefs_notifications_min_priority_any": "Herhangi bir öncelik",
"publish_dialog_topic_placeholder": "Konu adı, örn. benim_uyarilarim",
"alert_grant_button": "Şimdi ver",
"alert_notification_permission_required_button": "Şimdi ver",
"alert_not_supported_title": "Bildirimler desteklenmiyor",
"notifications_attachment_link_expires": "bağlantının süresi {{date}} tarihinde doluyor",
"notifications_click_copy_url_title": "Bağlantı URL'sini panoya kopyala",
@ -59,8 +59,8 @@
"notifications_attachment_open_button": "Eki aç",
"nav_button_documentation": "Belgelendirme",
"nav_button_publish_message": "Bildirim yayınla",
"alert_grant_title": "Bildirimler devre dışı",
"alert_grant_description": "Tarayıcınıza masaüstü bildirimlerini görüntüleme izni verin.",
"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.",
"notifications_copied_to_clipboard": "Panoya kopyalandı",
"notifications_tags": "Etiketler",
@ -355,5 +355,30 @@
"account_upgrade_dialog_billing_contact_website": "Faturalama ile ilgili sorularınız için lütfen <Link>web sitemizi ziyaret edin</Link>.",
"account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} ayırtılan konu",
"account_upgrade_dialog_tier_features_emails_one": "{{emails}} günlük e-posta",
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} günlük mesaj"
"account_upgrade_dialog_tier_features_messages_one": "{{messages}} günlük mesaj",
"account_upgrade_dialog_tier_features_calls_one": "{{calls}} günlük telefon araması",
"account_upgrade_dialog_tier_features_calls_other": "{{calls}} günlük telefon araması",
"publish_dialog_call_label": "Telefon araması",
"publish_dialog_call_reset": "Telefon aramasını kaldır",
"publish_dialog_chip_call_label": "Telefon araması",
"account_basics_phone_numbers_title": "Telefon numaraları",
"account_basics_phone_numbers_dialog_description": "Arama bildirimi özelliğini kullanmak için en az bir telefon numarası eklemeniz ve doğrulamanız gerekir. Doğrulama SMS veya telefon araması yoluyla yapılabilir.",
"account_basics_phone_numbers_description": "Telefon araması bildirimleri için",
"account_basics_phone_numbers_no_phone_numbers_yet": "Henüz telefon numarası yok",
"account_basics_phone_numbers_copied_to_clipboard": "Telefon numarası panoya kopyalandı",
"account_basics_phone_numbers_dialog_title": "Telefon numarası ekle",
"account_basics_phone_numbers_dialog_number_label": "Telefon numarası",
"account_basics_phone_numbers_dialog_check_verification_button": "Kodu doğrula",
"account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_channel_call": "Ara",
"account_usage_calls_none": "Bu hesapla telefon araması yapılamaz",
"publish_dialog_call_item": "{{number}} telefon numarasını ara",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Doğrulanan telefon numarası yok",
"account_basics_phone_numbers_dialog_number_placeholder": "örn. +905554443322",
"account_basics_phone_numbers_dialog_verify_button_sms": "SMS gönder",
"account_basics_phone_numbers_dialog_verify_button_call": "Beni ara",
"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"
}

View file

@ -10,9 +10,9 @@
"nav_button_subscribe": "Підписатися на тему",
"nav_button_muted": "Сповіщення вимкнено",
"nav_button_connecting": "підключення",
"alert_grant_title": "Сповіщення вимкнено",
"alert_grant_description": "Дозвольте браузеру показувати сповіщення.",
"alert_grant_button": "Дозволити",
"alert_notification_permission_required_title": "Сповіщення вимкнено",
"alert_notification_permission_required_description": "Дозвольте браузеру показувати сповіщення.",
"alert_notification_permission_required_button": "Дозволити",
"alert_not_supported_title": "Сповіщення не підтримуються",
"notifications_list_item": "Сповіщення",
"notifications_attachment_image": "Прикріплене зображення",
@ -352,5 +352,34 @@
"account_upgrade_dialog_reservations_warning_other": "Обраний рівень дозволяє менше зарезервованих тем, ніж ваш поточний рівень. Перш ніж змінити свій рівень, <strong>будь ласка, видаліть принаймні {{count}} резервувань</strong>. Ви можете видалити резервування в <Link>Налаштуваннях</Link>.",
"account_upgrade_dialog_button_cancel": "Скасувати",
"account_upgrade_dialog_button_redirect_signup": "Зареєструватися зараз",
"account_upgrade_dialog_button_pay_now": "Оплатити зараз і підписатися"
"account_upgrade_dialog_button_pay_now": "Оплатити зараз і підписатися",
"prefs_reservations_add_button": "Додати зарезервовану тему",
"prefs_reservations_edit_button": "Редагувати доступ до теми",
"prefs_reservations_limit_reached": "Ви досягли ліміту зарезервованих тем.",
"prefs_reservations_table_click_to_subscribe": "Натисніть, щоб підписатися",
"prefs_reservations_table_topic_header": "Тема",
"prefs_reservations_description": "Тут ви можете зарезервувати назви тем для особистого користування. Резервування теми дає вам право власності на тему і дозволяє визначати права доступу до неї інших користувачів.",
"prefs_reservations_table": "Таблиця зарезервованих тем",
"prefs_reservations_table_access_header": "Доступ",
"prefs_reservations_table_everyone_deny_all": "Тільки я можу публікувати та підписуватись",
"prefs_reservations_table_everyone_read_only": "Я можу публікувати та підписуватись, кожен може підписатися",
"prefs_reservations_table_everyone_write_only": "Я можу публікувати і підписуватися, кожен може публікувати",
"prefs_reservations_table_everyone_read_write": "Кожен може публікувати та підписуватися",
"prefs_reservations_table_not_subscribed": "Не підписаний",
"prefs_reservations_dialog_title_add": "Зарезервувати тему",
"prefs_reservations_dialog_title_edit": "Редагувати зарезервовану тему",
"prefs_reservations_title": "Зарезервовані теми",
"prefs_reservations_delete_button": "Скинути доступ до теми",
"prefs_reservations_dialog_description": "Резервування теми дає вам право власності на цю тему і дозволяє визначати права доступу до неї інших користувачів.",
"prefs_reservations_dialog_topic_label": "Тема",
"prefs_reservations_dialog_access_label": "Доступ",
"reservation_delete_dialog_description": "Видалення резервування позбавляє вас права власності на тему і дозволяє іншим зарезервувати її. Ви можете зберегти або видалити існуючі повідомлення і вкладення.",
"reservation_delete_dialog_submit_button": "Видалити резервування",
"publish_dialog_call_item": "Телефонувати за номером {{номер}}",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "Немає підтверджених номерів телефонів",
"prefs_reservations_dialog_title_delete": "Видалити резервування теми",
"reservation_delete_dialog_action_delete_title": "Видалення кешованих повідомлень і вкладень",
"reservation_delete_dialog_action_keep_title": "Збереження кешованих повідомлень і вкладень",
"reservation_delete_dialog_action_keep_description": "Повідомлення і вкладення, які кешуються на сервері, стають загальнодоступними для людей, які знають назву теми.",
"reservation_delete_dialog_action_delete_description": "Кешовані повідомлення та вкладення будуть видалені назавжди. Ця дія не може бути скасована."
}

View file

@ -15,9 +15,9 @@
"nav_button_publish_message": "发布通知",
"nav_button_subscribe": "订阅主题",
"nav_button_connecting": "正在连接",
"alert_grant_title": "已禁用通知",
"alert_grant_description": "授予浏览器显示桌面通知的权限。",
"alert_grant_button": "现在授予",
"alert_notification_permission_required_title": "已禁用通知",
"alert_notification_permission_required_description": "授予浏览器显示桌面通知的权限。",
"alert_notification_permission_required_button": "现在授予",
"alert_not_supported_title": "不支持通知",
"alert_not_supported_description": "您的浏览器不支持通知。",
"notifications_list": "通知列表",
@ -352,5 +352,12 @@
"account_upgrade_dialog_tier_price_billed_monthly": "{{price}} 每年。按月计费。",
"account_upgrade_dialog_tier_price_billed_yearly": "{{价格}} 按年计费。节省 {{save}}。",
"account_upgrade_dialog_billing_contact_email": "有关账单问题,请直接<Link>联系我们 </Link>。",
"account_upgrade_dialog_billing_contact_website": "有关账单问题,请参考我们的<Link>网站 </Link>。"
"account_upgrade_dialog_billing_contact_website": "有关账单问题,请参考我们的<Link>网站 </Link>。",
"publish_dialog_call_item": "拨打电话 {{number}}",
"publish_dialog_call_label": "拨号",
"publish_dialog_chip_call_label": "拨号",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "未验证的手机号",
"account_basics_phone_numbers_title": "电话号码",
"account_basics_phone_numbers_description": "电话通知",
"account_basics_phone_numbers_dialog_description": "要使用来电通知功能,您需要添加并验证至少一个电话号码。可以通过短信或电话进行验证。"
}

View file

@ -4,8 +4,8 @@
"action_bar_toggle_mute": "通知靜音/解除通知靜音",
"action_bar_toggle_action_menu": "開啟/關閉操作選單",
"message_bar_type_message": "在這輸入訊息",
"alert_grant_description": "允許瀏覽器權限以顯示桌面通知。",
"alert_grant_button": "允許",
"alert_notification_permission_required_description": "允許瀏覽器權限以顯示桌面通知。",
"alert_notification_permission_required_button": "允許",
"notifications_list": "通知清單",
"notifications_list_item": "通知",
"notifications_mark_read": "標示已讀",
@ -42,7 +42,7 @@
"nav_button_settings": "設定",
"nav_button_subscribe": "訂閱主題",
"nav_button_connecting": "連線中",
"alert_grant_title": "通知已關閉",
"alert_notification_permission_required_title": "通知已關閉",
"alert_not_supported_title": "不支援通知",
"alert_not_supported_description": "瀏覽器不支援通知。",
"notifications_tags": "標籤",
@ -197,5 +197,24 @@
"account_basics_password_dialog_current_password_incorrect": "密碼不正確",
"account_basics_tier_change_button": "更變",
"common_add": "新增",
"signup_form_confirm_password": "確認密碼"
"signup_form_confirm_password": "確認密碼",
"publish_dialog_drop_file_here": "拖曳檔案到此",
"account_basics_tier_interval_monthly": "每月",
"common_copy_to_clipboard": "複製到剪貼簿",
"publish_dialog_call_label": "電話",
"publish_dialog_call_reset": "移除電話",
"publish_dialog_chip_call_label": "電話",
"account_usage_reservations_none": "此帳戶沒有預留主題",
"account_usage_attachment_storage_title": "附件容量",
"account_basics_tier_canceled_subscription": "你的付費訂閱已取消,並於 {{date}} 下調為免費帳戶。",
"account_usage_messages_title": "已發佈的信息",
"publish_dialog_chip_call_no_verified_numbers_tooltip": "沒有已驗證的電話號碼",
"account_basics_tier_interval_yearly": "每年",
"account_usage_emails_title": "已發送電郵",
"account_usage_attachment_storage_description": "每個檔案約 {{filesize}},將於 {{expiry}} 後刪除",
"publish_dialog_attachment_limits_file_and_quota_reached": "已超過 {{fileSizeLimit}} 檔案上限,尚餘 {{remainingBytes}}",
"account_basics_tier_paid_until": "已付費訂閱至 {{date}} 並自動續期",
"account_basics_tier_payment_overdue": "你的費用已逾期。請更新付款方法,否則你的戶口等級將會下調。",
"publish_dialog_call_item": "致電 {{number}}",
"account_basics_tier_manage_billing_button": "管理付款方式"
}

257
web/public/sw.js Normal file
View file

@ -0,0 +1,257 @@
/* eslint-disable import/no-extraneous-dependencies */
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from "workbox-precaching";
import { NavigationRoute, registerRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";
import { clientsClaim } from "workbox-core";
import { dbAsync } from "../src/app/db";
import { toNotificationParams, icon, badge } from "../src/app/notificationUtils";
import i18n from "../src/app/i18n";
/**
* General docs for service workers and PWAs:
* https://vite-pwa-org.netlify.app/guide/
* https://developer.chrome.com/docs/workbox/
*
* This file uses the (event) => event.waitUntil(<promise>) pattern.
* This is because the event handler itself cannot be async, but
* the service worker needs to stay active while the promise completes.
*/
const broadcastChannel = new BroadcastChannel("web-push-broadcast");
const addNotification = async ({ subscriptionId, message }) => {
const db = await dbAsync();
await db.notifications.add({
...message,
subscriptionId,
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
new: 1,
});
await db.subscriptions.update(subscriptionId, {
last: message.id,
});
const badgeCount = await db.notifications.where({ new: 1 }).count();
console.log("[ServiceWorker] Setting new app badge count", { badgeCount });
self.navigator.setAppBadge?.(badgeCount);
};
/**
* Handle a received web push message and show notification.
*
* Since the service worker cannot play a sound, we send a broadcast to the web app, which (if it is running)
* receives the broadcast and plays a sound (see web/src/app/WebPush.js).
*/
const handlePushMessage = async (data) => {
const { subscription_id: subscriptionId, message } = data;
broadcastChannel.postMessage(message); // To potentially play sound
await addNotification({ subscriptionId, message });
await self.registration.showNotification(
...toNotificationParams({
subscriptionId,
message,
defaultTitle: message.topic,
topicRoute: new URL(message.topic, self.location.origin).toString(),
})
);
};
/**
* Handle a received web push subscription expiring.
*/
const handlePushSubscriptionExpiring = async (data) => {
await self.registration.showNotification(i18n.t("web_push_subscription_expiring_title"), {
body: i18n.t("web_push_subscription_expiring_body"),
icon,
data,
badge,
});
};
/**
* Handle unknown push message. We can't ignore the push, since
* permission can be revoked by the browser.
*/
const handlePushUnknown = async (data) => {
await self.registration.showNotification(i18n.t("web_push_unknown_notification_title"), {
body: i18n.t("web_push_unknown_notification_body"),
icon,
data,
badge,
});
};
/**
* Handle a received web push notification
* @param {object} data see server/types.go, type webPushPayload
*/
const handlePush = async (data) => {
if (data.event === "message") {
await handlePushMessage(data);
} else if (data.event === "subscription_expiring") {
await handlePushSubscriptionExpiring(data);
} else {
await handlePushUnknown(data);
}
};
/**
* Handle a user clicking on the displayed notification from `showNotification`.
* This is also called when the user clicks on an action button.
*/
const handleClick = async (event) => {
const clients = await self.clients.matchAll({ type: "window" });
const rootUrl = new URL(self.location.origin);
const rootClient = clients.find((client) => client.url === rootUrl.toString());
// perhaps open on another topic
const fallbackClient = clients[0];
if (!event.notification.data?.message) {
// e.g. something other than a message, e.g. a subscription_expiring event
// simply open the web app on the root route (/)
if (rootClient) {
rootClient.focus();
} else if (fallbackClient) {
fallbackClient.focus();
fallbackClient.navigate(rootUrl.toString());
} else {
self.clients.openWindow(rootUrl);
}
event.notification.close();
} else {
const { message, topicRoute } = event.notification.data;
if (event.action) {
const action = event.notification.data.message.actions.find(({ label }) => event.action === label);
if (action.action === "view") {
self.clients.openWindow(action.url);
} else if (action.action === "http") {
try {
const response = await fetch(action.url, {
method: action.method ?? "POST",
headers: action.headers ?? {},
body: action.body,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
} catch (e) {
console.error("[ServiceWorker] Error performing http action", e);
self.registration.showNotification(`${i18n.t("notifications_actions_failed_notification")}: ${action.label} (${action.action})`, {
body: e.message,
icon,
badge,
});
}
}
if (action.clear) {
event.notification.close();
}
} else if (message.click) {
self.clients.openWindow(message.click);
event.notification.close();
} else {
// If no action was clicked, and the message doesn't have a click url:
// - first try focus an open tab on the `/:topic` route
// - if not, use an open tab on the root route (`/`) and navigate to the topic
// - if not, use whichever tab we have open and navigate to the topic
// - finally, open a new tab focused on the topic
const topicClient = clients.find((client) => client.url === topicRoute);
if (topicClient) {
topicClient.focus();
} else if (rootClient) {
rootClient.focus();
rootClient.navigate(topicRoute);
} else if (fallbackClient) {
fallbackClient.focus();
fallbackClient.navigate(topicRoute);
} else {
self.clients.openWindow(topicRoute);
}
event.notification.close();
}
}
};
self.addEventListener("install", () => {
console.log("[ServiceWorker] Installed");
self.skipWaiting();
});
self.addEventListener("activate", () => {
console.log("[ServiceWorker] Activated");
self.skipWaiting();
});
// There's no good way to test this, and Chrome doesn't seem to implement this,
// so leaving it for now
self.addEventListener("pushsubscriptionchange", (event) => {
console.log("[ServiceWorker] PushSubscriptionChange");
console.log(event);
});
self.addEventListener("push", (event) => {
const data = event.data.json();
console.log("[ServiceWorker] Received Web Push Event", { event, data });
event.waitUntil(handlePush(data));
});
self.addEventListener("notificationclick", (event) => {
console.log("[ServiceWorker] NotificationClick");
event.waitUntil(handleClick(event));
});
// See https://vite-pwa-org.netlify.app/guide/inject-manifest.html#service-worker-code
// self.__WB_MANIFEST is the workbox injection point that injects the manifest of the
// vite dist files and their revision ids, for example:
// [{"revision":"aaabbbcccdddeeefff12345","url":"/index.html"},...]
precacheAndRoute(
// eslint-disable-next-line no-underscore-dangle
self.__WB_MANIFEST
);
// Claim all open windows
clientsClaim();
// Delete any cached old dist files from previous service worker versions
cleanupOutdatedCaches();
if (!import.meta.env.DEV) {
// we need the app_root setting, so we import the config.js file from the go server
// this does NOT include the same base_url as the web app running in a window,
// since we don't have access to `window` like in `src/app/config.js`
self.importScripts("/config.js");
// this is the fallback single-page-app route, matching vite.config.js PWA config,
// and is served by the go web server. It is needed for the single-page-app to work.
// https://developer.chrome.com/docs/workbox/modules/workbox-routing/#how-to-register-a-navigation-route
registerRoute(
new NavigationRoute(createHandlerBoundToURL("/app.html"), {
allowlist: [
// the app root itself, could be /, or not
new RegExp(`^${config.app_root}$`),
],
})
);
// the manifest excludes config.js (see vite.config.js) since the dist-file differs from the
// actual config served by the go server. this adds it back with `NetworkFirst`, so that the
// most recent config from the go server is cached, but the app still works if the network
// is unavailable. this is important since there's no "refresh" button in the installed pwa
// to force a reload.
registerRoute(({ url }) => url.pathname === "/config.js", new NetworkFirst());
}

View file

@ -367,7 +367,7 @@ class AccountApi {
} catch (e) {
console.log(`[AccountApi] Error fetching account`, e);
if (e instanceof UnauthorizedError) {
session.resetAndRedirect(routes.login);
await session.resetAndRedirect(routes.login);
}
return undefined;
}
@ -382,6 +382,10 @@ class AccountApi {
setTimeout(() => this.runWorker(), delayMillis);
}
stopWorker() {
clearTimeout(this.timer);
}
async runWorker() {
if (!session.token()) {
return;

View file

@ -6,6 +6,7 @@ import {
topicUrlAuth,
topicUrlJsonPoll,
topicUrlJsonPollWithSince,
webPushUrl,
} from "./utils";
import userManager from "./UserManager";
import { fetchOrThrow } from "./errors";
@ -113,6 +114,36 @@ class Api {
}
throw new Error(`Unexpected server response ${response.status}`);
}
async updateWebPush(pushSubscription, topics) {
const user = await userManager.get(config.base_url);
const url = webPushUrl(config.base_url);
console.log(`[Api] Updating Web Push subscription`, { url, topics, endpoint: pushSubscription.endpoint });
const serializedSubscription = JSON.parse(JSON.stringify(pushSubscription)); // Ugh ... https://stackoverflow.com/a/40525434/1440785
await fetchOrThrow(url, {
method: "POST",
headers: maybeWithAuth({}, user),
body: JSON.stringify({
endpoint: serializedSubscription.endpoint,
auth: serializedSubscription.keys.auth,
p256dh: serializedSubscription.keys.p256dh,
topics,
}),
});
}
async deleteWebPush(pushSubscription) {
const user = await userManager.get(config.base_url);
const url = webPushUrl(config.base_url);
console.log(`[Api] Deleting Web Push subscription`, { url, endpoint: pushSubscription.endpoint });
await fetchOrThrow(url, {
method: "DELETE",
headers: maybeWithAuth({}, user),
body: JSON.stringify({
endpoint: pushSubscription.endpoint,
}),
});
}
}
const api = new Api();

View file

@ -1,7 +1,7 @@
import Connection from "./Connection";
import { hashCode } from "./utils";
const makeConnectionId = async (subscription, user) =>
const makeConnectionId = (subscription, user) =>
user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`);
/**
@ -45,13 +45,12 @@ class ConnectionManager {
return;
}
console.log(`[ConnectionManager] Refreshing connections`);
const subscriptionsWithUsersAndConnectionId = await Promise.all(
subscriptions.map(async (s) => {
const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
const connectionId = await makeConnectionId(s, user);
return { ...s, user, connectionId };
})
);
const subscriptionsWithUsersAndConnectionId = subscriptions.map((s) => {
const [user] = users.filter((u) => u.baseUrl === s.baseUrl);
const connectionId = makeConnectionId(s, user);
return { ...s, user, connectionId };
});
const targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId);
const deletedIds = Array.from(this.connections.keys()).filter((id) => !targetIds.includes(id));

View file

@ -1,77 +1,103 @@
import { formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl } from "./utils";
import { playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils";
import { toNotificationParams } from "./notificationUtils";
import prefs from "./Prefs";
import subscriptionManager from "./SubscriptionManager";
import logo from "../img/ntfy.png";
import routes from "../components/routes";
/**
* The notifier is responsible for displaying desktop notifications. Note that not all modern browsers
* support this; most importantly, all iOS browsers do not support window.Notification.
*/
class Notifier {
async notify(subscriptionId, notification, onClickFallback) {
async notify(subscription, notification) {
if (!this.supported()) {
return;
}
const subscription = await subscriptionManager.get(subscriptionId);
const shouldNotify = await this.shouldNotify(subscription, notification);
if (!shouldNotify) {
return;
}
await this.playSound();
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
const displayName = topicDisplayName(subscription);
const message = formatMessage(notification);
const title = formatTitleWithDefault(notification, displayName);
const defaultTitle = topicDisplayName(subscription);
// Show notification
console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`);
const n = new Notification(title, {
body: message,
icon: logo,
});
if (notification.click) {
n.onclick = () => openUrl(notification.click);
} else {
n.onclick = () => onClickFallback(subscription);
}
console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}`);
const registration = await this.serviceWorkerRegistration();
await registration.showNotification(
...toNotificationParams({
subscriptionId: subscription.id,
message: notification,
defaultTitle,
topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(),
})
);
}
async playSound() {
// Play sound
const sound = await prefs.sound();
if (sound && sound !== "none") {
try {
await playSound(sound);
} catch (e) {
console.log(`[Notifier, ${shortUrl}] Error playing audio`, e);
console.log(`[Notifier] Error playing audio`, e);
}
}
}
async webPushSubscription(hasWebPushTopics) {
const pushManager = await this.pushManager();
const existingSubscription = await pushManager.getSubscription();
if (existingSubscription) {
return existingSubscription;
}
// Create a new subscription only if there are new topics to subscribe to. It is possible that Web Push
// was previously enabled and then disabled again in which case there would be an existingSubscription.
// If, however, it was _not_ enabled previously, we create a new subscription if it is now enabled.
if (hasWebPushTopics) {
return pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(config.web_push_public_key),
});
}
return undefined;
}
async pushManager() {
return (await this.serviceWorkerRegistration()).pushManager;
}
async serviceWorkerRegistration() {
const registration = await navigator.serviceWorker.getRegistration();
if (!registration) {
throw new Error("No service worker registration found");
}
return registration;
}
notRequested() {
return this.supported() && Notification.permission === "default";
}
granted() {
return this.supported() && Notification.permission === "granted";
}
maybeRequestPermission(cb) {
if (!this.supported()) {
cb(false);
return;
}
if (!this.granted()) {
Notification.requestPermission().then((permission) => {
const granted = permission === "granted";
cb(granted);
});
}
denied() {
return this.supported() && Notification.permission === "denied";
}
async shouldNotify(subscription, notification) {
if (subscription.mutedUntil === 1) {
async maybeRequestPermission() {
if (!this.supported()) {
return false;
}
const priority = notification.priority ? notification.priority : 3;
const minPriority = await prefs.minPriority();
if (priority < minPriority) {
return false;
}
return true;
return new Promise((resolve) => {
Notification.requestPermission((permission) => {
resolve(permission === "granted");
});
});
}
supported() {
@ -82,6 +108,14 @@ class Notifier {
return "Notification" in window;
}
pushSupported() {
return config.enable_web_push && "serviceWorker" in navigator && "PushManager" in window;
}
pushPossible() {
return this.pushSupported() && this.contextSupported() && this.granted() && !this.iosSupportedButInstallRequired();
}
/**
* Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
@ -89,6 +123,11 @@ class Notifier {
contextSupported() {
return window.location.protocol === "https:" || window.location.hostname.match("^127.") || window.location.hostname === "localhost";
}
iosSupportedButInstallRequired() {
// no PushManager when not installed, but it _is_ supported.
return config.enable_web_push && "serviceWorker" in navigator && window.navigator.standalone === false;
}
}
const notifier = new Notifier();

View file

@ -18,6 +18,10 @@ class Poller {
setTimeout(() => this.pollAll(), delayMillis);
}
stopWorker() {
clearTimeout(this.timer);
}
async pollAll() {
console.log(`[Poller] Polling all subscriptions`);
const subscriptions = await subscriptionManager.all();
@ -47,14 +51,13 @@ class Poller {
}
pollInBackground(subscription) {
const fn = async () => {
(async () => {
try {
await this.poll(subscription);
} catch (e) {
console.error(`[App] Error polling subscription ${subscription.id}`, e);
}
};
setTimeout(() => fn(), 0);
})();
}
}

View file

@ -1,33 +1,61 @@
import db from "./db";
export const THEME = {
DARK: "dark",
LIGHT: "light",
SYSTEM: "system",
};
class Prefs {
constructor(dbImpl) {
this.db = dbImpl;
}
async setSound(sound) {
db.prefs.put({ key: "sound", value: sound.toString() });
this.db.prefs.put({ key: "sound", value: sound.toString() });
}
async sound() {
const sound = await db.prefs.get("sound");
const sound = await this.db.prefs.get("sound");
return sound ? sound.value : "ding";
}
async setMinPriority(minPriority) {
db.prefs.put({ key: "minPriority", value: minPriority.toString() });
this.db.prefs.put({ key: "minPriority", value: minPriority.toString() });
}
async minPriority() {
const minPriority = await db.prefs.get("minPriority");
const minPriority = await this.db.prefs.get("minPriority");
return minPriority ? Number(minPriority.value) : 1;
}
async setDeleteAfter(deleteAfter) {
db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() });
await this.db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() });
}
async deleteAfter() {
const deleteAfter = await db.prefs.get("deleteAfter");
const deleteAfter = await this.db.prefs.get("deleteAfter");
return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week
}
async webPushEnabled() {
const webPushEnabled = await this.db.prefs.get("webPushEnabled");
return webPushEnabled?.value;
}
async setWebPushEnabled(enabled) {
await this.db.prefs.put({ key: "webPushEnabled", value: enabled });
}
async theme() {
const theme = await this.db.prefs.get("theme");
return theme?.value ?? THEME.SYSTEM;
}
async setTheme(mode) {
await this.db.prefs.put({ key: "theme", value: mode });
}
}
const prefs = new Prefs();
const prefs = new Prefs(db());
export default prefs;

View file

@ -18,6 +18,10 @@ class Pruner {
setTimeout(() => this.prune(), delayMillis);
}
stopWorker() {
clearTimeout(this.timer);
}
async prune() {
const deleteAfterSeconds = await prefs.deleteAfter();
const pruneThresholdTimestamp = Math.round(Date.now() / 1000) - deleteAfterSeconds;

View file

@ -1,17 +1,55 @@
import Dexie from "dexie";
/**
* Manages the logged-in user's session and access token.
* The session replica is stored in IndexedDB so that the service worker can access it.
*/
class Session {
store(username, token) {
constructor() {
const db = new Dexie("session-replica");
db.version(1).stores({
kv: "&key",
});
this.db = db;
// existing sessions (pre-v2.6.0) haven't called `store` with the session-replica,
// so attempt to sync any values from localStorage to IndexedDB
if (typeof localStorage !== "undefined" && this.exists()) {
const username = this.username();
const token = this.token();
this.db.kv
.bulkPut([
{ key: "user", value: username },
{ key: "token", value: token },
])
.then(() => {
console.log("[Session] Synced localStorage session to IndexedDB", { username });
})
.catch((e) => {
console.error("[Session] Failed to sync localStorage session to IndexedDB", e);
});
}
}
async store(username, token) {
await this.db.kv.bulkPut([
{ key: "user", value: username },
{ key: "token", value: token },
]);
localStorage.setItem("user", username);
localStorage.setItem("token", token);
}
reset() {
async resetAndRedirect(url) {
await this.db.delete();
localStorage.removeItem("user");
localStorage.removeItem("token");
window.location.href = url;
}
resetAndRedirect(url) {
this.reset();
window.location.href = url;
async usernameAsync() {
return (await this.db.kv.get({ key: "user" }))?.value;
}
exists() {

View file

@ -1,37 +1,92 @@
import api from "./Api";
import notifier from "./Notifier";
import prefs from "./Prefs";
import db from "./db";
import { topicUrl } from "./utils";
class SubscriptionManager {
constructor(dbImpl) {
this.db = dbImpl;
}
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
async all() {
const subscriptions = await db.subscriptions.toArray();
const subscriptions = await this.db.subscriptions.toArray();
return Promise.all(
subscriptions.map(async (s) => ({
...s,
new: await db.notifications.where({ subscriptionId: s.id, new: 1 }).count(),
new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(),
}))
);
}
async get(subscriptionId) {
return db.subscriptions.get(subscriptionId);
/**
* List of topics for which Web Push is enabled. This excludes (a) internal topics, (b) topics that are muted,
* and (c) topics from other hosts. Returns an empty list if Web Push is disabled.
*
* It is important to note that "mutedUntil" must be part of the where() query, otherwise the Dexie live query
* will not react to it, and the Web Push topics will not be updated when the user mutes a topic.
*/
async webPushTopics(pushPossible) {
if (!pushPossible) {
return [];
}
// the Promise.resolve wrapper is not superfluous, without it the live query breaks:
// https://dexie.org/docs/dexie-react-hooks/useLiveQuery()#calling-non-dexie-apis-from-querier
const enabled = await Promise.resolve(prefs.webPushEnabled());
if (!enabled) {
return [];
}
const subscriptions = await this.db.subscriptions.where({ baseUrl: config.base_url, mutedUntil: 0 }).toArray();
return subscriptions.filter(({ internal }) => !internal).map(({ topic }) => topic);
}
async add(baseUrl, topic, internal) {
async get(subscriptionId) {
return this.db.subscriptions.get(subscriptionId);
}
async notify(subscriptionId, notification) {
const subscription = await this.get(subscriptionId);
if (subscription.mutedUntil > 0) {
return;
}
const priority = notification.priority ?? 3;
if (priority < (await prefs.minPriority())) {
return;
}
await notifier.notify(subscription, notification);
}
/**
* @param {string} baseUrl
* @param {string} topic
* @param {object} opts
* @param {boolean} opts.internal
* @returns
*/
async add(baseUrl, topic, opts = {}) {
const id = topicUrl(baseUrl, topic);
const existingSubscription = await this.get(id);
if (existingSubscription) {
return existingSubscription;
}
const subscription = {
...opts,
id: topicUrl(baseUrl, topic),
baseUrl,
topic,
mutedUntil: 0,
last: null,
internal: internal || false,
};
await db.subscriptions.put(subscription);
await this.db.subscriptions.put(subscription);
return subscription;
}
@ -41,10 +96,9 @@ class SubscriptionManager {
// Add remote subscriptions
const remoteIds = await Promise.all(
remoteSubscriptions.map(async (remote) => {
const local = await this.add(remote.base_url, remote.topic, false);
const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null;
await this.update(local.id, {
const local = await this.add(remote.base_url, remote.topic, {
displayName: remote.display_name, // May be undefined
reservation, // May be null!
});
@ -54,29 +108,47 @@ class SubscriptionManager {
);
// Remove local subscriptions that do not exist remotely
const localSubscriptions = await db.subscriptions.toArray();
const localSubscriptions = await this.db.subscriptions.toArray();
await Promise.all(
localSubscriptions.map(async (local) => {
const remoteExists = remoteIds.includes(local.id);
if (!local.internal && !remoteExists) {
await this.remove(local.id);
await this.remove(local);
}
})
);
}
async updateState(subscriptionId, state) {
db.subscriptions.update(subscriptionId, { state });
async updateWebPushSubscriptions(topics) {
const hasWebPushTopics = topics.length > 0;
const browserSubscription = await notifier.webPushSubscription(hasWebPushTopics);
if (!browserSubscription) {
console.log(
"[SubscriptionManager] No browser subscription currently exists, so web push was never enabled or the notification permission was removed. Skipping."
);
return;
}
if (hasWebPushTopics) {
await api.updateWebPush(browserSubscription, topics);
} else {
await api.deleteWebPush(browserSubscription);
}
}
async remove(subscriptionId) {
await db.subscriptions.delete(subscriptionId);
await db.notifications.where({ subscriptionId }).delete();
async updateState(subscriptionId, state) {
this.db.subscriptions.update(subscriptionId, { state });
}
async remove(subscription) {
await this.db.subscriptions.delete(subscription.id);
await this.db.notifications.where({ subscriptionId: subscription.id }).delete();
}
async first() {
return db.subscriptions.toCollection().first(); // May be undefined
return this.db.subscriptions.toCollection().first(); // May be undefined
}
async getNotifications(subscriptionId) {
@ -84,7 +156,7 @@ class SubscriptionManager {
// It's actually fine, because the reading and filtering is quite fast. The rendering is what's
// killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
return db.notifications
return this.db.notifications
.orderBy("time") // Sort by time first
.filter((n) => n.subscriptionId === subscriptionId)
.reverse()
@ -92,7 +164,7 @@ class SubscriptionManager {
}
async getAllNotifications() {
return db.notifications
return this.db.notifications
.orderBy("time") // Efficient, see docs
.reverse()
.toArray();
@ -100,18 +172,19 @@ class SubscriptionManager {
/** Adds notification, or returns false if it already exists */
async addNotification(subscriptionId, notification) {
const exists = await db.notifications.get(notification.id);
const exists = await this.db.notifications.get(notification.id);
if (exists) {
return false;
}
try {
await db.notifications.add({
// sw.js duplicates this logic, so if you change it here, change it there too
await this.db.notifications.add({
...notification,
subscriptionId,
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
new: 1,
}); // FIXME consider put() for double tab
await db.subscriptions.update(subscriptionId, {
await this.db.subscriptions.update(subscriptionId, {
last: notification.id,
});
} catch (e) {
@ -124,19 +197,19 @@ class SubscriptionManager {
async addNotifications(subscriptionId, notifications) {
const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId }));
const lastNotificationId = notifications.at(-1).id;
await db.notifications.bulkPut(notificationsWithSubscriptionId);
await db.subscriptions.update(subscriptionId, {
await this.db.notifications.bulkPut(notificationsWithSubscriptionId);
await this.db.subscriptions.update(subscriptionId, {
last: lastNotificationId,
});
}
async updateNotification(notification) {
const exists = await db.notifications.get(notification.id);
const exists = await this.db.notifications.get(notification.id);
if (!exists) {
return false;
}
try {
await db.notifications.put({ ...notification });
await this.db.notifications.put({ ...notification });
} catch (e) {
console.error(`[SubscriptionManager] Error updating notification`, e);
}
@ -144,47 +217,46 @@ class SubscriptionManager {
}
async deleteNotification(notificationId) {
await db.notifications.delete(notificationId);
await this.db.notifications.delete(notificationId);
}
async deleteNotifications(subscriptionId) {
await db.notifications.where({ subscriptionId }).delete();
await this.db.notifications.where({ subscriptionId }).delete();
}
async markNotificationRead(notificationId) {
await db.notifications.where({ id: notificationId }).modify({ new: 0 });
await this.db.notifications.where({ id: notificationId }).modify({ new: 0 });
}
async markNotificationsRead(subscriptionId) {
await db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
await this.db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
}
async setMutedUntil(subscriptionId, mutedUntil) {
await db.subscriptions.update(subscriptionId, {
await this.db.subscriptions.update(subscriptionId, {
mutedUntil,
});
}
async setDisplayName(subscriptionId, displayName) {
await db.subscriptions.update(subscriptionId, {
await this.db.subscriptions.update(subscriptionId, {
displayName,
});
}
async setReservation(subscriptionId, reservation) {
await db.subscriptions.update(subscriptionId, {
await this.db.subscriptions.update(subscriptionId, {
reservation,
});
}
async update(subscriptionId, params) {
await db.subscriptions.update(subscriptionId, params);
await this.db.subscriptions.update(subscriptionId, params);
}
async pruneNotifications(thresholdTimestamp) {
await db.notifications.where("time").below(thresholdTimestamp).delete();
await this.db.notifications.where("time").below(thresholdTimestamp).delete();
}
}
const subscriptionManager = new SubscriptionManager();
export default subscriptionManager;
export default new SubscriptionManager(db());

Some files were not shown because too many files have changed in this diff Show more