diff --git a/README.md b/README.md index 40d595f..05c2176 100644 --- a/README.md +++ b/README.md @@ -22,36 +22,22 @@ go get github.com/imjasonh/webpush ## Quick Start -### 1. Generate VAPID Keys +See the [example/](example/) directory for a complete working demo with: +- VAPID key generation and storage on disk +- SQLite subscription storage +- Web client with service worker for push notifications +- Automatic periodic notifications and manual `/ping` endpoint -```go -package main +To run the example: -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()) -} +```bash +cd example +go run main.go ``` -### 2. Send Push Notifications +Then open http://localhost:8080 in your browser. + +## Basic Usage ```go package main @@ -63,9 +49,8 @@ import ( ) func main() { - // Load VAPID keys + // Load or generate 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) } @@ -73,7 +58,7 @@ func main() { // Create client with VAPID subject (must be mailto: or https: URL) client := webpush.NewClient(signer, "mailto:admin@example.com") - // Parse subscription from client + // Parse subscription from client (received from browser) sub, err := webpush.ParseSubscription([]byte(`{ "endpoint": "https://fcm.googleapis.com/fcm/send/...", "keys": { @@ -87,9 +72,8 @@ func main() { // 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 + TTL: 3600, + Urgency: "high", }) if err != nil { panic(err) @@ -97,97 +81,24 @@ func main() { } ``` -### 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... +signer, err := keys.NewKMSSigner(ctx, "projects/my-project/locations/global/keyRings/webpush/cryptoKeys/vapid/cryptoKeyVersions/1") +if err != nil { + panic(err) } +defer signer.Close() + +client := webpush.NewClient(signer, "mailto:admin@example.com") ``` -### Creating a KMS Key +Create 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 \ @@ -195,287 +106,23 @@ gcloud kms keys create vapid \ --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 + Urgency string // 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 @@ -487,7 +134,6 @@ 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") @@ -502,19 +148,20 @@ 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) +store.Save(ctx, record) +store.Get(ctx, id) +store.GetByEndpoint(ctx, endpoint) +store.GetByUserID(ctx, userID) +store.List(ctx, limit, offset) +store.Delete(ctx, id) +store.DeleteByEndpoint(ctx, endpoint) ``` -## Custom Storage Implementation +## Custom Implementations + +### Custom Storage Implement the `storage.Storage` interface: @@ -531,7 +178,7 @@ type Storage interface { } ``` -## Custom Key Provider +### Custom Key Provider Implement the `webpush.Signer` interface: @@ -542,7 +189,7 @@ type Signer interface { } ``` -The `Sign` method receives SHA-256 hash and should return IEEE P1363 format signature (64 bytes for P-256). +The `Sign` method receives a SHA-256 hash and should return an IEEE P1363 format signature (64 bytes for P-256). ## License diff --git a/example/main.go b/example/main.go index 532d6d7..5b269d6 100644 --- a/example/main.go +++ b/example/main.go @@ -10,7 +10,9 @@ package main import ( "context" + "embed" "encoding/json" + "io/fs" "log" "net/http" "os" @@ -24,6 +26,9 @@ import ( "github.com/imjasonh/webpush/storage" ) +//go:embed static/* +var staticFiles embed.FS + const ( keyPath = "vapid-private.pem" dbPath = "subscriptions.db" @@ -74,8 +79,11 @@ func main() { go periodicPush() // Set up HTTP handlers - http.HandleFunc("/", handleIndex) - http.HandleFunc("/sw.js", handleServiceWorker) + staticFS, err := fs.Sub(staticFiles, "static") + if err != nil { + log.Fatalf("Failed to create static file system: %v", err) + } + http.Handle("/", http.FileServer(http.FS(staticFS))) http.HandleFunc("/api/vapid-public-key", handleVAPIDPublicKey) http.HandleFunc("/api/subscribe", handleSubscribe) http.HandleFunc("/api/unsubscribe", handleUnsubscribe) @@ -153,20 +161,6 @@ func isGone(err error) bool { // HTTP Handlers -func handleIndex(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "text/html") - w.Write([]byte(indexHTML)) -} - -func handleServiceWorker(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/javascript") - w.Write([]byte(serviceWorkerJS)) -} - func handleVAPIDPublicKey(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ @@ -268,229 +262,3 @@ func handlePing(w http.ResponseWriter, r *http.Request) { "message": "Push notification queued", }) } - -// Static content - -const indexHTML = ` - - - - - Web Push Demo - - - -
-

🔔 Web Push Demo

-

Subscribe to receive push notifications from this server.

-

Notifications are sent:

- -
- -
-

Subscription

- - -
-
- -
-

Test Push

- -
- - - - -` - -const serviceWorkerJS = ` -self.addEventListener('push', function(event) { - let data = { title: 'Notification', body: '' }; - - if (event.data) { - try { - data = event.data.json(); - } catch (e) { - data.body = event.data.text(); - } - } - - const options = { - body: data.body || '', - icon: data.icon || '', - badge: data.badge || '', - data: data.data || {}, - requireInteraction: false - }; - - event.waitUntil( - self.registration.showNotification(data.title || 'Notification', options) - ); -}); - -self.addEventListener('notificationclick', function(event) { - event.notification.close(); - - if (event.notification.data && event.notification.data.url) { - event.waitUntil( - clients.openWindow(event.notification.data.url) - ); - } -}); -` diff --git a/example/static/index.html b/example/static/index.html new file mode 100644 index 0000000..2573d3d --- /dev/null +++ b/example/static/index.html @@ -0,0 +1,186 @@ + + + + + + Web Push Demo + + + +
+

🔔 Web Push Demo

+

Subscribe to receive push notifications from this server.

+

Notifications are sent:

+ +
+ +
+

Subscription

+ + +
+
+ +
+

Test Push

+ +
+ + + + diff --git a/example/static/sw.js b/example/static/sw.js new file mode 100644 index 0000000..5ab493e --- /dev/null +++ b/example/static/sw.js @@ -0,0 +1,33 @@ +self.addEventListener('push', function(event) { + let data = { title: 'Notification', body: '' }; + + if (event.data) { + try { + data = event.data.json(); + } catch (e) { + data.body = event.data.text(); + } + } + + const options = { + body: data.body || '', + icon: data.icon || '', + badge: data.badge || '', + data: data.data || {}, + requireInteraction: false + }; + + event.waitUntil( + self.registration.showNotification(data.title || 'Notification', options) + ); +}); + +self.addEventListener('notificationclick', function(event) { + event.notification.close(); + + if (event.notification.data && event.notification.data.url) { + event.waitUntil( + clients.openWindow(event.notification.data.url) + ); + } +});