mirror of
https://github.com/binwiederhier/ntfy.git
synced 2025-07-20 10:04:08 +00:00
Add PWA, service worker and Web Push
- Use new notification request/opt-in flow for push - Implement unsubscribing - Implement muting - Implement emojis in title - Add iOS specific PWA warning - Don’t use websockets when web push is enabled - Fix duplicate notifications - Implement default web push setting - Implement changing subscription type - Implement web push subscription refresh - Implement web push notification click
This commit is contained in:
parent
733ef4664b
commit
ff5c854192
53 changed files with 4363 additions and 249 deletions
|
@ -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)
|
||||
|
@ -146,6 +147,11 @@ type Config struct {
|
|||
EnableMetrics bool
|
||||
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
||||
Version string // injected by App
|
||||
WebPushEnabled bool
|
||||
WebPushPrivateKey string
|
||||
WebPushPublicKey string
|
||||
WebPushSubscriptionsFile string
|
||||
WebPushEmailAddress string
|
||||
}
|
||||
|
||||
// NewConfig instantiates a default new server config
|
||||
|
@ -227,5 +233,8 @@ func NewConfig() *Config {
|
|||
EnableReservations: false,
|
||||
AccessControlAllowOrigin: "*",
|
||||
Version: "",
|
||||
WebPushPrivateKey: "",
|
||||
WebPushPublicKey: "",
|
||||
WebPushSubscriptionsFile: "",
|
||||
}
|
||||
}
|
||||
|
|
|
@ -114,6 +114,7 @@ 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}
|
||||
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}
|
||||
|
|
290
server/server.go
290
server/server.go
|
@ -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,32 +25,43 @@ 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"
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
)
|
||||
|
||||
// Server is the main server, providing the UI and API for ntfy
|
||||
type Server struct {
|
||||
config *Config
|
||||
httpServer *http.Server
|
||||
httpsServer *http.Server
|
||||
httpMetricsServer *http.Server
|
||||
httpProfileServer *http.Server
|
||||
unixListener net.Listener
|
||||
smtpServer *smtp.Server
|
||||
smtpServerBackend *smtpBackend
|
||||
smtpSender mailer
|
||||
topics map[string]*topic
|
||||
visitors map[string]*visitor // ip:<ip> or user:<user>
|
||||
firebaseClient *firebaseClient
|
||||
messages int64 // Total number of messages (persisted if messageCache enabled)
|
||||
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
|
||||
fileCache *fileCache // File system based cache that stores attachments
|
||||
stripe stripeAPI // Stripe API, can be replaced with a mock
|
||||
priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!)
|
||||
metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set
|
||||
closeChan chan bool
|
||||
mu sync.RWMutex
|
||||
config *Config
|
||||
httpServer *http.Server
|
||||
httpsServer *http.Server
|
||||
httpMetricsServer *http.Server
|
||||
httpProfileServer *http.Server
|
||||
unixListener net.Listener
|
||||
smtpServer *smtp.Server
|
||||
smtpServerBackend *smtpBackend
|
||||
smtpSender mailer
|
||||
topics map[string]*topic
|
||||
visitors map[string]*visitor // ip:<ip> or user:<user>
|
||||
firebaseClient *firebaseClient
|
||||
messages int64 // Total number of messages (persisted if messageCache enabled)
|
||||
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
|
||||
webPushSubscriptionStore *webPushSubscriptionStore // 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!)
|
||||
metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set
|
||||
closeChan chan bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// handleFunc extends the normal http.HandlerFunc to be able to easily return errors
|
||||
|
@ -65,17 +69,21 @@ type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error
|
|||
|
||||
var (
|
||||
// If changed, don't forget to update Android App and auth_sqlite.go
|
||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
||||
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
||||
externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
|
||||
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
||||
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
||||
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
||||
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
|
||||
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
|
||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
||||
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
||||
externalTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
|
||||
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
||||
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
||||
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
||||
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
|
||||
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
|
||||
webPushPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/web-push$`)
|
||||
webPushUnsubscribePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/web-push/unsubscribe$`)
|
||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
||||
|
||||
webConfigPath = "/config.js"
|
||||
webManifestPath = "/manifest.webmanifest"
|
||||
webServiceWorkerPath = "/sw.js"
|
||||
accountPath = "/account"
|
||||
matrixPushPath = "/_matrix/push/v1/notify"
|
||||
metricsPath = "/metrics"
|
||||
|
@ -98,6 +106,7 @@ var (
|
|||
apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}"
|
||||
apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`)
|
||||
apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`)
|
||||
apiWebPushConfig = "/v1/web-push-config"
|
||||
staticRegex = regexp.MustCompile(`^/static/.+`)
|
||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
||||
|
@ -151,6 +160,10 @@ func New(conf *Config) (*Server, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
webPushSubscriptionStore, err := createWebPushSubscriptionStore(conf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topics, err := messageCache.Topics()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -188,17 +201,18 @@ func New(conf *Config) (*Server, error) {
|
|||
firebaseClient = newFirebaseClient(sender, auther)
|
||||
}
|
||||
s := &Server{
|
||||
config: conf,
|
||||
messageCache: messageCache,
|
||||
fileCache: fileCache,
|
||||
firebaseClient: firebaseClient,
|
||||
smtpSender: mailer,
|
||||
topics: topics,
|
||||
userManager: userManager,
|
||||
messages: messages,
|
||||
messagesHistory: []int64{messages},
|
||||
visitors: make(map[string]*visitor),
|
||||
stripe: stripe,
|
||||
config: conf,
|
||||
messageCache: messageCache,
|
||||
webPushSubscriptionStore: webPushSubscriptionStore,
|
||||
fileCache: fileCache,
|
||||
firebaseClient: firebaseClient,
|
||||
smtpSender: mailer,
|
||||
topics: topics,
|
||||
userManager: userManager,
|
||||
messages: messages,
|
||||
messagesHistory: []int64{messages},
|
||||
visitors: make(map[string]*visitor),
|
||||
stripe: stripe,
|
||||
}
|
||||
s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration)
|
||||
return s, nil
|
||||
|
@ -213,6 +227,14 @@ func createMessageCache(conf *Config) (*messageCache, error) {
|
|||
return newMemCache()
|
||||
}
|
||||
|
||||
func createWebPushSubscriptionStore(conf *Config) (*webPushSubscriptionStore, error) {
|
||||
if !conf.WebPushEnabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return newWebPushSubscriptionStore(conf.WebPushSubscriptionsFile)
|
||||
}
|
||||
|
||||
// Run executes the main server. It listens on HTTP (+ HTTPS, if configured), and starts
|
||||
// a manager go routine to print stats and prune messages.
|
||||
func (s *Server) Run() error {
|
||||
|
@ -342,6 +364,9 @@ func (s *Server) closeDatabases() {
|
|||
s.userManager.Close()
|
||||
}
|
||||
s.messageCache.Close()
|
||||
if s.webPushSubscriptionStore != nil {
|
||||
s.webPushSubscriptionStore.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// handle is the main entry point for all HTTP requests
|
||||
|
@ -416,6 +441,10 @@ 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.ensureWebEnabled(s.handleWebManifest)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webServiceWorkerPath {
|
||||
return s.ensureWebEnabled(s.handleStatic)(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 {
|
||||
|
@ -474,6 +503,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
|||
return s.handleStats(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath {
|
||||
return s.ensurePaymentsEnabled(s.handleBillingTiersGet)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiWebPushConfig {
|
||||
return s.ensureWebPushEnabled(s.handleAPIWebPushConfig)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
|
||||
return s.handleMatrixDiscovery(w)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
|
||||
|
@ -504,6 +535,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
|||
return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && webPushPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicRead(s.ensureWebPushEnabled(s.handleTopicWebPushSubscribe)))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && webPushUnsubscribePathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicRead(s.ensureWebPushEnabled(s.handleTopicWebPushUnsubscribe)))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
|
||||
return s.ensureWebEnabled(s.handleTopic)(w, r, v)
|
||||
}
|
||||
|
@ -535,6 +570,63 @@ func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visi
|
|||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleAPIWebPushConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
response := &apiWebPushConfigResponse{
|
||||
PublicKey: s.config.WebPushPublicKey,
|
||||
}
|
||||
|
||||
return s.writeJSON(w, response)
|
||||
}
|
||||
|
||||
func (s *Server) handleTopicWebPushSubscribe(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
var username string
|
||||
u := v.User()
|
||||
if u != nil {
|
||||
username = u.Name
|
||||
}
|
||||
|
||||
var sub webPushSubscribePayload
|
||||
err := json.NewDecoder(r.Body).Decode(&sub)
|
||||
|
||||
if err != nil || sub.BrowserSubscription.Endpoint == "" || sub.BrowserSubscription.Keys.P256dh == "" || sub.BrowserSubscription.Keys.Auth == "" {
|
||||
return errHTTPBadRequestWebPushSubscriptionInvalid
|
||||
}
|
||||
|
||||
topic, err := fromContext[*topic](r, contextTopic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.webPushSubscriptionStore.AddSubscription(topic.ID, username, sub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleTopicWebPushUnsubscribe(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||
var payload webPushUnsubscribePayload
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(&payload)
|
||||
|
||||
if err != nil {
|
||||
return errHTTPBadRequestWebPushSubscriptionInvalid
|
||||
}
|
||||
|
||||
topic, err := fromContext[*topic](r, contextTopic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.webPushSubscriptionStore.RemoveSubscription(topic.ID, payload.Endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
response := &apiHealthResponse{
|
||||
Healthy: true,
|
||||
|
@ -564,6 +656,11 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
|||
return err
|
||||
}
|
||||
|
||||
func (s *Server) handleWebManifest(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
return s.handleStatic(w, r, v)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
@ -763,6 +860,9 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
|||
if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream
|
||||
go s.forwardPollRequest(v, m)
|
||||
}
|
||||
if s.config.WebPushEnabled {
|
||||
go s.publishToWebPushEndpoints(v, m)
|
||||
}
|
||||
} else {
|
||||
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
|
||||
}
|
||||
|
@ -877,6 +977,95 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
|
||||
subscriptions, err := s.webPushSubscriptionStore.GetSubscriptionsForTopic(m.Topic)
|
||||
|
||||
if err != nil {
|
||||
logvm(v, m).Err(err).Warn("Unable to publish web push messages")
|
||||
return
|
||||
}
|
||||
|
||||
failedCount := 0
|
||||
totalCount := len(subscriptions)
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
wg.Add(totalCount)
|
||||
|
||||
ctx := log.Context{"topic": m.Topic, "message_id": m.ID, "total_count": totalCount}
|
||||
|
||||
// Importing the emojis in the service worker would add unnecessary complexity,
|
||||
// simply do it here for web push notifications instead
|
||||
var titleWithDefault string
|
||||
var formattedTitle string
|
||||
|
||||
emojis, _, err := toEmojis(m.Tags)
|
||||
if err != nil {
|
||||
logvm(v, m).Err(err).Fields(ctx).Debug("Unable to publish web push message")
|
||||
return
|
||||
}
|
||||
|
||||
if m.Title == "" {
|
||||
titleWithDefault = m.Topic
|
||||
} else {
|
||||
titleWithDefault = m.Title
|
||||
}
|
||||
|
||||
if len(emojis) > 0 {
|
||||
formattedTitle = fmt.Sprintf("%s %s", strings.Join(emojis[:], " "), titleWithDefault)
|
||||
} else {
|
||||
formattedTitle = titleWithDefault
|
||||
}
|
||||
|
||||
for i, xi := range subscriptions {
|
||||
go func(i int, sub webPushSubscription) {
|
||||
defer wg.Done()
|
||||
ctx := log.Context{"endpoint": sub.BrowserSubscription.Endpoint, "username": sub.Username, "topic": m.Topic, "message_id": m.ID}
|
||||
|
||||
payload := &webPushPayload{
|
||||
SubscriptionID: fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic),
|
||||
Message: *m,
|
||||
FormattedTitle: formattedTitle,
|
||||
}
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
|
||||
if err != nil {
|
||||
failedCount++
|
||||
logvm(v, m).Err(err).Fields(ctx).Debug("Unable to publish web push message")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = webpush.SendNotification(jsonPayload, &sub.BrowserSubscription, &webpush.Options{
|
||||
Subscriber: s.config.WebPushEmailAddress,
|
||||
VAPIDPublicKey: s.config.WebPushPublicKey,
|
||||
VAPIDPrivateKey: s.config.WebPushPrivateKey,
|
||||
// deliverability on iOS isn't great with lower urgency values,
|
||||
// and thus we can't really map lower ntfy priorities to lower urgency values
|
||||
Urgency: webpush.UrgencyHigh,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
failedCount++
|
||||
logvm(v, m).Err(err).Fields(ctx).Debug("Unable to publish web push message")
|
||||
|
||||
// probably need to handle different codes differently,
|
||||
// but for now just expire the subscription on any error
|
||||
err = s.webPushSubscriptionStore.ExpireWebPushEndpoint(sub.BrowserSubscription.Endpoint)
|
||||
if err != nil {
|
||||
logvm(v, m).Err(err).Fields(ctx).Warn("Unable to expire subscription")
|
||||
}
|
||||
}
|
||||
}(i, xi)
|
||||
}
|
||||
|
||||
ctx = log.Context{"topic": m.Topic, "message_id": m.ID, "failed_count": failedCount, "total_count": totalCount}
|
||||
|
||||
if failedCount > 0 {
|
||||
logvm(v, m).Fields(ctx).Warn("Unable to publish web push messages to %d of %d endpoints", failedCount, totalCount)
|
||||
} else {
|
||||
logvm(v, m).Fields(ctx).Debug("Published %d web push messages successfully", totalCount)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) {
|
||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||
|
@ -1692,6 +1881,9 @@ func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
|
|||
if s.config.UpstreamBaseURL != "" {
|
||||
go s.forwardPollRequest(v, m)
|
||||
}
|
||||
if s.config.WebPushEnabled {
|
||||
go s.publishToWebPushEndpoints(v, m)
|
||||
}
|
||||
if err := s.messageCache.MarkPublished(m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -38,6 +38,16 @@
|
|||
#
|
||||
# firebase-key-file: <filename>
|
||||
|
||||
# Enable web push
|
||||
#
|
||||
# Run ntfy web-push-keys to generate the keys
|
||||
#
|
||||
# web-push-enabled: true
|
||||
# web-push-public-key: ""
|
||||
# web-push-private-key: ""
|
||||
# web-push-subscriptions-file: ""
|
||||
# web-push-email-address: ""
|
||||
|
||||
# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory.
|
||||
# This allows for service restarts without losing messages in support of the since= parameter.
|
||||
#
|
||||
|
|
|
@ -170,6 +170,13 @@ 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.webPushSubscriptionStore != nil {
|
||||
err := s.webPushSubscriptionStore.ExpireWebPushForUser(u.Name)
|
||||
|
||||
if 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 {
|
||||
|
|
|
@ -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.WebPushEnabled {
|
||||
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 {
|
||||
|
|
|
@ -238,6 +238,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", "/manifest.webmanifest", "", 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", "/static/css/home.css", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
|
@ -250,6 +256,13 @@ 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", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
|
||||
|
||||
rr = request(t, s2, "GET", "/sw.js", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishLargeMessage(t *testing.T) {
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
_ "embed" // required by go:embed
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
|
@ -130,25 +128,3 @@ This message was sent by {ip} at {time} via {topicURL}`
|
|||
body = strings.ReplaceAll(body, "{ip}", senderIP)
|
||||
return body, nil
|
||||
}
|
||||
|
||||
var (
|
||||
//go:embed "mailer_emoji_map.json"
|
||||
emojisJSON string
|
||||
)
|
||||
|
||||
func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
|
||||
var emojiMap map[string]string
|
||||
if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
tagsOut = make([]string, 0)
|
||||
emojisOut = make([]string, 0)
|
||||
for _, t := range tags {
|
||||
if emoji, ok := emojiMap[t]; ok {
|
||||
emojisOut = append(emojisOut, emoji)
|
||||
} else {
|
||||
tagsOut = append(tagsOut, t)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"heckel.io/ntfy/util"
|
||||
)
|
||||
|
||||
|
@ -401,6 +402,10 @@ type apiConfigResponse struct {
|
|||
DisallowedTopics []string `json:"disallowed_topics"`
|
||||
}
|
||||
|
||||
type apiWebPushConfigResponse struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
|
||||
type apiAccountBillingPrices struct {
|
||||
Month int64 `json:"month"`
|
||||
Year int64 `json:"year"`
|
||||
|
@ -462,3 +467,22 @@ type apiStripeSubscriptionDeletedEvent struct {
|
|||
ID string `json:"id"`
|
||||
Customer string `json:"customer"`
|
||||
}
|
||||
|
||||
type webPushPayload struct {
|
||||
SubscriptionID string `json:"subscription_id"`
|
||||
Message message `json:"message"`
|
||||
FormattedTitle string `json:"formatted_title"`
|
||||
}
|
||||
|
||||
type webPushSubscription struct {
|
||||
BrowserSubscription webpush.Subscription
|
||||
Username string
|
||||
}
|
||||
|
||||
type webPushSubscribePayload struct {
|
||||
BrowserSubscription webpush.Subscription `json:"browser_subscription"`
|
||||
}
|
||||
|
||||
type webPushUnsubscribePayload struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ package server
|
|||
|
||||
import (
|
||||
"context"
|
||||
_ "embed" // required by go:embed
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
|
@ -133,3 +135,25 @@ func maybeDecodeHeader(header string) string {
|
|||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
var (
|
||||
//go:embed "mailer_emoji_map.json"
|
||||
emojisJSON string
|
||||
)
|
||||
|
||||
func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
|
||||
var emojiMap map[string]string
|
||||
if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
tagsOut = make([]string, 0)
|
||||
emojisOut = make([]string, 0)
|
||||
for _, t := range tags {
|
||||
if emoji, ok := emojiMap[t]; ok {
|
||||
emojisOut = append(emojisOut, emoji)
|
||||
} else {
|
||||
tagsOut = append(tagsOut, t)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
132
server/web_push.go
Normal file
132
server/web_push.go
Normal file
|
@ -0,0 +1,132 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
)
|
||||
|
||||
// Messages cache
|
||||
const (
|
||||
createWebPushSubscriptionsTableQuery = `
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS web_push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
topic TEXT NOT NULL,
|
||||
username TEXT,
|
||||
endpoint TEXT NOT NULL,
|
||||
key_auth TEXT NOT NULL,
|
||||
key_p256dh TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON web_push_subscriptions (topic);
|
||||
CREATE INDEX IF NOT EXISTS idx_endpoint ON web_push_subscriptions (endpoint);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_topic_endpoint ON web_push_subscriptions (topic, endpoint);
|
||||
COMMIT;
|
||||
`
|
||||
insertWebPushSubscriptionQuery = `
|
||||
INSERT OR REPLACE INTO web_push_subscriptions (topic, username, endpoint, key_auth, key_p256dh)
|
||||
VALUES (?, ?, ?, ?, ?);
|
||||
`
|
||||
deleteWebPushSubscriptionByEndpointQuery = `DELETE FROM web_push_subscriptions WHERE endpoint = ?`
|
||||
deleteWebPushSubscriptionByUsernameQuery = `DELETE FROM web_push_subscriptions WHERE username = ?`
|
||||
deleteWebPushSubscriptionByTopicAndEndpointQuery = `DELETE FROM web_push_subscriptions WHERE topic = ? AND endpoint = ?`
|
||||
|
||||
selectWebPushSubscriptionsForTopicQuery = `SELECT endpoint, key_auth, key_p256dh, username FROM web_push_subscriptions WHERE topic = ?`
|
||||
|
||||
selectWebPushSubscriptionsCountQuery = `SELECT COUNT(*) FROM web_push_subscriptions`
|
||||
)
|
||||
|
||||
type webPushSubscriptionStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func newWebPushSubscriptionStore(filename string) (*webPushSubscriptionStore, error) {
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setupSubscriptionDb(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
webPushSubscriptionStore := &webPushSubscriptionStore{
|
||||
db: db,
|
||||
}
|
||||
return webPushSubscriptionStore, nil
|
||||
}
|
||||
|
||||
func setupSubscriptionDb(db *sql.DB) error {
|
||||
// If 'messages' table does not exist, this must be a new database
|
||||
rowsMC, err := db.Query(selectWebPushSubscriptionsCountQuery)
|
||||
if err != nil {
|
||||
return setupNewSubscriptionDb(db)
|
||||
}
|
||||
rowsMC.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupNewSubscriptionDb(db *sql.DB) error {
|
||||
if _, err := db.Exec(createWebPushSubscriptionsTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *webPushSubscriptionStore) AddSubscription(topic string, username string, subscription webPushSubscribePayload) error {
|
||||
_, err := c.db.Exec(
|
||||
insertWebPushSubscriptionQuery,
|
||||
topic,
|
||||
username,
|
||||
subscription.BrowserSubscription.Endpoint,
|
||||
subscription.BrowserSubscription.Keys.Auth,
|
||||
subscription.BrowserSubscription.Keys.P256dh,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *webPushSubscriptionStore) RemoveSubscription(topic string, endpoint string) error {
|
||||
_, err := c.db.Exec(
|
||||
deleteWebPushSubscriptionByTopicAndEndpointQuery,
|
||||
topic,
|
||||
endpoint,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *webPushSubscriptionStore) GetSubscriptionsForTopic(topic string) (subscriptions []webPushSubscription, err error) {
|
||||
rows, err := c.db.Query(selectWebPushSubscriptionsForTopicQuery, topic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
data := []webPushSubscription{}
|
||||
for rows.Next() {
|
||||
i := webPushSubscription{}
|
||||
err = rows.Scan(&i.BrowserSubscription.Endpoint, &i.BrowserSubscription.Keys.Auth, &i.BrowserSubscription.Keys.P256dh, &i.Username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data = append(data, i)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *webPushSubscriptionStore) ExpireWebPushEndpoint(endpoint string) error {
|
||||
_, err := c.db.Exec(
|
||||
deleteWebPushSubscriptionByEndpointQuery,
|
||||
endpoint,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *webPushSubscriptionStore) ExpireWebPushForUser(username string) error {
|
||||
_, err := c.db.Exec(
|
||||
deleteWebPushSubscriptionByUsernameQuery,
|
||||
username,
|
||||
)
|
||||
return err
|
||||
}
|
||||
func (c *webPushSubscriptionStore) Close() error {
|
||||
return c.db.Close()
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue