rastrillo / aviso Public

Clone
git clone https://amadan.net/rastrillo/aviso

Plain git — no account needed to clone.

Download

Download this file

1// Command example is the smallest app that wires every aviso seam:
2// the schema, the three handlers, the two JS halves, a page with an
3// enable button, and a route that sends. It signs everyone in as
4// "dev" — an example, not a pattern — so the enrol/send loop can be
5// driven from one browser.
6//
7// Mint the key once and keep it — a subscription is bound to the key
8// it was made under, so a fresh key on every start would strand every
9// enrolled browser until it re-enrols (aviso.ErrKeyMismatch on send):
10//
11// go run amadan.net/rastrillo/aviso/cmd/aviso-key > .vapid-key # once
12// EXAMPLE_VAPID_PRIVATE_KEY="$(cat .vapid-key)" go run . # every start
13//
14// Push needs a secure context: http://localhost is one, so the default
15// origin works locally without TLS.
16package main
17
18import (
19 "context"
20 "embed"
21 "encoding/json"
22 "io/fs"
23 "log"
24 "net/http"
25 "os"
26 "time"
27
28 "amadan.net/rastrillo/rastrillo/csrf"
29 "amadan.net/rastrillo/rastrillo/db"
30 "amadan.net/rastrillo/rastrillo/migrate"
31 "amadan.net/rastrillo/rastrillo/sessions"
32
33 "amadan.net/rastrillo/aviso"
34)
35
36//go:embed index.html static/*
37var site embed.FS
38
39func serveBytes(contentType string, b []byte) http.HandlerFunc {
40 return func(w http.ResponseWriter, r *http.Request) {
41 w.Header().Set("Content-Type", contentType)
42 w.Header().Set("Cache-Control", "no-cache")
43 _, _ = w.Write(b)
44 }
45}
46
47func handler(svc *aviso.Service, origin string) http.Handler {
48 mux := http.NewServeMux()
49 static, _ := fs.Sub(site, "static")
50 index, _ := site.ReadFile("index.html")
51 sw, _ := site.ReadFile("static/sw.js")
52
53 mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
54 mux.HandleFunc("GET /static/aviso/push.mjs", serveBytes("text/javascript", aviso.JS()))
55 mux.HandleFunc("GET /static/aviso/aviso-sw.js", serveBytes("text/javascript", aviso.WorkerJS()))
56 // The worker at root scope, no-cache so a new one is noticed on
57 // the next load rather than after a cache expiry nobody chose.
58 mux.HandleFunc("GET /sw.js", serveBytes("text/javascript", sw))
59 mux.HandleFunc("GET /{$}", serveBytes("text/html; charset=utf-8", index))
60
61 mux.HandleFunc("GET /aviso/public-key", svc.PublicKey)
62 mux.HandleFunc("POST /aviso/subscribe", svc.Subscribe)
63 mux.HandleFunc("POST /aviso/unsubscribe", svc.Unsubscribe)
64
65 // The app's own policy: who gets what. Here, the caller, now. Gated
66 // like any state-changing POST in a rastrillo app.
67 mux.HandleFunc("POST /notify", func(w http.ResponseWriter, r *http.Request) {
68 if !csrf.SameOrigin(r, origin) {
69 http.Error(w, "cross-origin request refused", http.StatusForbidden)
70 return
71 }
72 sess, _ := sessions.Current(r)
73 payload, _ := json.Marshal(map[string]string{
74 "title": "Hello from aviso",
75 "body": "Sent at " + time.Now().Format(time.Kitchen),
76 "url": "/",
77 "tag": "example",
78 })
79 res, err := svc.SendTo(r.Context(), sess.Subject, payload, aviso.Options{TTL: 60 * time.Second})
80 if err != nil {
81 http.Error(w, err.Error(), http.StatusInternalServerError)
82 return
83 }
84 out := make([]map[string]any, 0, len(res))
85 for _, x := range res {
86 m := map[string]any{"id": x.ID, "status": x.Status}
87 if x.Err != nil {
88 m["error"] = x.Err.Error()
89 }
90 out = append(out, m)
91 }
92 w.Header().Set("Content-Type", "application/json")
93 _ = json.NewEncoder(w).Encode(out)
94 })
95
96 // Example-only: everyone is "dev". A real app runs
97 // sessions.Middleware (or auth.RequireSession) here instead.
98 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
99 mux.ServeHTTP(w, sessions.WithSession(r, sessions.Session{Subject: "dev"}))
100 })
101}
102
103func main() {
104 origin := os.Getenv("ORIGIN")
105 if origin == "" {
106 origin = "http://localhost:8080"
107 }
108 d, err := db.Open("example.db", nil)
109 if err != nil {
110 log.Fatal(err)
111 }
112 if _, err := migrate.Apply(context.Background(), d, migrate.Merge(sessions.Schema, aviso.Schema)); err != nil {
113 log.Fatal(err)
114 }
115 svc, err := aviso.New(aviso.Config{
116 DB: d.Writer(),
117 PrivateKey: os.Getenv("EXAMPLE_VAPID_PRIVATE_KEY"),
118 Contact: "mailto:ops@example.test",
119 Origin: origin,
120 })
121 if err != nil {
122 log.Fatal(err)
123 }
124 log.Println("listening on :8080 as", origin)
125 log.Fatal(http.ListenAndServe(":8080", handler(svc, origin)))
126}
127