Merge branch 'main' into markdown
2
.github/workflows/build.yaml
vendored
|
@ -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
.github/workflows/release.yaml
vendored
|
@ -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
|
||||
|
|
2
.github/workflows/test.yaml
vendored
|
@ -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
|
@ -1,4 +1,5 @@
|
|||
dist/
|
||||
dev-dist/
|
||||
build/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
@ -12,3 +13,4 @@ secrets/
|
|||
node_modules/
|
||||
.DS_Store
|
||||
__pycache__
|
||||
web/dev-dist/
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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")
|
||||
|
|
17
cmd/serve.go
|
@ -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)
|
||||
|
|
|
@ -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
|
@ -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
|
@ -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...))
|
||||
}
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
10
docs/faq.md
|
@ -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.
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
After Width: | Height: | Size: 185 KiB |
BIN
docs/static/img/pwa-install-chrome-android-menu.jpg
vendored
Normal file
After Width: | Height: | Size: 100 KiB |
BIN
docs/static/img/pwa-install-chrome-android-popup.jpg
vendored
Normal file
After Width: | Height: | Size: 69 KiB |
BIN
docs/static/img/pwa-install-chrome-android.jpg
vendored
Normal file
After Width: | Height: | Size: 112 KiB |
BIN
docs/static/img/pwa-install-firefox-android-menu.jpg
vendored
Normal file
After Width: | Height: | Size: 70 KiB |
BIN
docs/static/img/pwa-install-firefox-android-popup.jpg
vendored
Normal file
After Width: | Height: | Size: 52 KiB |
BIN
docs/static/img/pwa-install-safari-ios-add-icon.jpg
vendored
Normal file
After Width: | Height: | Size: 100 KiB |
BIN
docs/static/img/pwa-install-safari-ios-button.jpg
vendored
Normal file
After Width: | Height: | Size: 103 KiB |
BIN
docs/static/img/pwa-install-safari-ios-menu.jpg
vendored
Normal file
After Width: | Height: | Size: 124 KiB |
BIN
docs/static/img/pwa-install.png
vendored
Normal file
After Width: | Height: | Size: 107 KiB |
BIN
docs/static/img/pwa.png
vendored
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
docs/static/img/web-pin.png
vendored
Before Width: | Height: | Size: 18 KiB |
BIN
docs/static/img/web-subscribe.png
vendored
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 96 KiB |
|
@ -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
|
@ -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.
|
|
@ -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
|
@ -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
|
@ -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=
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
)
|
||||
|
|
|
@ -29,6 +29,7 @@ const (
|
|||
tagResetter = "resetter"
|
||||
tagWebsocket = "websocket"
|
||||
tagMatrix = "matrix"
|
||||
tagWebPush = "webpush"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -15,6 +15,7 @@ func (s *Server) execManager() {
|
|||
s.pruneTokens()
|
||||
s.pruneAttachments()
|
||||
s.pruneMessages()
|
||||
s.pruneAndNotifyWebPushSubscriptions()
|
||||
|
||||
// Message count per topic
|
||||
var messagesCached int
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
@ -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
|
||||
}
|
256
server/server_webpush_test.go
Normal 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)
|
||||
}
|
|
@ -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
|
@ -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()
|
||||
}
|
199
server/webpush_store_test.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
14
util/util.go
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -33,5 +33,6 @@
|
|||
"unnamedComponents": "arrow-function"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": [{ "files": ["./public/sw.js"], "rules": { "no-restricted-globals": "off" } }]
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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": [
|
||||
|
|
|
@ -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"],
|
||||
};
|
||||
|
|
BIN
web/public/static/images/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 15 KiB |
20
web/public/static/images/mask-icon.svg
Normal 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 |
BIN
web/public/static/images/pwa-192x192.png
Normal file
After Width: | Height: | Size: 6.5 KiB |
BIN
web/public/static/images/pwa-512x512.png
Normal file
After Width: | Height: | Size: 19 KiB |
|
@ -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 إلى الحافظة",
|
||||
|
|
|
@ -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": "Не се поддържат известия",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>.",
|
||||
|
|
|
@ -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>.",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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": "電話する"
|
||||
}
|
||||
|
|
|
@ -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": "읽음으로 표시",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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ń",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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ă"
|
||||
}
|
||||
|
|
|
@ -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": "Тэги",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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": "Кешовані повідомлення та вкладення будуть видалені назавжди. Ця дія не може бути скасована."
|
||||
}
|
||||
|
|
|
@ -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": "要使用来电通知功能,您需要添加并验证至少一个电话号码。可以通过短信或电话进行验证。"
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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());
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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());
|
||||
|
|