diff --git a/app/inithttp.go b/app/inithttp.go index 5478051117..9f66245452 100644 --- a/app/inithttp.go +++ b/app/inithttp.go @@ -1,14 +1,19 @@ package app import ( - "context" - "net/http" - "net/url" - "strings" - "time" - - "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/target/goalert/config" + "bytes" + "context" + "encoding/json" + "database/sql" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/target/goalert/config" "github.com/target/goalert/expflag" "github.com/target/goalert/genericapi" "github.com/target/goalert/grafana" @@ -18,8 +23,9 @@ import ( prometheus "github.com/target/goalert/prometheusalertmanager" "github.com/target/goalert/site24x7" "github.com/target/goalert/util/errutil" - "github.com/target/goalert/util/log" - "github.com/target/goalert/web" + "github.com/target/goalert/util/log" + "github.com/target/goalert/web" + webpush "github.com/SherClockHolmes/webpush-go" ) func (app *App) initHTTP(ctx context.Context) error { @@ -112,7 +118,117 @@ func (app *App) initHTTP(ctx context.Context) error { }) } - mux := http.NewServeMux() + mux := http.NewServeMux() + + // Ensure table for persisted Web Push subscriptions (quick bootstrap without migration). + if _, err := app.db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS user_web_push_subscriptions ( + endpoint text PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + data jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS user_web_push_subscriptions_user_id_idx ON user_web_push_subscriptions (user_id); + `); err != nil { + return err + } + sendPush := func(ctx context.Context, payload []byte, userIDs ...string) { + // Use a background context carrying logger + config to avoid request cancellation. + ctx = app.Context(log.FromContext(ctx).BackgroundContext()) + cfg := config.FromContext(ctx) + if !cfg.WebPush.Enable || cfg.WebPush.VAPIDPublicKey == "" || cfg.WebPush.VAPIDPrivateKey == "" { + log.Logf(ctx, "webpush: disabled or missing VAPID keys; skipping send (enabled=%t)", cfg.WebPush.Enable) + return + } + type subKeys struct{ P256dh, Auth string } + type sub struct{ + Endpoint string `json:"endpoint"` + Keys subKeys `json:"keys"` + } + var rows *sql.Rows + var err error + if len(userIDs) == 0 { + rows, err = app.db.QueryContext(ctx, `select data from user_web_push_subscriptions`) + } else { + var sb strings.Builder + sb.WriteString("select data from user_web_push_subscriptions where user_id in (") + for i := range userIDs { + if i > 0 { sb.WriteString(",") } + fmt.Fprintf(&sb, "$%d::uuid", i+1) + } + sb.WriteString(")") + args := make([]any, len(userIDs)) + for i, id := range userIDs { args[i] = id } + rows, err = app.db.QueryContext(ctx, sb.String(), args...) + } + if err != nil { + log.Logf(ctx, "webpush: query subscriptions failed: %v", err) + return + } + defer rows.Close() + var total int + for rows.Next() { + var raw json.RawMessage + if err := rows.Scan(&raw); err != nil { continue } + var s sub + if err := json.Unmarshal(raw, &s); err != nil || s.Endpoint == "" || s.Keys.P256dh == "" || s.Keys.Auth == "" { continue } + subObj := &webpush.Subscription{ Endpoint: s.Endpoint, Keys: webpush.Keys{ Auth: s.Keys.Auth, P256dh: s.Keys.P256dh } } + go func(subObj *webpush.Subscription) { + // Log destination provider based on endpoint host for diagnostics (iOS/APNs vs others) + host := "" + if u, perr := url.Parse(subObj.Endpoint); perr == nil { host = u.Host } + provider := "unknown" + if strings.Contains(host, "web.push.apple.com") { provider = "apple" } + if strings.Contains(host, "fcm.googleapis.com") || strings.Contains(host, "firebase") { provider = "fcm" } + if strings.Contains(host, "mozilla") || strings.Contains(host, "push.services.mozilla.com") { provider = "mozilla" } + if strings.Contains(host, "notify.windows.com") { provider = "wns" } + + // Use a stable, valid URL as VAPID subject based on configured PublicURL. + subj := cfg.PublicURL() + if subj == "" { + subj = "mailto:no-reply@localhost" + } + + resp, err := webpush.SendNotification(payload, subObj, &webpush.Options{ + Subscriber: subj, + VAPIDPublicKey: cfg.WebPush.VAPIDPublicKey, + VAPIDPrivateKey: cfg.WebPush.VAPIDPrivateKey, + TTL: 60, + Urgency: "high", + }) + var status int + var bodySnippet string + if resp != nil { + status = resp.StatusCode + // Read small body for diagnostics on error statuses + if status >= 400 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + bodySnippet = string(b) + } + _ = resp.Body.Close() + } + if err != nil { + log.Logf(ctx, "webpush: send failed; provider=%s host=%s status=%d err=%v", provider, host, status, err) + return + } + if status >= 400 { + log.Logf(ctx, "webpush: send non-2xx; provider=%s host=%s status=%d body=%q", provider, host, status, bodySnippet) + } else { + log.Logf(ctx, "webpush: send complete; provider=%s host=%s status=%d", provider, host, status) + } + // Clean up expired/invalid subscriptions + if status == http.StatusGone || status == http.StatusNotFound { + if _, derr := app.db.ExecContext(ctx, `delete from user_web_push_subscriptions where endpoint = $1`, subObj.Endpoint); derr != nil { + log.Logf(ctx, "webpush: cleanup failed; endpoint=%s err=%v", subObj.Endpoint, derr) + } else { + log.Logf(ctx, "webpush: removed expired subscription; endpoint=%s", subObj.Endpoint) + } + } + }(subObj) + total++ + } + log.Logf(ctx, "webpush: queued sends; targeted-users=%d total-subs=%d", len(userIDs), total) + } generic := genericapi.NewHandler(genericapi.Config{ AlertStore: app.AlertStore, @@ -126,6 +242,101 @@ func (app *App) initHTTP(ctx context.Context) error { mux.HandleFunc("GET /api/v2/config", app.ConfigStore.ServeConfig) mux.HandleFunc("PUT /api/v2/config", app.ConfigStore.ServeConfig) + // Accept Web Push subscription payloads (UI uses this endpoint). + mux.HandleFunc("POST /api/push/subscribe", func(w http.ResponseWriter, req *http.Request) { + data, err := io.ReadAll(io.LimitReader(req.Body, 1<<20)) + if err != nil { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest); return } + var tmp struct{ Endpoint string `json:"endpoint"` } + _ = json.Unmarshal(data, &tmp) + if tmp.Endpoint == "" { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest); return } + uid := permission.UserID(req.Context()) + if uid == "" { http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized); return } + // Log UA and endpoint host for iOS/Safari diagnostics + ua := req.Header.Get("User-Agent") + host := "" + if u, perr := url.Parse(tmp.Endpoint); perr == nil { host = u.Host } + provider := "unknown" + if strings.Contains(host, "web.push.apple.com") { provider = "apple" } + if strings.Contains(host, "fcm.googleapis.com") || strings.Contains(host, "firebase") { provider = "fcm" } + if strings.Contains(host, "mozilla") || strings.Contains(host, "push.services.mozilla.com") { provider = "mozilla" } + if strings.Contains(host, "notify.windows.com") { provider = "wns" } + log.Logf(req.Context(), "webpush: subscribe; user=%s provider=%s host=%s ua=%q", uid, provider, host, ua) + _, err = app.db.ExecContext(req.Context(), ` + insert into user_web_push_subscriptions (endpoint, user_id, data) + values ($1, $2::uuid, $3::jsonb) + on conflict (endpoint) do update set user_id = excluded.user_id, data = excluded.data + `, tmp.Endpoint, uid, data) + if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError); return } + w.WriteHeader(http.StatusNoContent) + }) + + // List current user's push subscriptions. + mux.HandleFunc("GET /api/push/subscriptions", func(w http.ResponseWriter, req *http.Request) { + uid := permission.UserID(req.Context()) + if uid == "" { http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized); return } + rows, err := app.db.QueryContext(req.Context(), ` + select endpoint, created_at from user_web_push_subscriptions where user_id = $1::uuid order by created_at desc + `, uid) + if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError); return } + defer rows.Close() + type respT struct{ Endpoint, Host, Provider string; CreatedAt time.Time } + var out []respT + for rows.Next() { + var endpoint string + var created time.Time + if err := rows.Scan(&endpoint, &created); err != nil { continue } + h := ""; prov := "unknown" + if u, e := url.Parse(endpoint); e == nil { h = u.Host } + if strings.Contains(h, "web.push.apple.com") { prov = "apple" } + if strings.Contains(h, "fcm.googleapis.com") || strings.Contains(h, "firebase") { prov = "fcm" } + if strings.Contains(h, "mozilla") || strings.Contains(h, "push.services.mozilla.com") { prov = "mozilla" } + if strings.Contains(h, "notify.windows.com") { prov = "wns" } + out = append(out, respT{ Endpoint: endpoint, Host: h, Provider: prov, CreatedAt: created }) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(out) + }) + + // Delete a specific subscription for current user by endpoint. + mux.HandleFunc("DELETE /api/push/subscriptions", func(w http.ResponseWriter, req *http.Request) { + uid := permission.UserID(req.Context()) + if uid == "" { http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized); return } + var endpoint string + // Allow query param or JSON body + endpoint = req.URL.Query().Get("endpoint") + if endpoint == "" { + var p struct{ Endpoint string `json:"endpoint"` } + body, _ := io.ReadAll(io.LimitReader(req.Body, 1<<16)) + _ = json.Unmarshal(body, &p) + endpoint = p.Endpoint + } + if endpoint == "" { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest); return } + // Restrict delete to current user's rows + res, err := app.db.ExecContext(req.Context(), ` + delete from user_web_push_subscriptions where endpoint = $1 and user_id = $2::uuid + `, endpoint, uid) + if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError); return } + n, _ := res.RowsAffected() + log.Logf(req.Context(), "webpush: delete subscription; user=%s endpoint=%s removed=%d", uid, endpoint, n) + w.WriteHeader(http.StatusNoContent) + }) + + // Admin/test endpoint to send a sample notification to all saved subscriptions. + mux.HandleFunc("POST /admin/test/webpush", func(w http.ResponseWriter, req *http.Request) { + if err := permission.LimitCheckAny(req.Context(), permission.Admin); err != nil { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + payload, _ := json.Marshal(struct{ + OnCall bool `json:"onCall"` + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + }{ OnCall: true, Title: "Test Notification", Body: "This is a test", URL: "/alerts" }) + go sendPush(req.Context(), payload) + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("GET /api/v2/identity/providers", app.AuthHandler.ServeProviders) mux.HandleFunc("POST /api/v2/identity/logout", app.AuthHandler.ServeLogout) @@ -144,11 +355,94 @@ func (app *App) initHTTP(ctx context.Context) error { mux.HandleFunc("POST /api/v2/uik", app.UIKHandler.ServeHTTP) } mux.HandleFunc("POST /api/v2/mailgun/incoming", mailgun.IngressWebhooks(app.AlertStore, app.IntegrationKeyStore)) - mux.HandleFunc("POST /api/v2/grafana/incoming", grafana.GrafanaToEventsAPI(app.AlertStore, app.IntegrationKeyStore)) +{ + inner := grafana.GrafanaToEventsAPI(app.AlertStore, app.IntegrationKeyStore) + mux.HandleFunc("POST /api/v2/grafana/incoming", func(w http.ResponseWriter, req *http.Request) { + b, _ := io.ReadAll(io.LimitReader(req.Body, 1<<20)) + req.Body = io.NopCloser(bytes.NewReader(b)) + inner(w, req) + + var p struct{ + Title string `json:"title"` + CommonAnnotations map[string]string `json:"commonAnnotations"` + } + _ = json.Unmarshal(b, &p) + title := p.Title + body := p.CommonAnnotations["summary"] + if body == "" { body = p.CommonAnnotations["message"] } + if title == "" && body == "" { return } + // Target only on-call users for the service + svcID := permission.ServiceID(req.Context()) + var tgtUserIDs []string + if svcID != "" { + permission.SudoContext(req.Context(), func(ctx context.Context) { + if oc, err := app.OnCallStore.OnCallUsersByService(ctx, svcID); err == nil { + for _, u := range oc { tgtUserIDs = append(tgtUserIDs, u.UserID) } + } else { + log.Logf(ctx, "webpush: grafana oncall lookup failed: %v", err) + } + }) + } + log.Logf(req.Context(), "webpush: grafana incoming; serviceID=%s title=%q body.len=%d target-users=%d", svcID, title, len(body), len(tgtUserIDs)) + note := struct{ + OnCall bool `json:"onCall"` + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + }{ OnCall: true, Title: title, Body: body, URL: "/alerts" } + payload, _ := json.Marshal(note) + // Send only to on-call users (skip if none) + if len(tgtUserIDs) == 0 { + log.Logf(req.Context(), "webpush: no on-call users; skipping send") + } else { + go sendPush(req.Context(), payload, tgtUserIDs...) + } + }) +} mux.HandleFunc("POST /api/v2/site24x7/incoming", site24x7.Site24x7ToEventsAPI(app.AlertStore, app.IntegrationKeyStore)) mux.HandleFunc("POST /api/v2/prometheusalertmanager/incoming", prometheus.PrometheusAlertmanagerEventsAPI(app.AlertStore, app.IntegrationKeyStore)) - mux.HandleFunc("POST /api/v2/generic/incoming", generic.ServeCreateAlert) + // Wrap generic incoming to also fan out web push notifications + { + h := generic.ServeCreateAlert + mux.HandleFunc("POST /api/v2/generic/incoming", func(w http.ResponseWriter, req *http.Request) { + b, _ := io.ReadAll(io.LimitReader(req.Body, 1<<20)) + req.Body = io.NopCloser(bytes.NewReader(b)) + h(w, req) + + // Attempt to extract a title/body for the notification + var p struct{ + Summary string `json:"summary"` + Details string `json:"details"` + } + _ = json.Unmarshal(b, &p) + if p.Summary == "" && p.Details == "" { return } + svcID := permission.ServiceID(req.Context()) + var tgtUserIDs []string + if svcID != "" { + permission.SudoContext(req.Context(), func(ctx context.Context) { + if oc, err := app.OnCallStore.OnCallUsersByService(ctx, svcID); err == nil { + for _, u := range oc { tgtUserIDs = append(tgtUserIDs, u.UserID) } + } else { + log.Logf(ctx, "webpush: generic oncall lookup failed: %v", err) + } + }) + } + log.Logf(req.Context(), "webpush: generic incoming; serviceID=%s title=%q body.len=%d target-users=%d", svcID, p.Summary, len(p.Details), len(tgtUserIDs)) + note := struct{ + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + }{ Title: p.Summary, Body: p.Details, URL: "/alerts" } + payload, _ := json.Marshal(note) + // Send only to on-call users (skip if none) + if len(tgtUserIDs) == 0 { + log.Logf(req.Context(), "webpush: no on-call users; skipping send") + } else { + go sendPush(req.Context(), payload, tgtUserIDs...) + } + }) + } mux.HandleFunc("POST /api/v2/heartbeat/{heartbeatID}", generic.ServeHeartbeatCheck) mux.HandleFunc("GET /api/v2/user-avatar/{userID}", generic.ServeUserAvatar) mux.HandleFunc("GET /api/v2/calendar", app.CalSubStore.ServeICalData) diff --git a/config/config.go b/config/config.go index 6a13183e6a..f4d61a093d 100644 --- a/config/config.go +++ b/config/config.go @@ -145,6 +145,13 @@ type Config struct { Enable bool `public:"true" info:"Enables Feedback link in nav bar."` OverrideURL string `public:"true" info:"Use a custom URL for Feedback link in nav bar."` } + + // WebPush contains configuration for browser push notifications (VAPID). + WebPush struct { + Enable bool `public:"true" info:"Enable Web Push notifications (requires VAPID keys)."` + VAPIDPublicKey string `public:"true" info:"Public VAPID key (Base64 URL-safe, unpadded) exposed to clients."` + VAPIDPrivateKey string `password:"true" info:"Private VAPID key used for signing push messages (keep secret)."` + } } // EmailIngressEnabled returns true if a provider is configured for generating alerts from email, otherwise false @@ -560,6 +567,12 @@ func (cfg Config) Validate() error { "From", cfg.SMTP.From, "Address", cfg.SMTP.Address, ), + + // If WebPush is enabled, require both VAPID keys. + validateEnable("WebPush", cfg.WebPush.Enable, + "VAPIDPublicKey", cfg.WebPush.VAPIDPublicKey, + "VAPIDPrivateKey", cfg.WebPush.VAPIDPrivateKey, + ), ) if cfg.Feedback.OverrideURL != "" { diff --git a/devtools/ci/dockerfiles/goalert/Dockerfile b/devtools/ci/dockerfiles/goalert/Dockerfile index ef9415305c..e62c49363a 100644 --- a/devtools/ci/dockerfiles/goalert/Dockerfile +++ b/devtools/ci/dockerfiles/goalert/Dockerfile @@ -1,13 +1,18 @@ FROM docker.io/goalert/build-env:go1.25.0 AS build -COPY / /build/ + +# Ensure we can write to the build workspace (base image runs as a non-root user) +USER root +COPY . /build/ +RUN chown -R user:user /build +USER user WORKDIR /build -RUN make clean bin/build/goalert-linux-amd64 +RUN make clean bin/build/goalert-linux-amd64 || ( echo "Make failed; printing env for diagnostics" && env && exit 1 ) FROM docker.io/library/alpine RUN apk --no-cache add ca-certificates -ENV GOALERT_LISTEN :8081 +ENV GOALERT_LISTEN=:8081 EXPOSE 8081 CMD ["/usr/bin/goalert"] COPY --from=build /build/bin/build/goalert-linux-amd64/goalert/bin/* /usr/bin/ -RUN /usr/bin/goalert self-test +RUN /usr/bin/goalert self-test \ No newline at end of file diff --git a/go.mod b/go.mod index e157a3d04b..f798de29a0 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.0 require ( github.com/99designs/gqlgen v0.17.78 + github.com/SherClockHolmes/webpush-go v1.3.0 github.com/brianvoe/gofakeit/v6 v6.28.0 github.com/coreos/go-oidc/v3 v3.15.0 github.com/creack/pty/v2 v2.0.1 @@ -145,6 +146,7 @@ require ( github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gofrs/flock v0.12.1 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect github.com/golangci/go-printf-func-name v0.1.0 // indirect diff --git a/go.sum b/go.sum index b327f9d65f..e06a7fe4d9 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgz github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg= github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= +github.com/SherClockHolmes/webpush-go v1.3.0 h1:CAu3FvEE9QS4drc3iKNgpBWFfGqNthKlZhp5QpYnu6k= +github.com/SherClockHolmes/webpush-go v1.3.0/go.mod h1:AxRHmJuYwKGG1PVgYzToik1lphQvDnqFYDqimHvwhIw= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -258,6 +260,8 @@ github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJA github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -857,6 +861,7 @@ golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= diff --git a/graphql2/mapconfig.go b/graphql2/mapconfig.go index dbd5c62523..f5cc41f769 100644 --- a/graphql2/mapconfig.go +++ b/graphql2/mapconfig.go @@ -24,7 +24,7 @@ func MapConfigHints(cfg config.Hints) []ConfigHint { // MapConfigValues will map a Config struct into a flat list of ConfigValue structs. func MapConfigValues(cfg config.Config) []ConfigValue { - return []ConfigValue{ + return []ConfigValue{ {ID: "General.ApplicationName", Type: ConfigTypeString, Description: "The name used in messaging and page titles. Defaults to \"GoAlert\".", Value: cfg.General.ApplicationName}, {ID: "General.PublicURL", Type: ConfigTypeString, Description: "Publicly routable URL for UI links and API calls.", Value: cfg.General.PublicURL, Deprecated: "Use --public-url flag instead, which takes precedence."}, {ID: "General.GoogleAnalyticsID", Type: ConfigTypeString, Description: "If set, will post user metrics to the corresponding data stream in Google Analytics 4.", Value: cfg.General.GoogleAnalyticsID}, @@ -90,13 +90,16 @@ func MapConfigValues(cfg config.Config) []ConfigValue { {ID: "Webhook.Enable", Type: ConfigTypeBoolean, Description: "Enables webhook as a contact method.", Value: fmt.Sprintf("%t", cfg.Webhook.Enable)}, {ID: "Webhook.AllowedURLs", Type: ConfigTypeStringList, Description: "If set, allows webhooks for these domains only.", Value: strings.Join(cfg.Webhook.AllowedURLs, "\n")}, {ID: "Feedback.Enable", Type: ConfigTypeBoolean, Description: "Enables Feedback link in nav bar.", Value: fmt.Sprintf("%t", cfg.Feedback.Enable)}, - {ID: "Feedback.OverrideURL", Type: ConfigTypeString, Description: "Use a custom URL for Feedback link in nav bar.", Value: cfg.Feedback.OverrideURL}, - } + {ID: "Feedback.OverrideURL", Type: ConfigTypeString, Description: "Use a custom URL for Feedback link in nav bar.", Value: cfg.Feedback.OverrideURL}, + {ID: "WebPush.Enable", Type: ConfigTypeBoolean, Description: "Enable Web Push notifications (requires VAPID keys).", Value: fmt.Sprintf("%t", cfg.WebPush.Enable)}, + {ID: "WebPush.VAPIDPublicKey", Type: ConfigTypeString, Description: "Public VAPID key (Base64 URL-safe, unpadded) exposed to clients.", Value: cfg.WebPush.VAPIDPublicKey}, + {ID: "WebPush.VAPIDPrivateKey", Type: ConfigTypeString, Description: "Private VAPID key used for signing push messages (keep secret).", Value: cfg.WebPush.VAPIDPrivateKey, Password: true}, + } } // MapPublicConfigValues will map a Config struct into a flat list of ConfigValue structs. func MapPublicConfigValues(cfg config.Config) []ConfigValue { - return []ConfigValue{ + return []ConfigValue{ {ID: "General.ApplicationName", Type: ConfigTypeString, Description: "The name used in messaging and page titles. Defaults to \"GoAlert\".", Value: cfg.General.ApplicationName}, {ID: "General.PublicURL", Type: ConfigTypeString, Description: "Publicly routable URL for UI links and API calls.", Value: cfg.General.PublicURL, Deprecated: "Use --public-url flag instead, which takes precedence."}, {ID: "General.GoogleAnalyticsID", Type: ConfigTypeString, Description: "If set, will post user metrics to the corresponding data stream in Google Analytics 4.", Value: cfg.General.GoogleAnalyticsID}, @@ -125,8 +128,10 @@ func MapPublicConfigValues(cfg config.Config) []ConfigValue { {ID: "Webhook.Enable", Type: ConfigTypeBoolean, Description: "Enables webhook as a contact method.", Value: fmt.Sprintf("%t", cfg.Webhook.Enable)}, {ID: "Webhook.AllowedURLs", Type: ConfigTypeStringList, Description: "If set, allows webhooks for these domains only.", Value: strings.Join(cfg.Webhook.AllowedURLs, "\n")}, {ID: "Feedback.Enable", Type: ConfigTypeBoolean, Description: "Enables Feedback link in nav bar.", Value: fmt.Sprintf("%t", cfg.Feedback.Enable)}, - {ID: "Feedback.OverrideURL", Type: ConfigTypeString, Description: "Use a custom URL for Feedback link in nav bar.", Value: cfg.Feedback.OverrideURL}, - } + {ID: "Feedback.OverrideURL", Type: ConfigTypeString, Description: "Use a custom URL for Feedback link in nav bar.", Value: cfg.Feedback.OverrideURL}, + {ID: "WebPush.Enable", Type: ConfigTypeBoolean, Description: "Enable Web Push notifications (requires VAPID keys).", Value: fmt.Sprintf("%t", cfg.WebPush.Enable)}, + {ID: "WebPush.VAPIDPublicKey", Type: ConfigTypeString, Description: "Public VAPID key (Base64 URL-safe, unpadded) exposed to clients.", Value: cfg.WebPush.VAPIDPublicKey}, + } } // ApplyConfigValues will apply a list of ConfigValues to a Config struct. @@ -157,8 +162,8 @@ func ApplyConfigValues(cfg config.Config, vals []ConfigValueInput) (config.Confi return false, validation.NewFieldError("\""+id+"\".Value", "boolean value invalid: expected 'true' or 'false'") } } - for _, v := range vals { - switch v.ID { + for _, v := range vals { + switch v.ID { case "General.ApplicationName": cfg.General.ApplicationName = v.Value case "General.PublicURL": @@ -389,11 +394,21 @@ func ApplyConfigValues(cfg config.Config, vals []ConfigValueInput) (config.Confi return cfg, err } cfg.Feedback.Enable = val - case "Feedback.OverrideURL": - cfg.Feedback.OverrideURL = v.Value - default: - return cfg, validation.NewFieldError("ID", fmt.Sprintf("unknown config ID '%s'", v.ID)) - } - } + case "Feedback.OverrideURL": + cfg.Feedback.OverrideURL = v.Value + case "WebPush.Enable": + val, err := parseBool(v.ID, v.Value) + if err != nil { + return cfg, err + } + cfg.WebPush.Enable = val + case "WebPush.VAPIDPublicKey": + cfg.WebPush.VAPIDPublicKey = v.Value + case "WebPush.VAPIDPrivateKey": + cfg.WebPush.VAPIDPrivateKey = v.Value + default: + return cfg, validation.NewFieldError("ID", fmt.Sprintf("unknown config ID '%s'", v.ID)) + } + } return cfg, nil } diff --git a/web-push.go b/web-push.go new file mode 100644 index 0000000000..01dd97c1bb --- /dev/null +++ b/web-push.go @@ -0,0 +1,18 @@ + +package main + +import ( + "fmt" + "log" + + "github.com/SherClockHolmes/webpush-go" +) + +func main() { + priv, pub, err := webpush.GenerateVAPIDKeys() + if err != nil { + log.Fatal(err) + } + fmt.Println("VAPID Public Key :", pub) // base64url-encoded (no padding) + fmt.Println("VAPID Private Key:", priv) // base64url-encoded (no padding) +} diff --git a/web/handler.go b/web/handler.go index b57302c679..bd4fc2a89d 100644 --- a/web/handler.go +++ b/web/handler.go @@ -26,6 +26,12 @@ var bundleFS embed.FS //go:embed live.js var liveJS []byte +//go:embed manifest.webmanifest +var pwaManifest []byte + +//go:embed service-worker.js +var serviceWorker []byte + // validateAppJS will return an error if the app.js file is not valid or missing. func validateAppJS(fs fs.FS) error { if version.GitVersion() == "dev" { @@ -75,6 +81,9 @@ func validateAppJS(fs fs.FS) error { func NewHandler(uiDir, prefix string) (http.Handler, error) { mux := http.NewServeMux() + // in-memory push subscription store + pushSubs := newPushStore() + var extraJS string if uiDir != "" { extraJS = "/static/live.js" @@ -96,6 +105,36 @@ func NewHandler(uiDir, prefix string) (http.Handler, error) { mux.Handle("/static/", NewEtagFileServer(http.FS(sub), true)) } + mux.HandleFunc("/manifest.webmanifest", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/manifest+json") + http.ServeContent(w, req, "/manifest.webmanifest", time.Time{}, bytes.NewReader(pwaManifest)) + }) + mux.HandleFunc("/service-worker.js", func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/javascript") + http.ServeContent(w, req, "/service-worker.js", time.Time{}, bytes.NewReader(serviceWorker)) + }) + + // Minimal endpoint to accept and store push subscriptions. + mux.HandleFunc("POST /api/push/subscribe", func(w http.ResponseWriter, req *http.Request) { + var raw json.RawMessage + data, err := io.ReadAll(io.LimitReader(req.Body, 1<<20)) // 1MB cap + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + raw = json.RawMessage(data) + var tmp struct{ + Endpoint string `json:"endpoint"` + } + _ = json.Unmarshal(raw, &tmp) + if tmp.Endpoint == "" { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + pushSubs.add(pushSubscription{Endpoint: tmp.Endpoint, Raw: raw}) + w.WriteHeader(http.StatusNoContent) + }) + mux.HandleFunc("/api/graphql/explore", func(w http.ResponseWriter, req *http.Request) { cfg := config.FromContext(req.Context()) @@ -104,6 +143,7 @@ func NewHandler(uiDir, prefix string) (http.Handler, error) { Prefix: prefix, ExtraJS: extraJS, Nonce: csp.NonceValue(req.Context()), + VAPIDPublicKey: cfg.WebPush.VAPIDPublicKey, }) }) @@ -115,6 +155,7 @@ func NewHandler(uiDir, prefix string) (http.Handler, error) { Prefix: prefix, ExtraJS: extraJS, Nonce: csp.NonceValue(req.Context()), + VAPIDPublicKey: cfg.WebPush.VAPIDPublicKey, }) }) diff --git a/web/index.go b/web/index.go index e95edd48a3..998dab95b5 100644 --- a/web/index.go +++ b/web/index.go @@ -56,6 +56,9 @@ type renderData struct { // Nonce is a CSP nonce value. Nonce string + + // VAPIDPublicKey is the Web Push VAPID public key exposed to the client. + VAPIDPublicKey string } func (r renderData) PathPrefix() string { return strings.TrimSuffix(r.Prefix, "/") } diff --git a/web/index.html b/web/index.html index f5fde126ae..1262b658f5 100644 --- a/web/index.html +++ b/web/index.html @@ -8,6 +8,7 @@ +