- docs/backend: admin controllers, forms, lists and filters, relation manager, users and permissions, settings, partials and widgets, admin SPA - docs/services: storage, outbound HTTP, realtime, Web Push, search, parity testing and the Frontend and AJAX (not provided) page - Examples for cabana (with testdata/docs YAML), fetchguard, lighthouse and its centrifugo driver, flare, beachcomber and typesense, tide; lighthouse and beachcomber TestDocs* regions run on their Postgres harnesses - concept map rows link their guide pages and the not-provided rows the Frontend and AJAX page; index lists Backend, Database and Services - TestDocsRequiredPages asserts the D-08 section order
138 lines
6.0 KiB
Markdown
138 lines
6.0 KiB
Markdown
---
|
|
title: Web Push
|
|
description: Send browser push notifications with flare over VAPID, only to https push service hosts on push.allowed_hosts and without redirects, and manage VAPID keys.
|
|
section: services
|
|
order: 130
|
|
---
|
|
# Web Push
|
|
|
|
[flare](../../modules/flare/README.md) sends browser push notifications. Push is a different channel from realtime: realtime reaches pages that hold an open connection, while a push goes to the browser vendor's push service, which wakes the browser even when no page is open. flare is written on the standard library: the RFC 8291 payload encryption and the RFC 8292 VAPID authorization are implemented in the package, with no Web Push library.
|
|
|
|
## Subscriptions belong to the application
|
|
|
|
When a browser subscribes, the frontend posts its `PushSubscription` (the endpoint URL and the `p256dh` and `auth` keys) to an application route, and the application stores it in its own table. flare never reads the database. Code that sends a push passes a `flare.Subscription` to the `flare.Pusher` that `flare.From` returns through `flare.Service.Pusher`.
|
|
|
|
For the operator commands below, the application also publishes a `flare.SubscriptionSource` on the app, which reads a user's stored subscriptions.
|
|
|
|
## Sending
|
|
|
|
`flare.Pusher.Send` encrypts the payload for the subscriber and posts it to the endpoint with the VAPID `Authorization` header. `flare.SendOptions` sets the `TTL` (default `push.ttl`), `Urgency` and `Topic` headers:
|
|
|
|
```go src=modules/flare/example_test.go#ExampleVAPIDPusher_Send
|
|
// A stand-in push service: 201 for a live subscription, 410 for one the
|
|
// browser dropped.
|
|
push := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/gone" {
|
|
w.WriteHeader(http.StatusGone)
|
|
return
|
|
}
|
|
fmt.Println("push service got", r.Header.Get("Content-Encoding"), r.Header.Get("TTL"), r.Header.Get("Urgency"))
|
|
w.WriteHeader(http.StatusCreated)
|
|
}))
|
|
defer push.Close()
|
|
|
|
keys, err := flare.GenerateVAPIDKeys() // websockets:generate-vapid-keys
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
cfg := flare.Config{
|
|
Enabled: true,
|
|
PublicKey: keys.PublicKey,
|
|
PrivateKey: keys.PrivateKey,
|
|
Subject: "mailto:admin@example.com",
|
|
TTL: time.Hour,
|
|
AllowedHosts: []string{"127.0.0.1"}, // production keeps the default push services
|
|
}
|
|
// The test server's client trusts its certificate.
|
|
pusher := flare.NewVAPIDPusher(cfg, push.Client())
|
|
|
|
ctx := context.Background()
|
|
payload := []byte(`{"title":"New comment","body":"Someone replied to your post"}`)
|
|
for _, endpoint := range []string{
|
|
push.URL + "/live",
|
|
push.URL + "/gone",
|
|
"https://push.attacker.example/steal",
|
|
"http://127.0.0.1/plain",
|
|
} {
|
|
sub, err := browserSubscription(endpoint)
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
err = pusher.Send(ctx, sub, payload, flare.SendOptions{Urgency: "normal"})
|
|
switch {
|
|
case err == nil:
|
|
fmt.Println("sent")
|
|
case errors.Is(err, flare.ErrSubscriptionGone):
|
|
fmt.Println("gone: delete the subscription")
|
|
case errors.Is(err, flare.ErrEndpointNotAllowed):
|
|
fmt.Println("refused before connecting")
|
|
default:
|
|
fmt.Println("error:", err)
|
|
}
|
|
}
|
|
// Formatting the keys never prints the private key.
|
|
fmt.Println(strings.Contains(fmt.Sprintf("%v %#v", keys, keys), keys.PrivateKey))
|
|
// Output:
|
|
// push service got aes128gcm 3600 normal
|
|
// sent
|
|
// gone: delete the subscription
|
|
// refused before connecting
|
|
// refused before connecting
|
|
// false
|
|
```
|
|
|
|
- A 2xx answer is success.
|
|
- 404 and 410 return `flare.ErrSubscriptionGone`: the browser unsubscribed, so delete the stored subscription.
|
|
- Any other status returns a `flare.StatusError` with the code, never the response body.
|
|
- A payload over `flare.MaxPayloadSize` (3993 bytes) returns `flare.ErrPayloadTooLarge`.
|
|
- While `push.enabled` is false, nothing is sent and `flare.ErrPushDisabled` is returned.
|
|
|
|
A send is one HTTP request with a 10-second timeout. Send from a queued job when a request would otherwise wait for it; see [Queued jobs](jobs.md).
|
|
|
|
## Endpoint safety
|
|
|
|
Endpoints come from browsers, so they are untrusted URLs. flare sends only to `https` endpoints whose host is on `push.allowed_hosts`, checks this before it opens a connection, and never follows a redirect, so a push service cannot bounce the request to another host. A refused endpoint returns `flare.ErrEndpointNotAllowed`, which names the host but never the endpoint path.
|
|
|
|
The default allowlist, `flare.DefaultAllowedHosts`, covers Firebase Cloud Messaging, Mozilla autopush, Apple and Windows push. `*.example.com` matches any subdomain but not `example.com` itself:
|
|
|
|
```go src=modules/flare/example_test.go#ExampleHostAllowed
|
|
allowed := []string{"fcm.googleapis.com", "*.push.apple.com"}
|
|
for _, host := range []string{"fcm.googleapis.com", "api.push.apple.com", "push.apple.com", "evil.example"} {
|
|
fmt.Println(host, flare.HostAllowed(host, allowed))
|
|
}
|
|
// Output:
|
|
// fcm.googleapis.com true
|
|
// api.push.apple.com true
|
|
// push.apple.com false
|
|
// evil.example false
|
|
```
|
|
|
|
Keep the default unless you know a browser your users run pushes through another service.
|
|
|
|
## VAPID keys
|
|
|
|
A push service accepts a push only when it carries a token signed with the application's VAPID key pair. Generate the pair once:
|
|
|
|
```sh
|
|
./bin/acme websockets:generate-vapid-keys
|
|
```
|
|
|
|
It prints `SUMMER_PUSH__PUBLIC_KEY=...` and `SUMMER_PUSH__PRIVATE_KEY=...` lines to set in the environment. With `--update` it saves the keys to the environment's `overrides.yaml` instead. Keep the private key out of committed files. flare never writes the private key to a log or an error, and `flare.VAPIDKeys` and `flare.Config` redact it when printed.
|
|
|
|
The frontend needs the public key to subscribe; serve it from a route of your own. Set `push.subject` to a `mailto:` or `https:` contact address for the push services, and `push.enabled` to `true`:
|
|
|
|
```yaml
|
|
enabled: true
|
|
subject: mailto:admin@example.com
|
|
```
|
|
|
|
in `config/push.yaml`.
|
|
|
|
`websockets:test-push <user_id>` prints the push configuration without the key values, lists the user's subscriptions from the published `flare.SubscriptionSource`, and sends each one a test notification:
|
|
|
|
```sh
|
|
./bin/acme websockets:test-push 42
|
|
```
|