1
0
Fork 0
mirror of https://github.com/imjasonh/webpush synced 2026-07-09 08:47:05 +00:00

Implement Web Push API Go library with VAPID support and pluggable storage

Co-authored-by: imjasonh <210737+imjasonh@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-24 21:19:16 +00:00
parent 7d0082c045
commit 78bd40dc88
15 changed files with 2708 additions and 1 deletions

168
storage/memory.go Normal file
View file

@ -0,0 +1,168 @@
package storage
import (
"context"
"errors"
"sync"
"time"
"github.com/imjasonh/webpush"
)
// ErrNotFound is returned when a record is not found.
var ErrNotFound = errors.New("record not found")
// Memory implements in-memory storage for testing and development.
type Memory struct {
mu sync.RWMutex
records map[string]*Record
}
// NewMemory creates a new in-memory storage.
func NewMemory() *Memory {
return &Memory{
records: make(map[string]*Record),
}
}
// Save stores or updates a subscription.
func (m *Memory) Save(_ context.Context, record *Record) error {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
if record.CreatedAt.IsZero() {
record.CreatedAt = now
}
record.UpdatedAt = now
// Make a copy to avoid external mutations
stored := &Record{
ID: record.ID,
UserID: record.UserID,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
Subscription: &webpush.Subscription{
Endpoint: record.Subscription.Endpoint,
Keys: webpush.Keys{
P256dh: record.Subscription.Keys.P256dh,
Auth: record.Subscription.Keys.Auth,
},
},
}
m.records[record.ID] = stored
return nil
}
// Get retrieves a subscription by ID.
func (m *Memory) Get(_ context.Context, id string) (*Record, error) {
m.mu.RLock()
defer m.mu.RUnlock()
record, ok := m.records[id]
if !ok {
return nil, ErrNotFound
}
return copyRecord(record), nil
}
// GetByEndpoint retrieves a subscription by its endpoint URL.
func (m *Memory) GetByEndpoint(_ context.Context, endpoint string) (*Record, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, record := range m.records {
if record.Subscription.Endpoint == endpoint {
return copyRecord(record), nil
}
}
return nil, ErrNotFound
}
// GetByUserID retrieves all subscriptions for a user.
func (m *Memory) GetByUserID(_ context.Context, userID string) ([]*Record, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var results []*Record
for _, record := range m.records {
if record.UserID == userID {
results = append(results, copyRecord(record))
}
}
return results, nil
}
// Delete removes a subscription by ID.
func (m *Memory) Delete(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.records[id]; !ok {
return ErrNotFound
}
delete(m.records, id)
return nil
}
// DeleteByEndpoint removes a subscription by its endpoint URL.
func (m *Memory) DeleteByEndpoint(_ context.Context, endpoint string) error {
m.mu.Lock()
defer m.mu.Unlock()
for id, record := range m.records {
if record.Subscription.Endpoint == endpoint {
delete(m.records, id)
return nil
}
}
return ErrNotFound
}
// List returns all subscriptions with pagination.
func (m *Memory) List(_ context.Context, limit, offset int) ([]*Record, error) {
m.mu.RLock()
defer m.mu.RUnlock()
// Collect all records
var all []*Record
for _, record := range m.records {
all = append(all, record)
}
// Apply pagination
if offset >= len(all) {
return nil, nil
}
end := offset + limit
if end > len(all) {
end = len(all)
}
results := make([]*Record, 0, end-offset)
for i := offset; i < end; i++ {
results = append(results, copyRecord(all[i]))
}
return results, nil
}
// Close is a no-op for in-memory storage.
func (m *Memory) Close() error {
return nil
}
func copyRecord(r *Record) *Record {
return &Record{
ID: r.ID,
UserID: r.UserID,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
Subscription: &webpush.Subscription{
Endpoint: r.Subscription.Endpoint,
Keys: webpush.Keys{
P256dh: r.Subscription.Keys.P256dh,
Auth: r.Subscription.Keys.Auth,
},
},
}
}

212
storage/sqlite.go Normal file
View file

