mirror of
https://github.com/imjasonh/webpush
synced 2026-07-09 00:45:28 +00:00
549 lines
No EOL
13 KiB
Markdown
549 lines
No EOL
13 KiB
Markdown
# webpush
|
|
|
|
A Go library for sending [Web Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) notifications with [VAPID](https://datatracker.ietf.org/doc/html/rfc8292) authentication.
|
|
|
|
## Features
|
|
|
|
- RFC 8291 compliant message encryption (aes128gcm)
|
|
- RFC 8292 VAPID authentication
|
|
- Pluggable VAPID key providers:
|
|
- File-based (PEM or base64 encoded)
|
|
- Google Cloud KMS
|
|
- Pluggable subscription storage:
|
|
- In-memory (for testing/development)
|
|
- SQLite
|
|
- Easy integration with JavaScript Push API clients
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
go get github.com/imjasonh/webpush
|
|
```
|
|
|
|
## Quick Start
|
|
|
|
### 1. Generate VAPID Keys
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"github.com/imjasonh/webpush/keys"
|
|
)
|
|
|
|
func main() {
|
|
// Generate a new key pair and get base64-encoded keys
|
|
privateKey, publicKey, err := keys.GenerateKeyPair()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
fmt.Println("Private Key:", privateKey)
|
|
fmt.Println("Public Key:", publicKey)
|
|
|
|
// Or generate and save to a PEM file
|
|
signer, err := keys.GenerateKey("vapid-private.pem")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
fmt.Println("Public Key:", signer.PublicKeyBase64())
|
|
}
|
|
```
|
|
|
|
### 2. Send Push Notifications
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"github.com/imjasonh/webpush"
|
|
"github.com/imjasonh/webpush/keys"
|
|
)
|
|
|
|
func main() {
|
|
// Load VAPID keys
|
|
signer, err := keys.NewFileSigner("vapid-private.pem")
|
|
// Or from base64: signer, err := keys.NewFileSignerFromBase64("your-base64-private-key")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Create client with VAPID subject (must be mailto: or https: URL)
|
|
client := webpush.NewClient(signer, "mailto:admin@example.com")
|
|
|
|
// Parse subscription from client
|
|
sub, err := webpush.ParseSubscription([]byte(`{
|
|
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
|
|
"keys": {
|
|
"p256dh": "...",
|
|
"auth": "..."
|
|
}
|
|
}`))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Send notification
|
|
err = client.Send(context.Background(), sub, []byte(`{"title":"Hello","body":"World"}`), &webpush.Options{
|
|
TTL: 3600, // Time-to-live in seconds
|
|
Urgency: "high", // very-low, low, normal, high
|
|
Topic: "updates", // For message collapsing
|
|
})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Store Subscriptions
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"github.com/google/uuid"
|
|
"github.com/imjasonh/webpush"
|
|
"github.com/imjasonh/webpush/storage"
|
|
)
|
|
|
|
func main() {
|
|
// Use SQLite for production
|
|
store, err := storage.NewSQLite("subscriptions.db")
|
|
// Or use in-memory for testing: store := storage.NewMemory()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer store.Close()
|
|
|
|
// Save a subscription
|
|
sub := &webpush.Subscription{
|
|
Endpoint: "https://fcm.googleapis.com/fcm/send/...",
|
|
Keys: webpush.Keys{
|
|
P256dh: "...",
|
|
Auth: "...",
|
|
},
|
|
}
|
|
|
|
record := &storage.Record{
|
|
ID: uuid.New().String(),
|
|
UserID: "user-123", // Optional: associate with your user
|
|
Subscription: sub,
|
|
}
|
|
|
|
err = store.Save(context.Background(), record)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Get subscriptions for a user
|
|
records, err := store.GetByUserID(context.Background(), "user-123")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Send to all user's devices
|
|
for _, r := range records {
|
|
// client.Send(ctx, r.Subscription, payload, opts)
|
|
}
|
|
}
|
|
```
|
|
|
|
## Using Google Cloud KMS
|
|
|
|
For production environments, you can store your VAPID private key in Google Cloud KMS:
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"github.com/imjasonh/webpush"
|
|
"github.com/imjasonh/webpush/keys"
|
|
)
|
|
|
|
func main() {
|
|
ctx := context.Background()
|
|
|
|
// Key name format: projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{key}/cryptoKeyVersions/{version}
|
|
keyName := "projects/my-project/locations/global/keyRings/webpush/cryptoKeys/vapid/cryptoKeyVersions/1"
|
|
|
|
signer, err := keys.NewKMSSigner(ctx, keyName)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer signer.Close()
|
|
|
|
client := webpush.NewClient(signer, "mailto:admin@example.com")
|
|
// Use client as normal...
|
|
}
|
|
```
|
|
|
|
### Creating a KMS Key
|
|
|
|
```bash
|
|
# Create a key ring
|
|
gcloud kms keyrings create webpush --location=global
|
|
|
|
# Create an EC P-256 signing key
|
|
gcloud kms keys create vapid \
|
|
--keyring=webpush \
|
|
--location=global \
|
|
--purpose=asymmetric-signing \
|
|
--default-algorithm=ec-sign-p256-sha256
|
|
```
|
|
|
|
## JavaScript Client Integration
|
|
|
|
### Service Worker Setup
|
|
|
|
1. Create a service worker file (`sw.js`):
|
|
|
|
```javascript
|
|
self.addEventListener('push', function(event) {
|
|
const data = event.data ? event.data.json() : {};
|
|
const title = data.title || 'Notification';
|
|
const options = {
|
|
body: data.body || '',
|
|
icon: data.icon || '/icon.png',
|
|
badge: data.badge || '/badge.png',
|
|
data: data.data || {}
|
|
};
|
|
|
|
event.waitUntil(
|
|
self.registration.showNotification(title, options)
|
|
);
|
|
});
|
|
|
|
self.addEventListener('notificationclick', function(event) {
|
|
event.notification.close();
|
|
|
|
// Handle notification click
|
|
if (event.notification.data && event.notification.data.url) {
|
|
event.waitUntil(
|
|
clients.openWindow(event.notification.data.url)
|
|
);
|
|
}
|
|
});
|
|
```
|
|
|
|
2. Register the service worker and subscribe to push:
|
|
|
|
```javascript
|
|
// Your VAPID public key from the server (base64 URL-safe encoded)
|
|
const VAPID_PUBLIC_KEY = 'YOUR_PUBLIC_KEY_HERE';
|
|
|
|
// Convert base64 to Uint8Array
|
|
function urlBase64ToUint8Array(base64String) {
|
|
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
|
const base64 = (base64String + padding)
|
|
.replace(/-/g, '+')
|
|
.replace(/_/g, '/');
|
|
|
|
const rawData = window.atob(base64);
|
|
const outputArray = new Uint8Array(rawData.length);
|
|
|
|
for (let i = 0; i < rawData.length; ++i) {
|
|
outputArray[i] = rawData.charCodeAt(i);
|
|
}
|
|
return outputArray;
|
|
}
|
|
|
|
async function subscribeToNotifications() {
|
|
// Register service worker
|
|
const registration = await navigator.serviceWorker.register('/sw.js');
|
|
|
|
// Wait for service worker to be ready
|
|
await navigator.serviceWorker.ready;
|
|
|
|
// Subscribe to push notifications
|
|
const subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
|
|
});
|
|
|
|
// Send subscription to your server
|
|
await fetch('/api/subscribe', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(subscription)
|
|
});
|
|
|
|
console.log('Subscribed to push notifications');
|
|
}
|
|
|
|
// Request permission and subscribe
|
|
async function enableNotifications() {
|
|
const permission = await Notification.requestPermission();
|
|
if (permission === 'granted') {
|
|
await subscribeToNotifications();
|
|
}
|
|
}
|
|
```
|
|
|
|
3. Create a simple server endpoint to receive subscriptions:
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/imjasonh/webpush"
|
|
"github.com/imjasonh/webpush/keys"
|
|
"github.com/imjasonh/webpush/storage"
|
|
)
|
|
|
|
var (
|
|
store storage.Storage
|
|
client *webpush.Client
|
|
)
|
|
|
|
func main() {
|
|
// Initialize storage
|
|
var err error
|
|
store, err = storage.NewSQLite("subscriptions.db")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
defer store.Close()
|
|
|
|
// Initialize VAPID signer
|
|
signer, err := keys.NewFileSigner("vapid-private.pem")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
client = webpush.NewClient(signer, "mailto:admin@example.com")
|
|
|
|
// Serve static files
|
|
http.Handle("/", http.FileServer(http.Dir("static")))
|
|
|
|
// API endpoints
|
|
http.HandleFunc("/api/vapid-public-key", handleVAPIDPublicKey)
|
|
http.HandleFunc("/api/subscribe", handleSubscribe)
|
|
http.HandleFunc("/api/push", handlePush)
|
|
|
|
http.ListenAndServe(":8080", nil)
|
|
}
|
|
|
|
func handleVAPIDPublicKey(w http.ResponseWriter, r *http.Request) {
|
|
// Return the public key for the JavaScript client
|
|
signer, _ := keys.NewFileSigner("vapid-private.pem")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"publicKey": signer.PublicKeyBase64(),
|
|
})
|
|
}
|
|
|
|
func handleSubscribe(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var sub webpush.Subscription
|
|
if err := json.NewDecoder(r.Body).Decode(&sub); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate subscription
|
|
if sub.Endpoint == "" || sub.Keys.P256dh == "" || sub.Keys.Auth == "" {
|
|
http.Error(w, "Invalid subscription", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if subscription already exists
|
|
existing, err := store.GetByEndpoint(r.Context(), sub.Endpoint)
|
|
if err == nil {
|
|
// Already subscribed
|
|
json.NewEncoder(w).Encode(map[string]string{"id": existing.ID})
|
|
return
|
|
}
|
|
|
|
// Save new subscription
|
|
record := &storage.Record{
|
|
ID: uuid.New().String(),
|
|
Subscription: &sub,
|
|
}
|
|
|
|
if err := store.Save(r.Context(), record); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"id": record.ID})
|
|
}
|
|
|
|
func handlePush(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Title string `json:"title"`
|
|
Body string `json:"body"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
payload, _ := json.Marshal(req)
|
|
|
|
// Get all subscriptions
|
|
records, err := store.List(r.Context(), 1000, 0)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Send to all subscriptions
|
|
ctx := context.Background()
|
|
var sent, failed int
|
|
for _, record := range records {
|
|
err := client.Send(ctx, record.Subscription, payload, nil)
|
|
if err != nil {
|
|
failed++
|
|
// If subscription is gone (410), delete it
|
|
// if strings.Contains(err.Error(), "410") {
|
|
// store.Delete(ctx, record.ID)
|
|
// }
|
|
} else {
|
|
sent++
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]int{
|
|
"sent": sent,
|
|
"failed": failed,
|
|
})
|
|
}
|
|
```
|
|
|
|
## API Reference
|
|
|
|
### Types
|
|
|
|
#### `Subscription`
|
|
|
|
```go
|
|
type Subscription struct {
|
|
Endpoint string `json:"endpoint"`
|
|
Keys Keys `json:"keys"`
|
|
}
|
|
|
|
type Keys struct {
|
|
P256dh string `json:"p256dh"`
|
|
Auth string `json:"auth"`
|
|
}
|
|
```
|
|
|
|
#### `Options`
|
|
|
|
```go
|
|
type Options struct {
|
|
TTL int // Time-to-live in seconds (default: 2419200 = 4 weeks)
|
|
Urgency string // Urgency: very-low, low, normal, high
|
|
Topic string // Topic for message replacement
|
|
}
|
|
```
|
|
|
|
### Client
|
|
|
|
```go
|
|
// Create a new client
|
|
client := webpush.NewClient(signer, subject)
|
|
|
|
// Optionally set custom HTTP client
|
|
client.WithHTTPClient(httpClient)
|
|
|
|
// Send a notification
|
|
err := client.Send(ctx, subscription, payload, options)
|
|
|
|
// Parse subscription from JSON
|
|
sub, err := webpush.ParseSubscription(jsonBytes)
|
|
```
|
|
|
|
### Key Providers
|
|
|
|
```go
|
|
// File-based (PEM)
|
|
signer, err := keys.NewFileSigner("path/to/key.pem")
|
|
|
|
// File-based (base64)
|
|
signer, err := keys.NewFileSignerFromBase64("base64-private-key")
|
|
|
|
// Google Cloud KMS
|
|
signer, err := keys.NewKMSSigner(ctx, "projects/.../cryptoKeyVersions/1")
|
|
defer signer.Close()
|
|
|
|
// Generate new keys
|
|
signer, err := keys.GenerateKey("path/to/save.pem")
|
|
privateB64, publicB64, err := keys.GenerateKeyPair()
|
|
```
|
|
|
|
### Storage
|
|
|
|
```go
|
|
// In-memory (for testing)
|
|
store := storage.NewMemory()
|
|
|
|
// SQLite
|
|
store, err := storage.NewSQLite("subscriptions.db")
|
|
defer store.Close()
|
|
|
|
// Operations
|
|
err := store.Save(ctx, record)
|
|
record, err := store.Get(ctx, id)
|
|
record, err := store.GetByEndpoint(ctx, endpoint)
|
|
records, err := store.GetByUserID(ctx, userID)
|
|
records, err := store.List(ctx, limit, offset)
|
|
err := store.Delete(ctx, id)
|
|
err := store.DeleteByEndpoint(ctx, endpoint)
|
|
```
|
|
|
|
## Custom Storage Implementation
|
|
|
|
Implement the `storage.Storage` interface:
|
|
|
|
```go
|
|
type Storage interface {
|
|
Save(ctx context.Context, record *Record) error
|
|
Get(ctx context.Context, id string) (*Record, error)
|
|
GetByEndpoint(ctx context.Context, endpoint string) (*Record, error)
|
|
GetByUserID(ctx context.Context, userID string) ([]*Record, error)
|
|
Delete(ctx context.Context, id string) error
|
|
DeleteByEndpoint(ctx context.Context, endpoint string) error
|
|
List(ctx context.Context, limit, offset int) ([]*Record, error)
|
|
Close() error
|
|
}
|
|
```
|
|
|
|
## Custom Key Provider
|
|
|
|
Implement the `webpush.Signer` interface:
|
|
|
|
```go
|
|
type Signer interface {
|
|
Sign(ctx context.Context, data []byte) ([]byte, error)
|
|
PublicKey() []byte
|
|
}
|
|
```
|
|
|
|
The `Sign` method receives SHA-256 hash and should return IEEE P1363 format signature (64 bytes for P-256).
|
|
|
|
## License
|
|
|
|
Apache 2.0 - See [LICENSE](LICENSE) for details. |