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 # 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> </div>
<!-- markdownlint-enable MD033 --> <!-- 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 ## Setup
@ -31,7 +31,7 @@ nano .env # Set API_KEY=your-secret-key-here
docker compose up -d 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 ## Integrations
@ -39,7 +39,8 @@ All integrations are optional. Enable only what you need.
### Signal ### 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`:** **1. Enable Signal in `.env`:**
@ -47,7 +48,7 @@ Send notifications through Signal groups instead of WebPush.
FEATURE_ENABLE_SIGNAL=true FEATURE_ENABLE_SIGNAL=true
``` ```
**2. Start Prism with Signal:** **2. Start Prism with the Signal service:**
```bash ```bash
docker compose --profile signal up -d 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** **Settings → Linked Devices → Link New Device**
![QR code linking screen](assets/screenshots/2.webp)
Once linked, new apps will default to Signal delivery. Once linked, new apps will default to Signal delivery.
### Telegram ### 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:** **1. Create a Telegram bot:**
@ -103,10 +103,12 @@ All notifications will now be sent to your Telegram chat. Unlike Signal, Telegra
### Proton Mail ### Proton Mail
Receive notifications when new Proton Mail emails arrive. 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 ```bash
docker compose run --rm protonmail-bridge init docker compose run --rm protonmail-bridge init
@ -140,15 +142,9 @@ PROTON_IMAP_PASSWORD=password-from-info-command
**5. Start Prism with Proton Mail:** **5. Start Prism with Proton Mail:**
```bash ```bash
# Start only Prism + Proton Mail
docker compose --profile proton up -d 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 ## Real-World Examples
### Proton Mail Notifications ### Proton Mail Notifications
@ -185,9 +181,9 @@ Reboot your Home Assistant system and you'll then be able to send Signal notific
### Send Notification ### 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 ```bash
curl -X POST http://localhost:8080/my-app \ 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: For API-based monitoring, call `/api/health` which returns JSON:
```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/go-chi/chi/v5 v5.2.4
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mymmrac/telego 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 golang.org/x/time v0.14.0
modernc.org/sqlite v1.34.4
) )
require ( require (
@ -18,14 +18,18 @@ require (
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // 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-message v0.18.1 // indirect
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // 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/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/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/ncruces/julianday v1.0.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/tetratelabs/wazero v1.11.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.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/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/crypto v0.47.0 // indirect golang.org/x/crypto v0.47.0 // indirect
golang.org/x/sys v0.40.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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:lNznYWa5uhMrngnSYEklzCeye4DBq9TEJ+pr0K593+8=
github.com/emersion/go-imap/v2 v2.0.0-beta.7/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk= 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= 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 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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/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 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 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 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= 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 h1:BnPPo158ABpHdS6xsTymLb8ut1gLwS927y87c+14mV8=
github.com/mymmrac/telego v1.5.1/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= 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-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-sqlite3 v0.30.5/go.mod h1:0I0JFflTKzfs3Ogfv8erP7CCoV/Z8uxigVDNOR0AQ5E= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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= 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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.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.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/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-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-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.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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.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/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-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-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-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.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.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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.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.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.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.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 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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-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.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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 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/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= 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/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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 { type Config struct {
Port int TelegramChatID int64
APIKey string Port int
VerboseLogging bool RateLimit int
RateLimit int APIKey string
DeviceName string DeviceName string
PrismEndpointPrefix string PrismEndpointPrefix string
EnableSignal bool
SignalSocket string SignalSocket string
ProtonIMAPUsername string
EnableProton bool ProtonIMAPPassword string
ProtonIMAPUsername string ProtonBridgeAddr string
ProtonIMAPPassword string TelegramBotToken string
ProtonBridgeAddr string StoragePath string
VerboseLogging bool
EnableTelegram bool EnableSignal bool
TelegramBotToken string EnableProton bool
TelegramChatID int64 EnableTelegram bool
StoragePath string
} }
func Load() (*Config, error) { func Load() (*Config, error) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,7 @@ package notification
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"time"
"prism/service/util" "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 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 package notification
type Action struct { type Action struct {
ID string `json:"id"` ID string `json:"id"`
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint"`
Method string `json:"method"` Method string `json:"method"`
Data map[string]interface{} `json:"data,omitempty"` Data map[string]any `json:"data,omitempty"`
} }
type Notification struct { type Notification struct {
@ -68,8 +68,8 @@ type SignalSubscription struct {
} }
type Mapping struct { type Mapping struct {
AppName string
Channel Channel
Signal *SignalSubscription Signal *SignalSubscription
WebPush *WebPushSubscription WebPush *WebPushSubscription
AppName string
Channel Channel
} }

View file

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

View file

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

View file

@ -21,8 +21,7 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
var buf bytes.Buffer var buf bytes.Buffer
if err := s.fragmentTmpl.ExecuteTemplate(&buf, "app-list.html", s.buildAppListData(mappings)); err != nil { if err := s.fragmentTmpl.ExecuteTemplate(&buf, "app-list.html", s.buildAppListData(mappings)); err != nil {
s.logger.Error("Failed to execute template", "error", err) util.LogAndError(w, s.logger, "Failed to execute template", http.StatusInternalServerError, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return 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" "time"
"prism/service/notification" "prism/service/notification"
"prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) { func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
topic := chi.URLParam(r, "endpoint") appName := chi.URLParam(r, "appName")
if topic == "" { if appName == "" {
topic = chi.URLParam(r, "topic") appName = chi.URLParam(r, "endpoint")
} }
topic, _ = url.QueryUnescape(topic) appName, _ = url.QueryUnescape(appName)
if topic == "" || strings.Contains(topic, "/") { if appName == "" || strings.Contains(appName, "/") {
http.Error(w, "Invalid topic", http.StatusBadRequest) http.Error(w, "Invalid app name", http.StatusBadRequest)
return return
} }
@ -30,24 +31,25 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
return return
} }
message := string(body) var message, title string
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")
}
contentType := r.Header.Get("Content-Type") contentType := r.Header.Get("Content-Type")
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
if values, err := url.ParseQuery(message); err == nil { if strings.Contains(contentType, "application/json") {
if m := values.Get("message"); m != "" { var payload struct {
message = m 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 title == "" {
if t := values.Get("title"); t != "" { if t := values.Get("title"); t != "" {
@ -56,10 +58,29 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
title = t 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 = "" title = ""
} }
@ -68,20 +89,20 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
Message: message, Message: message,
} }
if err := s.dispatcher.Send(topic, notif); err != nil { if err := s.dispatcher.Send(appName, notif); err != nil {
s.logger.Error("Failed to send notification", "app", topic, "error", err) util.LogAndError(w, s.logger, "Failed to send notification", http.StatusInternalServerError, err, "app", appName)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return 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") w.Header().Set("Content-Type", "application/json")
response := map[string]interface{}{ response := map[string]any{
"id": time.Now().UnixNano(), "id": now.UnixNano(),
"time": time.Now().Unix(), "time": now.Unix(),
"event": "message", "event": "message",
"topic": topic, "topic": appName,
"message": message, "message": message,
} }
if err := json.NewEncoder(w).Encode(response); err != nil { if err := json.NewEncoder(w).Encode(response); err != nil {

View file

@ -131,3 +131,12 @@ func cleanupVisitors() {
visitorsMu.Unlock() 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 var fragmentTemplates embed.FS
type Server struct { type Server struct {
startTime time.Time
publicAssets embed.FS
cfg *config.Config cfg *config.Config
store *notification.Store store *notification.Store
dispatcher *notification.Dispatcher dispatcher *notification.Dispatcher
@ -35,11 +37,9 @@ type Server struct {
logger *slog.Logger logger *slog.Logger
router *chi.Mux router *chi.Mux
httpServer *http.Server httpServer *http.Server
startTime time.Time
version string
indexTmpl *template.Template indexTmpl *template.Template
fragmentTmpl *template.Template fragmentTmpl *template.Template
publicAssets embed.FS version string
} }
func New(cfg *config.Config, publicAssets embed.FS) (*Server, error) { 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.Compress(5))
r.Use(middleware.StripSlashes) r.Use(middleware.StripSlashes)
r.Use(rateLimitMiddleware(s.cfg.RateLimit)) r.Use(rateLimitMiddleware(s.cfg.RateLimit))
r.Use(maxBodySizeMiddleware(1 << 20))
r.Get("/", s.handleIndex) r.Get("/", s.handleIndex)
@ -140,7 +141,9 @@ func (s *Server) setupRoutes() {
r.Get("/stats", s.handleGetStats) 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 s.router = r
} }

View file

@ -1,15 +1,23 @@
package util package util
import ( import (
"encoding/json"
"log/slog" "log/slog"
"net/http" "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 { if err != nil {
logger.Error(message, "error", err) logAttrs := append([]any{"error", err}, attrs...)
logger.Error(message, logAttrs...)
} else { } else {
logger.Error(message) logger.Error(message, attrs...)
} }
http.Error(w, message, code) 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} 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 var buf bytes.Buffer
if err := tr.tmpl.ExecuteTemplate(&buf, name, data); err != nil { if err := tr.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
return "", err return "", err
@ -21,7 +21,7 @@ func (tr *TemplateRenderer) Render(name string, data interface{}) (string, error
return buf.String(), nil 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) content, err := tr.Render(name, data)
return template.HTML(content), err return template.HTML(content), err
} }