improve README, code cleaning, use the other sqlite lib for less RAM usage, use any instead of interface, add 3 retries with exponential backoff for undelivered notifications

This commit is contained in:
lone-cloud 2026-02-07 01:54:06 -08:00
parent 7fdc62e91b
commit 7fd57101a3
24 changed files with 280 additions and 153 deletions

View file

@ -4,15 +4,15 @@
# Prism
**Self-hosted notification gateway using WebPush and optional Signal for transport**
**Self-hosted notification gateway**
[Setup](#setup) • [Real-World Examples](#real-world-examples) • [Architecture](#architecture)
[Setup](#setup) • [Real-World Examples](#real-world-examples)
</div>
<!-- markdownlint-enable MD033 -->
Prism is a self-hosted notification gateway that receives HTTP requests and routes them through WebPush apps or optionally through Signal groups. Route notifications through Signal to avoid exposing unique network fingerprints, or forward them to your own WebPush apps for custom handling.
Prism is a self-hosted notification gateway. Prism can receive messages and route them to Signal, Telegram or WebPush URLs. Messages can be sent via Webhooks or from an optional Proton Mail integration.
## Setup
@ -31,7 +31,7 @@ nano .env # Set API_KEY=your-secret-key-here
docker compose up -d
```
Prism is now running at <http://localhost:8080>. By default, all notifications use WebPush (encrypted push notifications or plain HTTP webhooks). Enable optional integrations below for Signal or Telegram delivery, or Proton Mail monitoring.
Prism is now running at <http://localhost:8080>. Enable optional integrations below for custom functionality.
## Integrations
@ -39,7 +39,8 @@ All integrations are optional. Enable only what you need.
### Signal
Send notifications through Signal groups instead of WebPush.
Send notifications through Signal groups.
Each registered app will route messages to private Signal groups matching the app name.
**1. Enable Signal in `.env`:**
@ -47,7 +48,7 @@ Send notifications through Signal groups instead of WebPush.
FEATURE_ENABLE_SIGNAL=true
```
**2. Start Prism with Signal:**
**2. Start Prism with the Signal service:**
```bash
docker compose --profile signal up -d
@ -59,13 +60,12 @@ Visit <http://localhost:8080>, authenticate with your API_KEY, and scan the QR c
**Settings → Linked Devices → Link New Device**
![QR code linking screen](assets/screenshots/2.webp)
Once linked, new apps will default to Signal delivery.
### Telegram
Send notifications through Telegram instead of WebPush.
Send notifications through Telegram instead.
Unlike the Signal integration, Telegram relies on creating a Telegram bot as it will serve as the notifications messenger.
**1. Create a Telegram bot:**
@ -103,10 +103,12 @@ All notifications will now be sent to your Telegram chat. Unlike Signal, Telegra
### Proton Mail
Receive notifications when new Proton Mail emails arrive.
Unlike other integrations, this one will generate new messages to be delivered by one of the configured transports.
Note that using this integration requires a paid Proton Mail account to be able to use the Proton Mail Bridge that this integration relies on.
> **Note:** The default image (`shenxn/protonmail-bridge:build`) compiles from source and supports all architectures. For x86_64 only, you can use `shenxn/protonmail-bridge:latest` (smaller, faster).
> **Note:** The default image (`shenxn/protonmail-bridge:build`) used by Prism compiles from source and supports all architectures. For x86_64 only, you can use `shenxn/protonmail-bridge:latest` (smaller, faster).
**1. Initialize the bridge:**
**1. Initialize the Proton Mail Bridge:**
```bash
docker compose run --rm protonmail-bridge init
@ -140,15 +142,9 @@ PROTON_IMAP_PASSWORD=password-from-info-command
**5. Start Prism with Proton Mail:**
```bash
# Start only Prism + Proton Mail
docker compose --profile proton up -d
# Or with Signal + Proton Mail
docker compose --profile signal --profile proton up -d
```
Prism will now forward Proton Mail notifications to your configured channel (Signal, Telegram, or WebPush).
## Real-World Examples
### Proton Mail Notifications
@ -185,9 +181,9 @@ Reboot your Home Assistant system and you'll then be able to send Signal notific
### Send Notification
#### POST /{topic}
#### POST /{appName}
Send a notification to a registered app/topic. Compatible with ntfy format.
Send a notification. Compatible with ntfy format.
```bash
curl -X POST http://localhost:8080/my-app \
@ -253,18 +249,5 @@ The health of the system can be viewed in the same admin UI used for linking Sig
For API-based monitoring, call `/api/health` which returns JSON:
```json
{"uptime":"3s","signal":{"daemon":"running","linked":true},"protonMail":"connected"}
{"uptime":"3s","signal":{"linked":true},"proton":{"linked":true},"telegram":{"linked":true}}
```
## Architecture
Prism accepts notifications via HTTP POST requests and routes them based on your configured delivery method:
- **WebPush** (default): 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`
- **Signal groups** (optional): Uses [signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api) to create a Signal group for each app and send notifications as messages
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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

16
go.mod
View file

@ -8,8 +8,8 @@ require (
github.com/go-chi/chi/v5 v5.2.4
github.com/joho/godotenv v1.5.1
github.com/mymmrac/telego v1.5.1
github.com/ncruces/go-sqlite3 v0.30.5
golang.org/x/time v0.14.0
modernc.org/sqlite v1.34.4
)
require (
@ -18,14 +18,18 @@ require (
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
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
github.com/google/uuid v1.6.0 // indirect
github.com/grbit/go-json v0.11.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/tetratelabs/wazero v1.11.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
@ -33,4 +37,10 @@ require (
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/sys v0.40.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

52
go.sum
View file

@ -13,6 +13,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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=
@ -24,22 +26,30 @@ github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfup
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/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mymmrac/telego v1.5.1 h1:BnPPo158ABpHdS6xsTymLb8ut1gLwS927y87c+14mV8=
github.com/mymmrac/telego v1.5.1/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
github.com/ncruces/go-sqlite3 v0.30.5 h1:6usmTQ6khriL8oWilkAZSJM/AIpAlVL2zFrlcpDldCE=
github.com/ncruces/go-sqlite3 v0.30.5/go.mod h1:0I0JFflTKzfs3Ogfv8erP7CCoV/Z8uxigVDNOR0AQ5E=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -50,8 +60,6 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@ -79,6 +87,7 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
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 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
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=
@ -94,6 +103,7 @@ 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 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
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=
@ -101,6 +111,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
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.6.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=
@ -126,8 +137,6 @@ 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/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
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=
@ -135,9 +144,36 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
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 h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
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=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View file

@ -7,26 +7,22 @@ import (
)
type Config struct {
Port int
APIKey string
VerboseLogging bool
RateLimit int
TelegramChatID int64
Port int
RateLimit int
APIKey string
DeviceName string
PrismEndpointPrefix string
EnableSignal bool
SignalSocket string
EnableProton bool
ProtonIMAPUsername string
ProtonIMAPPassword string
ProtonBridgeAddr string
EnableTelegram bool
TelegramBotToken string
TelegramChatID int64
StoragePath string
ProtonIMAPUsername string
ProtonIMAPPassword string
ProtonBridgeAddr string
TelegramBotToken string
StoragePath string
VerboseLogging bool
EnableSignal bool
EnableProton bool
EnableTelegram bool
}
func Load() (*Config, error) {

View file

@ -82,7 +82,7 @@ func (m *Monitor) sendNotification() error {
ID: "mark-read",
Endpoint: "/api/proton-mail/mark-read",
Method: "POST",
Data: map[string]interface{}{
Data: map[string]any{
"uid": msgData.UID,
},
},

View file

@ -94,31 +94,23 @@ type markReadRequest struct {
func (h *Handlers) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
var req markReadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"})
util.JSONError(w, "invalid request body", http.StatusBadRequest)
return
}
if req.UID == 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "uid (number) is required"})
util.JSONError(w, "uid (number) is required", http.StatusBadRequest)
return
}
if h.monitor == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Proton integration not enabled"})
util.JSONError(w, "Proton integration not enabled", http.StatusBadRequest)
return
}
if err := h.monitor.MarkAsRead(req.UID); err != nil {
h.logger.Error("failed to mark email as read", "uid", req.UID, "error", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "failed to mark as read"})
util.JSONError(w, "failed to mark as read", http.StatusInternalServerError)
return
}

View file

@ -20,10 +20,10 @@ func NewClient(socketPath string) *Client {
}
type RPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int32 `json:"id"`
Method string `json:"method"`
Params map[string]interface{} `json:"params"`
JSONRPC string `json:"jsonrpc"`
ID int32 `json:"id"`
Method string `json:"method"`
Params map[string]any `json:"params"`
}
type RPCResponse struct {
@ -38,7 +38,7 @@ type RPCError struct {
Message string `json:"message"`
}
func (c *Client) Call(method string, params map[string]interface{}) (json.RawMessage, error) {
func (c *Client) Call(method string, params map[string]any) (json.RawMessage, error) {
conn, err := net.Dial("unix", c.SocketPath)
if err != nil {
return nil, fmt.Errorf("failed to connect: %w", err)
@ -68,9 +68,9 @@ func (c *Client) Call(method string, params map[string]interface{}) (json.RawMes
return response.Result, nil
}
func (c *Client) CallWithAccount(method string, params map[string]interface{}, account string) (json.RawMessage, error) {
func (c *Client) CallWithAccount(method string, params map[string]any, account string) (json.RawMessage, error) {
if params == nil {
params = make(map[string]interface{})
params = make(map[string]any)
}
params["account"] = account
return c.Call(method, params)
@ -120,7 +120,7 @@ func (c *Client) CreateGroup(name string) (string, string, error) {
return "", "", fmt.Errorf("no linked Signal account")
}
params := map[string]interface{}{
params := map[string]any{
"name": name,
"member": []string{},
}
@ -157,7 +157,7 @@ func (c *Client) SendGroupMessage(groupID, message string) error {
return fmt.Errorf("no linked account")
}
params := map[string]interface{}{
params := map[string]any{
"groupId": groupID,
"message": message,
"notifySelf": true,

View file

@ -62,7 +62,7 @@ func (l *LinkDevice) GenerateQR() (string, error) {
}
func (l *LinkDevice) finishLink() {
params := map[string]interface{}{
params := map[string]any{
"deviceLinkUri": l.deviceLinkUri,
"deviceName": l.deviceName,
}

View file

@ -40,16 +40,12 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
}
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"})
util.JSONError(w, "appName and pushEndpoint are required", http.StatusBadRequest)
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"})
util.JSONError(w, "Invalid pushEndpoint URL", http.StatusBadRequest)
return
}
@ -82,8 +78,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
} else {
channel := notification.ChannelWebPush
if err := h.store.Register(req.AppName, &channel, nil, webPush); err != nil {
h.logger.Error("Failed to register webpush endpoint", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
util.LogAndError(w, h.logger, "Failed to register webpush endpoint", http.StatusInternalServerError, err)
return
}
h.logger.Info("Registered new webpush endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint)
@ -105,8 +100,7 @@ func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) {
}
if err := h.store.ClearWebPush(appName); err != nil {
h.logger.Error("Failed to clear webpush endpoint", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
util.LogAndError(w, h.logger, "Failed to clear webpush endpoint", http.StatusInternalServerError, err)
return
}

View file

@ -3,6 +3,7 @@ package notification
import (
"fmt"
"log/slog"
"time"
"prism/service/util"
)
@ -85,5 +86,31 @@ func (d *Dispatcher) Send(appName string, notif Notification) error {
return fmt.Errorf("no sender for channel: %s", mapping.Channel)
}
return sender.Send(mapping, notif)
return d.sendWithRetry(sender, mapping, notif, appName)
}
func (d *Dispatcher) sendWithRetry(sender NotificationSender, mapping *Mapping, notif Notification, appName string) error {
maxRetries := 3
baseDelay := 100 * time.Millisecond
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
err := sender.Send(mapping, notif)
if err == nil {
if attempt > 0 {
d.logger.Info("Notification sent after retry", "app", appName, "attempt", attempt+1)
}
return nil
}
lastErr = err
if attempt < maxRetries-1 {
delay := baseDelay * time.Duration(1<<uint(attempt))
d.logger.Warn("Failed to send notification, retrying", "app", appName, "attempt", attempt+1, "error", err, "retryIn", delay)
time.Sleep(delay)
}
}
d.logger.Error("Failed to send notification after retries", "app", appName, "attempts", maxRetries, "error", lastErr)
return lastErr
}

View file

@ -1,10 +1,10 @@
package notification
type Action struct {
ID string `json:"id"`
Endpoint string `json:"endpoint"`
Method string `json:"method"`
Data map[string]interface{} `json:"data,omitempty"`
ID string `json:"id"`
Endpoint string `json:"endpoint"`
Method string `json:"method"`
Data map[string]any `json:"data,omitempty"`
}
type Notification struct {
@ -68,8 +68,8 @@ type SignalSubscription struct {
}
type Mapping struct {
AppName string
Channel Channel
Signal *SignalSubscription
WebPush *WebPushSubscription
AppName string
Channel Channel
}

View file

@ -6,8 +6,7 @@ import (
"os"
"path/filepath"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
_ "modernc.org/sqlite"
)
type Store struct {

View file

@ -2,7 +2,6 @@ package server
import (
"encoding/json"
"log/slog"
"net/http"
"time"
@ -21,7 +20,7 @@ func (s *Server) handleGetMappings(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(mappings); err != nil {
slog.Error("Failed to encode response", "error", err)
s.logger.Error("Failed to encode response", "error", err)
}
}
@ -109,8 +108,7 @@ func (s *Server) handleUpdateChannel(w http.ResponseWriter, r *http.Request) {
}
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)
util.LogAndError(w, s.logger, "Failed to update channel", http.StatusInternalServerError, err)
return
}
@ -123,14 +121,13 @@ func (s *Server) handleUpdateChannel(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
mappings, err := s.store.GetAllMappings()
if err != nil {
s.logger.Error("Failed to get mappings", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
util.LogAndError(w, s.logger, "Failed to get mappings", http.StatusInternalServerError, err)
return
}
uptime := time.Since(s.startTime)
stats := map[string]interface{}{
stats := map[string]any{
"uptime": util.FormatUptime(uptime),
"uptimeSeconds": int(uptime.Seconds()),
"mappingsCount": len(mappings),

View file

@ -21,8 +21,7 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
var buf bytes.Buffer
if err := s.fragmentTmpl.ExecuteTemplate(&buf, "app-list.html", s.buildAppListData(mappings)); err != nil {
s.logger.Error("Failed to execute template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
util.LogAndError(w, s.logger, "Failed to execute template", http.StatusInternalServerError, err)
return
}

View file

@ -0,0 +1,53 @@
package server
import (
"encoding/json"
"net/http"
"time"
"prism/service/util"
)
type healthResponse struct {
Uptime string `json:"uptime"`
Signal *integrationHealth `json:"signal,omitempty"`
Proton *integrationHealth `json:"proton,omitempty"`
Telegram *integrationHealth `json:"telegram,omitempty"`
}
type integrationHealth struct {
Linked bool `json:"linked"`
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
uptime := time.Since(s.startTime)
resp := healthResponse{
Uptime: util.FormatUptime(uptime),
}
if s.integrations.Signal != nil && s.integrations.Signal.IsEnabled() {
signalClient := s.integrations.Signal.GetHandlers().GetClient()
account, _ := signalClient.GetLinkedAccount()
resp.Signal = &integrationHealth{
Linked: account != nil,
}
}
if s.cfg.IsProtonEnabled() {
resp.Proton = &integrationHealth{
Linked: true,
}
}
if s.cfg.IsTelegramEnabled() {
resp.Telegram = &integrationHealth{
Linked: true,
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
s.logger.Error("Failed to encode health response", "error", err)
}
}

View file

@ -9,18 +9,19 @@ import (
"time"
"prism/service/notification"
"prism/service/util"
"github.com/go-chi/chi/v5"
)
func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
topic := chi.URLParam(r, "endpoint")
if topic == "" {
topic = chi.URLParam(r, "topic")
appName := chi.URLParam(r, "appName")
if appName == "" {
appName = chi.URLParam(r, "endpoint")
}
topic, _ = url.QueryUnescape(topic)
if topic == "" || strings.Contains(topic, "/") {
http.Error(w, "Invalid topic", http.StatusBadRequest)
appName, _ = url.QueryUnescape(appName)
if appName == "" || strings.Contains(appName, "/") {
http.Error(w, "Invalid app name", http.StatusBadRequest)
return
}
@ -30,24 +31,25 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
return
}
message := string(body)
if message == "" {
http.Error(w, "Message required", http.StatusBadRequest)
return
}
title := r.Header.Get("X-Title")
if title == "" {
title = r.Header.Get("Title")
}
if title == "" {
title = r.Header.Get("t")
}
var message, title string
contentType := r.Header.Get("Content-Type")
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
if values, err := url.ParseQuery(message); err == nil {
if m := values.Get("message"); m != "" {
message = m
if strings.Contains(contentType, "application/json") {
var payload struct {
Title string `json:"title"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &payload); err == nil {
message = payload.Message
title = payload.Title
} else {
message = string(body)
}
} else if strings.Contains(contentType, "application/x-www-form-urlencoded") {
if values, err := url.ParseQuery(string(body)); err == nil {
message = values.Get("message")
if message == "" {
message = string(body)
}
if title == "" {
if t := values.Get("title"); t != "" {
@ -56,10 +58,29 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
title = t
}
}
} else {
message = string(body)
}
} else {
message = string(body)
}
if title == "" {
title = r.Header.Get("X-Title")
if title == "" {
title = r.Header.Get("Title")
}
if title == "" {
title = r.Header.Get("t")
}
}
if title == topic {
if message == "" {
http.Error(w, "Message required", http.StatusBadRequest)
return
}
if title == appName {
title = ""
}
@ -68,20 +89,20 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
Message: message,
}
if err := s.dispatcher.Send(topic, notif); err != nil {
s.logger.Error("Failed to send notification", "app", topic, "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
if err := s.dispatcher.Send(appName, notif); err != nil {
util.LogAndError(w, s.logger, "Failed to send notification", http.StatusInternalServerError, err, "app", appName)
return
}
s.logger.Debug("Sent ntfy message", "topic", topic, "preview", truncate(message, 50))
s.logger.Debug("Sent ntfy message", "app", appName, "preview", truncate(message, 50))
now := time.Now()
w.Header().Set("Content-Type", "application/json")
response := map[string]interface{}{
"id": time.Now().UnixNano(),
"time": time.Now().Unix(),
response := map[string]any{
"id": now.UnixNano(),
"time": now.Unix(),
"event": "message",
"topic": topic,
"topic": appName,
"message": message,
}
if err := json.NewEncoder(w).Encode(response); err != nil {

View file

@ -131,3 +131,12 @@ func cleanupVisitors() {
visitorsMu.Unlock()
}
}
func maxBodySizeMiddleware(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
next.ServeHTTP(w, r)
})
}
}

View file

@ -28,6 +28,8 @@ import (
var fragmentTemplates embed.FS
type Server struct {
startTime time.Time
publicAssets embed.FS
cfg *config.Config
store *notification.Store
dispatcher *notification.Dispatcher
@ -35,11 +37,9 @@ type Server struct {
logger *slog.Logger
router *chi.Mux
httpServer *http.Server
startTime time.Time
version string
indexTmpl *template.Template
fragmentTmpl *template.Template
publicAssets embed.FS
version string
}
func New(cfg *config.Config, publicAssets embed.FS) (*Server, error) {
@ -110,6 +110,7 @@ func (s *Server) setupRoutes() {
r.Use(middleware.Compress(5))
r.Use(middleware.StripSlashes)
r.Use(rateLimitMiddleware(s.cfg.RateLimit))
r.Use(maxBodySizeMiddleware(1 << 20))
r.Get("/", s.handleIndex)
@ -140,7 +141,9 @@ func (s *Server) setupRoutes() {
r.Get("/stats", s.handleGetStats)
})
r.With(authMiddleware(s.cfg.APIKey)).Post("/{topic}", s.handleNtfyPublish)
r.With(authMiddleware(s.cfg.APIKey)).Get("/api/health", s.handleHealth)
r.With(authMiddleware(s.cfg.APIKey)).Post("/{appName}", s.handleNtfyPublish)
s.router = r
}

View file

@ -1,15 +1,23 @@
package util
import (
"encoding/json"
"log/slog"
"net/http"
)
func LogAndError(w http.ResponseWriter, logger *slog.Logger, message string, code int, err error) {
func LogAndError(w http.ResponseWriter, logger *slog.Logger, message string, code int, err error, attrs ...any) {
if err != nil {
logger.Error(message, "error", err)
logAttrs := append([]any{"error", err}, attrs...)
logger.Error(message, logAttrs...)
} else {
logger.Error(message)
logger.Error(message, attrs...)
}
http.Error(w, message, code)
}
func JSONError(w http.ResponseWriter, message string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}

View file

@ -13,7 +13,7 @@ func NewTemplateRenderer(tmpl *template.Template) *TemplateRenderer {
return &TemplateRenderer{tmpl: tmpl}
}
func (tr *TemplateRenderer) Render(name string, data interface{}) (string, error) {
func (tr *TemplateRenderer) Render(name string, data any) (string, error) {
var buf bytes.Buffer
if err := tr.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", err
@ -21,7 +21,7 @@ func (tr *TemplateRenderer) Render(name string, data interface{}) (string, error
return buf.String(), nil
}
func (tr *TemplateRenderer) RenderHTML(name string, data interface{}) (template.HTML, error) {
func (tr *TemplateRenderer) RenderHTML(name string, data any) (template.HTML, error) {
content, err := tr.Render(name, data)
return template.HTML(content), err
}