| 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. |
| 16 | package main |
| 17 | |
| 18 | import ( |
| 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/* |
| 37 | var site embed.FS |
| 38 | |
| 39 | func 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 | |
| 47 | func 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 | |
| 103 | func 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 | |