Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
320 changes: 307 additions & 13 deletions app/inithttp.go

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand Down
13 changes: 9 additions & 4 deletions devtools/ci/dockerfiles/goalert/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
43 changes: 29 additions & 14 deletions graphql2/mapconfig.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions web-push.go
Original file line number Diff line number Diff line change
@@ -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)
}
41 changes: 41 additions & 0 deletions web/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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"
Expand All @@ -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())

Expand All @@ -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,
})
})

Expand All @@ -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,
})
})

Expand Down
3 changes: 3 additions & 0 deletions web/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "/") }
Expand Down
28 changes: 28 additions & 0 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<meta http-equiv="x-goalert-git-commit" content="{{ .GitCommit }}" />
<meta http-equiv="x-goalert-git-tree-state" content="{{ .GitTreeState }}" />
<meta property="csp-nonce" content="{{ .Nonce }}" />
<meta name="vapid-public-key" content="{{ .VAPIDPublicKey }}" />
<link rel="stylesheet" html="{{ .Prefix }}/static/loading.css" />

<title>{{ .ApplicationName }}</title>
Expand Down Expand Up @@ -43,6 +44,7 @@
type="image/png"
href="{{ .Prefix }}/static/favicon-192.png"
/>
<link rel="manifest" href="{{ .Prefix }}/manifest.webmanifest" />
</head>
<style nonce="{{ .Nonce }}">
#app > .initial-load {
Expand Down Expand Up @@ -153,5 +155,31 @@
{{- if .ExtraJS }}
<script src="{{ .Prefix }}{{ .ExtraJS }}"></script>
{{- end }}
<script nonce="{{ .Nonce }}">
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
var ua = navigator.userAgent || ''
var isStandalone = (window.navigator.standalone === true) || (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches)
console.log('[push] page load; ua=%s standalone=%s permission=%s', ua, isStandalone, (window.Notification && Notification.permission))
navigator.serviceWorker
.register('{{ .Prefix }}/service-worker.js')
.then(function (reg) {
console.log('[push] service worker registered', reg && reg.scope)
if ('PushManager' in window) {
// React app handles permission and subscription UX.
// We just log availability here.
console.log('[push] PushManager available; React component will manage subscription')
} else {
console.warn('[push] PushManager not available in this environment')
}
})
.catch(function (err) {
console.error('[push] service worker registration failed', err)
})
})
} else {
console.warn('[push] serviceWorker not supported in this browser')
}
</script>
</body>
</html>
21 changes: 21 additions & 0 deletions web/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"short_name": "GoAlert",
"name": "GoAlert",
"icons": [
{
"src": "static/favicon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "static/favicon-128.png",
"type": "image/png",
"sizes": "128x128"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#cc0000",
"background_color": "#ffffff"
}

32 changes: 32 additions & 0 deletions web/pushsubs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package web

import (
"encoding/json"
"sync"
)

// pushSubscription represents the minimal fields we care about for a Push API subscription.
// It accepts and preserves any additional fields via Raw for potential future use.
type pushSubscription struct {
Endpoint string `json:"endpoint"`
Raw json.RawMessage
}

type pushStore struct {
mx sync.RWMutex
subs map[string]json.RawMessage // key: endpoint
}

func newPushStore() *pushStore {
return &pushStore{subs: make(map[string]json.RawMessage)}
}

func (s *pushStore) add(sub pushSubscription) {
if sub.Endpoint == "" || len(sub.Raw) == 0 {
return
}
s.mx.Lock()
s.subs[sub.Endpoint] = sub.Raw
s.mx.Unlock()
}

Loading
Loading