feat(11.1-04): add the Backend section, the remaining Services pages and the concept map links
- 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
This commit is contained in:
137
docs/services/push.md
Normal file
137
docs/services/push.md
Normal file
@@ -0,0 +1,137 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
Reference in New Issue
Block a user