diff --git a/README.md b/README.md index 49ec73e..350d8c0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Prism -**Self-hosted notification gateway using Signal and Webhooks for transport** +**Self-hosted notification gateway using Signal and WebPush for transport** [Setup](#setup) • [Real-World Examples](#real-world-examples) • [Architecture](#architecture) @@ -12,7 +12,7 @@ -Prism is a self-hosted notification gateway that receives HTTP requests and routes them through Signal groups or custom webhooks. Route notifications through Signal to avoid exposing unique network fingerprints, or forward them to your own webhook endpoints for custom handling. +Prism is a self-hosted notification gateway that receives HTTP requests and routes them through Signal groups or WebPush apps. Route notifications through Signal to avoid exposing unique network fingerprints, or forward them to your own WebPush apps for custom handling. ## Setup @@ -166,6 +166,71 @@ prism_api_key: "Bearer YOUR_API_KEY_HERE" Reboot your Home Assistant system and you'll then be able to send Signal notifications to yourself by using this notify prism action. +## API Reference + +### Send Notification + +#### POST /{topic} + +Send a notification to a registered app/topic. Compatible with ntfy format. + +```bash +curl -X POST http://localhost:8080/my-app \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"title": "Alert", "message": "Something happened"}' +``` + +Or ntfy-style: + +```bash +curl -X POST http://localhost:8080/my-app \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -d "Simple message text" +``` + +### WebPush/Webhook Management + +#### POST /webpush/app + +Register or update a WebPush subscription or plain webhook. + +Encrypted WebPush (all crypto fields required): + +```bash +curl -X POST http://localhost:8080/webpush/app \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "appName": "my-app", + "pushEndpoint": "https://updates.push.services.mozilla.org/...", + "p256dh": "base64-encoded-key", + "auth": "base64-encoded-auth", + "vapidPrivateKey": "base64-encoded-vapid-key" + }' +``` + +Plain HTTP webhook (no encryption): + +```bash +curl -X POST http://localhost:8080/webpush/app \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "appName": "my-app", + "pushEndpoint": "https://your-server.com/webhook" + }' +``` + +#### DELETE /webpush/app/{appName} + +Unregister a WebPush subscription (clears WebPush settings, reverts to Signal). + +```bash +curl -X DELETE http://localhost:8080/webpush/app/my-app \ + -H "Authorization: Bearer YOUR_API_KEY" +``` + ## Monitoring The health of the system can be viewed in the same admin UI used for linking Signal. Prism uses [basic access authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) - provide your `API_KEY` as the password (username can be anything). @@ -181,8 +246,10 @@ For API-based monitoring, call `/api/health` which returns JSON: Prism accepts notifications via HTTP POST requests and routes them based on your configured delivery method: - **Signal groups**: Uses [signal-cli](https://github.com/AsamK/signal-cli) to create a Signal group for each app and send notifications as messages -- **Webhook forwarding**: Forwards notifications to your own webhook URL (useful for UnifiedPush distributors, ntfy or custom handlers) +- **WebPush**: Supports both encrypted WebPush (with VAPID signing and payload encryption) and plain HTTP webhooks + - **Encrypted WebPush**: Full WebPush protocol with end-to-end encryption - requires `appName`, `pushEndpoint`, `p256dh`, `auth`, and `vapidPrivateKey` + - **Plain webhooks**: Simple JSON POST to any HTTP endpoint - only requires `appName` and `pushEndpoint` -Each endpoint can be independently configured to use either delivery method through the admin UI. +Each app can be independently configured to use either delivery method through the admin UI. For the optional Proton Mail integration, Prism requires a server that runs Proton's official [proton-bridge](https://github.com/ProtonMail/proton-bridge). Prism's docker compose process will run an image from [protonmail-bridge-docker](https://github.com/shenxn/protonmail-bridge-docker). Once authenticated, the communication between Prism and proton-bridge will be over IMAP. diff --git a/go.mod b/go.mod index bad3687..9dc4a89 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module prism go 1.25.6 require ( + github.com/SherClockHolmes/webpush-go v1.4.0 github.com/emersion/go-imap/v2 v2.0.0-beta.7 github.com/go-chi/chi/v5 v5.2.4 github.com/joho/godotenv v1.5.1 @@ -13,4 +14,6 @@ require ( require ( github.com/emersion/go-message v0.18.1 // indirect github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + golang.org/x/crypto v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index 9058e13..73566cc 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/emersion/go-imap/v2 v2.0.0-beta.7 h1:lNznYWa5uhMrngnSYEklzCeye4DBq9TEJ+pr0K593+8= github.com/emersion/go-imap/v2 v2.0.0-beta.7/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk= github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E= @@ -6,6 +8,9 @@ github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpz github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= @@ -13,33 +18,66 @@ github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/public/index.css b/public/index.css index 16df541..e6aeb33 100644 --- a/public/index.css +++ b/public/index.css @@ -190,14 +190,14 @@ h2 { color: white; } -.endpoint-list { +.app-list { list-style: none; padding: 0; max-height: 18.75rem; overflow: visible; } -.endpoint-item { +.app-item { display: flex; align-items: center; justify-content: space-between; @@ -208,18 +208,18 @@ h2 { gap: 1rem; } -.endpoint-info { +.app-info { flex: 1; display: flex; flex-direction: column; gap: 0.25rem; } -.endpoint-name { +.app-name { font-size: 1.1em; } -.endpoint-channel { +.app-channel { display: flex; align-items: center; gap: 0.5rem; @@ -244,12 +244,12 @@ h2 { color: white; } -.endpoint-detail { +.app-detail { color: var(--text-secondary); font-size: 0.85em; } -.endpoint-actions { +.app-actions { display: flex; gap: 0.5rem; align-items: center; diff --git a/public/index.html b/public/index.html index 966ef19..d835461 100644 --- a/public/index.html +++ b/public/index.html @@ -26,7 +26,7 @@

Signal

+ hx-trigger="load">
@@ -34,9 +34,9 @@
-

Endpoints

-
Registered Applications +
diff --git a/service/notification/dispatcher.go b/service/notification/dispatcher.go index 78c3fd1..7c76ee6 100644 --- a/service/notification/dispatcher.go +++ b/service/notification/dispatcher.go @@ -8,6 +8,8 @@ import ( "net/http" "prism/service/signal" + + webpush "github.com/SherClockHolmes/webpush-go" ) type Dispatcher struct { @@ -24,33 +26,31 @@ func NewDispatcher(store *Store, signalClient *signal.Client, logger *slog.Logge } } -func (d *Dispatcher) Send(endpoint string, notif Notification) error { - mapping, err := d.store.GetEndpointMapping(endpoint) +func (d *Dispatcher) Send(appName string, notif Notification) error { + mapping, err := d.store.GetApp(appName) if err != nil { - d.logger.Error("Failed to get endpoint mapping", "endpoint", endpoint, "error", err) + d.logger.Error("Failed to get app mapping", "app", appName, "error", err) return fmt.Errorf("failed to get mapping: %w", err) } if mapping == nil { - d.logger.Info("No mapping found for endpoint, creating new registration", "endpoint", endpoint) + d.logger.Info("Registering new app", "app", appName) - d.logger.Info("Registering new endpoint", "endpoint", endpoint, "appName", endpoint, "channel", ChannelSignal) - - if err := d.store.Register(endpoint, endpoint, ChannelSignal, nil, nil); err != nil { - d.logger.Error("Failed to register endpoint", "endpoint", endpoint, "error", err) - return fmt.Errorf("failed to register endpoint: %w", err) + if err := d.store.RegisterDefault(appName); err != nil { + d.logger.Error("Failed to register app", "app", appName, "error", err) + return fmt.Errorf("failed to register app: %w", err) } - mapping, err = d.store.GetEndpointMapping(endpoint) + mapping, err = d.store.GetApp(appName) if err != nil { - d.logger.Error("Failed to get mapping after registration", "endpoint", endpoint, "error", err) + d.logger.Error("Failed to get mapping after registration", "app", appName, "error", err) return fmt.Errorf("failed to get mapping after registration: %w", err) } } switch mapping.Channel { - case ChannelWebhook: - return d.sendWebhook(mapping, notif) + case ChannelWebPush: + return d.sendWebPush(mapping, notif) case ChannelSignal: return d.sendSignal(mapping, notif) default: @@ -60,39 +60,63 @@ func (d *Dispatcher) Send(endpoint string, notif Notification) error { } func (d *Dispatcher) sendSignal(mapping *Mapping, notif Notification) error { - groupID := mapping.GroupID - if groupID == nil || *groupID == "" { - d.logger.Info("No group ID found, creating new group", "appName", mapping.AppName) - - newGroupID, err := d.createGroup(mapping.AppName) - if err != nil { - d.logger.Error("Failed to create group", "appName", mapping.AppName, "error", err) - return fmt.Errorf("failed to create group: %w", err) - } - groupID = &newGroupID - - d.logger.Info("Created new group", "appName", mapping.AppName, "groupID", *groupID) - - if err := d.store.UpdateGroupID(mapping.Endpoint, *groupID); err != nil { - d.logger.Warn("Failed to update group ID", "error", err) - } + account, err := d.signalClient.GetLinkedAccount() + if err != nil { + d.logger.Error("Failed to get linked account", "error", err) + return fmt.Errorf("failed to get linked account: %w", err) + } + if account == nil { + d.logger.Error("No linked Signal account found") + return fmt.Errorf("no linked Signal account") } - if err := d.sendGroupMessage(*groupID, notif); err != nil { - d.logger.Error("Failed to send group message", "groupID", *groupID, "error", err) - return fmt.Errorf("failed to send group message: %w", err) + var signalGroupID string + needsNewGroup := mapping.Signal == nil || mapping.Signal.GroupID == "" + + if !needsNewGroup && mapping.Signal.Account != account.Number { + d.logger.Info("Signal account changed, recreating group", + "app", mapping.AppName, + "oldAccount", mapping.Signal.Account, + "newAccount", account.Number) + needsNewGroup = true + } + + if needsNewGroup { + d.logger.Info("Creating new group", "app", mapping.AppName, "account", account.Number) + + newGroupID, accountNumber, err := d.createGroup(mapping.AppName) + if err != nil { + d.logger.Error("Failed to create group", "app", mapping.AppName, "error", err) + return fmt.Errorf("failed to create group: %w", err) + } + signalGroupID = newGroupID + + d.logger.Info("Created new group", "app", mapping.AppName, "groupID", signalGroupID, "account", accountNumber) + + if err := d.store.UpdateSignal(mapping.AppName, &SignalSubscription{ + GroupID: signalGroupID, + Account: accountNumber, + }); err != nil { + d.logger.Warn("Failed to update signal subscription", "error", err) + } + } else { + signalGroupID = mapping.Signal.GroupID + } + + if err := d.sendGroupMessage(signalGroupID, notif); err != nil { + d.logger.Error("Failed to send group message", "groupID", signalGroupID, "error", err) } return nil } -func (d *Dispatcher) createGroup(appName string) (string, error) { +func (d *Dispatcher) createGroup(appName string) (string, string, error) { account, err := d.signalClient.GetLinkedAccount() if err != nil { - return "", fmt.Errorf("failed to get linked account: %w", err) + return "", "", fmt.Errorf("failed to get linked account: %w", err) } if account == nil { - return "", fmt.Errorf("no linked Signal account") + return "", "", fmt.Errorf("no linked Signal account") } params := map[string]interface{}{ @@ -102,21 +126,21 @@ func (d *Dispatcher) createGroup(appName string) (string, error) { result, err := d.signalClient.CallWithAccount("updateGroup", params, account.Number) if err != nil { - return "", err + return "", "", err } var response struct { GroupID string `json:"groupId"` } if err := json.Unmarshal(result, &response); err != nil { - return "", fmt.Errorf("failed to parse updateGroup response: %w", err) + return "", "", fmt.Errorf("failed to parse updateGroup response: %w", err) } if response.GroupID == "" { - return "", fmt.Errorf("empty groupId in response") + return "", "", fmt.Errorf("empty groupId in response") } - return response.GroupID, nil + return response.GroupID, account.Number, nil } func (d *Dispatcher) sendGroupMessage(groupID string, notif Notification) error { @@ -148,9 +172,9 @@ func (d *Dispatcher) sendGroupMessage(groupID string, notif Notification) error return err } -func (d *Dispatcher) sendWebhook(mapping *Mapping, notif Notification) error { - if mapping.UpEndpoint == nil || *mapping.UpEndpoint == "" { - return fmt.Errorf("webhook endpoint not configured for %s", mapping.AppName) +func (d *Dispatcher) sendWebPush(mapping *Mapping, notif Notification) error { + if mapping.WebPush == nil { + return fmt.Errorf("no push endpoint configured for %s", mapping.AppName) } payload, err := json.Marshal(notif) @@ -158,16 +182,42 @@ func (d *Dispatcher) sendWebhook(mapping *Mapping, notif Notification) error { return fmt.Errorf("failed to marshal notification: %w", err) } - resp, err := http.Post(*mapping.UpEndpoint, "application/json", bytes.NewReader(payload)) - if err != nil { - return fmt.Errorf("failed to send webhook: %w", err) - } - defer resp.Body.Close() + if mapping.WebPush.HasEncryption() { + subscription := &webpush.Subscription{ + Endpoint: mapping.WebPush.Endpoint, + Keys: webpush.Keys{ + P256dh: mapping.WebPush.P256dh, + Auth: mapping.WebPush.Auth, + }, + } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("webhook returned status %d", resp.StatusCode) + resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{ + VAPIDPrivateKey: mapping.WebPush.VapidPrivateKey, + TTL: 86400, + }) + if err != nil { + return fmt.Errorf("failed to send webpush: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webpush returned status %d", resp.StatusCode) + } + + d.logger.Debug("Sent encrypted webpush notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint) + } else { + resp, err := http.Post(mapping.WebPush.Endpoint, "application/json", bytes.NewBuffer(payload)) + if err != nil { + return fmt.Errorf("failed to send webhook: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webhook returned status %d", resp.StatusCode) + } + + d.logger.Debug("Sent plain webhook notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint) } - d.logger.Debug("Sent webhook notification", "app", mapping.AppName, "endpoint", *mapping.UpEndpoint) return nil } diff --git a/service/notification/notification.go b/service/notification/notification.go index 51d8791..8e87831 100644 --- a/service/notification/notification.go +++ b/service/notification/notification.go @@ -17,13 +17,28 @@ type Channel string const ( ChannelSignal Channel = "signal" - ChannelWebhook Channel = "webhook" + ChannelWebPush Channel = "webpush" ) -type Mapping struct { - Endpoint string - AppName string - Channel Channel - GroupID *string - UpEndpoint *string +type WebPushSubscription struct { + Endpoint string + P256dh string + Auth string + VapidPrivateKey string +} + +func (w *WebPushSubscription) HasEncryption() bool { + return w.P256dh != "" && w.Auth != "" && w.VapidPrivateKey != "" +} + +type SignalSubscription struct { + GroupID string + Account string +} + +type Mapping struct { + AppName string + Channel Channel + Signal *SignalSubscription + WebPush *WebPushSubscription } diff --git a/service/notification/store.go b/service/notification/store.go index b56d3e2..76da5a4 100644 --- a/service/notification/store.go +++ b/service/notification/store.go @@ -39,11 +39,14 @@ func NewStore(dbPath string) (*Store, error) { func (s *Store) createTables() error { query := ` CREATE TABLE IF NOT EXISTS mappings ( - endpoint TEXT PRIMARY KEY, - groupId TEXT, - appName TEXT NOT NULL, + appName TEXT PRIMARY KEY, + signalGroupId TEXT, + signalAccount TEXT, channel TEXT NOT NULL DEFAULT 'signal', - upEndpoint TEXT + pushEndpoint TEXT, + p256dh TEXT, + auth TEXT, + vapidPrivateKey TEXT ) ` _, err := s.db.Exec(query) @@ -57,26 +60,57 @@ func (s *Store) Close() error { return s.db.Close() } -func (s *Store) Register(endpoint, appName string, channel Channel, groupID, upEndpoint *string) error { +func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscription, webPush *WebPushSubscription) error { + var signalGroupID, signalAccount *string + if signal != nil { + signalGroupID = &signal.GroupID + signalAccount = &signal.Account + } + + var pushEndpoint, p256dh, auth, vapidPrivateKey *string + if webPush != nil { + pushEndpoint = &webPush.Endpoint + p256dh = &webPush.P256dh + auth = &webPush.Auth + vapidPrivateKey = &webPush.VapidPrivateKey + } + + ch := ChannelSignal + if channel != nil { + ch = *channel + } + query := ` - INSERT OR IGNORE INTO mappings (endpoint, groupId, appName, channel, upEndpoint) - VALUES (?, ?, ?, ?, ?) + INSERT INTO mappings (appName, signalGroupId, signalAccount, channel, pushEndpoint, p256dh, auth, vapidPrivateKey) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(appName) DO UPDATE SET + channel = excluded.channel, + signalGroupId = excluded.signalGroupId, + signalAccount = excluded.signalAccount, + pushEndpoint = excluded.pushEndpoint, + p256dh = excluded.p256dh, + auth = excluded.auth, + vapidPrivateKey = excluded.vapidPrivateKey ` - _, err := s.db.Exec(query, endpoint, groupID, appName, channel, upEndpoint) + _, err := s.db.Exec(query, appName, signalGroupID, signalAccount, ch, pushEndpoint, p256dh, auth, vapidPrivateKey) return err } -func (s *Store) GetEndpointMapping(endpoint string) (*Mapping, error) { +func (s *Store) RegisterDefault(appName string) error { + return s.Register(appName, nil, nil, nil) +} + +func (s *Store) GetApp(appName string) (*Mapping, error) { query := ` - SELECT endpoint, groupId, appName, channel, upEndpoint + SELECT appName, signalGroupId, signalAccount, channel, pushEndpoint, p256dh, auth, vapidPrivateKey FROM mappings - WHERE endpoint = ? + WHERE appName = ? ` - row := s.db.QueryRow(query, endpoint) + row := s.db.QueryRow(query, appName) var m Mapping - var groupID, upEndpoint sql.NullString - err := row.Scan(&m.Endpoint, &groupID, &m.AppName, &m.Channel, &upEndpoint) + var signalGroupID, signalAccount, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString + err := row.Scan(&m.AppName, &signalGroupID, &signalAccount, &m.Channel, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey) if err == sql.ErrNoRows { return nil, nil } @@ -84,11 +118,25 @@ func (s *Store) GetEndpointMapping(endpoint string) (*Mapping, error) { return nil, err } - if groupID.Valid { - m.GroupID = &groupID.String + if signalGroupID.Valid && signalAccount.Valid { + m.Signal = &SignalSubscription{ + GroupID: signalGroupID.String, + Account: signalAccount.String, + } } - if upEndpoint.Valid { - m.UpEndpoint = &upEndpoint.String + if pushEndpoint.Valid { + m.WebPush = &WebPushSubscription{ + Endpoint: pushEndpoint.String, + } + if p256dh.Valid { + m.WebPush.P256dh = p256dh.String + } + if auth.Valid { + m.WebPush.Auth = auth.String + } + if vapidPrivateKey.Valid { + m.WebPush.VapidPrivateKey = vapidPrivateKey.String + } } return &m, nil @@ -96,7 +144,7 @@ func (s *Store) GetEndpointMapping(endpoint string) (*Mapping, error) { func (s *Store) GetAllMappings() ([]Mapping, error) { query := ` - SELECT endpoint, groupId, appName, channel, upEndpoint + SELECT appName, signalGroupId, signalAccount, channel, pushEndpoint, p256dh, auth, vapidPrivateKey FROM mappings ` rows, err := s.db.Query(query) @@ -108,16 +156,30 @@ func (s *Store) GetAllMappings() ([]Mapping, error) { var mappings []Mapping for rows.Next() { var m Mapping - var groupID, upEndpoint sql.NullString - if err := rows.Scan(&m.Endpoint, &groupID, &m.AppName, &m.Channel, &upEndpoint); err != nil { + var signalGroupID, signalAccount, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString + if err := rows.Scan(&m.AppName, &signalGroupID, &signalAccount, &m.Channel, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey); err != nil { return nil, err } - if groupID.Valid { - m.GroupID = &groupID.String + if signalGroupID.Valid && signalAccount.Valid { + m.Signal = &SignalSubscription{ + GroupID: signalGroupID.String, + Account: signalAccount.String, + } } - if upEndpoint.Valid { - m.UpEndpoint = &upEndpoint.String + if pushEndpoint.Valid { + m.WebPush = &WebPushSubscription{ + Endpoint: pushEndpoint.String, + } + if p256dh.Valid { + m.WebPush.P256dh = p256dh.String + } + if auth.Valid { + m.WebPush.Auth = auth.String + } + if vapidPrivateKey.Valid { + m.WebPush.VapidPrivateKey = vapidPrivateKey.String + } } mappings = append(mappings, m) @@ -126,20 +188,48 @@ func (s *Store) GetAllMappings() ([]Mapping, error) { return mappings, rows.Err() } -func (s *Store) UpdateChannel(endpoint string, channel Channel) error { - query := `UPDATE mappings SET channel = ? WHERE endpoint = ?` - _, err := s.db.Exec(query, channel, endpoint) +func (s *Store) UpdateChannel(appName string, channel Channel) error { + query := `UPDATE mappings SET channel = ? WHERE appName = ?` + _, err := s.db.Exec(query, channel, appName) return err } -func (s *Store) UpdateGroupID(endpoint string, groupID string) error { - query := `UPDATE mappings SET groupId = ? WHERE endpoint = ?` - _, err := s.db.Exec(query, groupID, endpoint) +func (s *Store) UpdateSignal(appName string, signal *SignalSubscription) error { + if signal == nil { + return fmt.Errorf("signal cannot be nil") + } + + query := `UPDATE mappings SET signalGroupId = ?, signalAccount = ? WHERE appName = ?` + _, err := s.db.Exec(query, signal.GroupID, signal.Account, appName) return err } -func (s *Store) RemoveEndpoint(endpoint string) error { - query := `DELETE FROM mappings WHERE endpoint = ?` - _, err := s.db.Exec(query, endpoint) +func (s *Store) UpdateWebPush(appName string, webPush *WebPushSubscription) error { + if webPush == nil { + return fmt.Errorf("webPush cannot be nil") + } + + query := ` + UPDATE mappings + SET pushEndpoint = ?, p256dh = ?, auth = ?, vapidPrivateKey = ? + WHERE appName = ? + ` + _, err := s.db.Exec(query, webPush.Endpoint, webPush.P256dh, webPush.Auth, webPush.VapidPrivateKey, appName) + return err +} + +func (s *Store) RemoveApp(appName string) error { + query := `DELETE FROM mappings WHERE appName = ?` + _, err := s.db.Exec(query, appName) + return err +} + +func (s *Store) ClearWebPush(appName string) error { + query := ` + UPDATE mappings + SET pushEndpoint = NULL, p256dh = NULL, auth = NULL, vapidPrivateKey = NULL, channel = 'signal' + WHERE appName = ? + ` + _, err := s.db.Exec(query, appName) return err } diff --git a/service/server/handlers_action.go b/service/server/handlers_action.go index 1227616..8dd2635 100644 --- a/service/server/handlers_action.go +++ b/service/server/handlers_action.go @@ -4,28 +4,25 @@ import ( "net/http" "prism/service/notification" + + "github.com/go-chi/chi/v5" ) -func (s *Server) handleDeleteEndpointAction(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, "Invalid form data", http.StatusBadRequest) +func (s *Server) handleDeleteAppAction(w http.ResponseWriter, r *http.Request) { + app := chi.URLParam(r, "appName") + if app == "" { + http.Error(w, "Missing app parameter", http.StatusBadRequest) return } - endpoint := r.FormValue("endpoint") - if endpoint == "" { - http.Error(w, "Missing endpoint parameter", http.StatusBadRequest) - return - } - - if err := s.store.RemoveEndpoint(endpoint); err != nil { - s.logger.Error("Failed to delete endpoint", "error", err) - http.Error(w, "Failed to delete endpoint", http.StatusInternalServerError) + if err := s.store.RemoveApp(app); err != nil { + s.logger.Error("Failed to delete app", "error", err) + http.Error(w, "Failed to delete app", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html") - s.handleFragmentEndpoints(w, r) + s.handleFragmentApps(w, r) } func (s *Server) handleToggleChannelAction(w http.ResponseWriter, r *http.Request) { @@ -34,25 +31,25 @@ func (s *Server) handleToggleChannelAction(w http.ResponseWriter, r *http.Reques return } - endpoint := r.FormValue("endpoint") + app := r.FormValue("app") channel := r.FormValue("channel") - if endpoint == "" || channel == "" { - http.Error(w, "Missing endpoint or channel parameter", http.StatusBadRequest) + if app == "" || channel == "" { + http.Error(w, "Missing app or channel parameter", http.StatusBadRequest) return } - if channel != string(notification.ChannelSignal) && channel != string(notification.ChannelWebhook) { + if channel != string(notification.ChannelSignal) && channel != string(notification.ChannelWebPush) { http.Error(w, "Invalid channel", http.StatusBadRequest) return } - if err := s.store.UpdateChannel(endpoint, notification.Channel(channel)); err != nil { + if err := s.store.UpdateChannel(app, notification.Channel(channel)); err != nil { s.logger.Error("Failed to update channel", "error", err) http.Error(w, "Failed to update channel", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html") - s.handleFragmentEndpoints(w, r) + s.handleFragmentApps(w, r) } diff --git a/service/server/handlers_admin.go b/service/server/handlers_admin.go index 6059775..6de1f5b 100644 --- a/service/server/handlers_admin.go +++ b/service/server/handlers_admin.go @@ -27,10 +27,9 @@ func (s *Server) handleGetMappings(w http.ResponseWriter, r *http.Request) { } type createMappingRequest struct { - Endpoint string `json:"endpoint"` - AppName string `json:"appName"` - Channel notification.Channel `json:"channel"` - UpEndpoint *string `json:"upEndpoint,omitempty"` + App string `json:"app"` + Channel notification.Channel `json:"channel"` + PushEndpoint *string `json:"pushEndpoint,omitempty"` } func (s *Server) handleCreateMapping(w http.ResponseWriter, r *http.Request) { @@ -40,7 +39,7 @@ func (s *Server) handleCreateMapping(w http.ResponseWriter, r *http.Request) { return } - if req.Endpoint == "" || req.AppName == "" { + if req.App == "" { http.Error(w, "Missing required fields", http.StatusBadRequest) return } @@ -49,12 +48,19 @@ func (s *Server) handleCreateMapping(w http.ResponseWriter, r *http.Request) { req.Channel = notification.ChannelSignal } - if req.Channel == notification.ChannelWebhook && req.UpEndpoint == nil { - http.Error(w, "upEndpoint required for webhook channel", http.StatusBadRequest) + if req.Channel == notification.ChannelWebPush && req.PushEndpoint == nil { + http.Error(w, "pushEndpoint required for webpush channel", http.StatusBadRequest) return } - if err := s.store.Register(req.Endpoint, req.AppName, req.Channel, nil, req.UpEndpoint); err != nil { + var webPush *notification.WebPushSubscription + if req.Channel == notification.ChannelWebPush && req.PushEndpoint != nil { + webPush = ¬ification.WebPushSubscription{ + Endpoint: *req.PushEndpoint, + } + } + + if err := s.store.Register(req.App, &req.Channel, nil, webPush); err != nil { s.logger.Error("Failed to register mapping", "error", err) http.Error(w, "Failed to create mapping", http.StatusInternalServerError) return @@ -67,13 +73,13 @@ func (s *Server) handleCreateMapping(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleDeleteMapping(w http.ResponseWriter, r *http.Request) { - endpoint := chi.URLParam(r, "endpoint") - if endpoint == "" { - http.Error(w, "Missing endpoint parameter", http.StatusBadRequest) + appName := chi.URLParam(r, "appName") + if appName == "" { + http.Error(w, "Missing appName parameter", http.StatusBadRequest) return } - if err := s.store.RemoveEndpoint(endpoint); err != nil { + if err := s.store.RemoveApp(appName); err != nil { s.logger.Error("Failed to delete mapping", "error", err) http.Error(w, "Failed to delete mapping", http.StatusInternalServerError) return @@ -88,9 +94,9 @@ type updateChannelRequest struct { } func (s *Server) handleUpdateChannel(w http.ResponseWriter, r *http.Request) { - endpoint := chi.URLParam(r, "endpoint") - if endpoint == "" { - http.Error(w, "Missing endpoint parameter", http.StatusBadRequest) + appName := chi.URLParam(r, "appName") + if appName == "" { + http.Error(w, "Missing appName parameter", http.StatusBadRequest) return } @@ -100,12 +106,12 @@ func (s *Server) handleUpdateChannel(w http.ResponseWriter, r *http.Request) { return } - if req.Channel == notification.ChannelWebhook && req.UpEndpoint == nil { - http.Error(w, "upEndpoint required for webhook channel", http.StatusBadRequest) + if req.Channel == notification.ChannelWebPush && req.UpEndpoint == nil { + http.Error(w, "upEndpoint required for webpush channel", http.StatusBadRequest) return } - if err := s.store.UpdateChannel(endpoint, req.Channel); err != nil { + if err := s.store.UpdateChannel(appName, req.Channel); err != nil { s.logger.Error("Failed to update channel", "error", err) http.Error(w, "Failed to update channel", http.StatusInternalServerError) return @@ -132,7 +138,7 @@ func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) { "uptimeSeconds": int(uptime.Seconds()), "mappingsCount": len(mappings), "signalCount": countByChannel(mappings, notification.ChannelSignal), - "webhookCount": countByChannel(mappings, notification.ChannelWebhook), + "webpushCount": countByChannel(mappings, notification.ChannelWebPush), "protonEnabled": s.cfg.IsProtonEnabled(), } diff --git a/service/server/handlers_fragment.go b/service/server/handlers_fragment.go index 56183a4..18df732 100644 --- a/service/server/handlers_fragment.go +++ b/service/server/handlers_fragment.go @@ -44,11 +44,30 @@ func (s *Server) handleFragmentHealth(w http.ResponseWriter, r *http.Request) {
Proton Mail: %s%s
`, protonClass, protonStatus, protonTooltip) } - html += `
-
` - html += s.getSignalInfoHTML() html += `
` + currentLinked := linked != nil + if s.lastSignalLinked == nil || *s.lastSignalLinked != currentLinked { + html += ` +
` + html += s.getSignalInfoHTML() + html += `
` + s.lastSignalLinked = ¤tLinked + } + + // Check if apps changed + mappings, err := s.store.GetAllMappings() + if err == nil { + currentAppsCount := len(mappings) + if s.lastAppsCount == nil || *s.lastAppsCount != currentAppsCount { + html += ` +
` + html += s.getAppsListHTML(mappings) + html += `
` + s.lastAppsCount = ¤tAppsCount + } + } + _, _ = fmt.Fprint(w, html) //nolint:errcheck // Error writing to ResponseWriter is handled by HTTP server } @@ -98,7 +117,7 @@ func (s *Server) getQRCodeHTML() string {
`, qrCode) } -func (s *Server) handleFragmentEndpoints(w http.ResponseWriter, r *http.Request) { +func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) { mappings, err := s.store.GetAllMappings() if err != nil { s.logger.Error("Failed to get mappings", "error", err) @@ -107,58 +126,67 @@ func (s *Server) handleFragmentEndpoints(w http.ResponseWriter, r *http.Request) } w.Header().Set("Content-Type", "text/html") + _, _ = fmt.Fprint(w, s.getAppsListHTML(mappings)) //nolint:errcheck +} +func (s *Server) getAppsListHTML(mappings []notification.Mapping) string { if len(mappings) == 0 { - _, _ = fmt.Fprint(w, `

No endpoints registered

`) //nolint:errcheck - return + return `

No apps registered

` } - _, _ = fmt.Fprint(w, `
` - if m.UpEndpoint != nil { - html += fmt.Sprintf(`
- - + -
`, m.Endpoint, map[bool]string{true: " selected", false: ""}[isSignal], map[bool]string{true: " selected", false: ""}[isWebhook]) + `, m.AppName, map[bool]string{true: " selected", false: ""}[isSignal], map[bool]string{true: " selected", false: ""}[isWebPush], webpushLabel) } - html += fmt.Sprintf(`
- - -
`, m.Endpoint) + itemHTML += fmt.Sprintf(`
`, m.AppName) - _, _ = fmt.Fprint(w, html) //nolint:errcheck + html += itemHTML } - _, _ = fmt.Fprint(w, ``) //nolint:errcheck + html += `` + return html } diff --git a/service/server/handlers_ntfy.go b/service/server/handlers_ntfy.go index 9649558..ae338e4 100644 --- a/service/server/handlers_ntfy.go +++ b/service/server/handlers_ntfy.go @@ -69,7 +69,7 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) { } if err := s.dispatcher.Send(topic, notif); err != nil { - s.logger.Error("Failed to send notification", "endpoint", topic, "error", err) + s.logger.Error("Failed to send notification", "app", topic, "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } diff --git a/service/server/handlers_webhook.go b/service/server/handlers_webhook.go deleted file mode 100644 index a241118..0000000 --- a/service/server/handlers_webhook.go +++ /dev/null @@ -1,56 +0,0 @@ -package server - -import ( - "encoding/json" - "net/http" - "net/url" - - "prism/service/notification" -) - -type registerWebhookRequest struct { - AppName string `json:"appName"` - UpEndpoint string `json:"upEndpoint"` -} - -func (s *Server) handleWebhookRegister(w http.ResponseWriter, r *http.Request) { - var req registerWebhookRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.AppName == "" || req.UpEndpoint == "" { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "appName and upEndpoint are required"}) //nolint:errcheck - return - } - - if _, err := url.Parse(req.UpEndpoint); err != nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "Invalid upEndpoint URL"}) //nolint:errcheck - return - } - - if err := s.store.Register(req.AppName, req.AppName, notification.ChannelWebhook, nil, &req.UpEndpoint); err != nil { - s.logger.Error("Failed to register webhook endpoint", "error", err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "Internal server error"}) //nolint:errcheck - return - } - - s.logger.Debug("Registered webhook endpoint", "app", req.AppName, "upEndpoint", req.UpEndpoint) - - w.Header().Set("Content-Type", "application/json") - response := map[string]string{ - "endpoint": req.AppName, - "appName": req.AppName, - "channel": "webhook", - } - if err := json.NewEncoder(w).Encode(response); err != nil { - s.logger.Error("Failed to encode response", "error", err) - } -} diff --git a/service/server/handlers_webpush.go b/service/server/handlers_webpush.go new file mode 100644 index 0000000..963bba0 --- /dev/null +++ b/service/server/handlers_webpush.go @@ -0,0 +1,113 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/url" + + "prism/service/notification" + + "github.com/go-chi/chi/v5" +) + +type registerWebPushRequest struct { + AppName string `json:"appName"` + PushEndpoint string `json:"pushEndpoint"` + P256dh *string `json:"p256dh,omitempty"` + Auth *string `json:"auth,omitempty"` + VapidPrivateKey *string `json:"vapidPrivateKey,omitempty"` +} + +func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) { + var req registerWebPushRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.AppName == "" || req.PushEndpoint == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "appName and pushEndpoint are required"}) //nolint:errcheck + return + } + + if _, err := url.Parse(req.PushEndpoint); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "Invalid pushEndpoint URL"}) //nolint:errcheck + return + } + + existing, err := s.store.GetApp(req.AppName) + if err != nil { + s.logger.Error("Failed to check existing app", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + var webPush *notification.WebPushSubscription + if req.P256dh != nil && req.Auth != nil && req.VapidPrivateKey != nil { + webPush = ¬ification.WebPushSubscription{ + Endpoint: req.PushEndpoint, + P256dh: *req.P256dh, + Auth: *req.Auth, + VapidPrivateKey: *req.VapidPrivateKey, + } + } else { + webPush = ¬ification.WebPushSubscription{ + Endpoint: req.PushEndpoint, + } + } + + if existing != nil { + if err := s.store.UpdateWebPush(req.AppName, webPush); err != nil { + s.logger.Error("Failed to update webpush endpoint", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + s.logger.Info("Updated webpush for existing endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint) + } else { + channel := notification.ChannelWebPush + if err := s.store.Register(req.AppName, &channel, nil, webPush); err != nil { + s.logger.Error("Failed to register webpush endpoint", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + s.logger.Info("Registered new webpush endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint) + } + + w.Header().Set("Content-Type", "application/json") + response := map[string]string{ + "appName": req.AppName, + "channel": "webpush", + } + if err := json.NewEncoder(w).Encode(response); err != nil { + s.logger.Error("Failed to encode response", "error", err) + } +} + +func (s *Server) handleWebPushUnregister(w http.ResponseWriter, r *http.Request) { + appName := chi.URLParam(r, "appName") + if appName == "" { + http.Error(w, "appName is required", http.StatusBadRequest) + return + } + + if err := s.store.ClearWebPush(appName); err != nil { + s.logger.Error("Failed to clear webpush endpoint", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + s.logger.Info("Cleared webpush subscription", "app", appName) + + w.Header().Set("Content-Type", "application/json") + response := map[string]string{ + "status": "unregistered", + "appName": appName, + } + if err := json.NewEncoder(w).Encode(response); err != nil { + s.logger.Error("Failed to encode response", "error", err) + } +} diff --git a/service/server/server.go b/service/server/server.go index 7fb86d3..1bef563 100644 --- a/service/server/server.go +++ b/service/server/server.go @@ -18,16 +18,18 @@ import ( ) type Server struct { - cfg *config.Config - store *notification.Store - dispatcher *notification.Dispatcher - protonMonitor *proton.Monitor - signalDaemon *signal.Daemon - linkDevice *signal.LinkDevice - logger *slog.Logger - router *chi.Mux - httpServer *http.Server - startTime time.Time + cfg *config.Config + store *notification.Store + dispatcher *notification.Dispatcher + protonMonitor *proton.Monitor + signalDaemon *signal.Daemon + linkDevice *signal.LinkDevice + logger *slog.Logger + router *chi.Mux + httpServer *http.Server + startTime time.Time + lastSignalLinked *bool // Track signal linked status for change detection + lastAppsCount *int // Track apps count for change detection } func New(cfg *config.Config) (*Server, error) { @@ -80,27 +82,28 @@ func (s *Server) setupRoutes() { r.Use(authMiddleware(s.cfg.APIKey)) r.Get("/health", s.handleFragmentHealth) r.Get("/signal-info", s.handleFragmentSignalInfo) - r.Get("/endpoints", s.handleFragmentEndpoints) + r.Get("/apps", s.handleFragmentApps) }) r.Route("/action", func(r chi.Router) { r.Use(authMiddleware(s.cfg.APIKey)) - r.Delete("/delete-endpoint", s.handleDeleteEndpointAction) + r.Delete("/delete-app/{appName}", s.handleDeleteAppAction) r.Post("/toggle-channel", s.handleToggleChannelAction) }) - r.Route("/admin", func(r chi.Router) { + r.Route("/api/admin", func(r chi.Router) { r.Use(authMiddleware(s.cfg.APIKey)) r.Get("/mappings", s.handleGetMappings) r.Post("/mappings", s.handleCreateMapping) - r.Delete("/mappings/{endpoint}", s.handleDeleteMapping) - r.Put("/mappings/{endpoint}/channel", s.handleUpdateChannel) + r.Delete("/mappings/{appName}", s.handleDeleteMapping) + r.Put("/mappings/{appName}/channel", s.handleUpdateChannel) r.Get("/stats", s.handleGetStats) }) - r.Route("/api/webhook", func(r chi.Router) { + r.Route("/webpush/app", func(r chi.Router) { r.Use(authMiddleware(s.cfg.APIKey)) - r.Post("/register", s.handleWebhookRegister) + r.Post("/", s.handleWebPushRegister) + r.Delete("/{appName}", s.handleWebPushUnregister) }) r.Route("/api/proton-mail", func(r chi.Router) { @@ -134,12 +137,11 @@ func (s *Server) Start(ctx context.Context) error { IdleTimeout: 60 * time.Second, } - s.logger.Info("Prism running on:") - s.logger.Info(fmt.Sprintf("Local: http://localhost:%d", s.cfg.Port)) - + msg := fmt.Sprintf("Prism running on:\n Local: http://localhost:%d", s.cfg.Port) if lanIP := util.GetLANIP(); lanIP != "" { - s.logger.Debug(fmt.Sprintf("Network: http://%s:%d", lanIP, s.cfg.Port)) + msg += fmt.Sprintf("\n Network: http://%s:%d", lanIP, s.cfg.Port) } + s.logger.Info(msg) errCh := make(chan error, 1) go func() {