Each module README now says where the module sits in the dependency graph, why it is its own module, and lists use cases for application developers.
152 lines
11 KiB
Markdown
152 lines
11 KiB
Markdown
# flare
|
|
|
|
Web Push delivery with VAPID (RFC 8292) and aes128gcm payload encryption (RFC 8291) behind a small Pusher interface.
|
|
|
|
`import "git.golem15.com/golem15/summercms/modules/flare"`
|
|
|
|
**Name:** A signal flare reaches people far away; it sends Web Push notifications to browsers.
|
|
|
|
## Overview
|
|
|
|
flare sends browser push notifications. Push is a separate channel from realtime: `lighthouse` publishes to clients that hold an open connection, while flare hands a message to the browser vendor's push service, which wakes the browser even when no page is open.
|
|
|
|
The application owns the subscriptions. When a browser subscribes, the frontend posts its `PushSubscription` (the endpoint URL and the `p256dh` and `auth` keys) to the application, which stores it. flare never reads a database. Code that sends a push passes a `flare.Subscription` to a `flare.Pusher`, and operator tooling reads stored subscriptions through a `flare.SubscriptionSource` that the application publishes on the app.
|
|
|
|
`flare.From` builds the app-scoped `flare.Service` from `push.*` on first use. Its `flare.Service.Pusher` is the VAPID driver, `flare.VAPIDPusher`, which talks to push services directly with the standard library:
|
|
|
|
- The payload is encrypted for the subscriber with `flare.Encrypt`: an ephemeral P-256 key agreement (`crypto/ecdh`) with the subscription's `p256dh` key, mixed with its `auth` secret through HKDF-SHA-256 (`crypto/hkdf`), then one AES-128-GCM record in the `aes128gcm` content coding. The implementation reproduces the RFC 8291 Appendix A test vector byte for byte.
|
|
- Every request carries `Authorization: vapid t=<JWT>, k=<public key>` from `flare.VAPIDHeader`. The ES256 token's `aud` is the endpoint's origin, `exp` lies `flare.VAPIDTokenLifetime` (12 hours) ahead and `sub` is `push.subject`.
|
|
|
|
Endpoints come from browsers, so they are untrusted URLs. The driver only sends to `https` endpoints whose host is in `push.allowed_hosts`, checks this before it opens a connection, and never follows a redirect.
|
|
|
|
## Role in the framework
|
|
|
|
A leaf service module on `backpack`, `compass` and `bonfire`; the summer CLI uses it for VAPID key commands. It has no database dependency: the application stores subscriptions and hands them to the pusher. It is separate from `lighthouse` because Web Push reaches devices while the page is closed, while `lighthouse` serves live, subscribed browsers.
|
|
|
|
Use cases:
|
|
|
|
- Notify users on their devices when something they follow changes, even with the site closed.
|
|
- Generate VAPID keys from the command line.
|
|
- Clean up dead subscriptions when the push service reports them gone.
|
|
|
|
## Features
|
|
|
|
- `flare.Pusher` with one method, `Send(ctx, sub, payload, opts)`. `flare.SendOptions` sets the `TTL` (default `push.ttl`), `Urgency` and `Topic` headers.
|
|
- The VAPID driver POSTs the encrypted body with `TTL`, `Content-Encoding: aes128gcm`, `Content-Type: application/octet-stream`, the optional `Urgency` and `Topic` and the VAPID `Authorization` header. A 2xx answer is success. 404 and 410 return `flare.ErrSubscriptionGone`, so the caller can delete the subscription. Any other status returns a `*flare.StatusError` with the code and without the response body. Requests time out after `flare.DefaultTimeout` (10 s).
|
|
- Nothing is sent while `push.enabled` is false: `Send` returns `flare.ErrPushDisabled`.
|
|
- Endpoint allowlist: `flare.HostAllowed` matches a host against `push.allowed_hosts`, where `*.example.com` matches any subdomain of `example.com` (not `example.com` itself). The defaults, `flare.DefaultAllowedHosts`, are Firebase Cloud Messaging, Mozilla autopush, Apple and Windows push. A refused endpoint returns `flare.ErrEndpointNotAllowed`, and the error names the host, never the endpoint path.
|
|
- Payloads up to `flare.MaxPayloadSize` (3993 bytes, the RFC 8291 limit for a 4096-byte body). A larger one returns `flare.ErrPayloadTooLarge`.
|
|
- VAPID keys: `flare.GenerateVAPIDKeys` returns a P-256 pair as unpadded base64url (`flare.PublicKeyLength`, 87 characters, and `flare.PrivateKeyLength`, 43 characters). `flare.ParseVAPIDKeys` accepts padded or unpadded input and checks that the public key belongs to the private key; a bad pair returns `flare.ErrInvalidVAPIDKeys`. A subject that is not `mailto:` or `https:` returns `flare.ErrInvalidSubject`.
|
|
- The private key never reaches logs or formatted output: `flare.VAPIDKeys` and `flare.Config` redact it in `String`, `GoString` and (for the config) `LogValue`, and no error carries key material.
|
|
|
|
## Usage
|
|
|
|
Send a push to one stored subscription:
|
|
|
|
```go
|
|
svc, err := flare.From(app)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payload := []byte(`{"title":"New comment","body":"Someone replied to your post"}`)
|
|
err = svc.Pusher().Send(ctx, flare.Subscription{
|
|
Endpoint: row.Endpoint,
|
|
P256dh: row.P256dh,
|
|
Auth: row.Auth,
|
|
}, payload, flare.SendOptions{Urgency: "normal"})
|
|
if errors.Is(err, flare.ErrSubscriptionGone) {
|
|
// The browser unsubscribed: delete the row.
|
|
}
|
|
```
|
|
|
|
Publish a subscription source from a plugin's `Boot`, so operator commands can read the stored subscriptions of a user:
|
|
|
|
```go
|
|
type blogSubscriptions struct{ db *gorm.DB }
|
|
|
|
func (s blogSubscriptions) Subscriptions(ctx context.Context, userID uint) ([]flare.SubscriptionInfo, error) {
|
|
var user models.User
|
|
if err := s.db.WithContext(ctx).First(&user, userID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, flare.ErrUserNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
var rows []models.PushSubscription
|
|
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]flare.SubscriptionInfo, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, flare.SubscriptionInfo{
|
|
Subscription: flare.Subscription{Endpoint: r.Endpoint, P256dh: r.P256dh, Auth: r.Auth},
|
|
ID: r.ID,
|
|
UserAgent: r.UserAgent,
|
|
SubscribedAt: &r.CreatedAt,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// In Boot:
|
|
if err := app.Publish[flare.SubscriptionSource](blogSubscriptions{db: gdb}); err != nil {
|
|
return err
|
|
}
|
|
```
|
|
|
|
## API reference
|
|
|
|
| Identifier | Description |
|
|
|------------|-------------|
|
|
| `flare.From(app)` | The app's `*flare.Service`, built from `push.*` and published on first use. |
|
|
| `flare.Service` | `Config`, `Enabled`, `Pusher`, `SetHTTPClient` (replace the driver's HTTP client, for example in tests) and `Logger`. |
|
|
| `flare.Pusher` | `Send(ctx, sub, payload, opts) error`. |
|
|
| `flare.VAPIDPusher`, `flare.NewVAPIDPusher(cfg, hc)` | The VAPID driver. `hc` may be nil; a given client is copied and never follows redirects. |
|
|
| `flare.Subscription` | `Endpoint`, `P256dh`, `Auth`, as `PushSubscription.toJSON` returns them. |
|
|
| `flare.SendOptions` | `TTL`, `Urgency`, `Topic`. |
|
|
| `flare.SubscriptionSource`, `flare.SubscriptionInfo` | The application's subscription store: `Subscriptions(ctx, userID)` returns the subscriptions with `ID`, `UserAgent`, `SubscribedAt` and `LastUsedAt`. |
|
|
| `flare.Config`, `flare.LoadConfig` | The `push.*` settings with their defaults; `Keys` returns the key pair. |
|
|
| `flare.VAPIDKeys`, `flare.GenerateVAPIDKeys`, `flare.ParseVAPIDKeys` | VAPID key pairs as unpadded base64url. |
|
|
| `flare.VAPIDHeader(endpoint, subject, keys, now)` | The RFC 8292 `Authorization` header value. |
|
|
| `flare.Encrypt(payload, sub)` | The RFC 8291 `aes128gcm` request body. |
|
|
| `flare.HostAllowed(host, allowed)`, `flare.DefaultAllowedHosts` | The endpoint host allowlist and its default. |
|
|
| `flare.Commands(app)`, `flare.GenerateVAPIDKeysCommandName`, `flare.TestPushCommandName` | The `websockets:generate-vapid-keys` and `websockets:test-push` commands and their names. |
|
|
| `flare.ErrPushDisabled`, `flare.ErrEndpointNotAllowed`, `flare.ErrSubscriptionGone`, `flare.ErrUserNotFound`, `flare.ErrPayloadTooLarge`, `flare.ErrInvalidVAPIDKeys`, `flare.ErrInvalidSubject`, `flare.StatusError` | Errors. |
|
|
| `flare.ContentEncoding`, `flare.MaxPayloadSize`, `flare.DefaultTTL`, `flare.DefaultTimeout`, `flare.VAPIDTokenLifetime`, `flare.PublicKeyLength`, `flare.PrivateKeyLength` | Constants. |
|
|
|
|
## Configuration
|
|
|
|
| Key | Default | Description |
|
|
|-----|---------|-------------|
|
|
| `push.enabled` | `false` | Nothing is sent while false. |
|
|
| `push.public_key` | `""` | VAPID public key, base64url (87 characters unpadded). |
|
|
| `push.private_key` | `""` | VAPID private key, base64url (43 characters). Keep it out of committed files; set `SUMMER_PUSH__PRIVATE_KEY`. |
|
|
| `push.subject` | `""` | VAPID `sub` claim: a `mailto:` or `https:` contact URI. |
|
|
| `push.ttl` | `2419200` | Default `TTL` header, in seconds or as a duration string. |
|
|
| `push.allowed_hosts` | FCM, Mozilla autopush, `*.push.apple.com`, `*.notify.windows.com` | Push service hosts an endpoint may point at, as a list or a comma-separated string. |
|
|
|
|
## CLI commands
|
|
|
|
`flare.Commands(app)` returns two commands for the application binary. An application adds them to the list its plugin returns from `Commands`.
|
|
|
|
| Command | Description |
|
|
|---------|-------------|
|
|
| `websockets:generate-vapid-keys [--update] [--show-current]` | Shows the configured keys, truncated to the first 8 and last 4 characters with their length and a check mark when they decode to a valid pair. `--show-current` stops there. Otherwise it generates a new P-256 pair, validates its length and base64url alphabet and prints both keys. With `--update` the keys are saved through `compass.Config.Set` and `compass.Config.Persist` to `env/<environment>/overrides.yaml` in the config directory (mode 0600; other keys in the file are kept). Without it, the command prints `SUMMER_PUSH__PUBLIC_KEY=…` and `SUMMER_PUSH__PRIVATE_KEY=…` lines to set by hand. |
|
|
| `websockets:test-push <user_id> [--show-config]` | Prints the push configuration: enabled, whether each key is set with its length (never the value), the subject and its format. It then reads the user's subscriptions from the published `flare.SubscriptionSource`, lists them (endpoint shortened to 60 characters, user agent, when subscribed and last used) and asks `Send test notification?` (default yes; a non-interactive run takes the default). It sends one encrypted test push to each subscription and reports each result. `--show-config` is accepted for compatibility; the configuration is always shown. |
|
|
|
|
`websockets:test-push` exits 1 when no subscription source is published (`no subscription source registered`), when the user is unknown or has no subscriptions, when push is disabled (it lists the subscriptions but sends nothing), and when any send fails. The test payload is `{"title":"<app.name> test","body":"This is a test push notification sent at HH:MM:SS","data":{"test":true,"timestamp":<unix>}}`.
|
|
|
|
## Dependencies
|
|
|
|
- `backpack`, `bonfire` and `compass` from this repository.
|
|
- `github.com/golang-jwt/jwt/v5` (the ES256 VAPID token).
|
|
- Everything else is the standard library: `crypto/ecdh`, `crypto/ecdsa`, `crypto/hkdf`, `crypto/aes`, `crypto/cipher` and `net/http`. No Web Push library is used.
|
|
|
|
## Testing
|
|
|
|
```bash
|
|
go test ./modules/flare/...
|
|
```
|
|
|
|
`TestRFC8291AppendixA` fixes the RFC's salt and application server key and compares the output with the RFC's published bytes. The send tests run an `httptest.NewTLSServer` push service with `127.0.0.1` in the allowlist; it verifies the VAPID token with the key from `k=` and decrypts the body as a browser would. Pass the test server's client to `flare.NewVAPIDPusher` or `flare.Service.SetHTTPClient` so it trusts the test certificate.
|