@ -0,0 +1,212 @@
package storage
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/imjasonh/webpush"
_ "github.com/mattn/go-sqlite3" // SQLite driver
)
// SQLite implements storage using SQLite.
type SQLite struct {
db *sql.DB
}
// NewSQLite creates a new SQLite storage.
// dsn is the data source name, e.g., "webpush.db" or ":memory:".
func NewSQLite(dsn string) (*SQLite, error) {
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
// Create table if it doesn't exist
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_id ON subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_endpoint ON subscriptions(endpoint);
`)
if err != nil {
db.Close()
return nil, fmt.Errorf("creating table: %w", err)
}
return &SQLite{db: db}, nil
}
// Save stores or updates a subscription.
func (s *SQLite) Save(ctx context.Context, record *Record) error {
now := time.Now()
if record.CreatedAt.IsZero() {
record.CreatedAt = now
}
record.UpdatedAt = now
_, err := s.db.ExecContext(ctx, `
INSERT INTO subscriptions (id, user_id, endpoint, p256dh, auth, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
user_id = excluded.user_id,
endpoint = excluded.endpoint,
p256dh = excluded.p256dh,
auth = excluded.auth,
updated_at = excluded.updated_at
`,
record.ID,
record.UserID,
record.Subscription.Endpoint,
record.Subscription.Keys.P256dh,
record.Subscription.Keys.Auth,
record.CreatedAt,
record.UpdatedAt,
)
if err != nil {
return fmt.Errorf("saving subscription: %w", err)
}
return nil
}
// Get retrieves a subscription by ID.
func (s *SQLite) Get(ctx context.Context, id string) (*Record, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, user_id, endpoint, p256dh, auth, created_at, updated_at
FROM subscriptions WHERE id = ?
`, id)
return scanRecord(row)
}
// GetByEndpoint retrieves a subscription by its endpoint URL.
func (s *SQLite) GetByEndpoint(ctx context.Context, endpoint string) (*Record, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, user_id, endpoint, p256dh, auth, created_at, updated_at
FROM subscriptions WHERE endpoint = ?
`, endpoint)
return scanRecord(row)
}
// GetByUserID retrieves all subscriptions for a user.
func (s *SQLite) GetByUserID(ctx context.Context, userID string) ([]*Record, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, user_id, endpoint, p256dh, auth, created_at, updated_at
FROM subscriptions WHERE user_id = ?
`, userID)
if err != nil {
return nil, fmt.Errorf("querying subscriptions: %w", err)
}
defer rows.Close()
return scanRecords(rows)
}
// Delete removes a subscription by ID.
func (s *SQLite) Delete(ctx context.Context, id string) error {
result, err := s.db.ExecContext(ctx, "DELETE FROM subscriptions WHERE id = ?", id)
if err != nil {
return fmt.Errorf("deleting subscription: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("checking rows affected: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
// DeleteByEndpoint removes a subscription by its endpoint URL.
func (s *SQLite) DeleteByEndpoint(ctx context.Context, endpoint string) error {
result, err := s.db.ExecContext(ctx, "DELETE FROM subscriptions WHERE endpoint = ?", endpoint)
if err != nil {
return fmt.Errorf("deleting subscription: %w", err)
}
n, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("checking rows affected: %w", err)
}
if n == 0 {
return ErrNotFound
}
return nil
}
// List returns all subscriptions with pagination.
func (s *SQLite) List(ctx context.Context, limit, offset int) ([]*Record, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, user_id, endpoint, p256dh, auth, created_at, updated_at
FROM subscriptions
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`, limit, offset)
if err != nil {
return nil, fmt.Errorf("querying subscriptions: %w", err)
}
defer rows.Close()
return scanRecords(rows)
}
// Close closes the database connection.
func (s *SQLite) Close() error {
return s.db.Close()
}
type scanner interface {
Scan(dest ...interface{}) error
}
func scanRecord(row scanner) (*Record, error) {
var (
id string
userID sql.NullString
endpoint string
p256dh string
auth string
createdAt time.Time
updatedAt time.Time
)
err := row.Scan(&id, &userID, &endpoint, &p256dh, &auth, &createdAt, &updatedAt)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("scanning row: %w", err)
}
return &Record{
ID: id,
UserID: userID.String,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
Subscription: &webpush.Subscription{
Endpoint: endpoint,
Keys: webpush.Keys{
P256dh: p256dh,
Auth: auth,
},
},
}, nil
}
func scanRecords(rows *sql.Rows) ([]*Record, error) {
var records []*Record
for rows.Next() {
record, err := scanRecord(rows)
if err != nil {
return nil, err
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating rows: %w", err)
}
return records, nil
}

46
storage/storage.go Normal file
View file

@ -0,0 +1,46 @@
// Package storage provides interfaces and implementations for storing
// web push subscriptions.
package storage
import (
"context"
"time"
"github.com/imjasonh/webpush"
)
// Record represents a stored subscription with metadata.
type Record struct {
ID string `json:"id"`
UserID string `json:"user_id,omitempty"`
Subscription *webpush.Subscription `json:"subscription"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Storage defines the interface for storing web push subscriptions.
type Storage interface {
// Save stores or updates a subscription.
Save(ctx context.Context, record *Record) error
// Get retrieves a subscription by ID.
Get(ctx context.Context, id string) (*Record, error)
// GetByEndpoint retrieves a subscription by its endpoint URL.
GetByEndpoint(ctx context.Context, endpoint string) (*Record, error)
// GetByUserID retrieves all subscriptions for a user.
GetByUserID(ctx context.Context, userID string) ([]*Record, error)
// Delete removes a subscription by ID.
Delete(ctx context.Context, id string) error
// DeleteByEndpoint removes a subscription by its endpoint URL.
DeleteByEndpoint(ctx context.Context, endpoint string) error
// List returns all subscriptions with pagination.
List(ctx context.Context, limit, offset int) ([]*Record, error)
// Close closes the storage connection.
Close() error
}

250
storage/storage_test.go Normal file
View file

@ -0,0 +1,250 @@
package storage
import (
"context"
"testing"
"github.com/imjasonh/webpush"
)
func TestMemory(t *testing.T) {
testStorage(t, NewMemory())
}
func TestSQLite(t *testing.T) {
// Use in-memory SQLite for testing
storage, err := NewSQLite(":memory:")
if err != nil {
t.Fatalf("NewSQLite() error = %v", err)
}
defer storage.Close()
testStorage(t, storage)
}
func testStorage(t *testing.T, s Storage) {
ctx := context.Background()
// Test Save and Get
record := &Record{
ID: "test-id-1",
UserID: "user-1",
Subscription: &webpush.Subscription{
Endpoint: "https://push.example.com/abc123",
Keys: webpush.Keys{
P256dh: "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
Auth: "tBHItJI5svbpez7KI4CCXg",
},
},
}
if err := s.Save(ctx, record); err != nil {
t.Fatalf("Save() error = %v", err)
}
// Get by ID
got, err := s.Get(ctx, record.ID)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if got.ID != record.ID {
t.Errorf("Get() ID = %q, want %q", got.ID, record.ID)
}
if got.UserID != record.UserID {
t.Errorf("Get() UserID = %q, want %q", got.UserID, record.UserID)
}
if got.Subscription.Endpoint != record.Subscription.Endpoint {
t.Errorf("Get() Endpoint = %q, want %q", got.Subscription.Endpoint, record.Subscription.Endpoint)
}
if got.CreatedAt.IsZero() {
t.Error("Get() CreatedAt is zero")
}
// Get by endpoint
got, err = s.GetByEndpoint(ctx, record.Subscription.Endpoint)
if err != nil {
t.Fatalf("GetByEndpoint() error = %v", err)
}
if got.ID != record.ID {
t.Errorf("GetByEndpoint() ID = %q, want %q", got.ID, record.ID)
}
// Get by user ID
records, err := s.GetByUserID(ctx, record.UserID)
if err != nil {
t.Fatalf("GetByUserID() error = %v", err)
}
if len(records) != 1 {
t.Errorf("GetByUserID() count = %d, want 1", len(records))
}
// Add another record for same user
record2 := &Record{
ID: "test-id-2",
UserID: "user-1",
Subscription: &webpush.Subscription{
Endpoint: "https://push.example.com/def456",
Keys: webpush.Keys{
P256dh: "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
Auth: "tBHItJI5svbpez7KI4CCXg",
},
},
}
if err := s.Save(ctx, record2); err != nil {
t.Fatalf("Save() error = %v", err)
}
// Get by user ID should return 2
records, err = s.GetByUserID(ctx, "user-1")
if err != nil {
t.Fatalf("GetByUserID() error = %v", err)
}
if len(records) != 2 {
t.Errorf("GetByUserID() count = %d, want 2", len(records))
}
// List
records, err = s.List(ctx, 10, 0)
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(records) != 2 {
t.Errorf("List() count = %d, want 2", len(records))
}
// List with pagination
records, err = s.List(ctx, 1, 0)
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(records) != 1 {
t.Errorf("List(limit=1) count = %d, want 1", len(records))
}
// Delete by ID
if err := s.Delete(ctx, record.ID); err != nil {
t.Fatalf("Delete() error = %v", err)
}
// Verify deleted
_, err = s.Get(ctx, record.ID)
if err != ErrNotFound {
t.Errorf("Get() after delete error = %v, want ErrNotFound", err)
}
// Delete by endpoint
if err := s.DeleteByEndpoint(ctx, record2.Subscription.Endpoint); err != nil {
t.Fatalf("DeleteByEndpoint() error = %v", err)
}
// Verify deleted
_, err = s.GetByEndpoint(ctx, record2.Subscription.Endpoint)
if err != ErrNotFound {
t.Errorf("GetByEndpoint() after delete error = %v, want ErrNotFound", err)
}
// List should be empty now
records, err = s.List(ctx, 10, 0)
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(records) != 0 {
t.Errorf("List() count = %d, want 0", len(records))
}
}
func TestMemory_NotFound(t *testing.T) {
s := NewMemory()
ctx := context.Background()
_, err := s.Get(ctx, "nonexistent")
if err != ErrNotFound {
t.Errorf("Get() error = %v, want ErrNotFound", err)
}
_, err = s.GetByEndpoint(ctx, "https://nonexistent")
if err != ErrNotFound {
t.Errorf("GetByEndpoint() error = %v, want ErrNotFound", err)
}
err = s.Delete(ctx, "nonexistent")
if err != ErrNotFound {
t.Errorf("Delete() error = %v, want ErrNotFound", err)
}
err = s.DeleteByEndpoint(ctx, "https://nonexistent")
if err != ErrNotFound {
t.Errorf("DeleteByEndpoint() error = %v, want ErrNotFound", err)
}
}
func TestMemory_Update(t *testing.T) {
s := NewMemory()
ctx := context.Background()
record := &Record{
ID: "test-id",
UserID: "user-1",
Subscription: &webpush.Subscription{
Endpoint: "https://push.example.com/abc123",
Keys: webpush.Keys{
P256dh: "key1",
Auth: "auth1",
},
},
}
if err := s.Save(ctx, record); err != nil {
t.Fatalf("Save() error = %v", err)
}
// Update
record.UserID = "user-2"
record.Subscription.Endpoint = "https://push.example.com/new"
if err := s.Save(ctx, record); err != nil {
t.Fatalf("Save() error = %v", err)
}
got, err := s.Get(ctx, record.ID)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if got.UserID != "user-2" {
t.Errorf("Get() UserID = %q, want %q", got.UserID, "user-2")
}
if got.Subscription.Endpoint != "https://push.example.com/new" {
t.Errorf("Get() Endpoint = %q, want %q", got.Subscription.Endpoint, "https://push.example.com/new")
}
}
func TestSQLite_NotFound(t *testing.T) {
s, err := NewSQLite(":memory:")
if err != nil {
t.Fatalf("NewSQLite() error = %v", err)
}
defer s.Close()
ctx := context.Background()
_, err = s.Get(ctx, "nonexistent")
if err != ErrNotFound {
t.Errorf("Get() error = %v, want ErrNotFound", err)
}
_, err = s.GetByEndpoint(ctx, "https://nonexistent")
if err != ErrNotFound {
t.Errorf("GetByEndpoint() error = %v, want ErrNotFound", err)
}
err = s.Delete(ctx, "nonexistent")
if err != ErrNotFound {
t.Errorf("Delete() error = %v, want ErrNotFound", err)
}
err = s.DeleteByEndpoint(ctx, "https://nonexistent")
if err != ErrNotFound {
t.Errorf("DeleteByEndpoint() error = %v, want ErrNotFound", err)
}
}