// Command example is the smallest app that wires every aviso seam: // the schema, the three handlers, the two JS halves, a page with an // enable button, and a route that sends. It signs everyone in as // "dev" — an example, not a pattern — so the enrol/send loop can be // driven from one browser. // // Mint the key once and keep it — a subscription is bound to the key // it was made under, so a fresh key on every start would strand every // enrolled browser until it re-enrols (aviso.ErrKeyMismatch on send): // // go run amadan.net/rastrillo/aviso/cmd/aviso-key > .vapid-key # once // EXAMPLE_VAPID_PRIVATE_KEY="$(cat .vapid-key)" go run . # every start // // Push needs a secure context: http://localhost is one, so the default // origin works locally without TLS. package main import ( "context" "embed" "encoding/json" "io/fs" "log" "net/http" "os" "time" "amadan.net/rastrillo/rastrillo/csrf" "amadan.net/rastrillo/rastrillo/db" "amadan.net/rastrillo/rastrillo/migrate" "amadan.net/rastrillo/rastrillo/sessions" "amadan.net/rastrillo/aviso" ) //go:embed index.html static/* var site embed.FS func serveBytes(contentType string, b []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", "no-cache") _, _ = w.Write(b) } } func handler(svc *aviso.Service, origin string) http.Handler { mux := http.NewServeMux() static, _ := fs.Sub(site, "static") index, _ := site.ReadFile("index.html") sw, _ := site.ReadFile("static/sw.js") mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static)))) mux.HandleFunc("GET /static/aviso/push.mjs", serveBytes("text/javascript", aviso.JS())) mux.HandleFunc("GET /static/aviso/aviso-sw.js", serveBytes("text/javascript", aviso.WorkerJS())) // The worker at root scope, no-cache so a new one is noticed on // the next load rather than after a cache expiry nobody chose. mux.HandleFunc("GET /sw.js", serveBytes("text/javascript", sw)) mux.HandleFunc("GET /{$}", serveBytes("text/html; charset=utf-8", index)) mux.HandleFunc("GET /aviso/public-key", svc.PublicKey) mux.HandleFunc("POST /aviso/subscribe", svc.Subscribe) mux.HandleFunc("POST /aviso/unsubscribe", svc.Unsubscribe) // The app's own policy: who gets what. Here, the caller, now. Gated // like any state-changing POST in a rastrillo app. mux.HandleFunc("POST /notify", func(w http.ResponseWriter, r *http.Request) { if !csrf.SameOrigin(r, origin) { http.Error(w, "cross-origin request refused", http.StatusForbidden) return } sess, _ := sessions.Current(r) payload, _ := json.Marshal(map[string]string{ "title": "Hello from aviso", "body": "Sent at " + time.Now().Format(time.Kitchen), "url": "/", "tag": "example", }) res, err := svc.SendTo(r.Context(), sess.Subject, payload, aviso.Options{TTL: 60 * time.Second}) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } out := make([]map[string]any, 0, len(res)) for _, x := range res { m := map[string]any{"id": x.ID, "status": x.Status} if x.Err != nil { m["error"] = x.Err.Error() } out = append(out, m) } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(out) }) // Example-only: everyone is "dev". A real app runs // sessions.Middleware (or auth.RequireSession) here instead. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mux.ServeHTTP(w, sessions.WithSession(r, sessions.Session{Subject: "dev"})) }) } func main() { origin := os.Getenv("ORIGIN") if origin == "" { origin = "http://localhost:8080" } d, err := db.Open("example.db", nil) if err != nil { log.Fatal(err) } if _, err := migrate.Apply(context.Background(), d, migrate.Merge(sessions.Schema, aviso.Schema)); err != nil { log.Fatal(err) } svc, err := aviso.New(aviso.Config{ DB: d.Writer(), PrivateKey: os.Getenv("EXAMPLE_VAPID_PRIVATE_KEY"), Contact: "mailto:ops@example.test", Origin: origin, }) if err != nil { log.Fatal(err) } log.Println("listening on :8080 as", origin) log.Fatal(http.ListenAndServe(":8080", handler(svc, origin))) }