support webpush and webhooks together, allow unregistered webpush, rename endpoints to apps

This commit is contained in:
Egor 2026-02-03 19:31:47 -08:00
parent 14e77c2b56
commit 03e5981482
15 changed files with 611 additions and 258 deletions

View file

@ -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 @@
<!-- markdownlint-enable MD033 -->
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.

3
go.mod
View file

@ -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
)

38
go.sum
View file

@ -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=

View file

@ -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;

View file

@ -26,7 +26,7 @@
<h2>Signal</h2>
<div id="signal-info"
hx-get="/fragment/signal-info"
hx-trigger="load, every 3s">
hx-trigger="load">
<div class="loading">
<div class="spinner"></div>
</div>
@ -34,9 +34,9 @@
</div>
<div class="card">
<h2>Endpoints</h2>
<div id="endpoints-list"
hx-get="/fragment/endpoints"
<h2>Registered Applications</h2>
<div id="apps-list"
hx-get="/fragment/apps"
hx-trigger="load">
<div class="loading">
<div class="spinner"></div>

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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 = &notification.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(),
}

View file

@ -44,11 +44,30 @@ func (s *Server) handleFragmentHealth(w http.ResponseWriter, r *http.Request) {
<div class="status-item %s">Proton Mail: %s%s</div>`, protonClass, protonStatus, protonTooltip)
}
html += `</div>
<div id="signal-info" hx-swap-oob="true">`
html += s.getSignalInfoHTML()
html += `</div>`
currentLinked := linked != nil
if s.lastSignalLinked == nil || *s.lastSignalLinked != currentLinked {
html += `
<div id="signal-info" hx-swap-oob="true">`
html += s.getSignalInfoHTML()
html += `</div>`
s.lastSignalLinked = &currentLinked
}
// Check if apps changed
mappings, err := s.store.GetAllMappings()
if err == nil {
currentAppsCount := len(mappings)
if s.lastAppsCount == nil || *s.lastAppsCount != currentAppsCount {
html += `
<div id="apps-list" hx-swap-oob="true">`
html += s.getAppsListHTML(mappings)
html += `</div>`
s.lastAppsCount = &currentAppsCount
}
}
_, _ = fmt.Fprint(w, html) //nolint:errcheck // Error writing to ResponseWriter is handled by HTTP server
}
@ -98,7 +117,7 @@ func (s *Server) getQRCodeHTML() string {
</div>`, 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, `<p>No endpoints registered</p>`) //nolint:errcheck
return
return `<p>No apps registered</p>`
}
_, _ = fmt.Fprint(w, `<ul class="endpoint-list">`) //nolint:errcheck
var html string
html += `<ul class="app-list">`
for _, m := range mappings {
isSignal := m.Channel == notification.ChannelSignal
isWebhook := m.Channel == notification.ChannelWebhook
isWebPush := m.Channel == notification.ChannelWebPush
channelBadge := "Signal"
channelTooltip := ""
if isWebhook {
channelBadge = "Webhook"
}
if isSignal && m.GroupID != nil {
channelTooltip = fmt.Sprintf(`<span class="tooltip">Group ID: %s</span>`, *m.GroupID)
}
if isWebhook && m.UpEndpoint != nil {
channelTooltip = fmt.Sprintf(`<span class="tooltip">%s</span>`, *m.UpEndpoint)
if isWebPush && m.WebPush != nil {
if m.WebPush.HasEncryption() {
channelBadge = "WebPush"
} else {
channelBadge = "Webhook"
}
channelTooltip = fmt.Sprintf(`<span class="tooltip">%s</span>`, m.WebPush.Endpoint)
}
html := fmt.Sprintf(`<li class="endpoint-item">
<div class="endpoint-info">
<div class="endpoint-name"><strong>%s</strong></div>
<div class="endpoint-channel">
if isSignal && m.Signal != nil && m.Signal.GroupID != "" {
channelTooltip = fmt.Sprintf(`<span class="tooltip">Group ID: %s</span>`, m.Signal.GroupID)
}
itemHTML := fmt.Sprintf(`<li class="app-item">
<div class="app-info">
<div class="app-name"><strong>%s</strong></div>
<div class="app-channel">
<span class="channel-badge channel-%s">%s%s</span>`, m.AppName, m.Channel, channelBadge, channelTooltip)
if m.UpEndpoint != nil && isWebhook {
if u, err := url.Parse(*m.UpEndpoint); err == nil {
html += fmt.Sprintf(`<span class="endpoint-detail">%s</span>`, u.Hostname())
if m.WebPush != nil && isWebPush {
if u, err := url.Parse(m.WebPush.Endpoint); err == nil {
itemHTML += fmt.Sprintf(`<span class="app-detail">%s</span>`, u.Hostname())
}
}
html += `</div></div><div class="endpoint-actions">`
itemHTML += `</div></div><div class="app-actions">`
if m.UpEndpoint != nil {
html += fmt.Sprintf(`<form style="display: inline;">
<input type="hidden" name="endpoint" value="%s" />
<select class="channel-select" name="channel" hx-post="/action/toggle-channel" hx-target="#endpoints-list" hx-swap="innerHTML" hx-include="closest form">
if m.WebPush != nil {
webpushLabel := "WebPush"
if !m.WebPush.HasEncryption() {
webpushLabel = "Webhook"
}
itemHTML += fmt.Sprintf(`<form style="display: inline;">
<input type="hidden" name="app" value="%s" />
<select class="channel-select" name="channel" hx-post="/action/toggle-channel" hx-target="#apps-list" hx-swap="innerHTML" hx-include="closest form">
<option value="signal"%s>Signal</option>
<option value="webhook"%s>Webhook</option>
<option value="webpush"%s>%s</option>
</select>
</form>`, m.Endpoint, map[bool]string{true: " selected", false: ""}[isSignal], map[bool]string{true: " selected", false: ""}[isWebhook])
</form>`, m.AppName, map[bool]string{true: " selected", false: ""}[isSignal], map[bool]string{true: " selected", false: ""}[isWebPush], webpushLabel)
}
html += fmt.Sprintf(`<form style="display: inline;">
<input type="hidden" name="endpoint" value="%s" />
<button class="btn-delete" hx-delete="/action/delete-endpoint" hx-target="#endpoints-list" hx-swap="innerHTML" hx-include="closest form">Delete</button>
</form></div></li>`, m.Endpoint)
itemHTML += fmt.Sprintf(`<button class="btn-delete" hx-delete="/action/delete-app/%s" hx-target="#apps-list" hx-swap="innerHTML">Delete</button></div></li>`, m.AppName)
_, _ = fmt.Fprint(w, html) //nolint:errcheck
html += itemHTML
}
_, _ = fmt.Fprint(w, `</ul>`) //nolint:errcheck
html += `</ul>`
return html
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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 = &notification.WebPushSubscription{
Endpoint: req.PushEndpoint,
P256dh: *req.P256dh,
Auth: *req.Auth,
VapidPrivateKey: *req.VapidPrivateKey,
}
} else {
webPush = &notification.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)
}
}

View file

@ -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() {