re-architect to a new integration system, ensure that signal is optional, adding telegram support

This commit is contained in:
lone-cloud 2026-02-05 15:46:28 -08:00
parent 4dd14a2833
commit ac40783aa7
55 changed files with 1928 additions and 912 deletions

View file

@ -1,30 +1,47 @@
# Required: API key for authentication # Required: API key for authentication
# API_KEY=your-secret-key-here # API_KEY=your-secret-key-here
# Optional: Feature flags to enable/disable integrations
# FEATURE_ENABLE_SIGNAL=false
# FEATURE_ENABLE_PROTON=false
# FEATURE_ENABLE_TELEGRAM=false
# Advanced Configuration
# Optional: Server port # Optional: Server port
# Default: 8080
# PORT=8080 # PORT=8080
# Optional: Rate limit for requests (per 15 minute window) # Optional: Rate limit for requests (per 15 minute window)
# Default: 100 (requests per 15 minutes)
# RATE_LIMIT=100 # RATE_LIMIT=100
# Optional: Device name shown in Signal app's linked devices list # Optional: Device name shown in Signal app's linked devices list
# Default: Prism # DEVICE_NAME=Prism
# DEVICE_NAME=Prism Dev
# Optional: Enable verbose logging # Optional: Enable verbose logging
# Default: false
# VERBOSE_LOGGING=true # VERBOSE_LOGGING=true
# Optional: signal-cli version to install/use
# Default: 0.13.23
# SIGNAL_CLI_VERSION=0.13.23
# Optional: Proton Mail integration - receive email notifications via Signal # Signal Integration (requires FEATURE_ENABLE_SIGNAL=true)
# Get credentials by running: docker compose run --rm protonmail-bridge init
# Then use 'login' and 'info' commands to get IMAP username/password # Optional: Path to the signal-cli daemon Unix socket
# SIGNAL_SOCKET=/run/signal-cli/socket
# Proton Mail Integration (requires FEATURE_ENABLE_PROTON=true)
# Required: IMAP credentials for Proton Mail Bridge
# PROTON_IMAP_USERNAME=your-email@proton.me # PROTON_IMAP_USERNAME=your-email@proton.me
# PROTON_IMAP_PASSWORD=bridge-generated-password # PROTON_IMAP_PASSWORD=bridge-generated-password
# PROTON_BRIDGE_HOST=protonmail-bridge
# PROTON_BRIDGE_PORT=143 # Optional: Address of the Proton Mail Bridge service
# PROTON_BRIDGE_ADDR=protonmail-bridge:143
# Telegram Integration (requires FEATURE_ENABLE_TELEGRAM=true)
# Required: Bot token from @BotFather
# TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
# Required: Your personal chat ID (message @userinfobot on Telegram to get it)
# TELEGRAM_CHAT_ID=123456789

35
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,35 @@
# Copilot Instructions
## Code Style
- **NO USELESS COMMENTS**: Don't add comments that just restate what the code does. If the code needs a comment to be understood, refactor it to be clearer instead.
- **Self-documenting code**: Use clear variable and function names. The code should explain itself.
- **Only comment WHY, not WHAT**: If you must add a comment, explain WHY something is done, not WHAT is being done.
### Bad Comments (Don't Do This)
```go
// Check what channels are actually available
signalLinked := false
// Build channel selector with only available options
var channelOptions string
```
### Good Comments (Rare, Only When Necessary)
```go
// HACK: QR service has 60s timeout, cache to avoid repeated calls
if time.Since(l.generatedAt) < l.ttl {
return l.qrCode, nil
}
```
## General Rules
- Code must be idiomatic and follow Go best practices
- Keep functions focused and small
- Use clear, descriptive names
- Prefer composition over inheritance
- Handle errors properly, don't ignore them

View file

@ -0,0 +1,30 @@
name: Release Signal CLI
on:
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push signal-cli Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.signal-cli
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/lone-cloud/prism-signal-cli:latest

View file

@ -1,14 +1,7 @@
name: Release name: Release
on: on:
push:
tags: ['v*']
workflow_dispatch: workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., 0.1.0)'
required: true
type: string
jobs: jobs:
release: release:
@ -29,11 +22,7 @@ jobs:
- name: Extract version - name: Extract version
id: version id: version
run: | run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "tag=$(cat VERSION)" >> $GITHUB_OUTPUT
echo "tag=${{ inputs.version }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
fi
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6

2
.gitignore vendored
View file

@ -4,3 +4,5 @@ data/
.env .env
/prism /prism
prism-* prism-*
tmp
.VSCodeCounter/

View file

@ -9,13 +9,14 @@ RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo \ RUN CGO_ENABLED=1 GOOS=linux go build \
-trimpath \
-ldflags="-w -s -X main.version=$(cat VERSION 2>/dev/null || echo dev) -X main.commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \ -ldflags="-w -s -X main.version=$(cat VERSION 2>/dev/null || echo dev) -X main.commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \
-o prism . -o prism .
FROM alpine:latest FROM alpine:latest
RUN apk --no-cache add ca-certificates signal-cli openjdk21-jre RUN apk --no-cache add ca-certificates
WORKDIR /app WORKDIR /app
@ -23,14 +24,11 @@ COPY --from=builder /build/prism .
COPY public ./public COPY public ./public
RUN adduser -D -u 1000 prism && \ RUN adduser -D -u 1000 prism && \
mkdir -p /var/run/signal-cli /app/data && \ mkdir -p /app/data && \
chown -R prism:prism /app /var/run/signal-cli chown -R prism:prism /app
USER prism USER prism
ENV SIGNAL_CLI_BINARY=signal-cli
ENV SIGNAL_CLI_SOCKET=/var/run/signal-cli/socket
EXPOSE 8080 EXPOSE 8080
CMD ["./prism"] CMD ["./prism"]

12
Dockerfile.signal-cli Normal file
View file

@ -0,0 +1,12 @@
FROM alpine:latest
RUN apk add --no-cache signal-cli openjdk21-jre bash su-exec
RUN adduser -D -u 1000 signal && \
mkdir -p /home/signal/.signal-cli /home/.local/share/signal-cli && \
chown -R signal:signal /home/signal /home/.local/share/signal-cli
RUN printf '#!/bin/bash\nset -e\nmkdir -p /home/signal/.signal-cli\nchown signal:signal /home/signal/.signal-cli\nchmod 755 /home/signal/.signal-cli\nexec su-exec signal signal-cli --config /home/.local/share/signal-cli daemon --socket /home/signal/.signal-cli/socket\n' > /entrypoint.sh && \
chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -3,7 +3,7 @@
BINARY_NAME=prism BINARY_NAME=prism
VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev") VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev")
COMMIT?=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") COMMIT?=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
GOBIN?=$(shell go env GOPATH)/bin GOBIN?=$(shell command -v go >/dev/null 2>&1 && go env GOPATH || echo "${HOME}/go")/bin
export PATH := $(GOBIN):$(PATH) export PATH := $(GOBIN):$(PATH)
all: fmt lint build all: fmt lint build
@ -39,8 +39,6 @@ install-tools:
@echo "Installing Go tools to $(GOBIN)..." @echo "Installing Go tools to $(GOBIN)..."
go install golang.org/x/tools/cmd/goimports@latest go install golang.org/x/tools/cmd/goimports@latest
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v2.8.0 curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v2.8.0
@echo "Installing signal-cli..."
@bash scripts/install-signal-cli.sh
deps: deps:
go mod download go mod download
@ -59,7 +57,13 @@ docker-down:
docker compose -f docker-compose.dev.yml down docker compose -f docker-compose.dev.yml down
docker-up-proton: docker-up-proton:
docker compose -f docker-compose.dev.yml --profile protonmail up -d docker compose -f docker-compose.dev.yml up -d --build protonmail-bridge
docker-up-signal:
docker compose -f docker-compose.dev.yml up -d --build signal-cli
docker-up-telegram:
docker compose -f docker-compose.dev.yml up -d --build telegram-bot
release: release:
@if [ ! -f VERSION ]; then \ @if [ ! -f VERSION ]; then \
@ -70,4 +74,8 @@ release:
echo "Releasing v$$VERSION..."; \ echo "Releasing v$$VERSION..."; \
git tag -a "v$$VERSION" -m "Release v$$VERSION"; \ git tag -a "v$$VERSION" -m "Release v$$VERSION"; \
git push origin "v$$VERSION"; \ git push origin "v$$VERSION"; \
echo "Tag pushed. GitHub Actions will build and push Docker image." gh workflow run release.yml
release-signal:
@echo "Triggering signal-cli image release via GitHub Actions..."
gh workflow run release-signal-cli.yml

191
README.md
View file

@ -4,7 +4,7 @@
# Prism # Prism
**Self-hosted notification gateway using Signal and WebPush for transport** **Self-hosted notification gateway using WebPush and optional Signal for transport**
[Setup](#setup) • [Real-World Examples](#real-world-examples) • [Architecture](#architecture) [Setup](#setup) • [Real-World Examples](#real-world-examples) • [Architecture](#architecture)
@ -12,128 +12,147 @@
<!-- markdownlint-enable MD033 --> <!-- markdownlint-enable MD033 -->
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. 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.
## Setup ## Setup
### 1. Proton Mail Integration
A Proton Mail Bridge is optionally available if you want to receive push notifications for incoming emails.
> **Note:** The default Proton Mail Bridge image uses `shenxn/protonmail-bridge:build` which compiles from source and supports multiple architectures. For x86_64 systems, you can use `shenxn/protonmail-bridge:latest` (pre-built binary, smaller and faster). For ARM devices (Raspberry Pi), stick with `:build`.
To receive Proton Mail notifications via Signal:
1. **Initialize Proton Mail Bridge** (one-time setup):
```bash ```bash
# Download docker-compose.yml # Download docker-compose.yml
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/docker-compose.yml curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/docker-compose.yml
docker compose run --rm protonmail-bridge init # Download .env.example
```
2.**Login to Proton Mail Bridge**:
- At the `>>>` prompt, run: `login`
- Enter your email
- Enter your password
- Enter your 2FA code
3.**Get IMAP credentials**:
- Run: `info`
- Copy the Username and Password shown
- Run: `exit` to quit
4.**Add credentials to .env**:
```bash
# Add these to your .env file
PROTON_IMAP_USERNAME=bridge-username-from-info-command
PROTON_IMAP_PASSWORD=bridge-generated-password-from-info-command
```
5.**Start all services with Proton Mail**:
```bash
docker compose --profile protonmail up -d
```
Your phone will now receive Signal notifications when Proton Mail receives new emails.
Note that the bridge will first need to sync all of your old emails before you can start getting new email notifications which may take a while, but this is a one-time setup.
### 2. Install Prism Server
```bash
# Download docker-compose.yml
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/docker-compose.yml
# Download .env.example (optional)
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example
# Configure Prism server through environment variables (optional) # Configure your API key
cp .env.example .env cp .env.example .env
nano .env nano .env # Set API_KEY=your-secret-key-here
# Start Prism server # Start Prism
docker compose up -d docker compose up -d
``` ```
### 3. Link Your Signal Account 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.
Visit <http://localhost:8080> and link your Signal account (one-time setup): ## Integrations
#### 1. Authenticate with your API_KEY All integrations are optional. Enable only what you need.
![Admin login screen](assets/screenshots/1.webp) ### Signal
#### 2. Scan the QR code from your Signal app Send notifications through Signal groups instead of WebPush.
Go to **Settings → Linked Devices → Link New Device** in Signal. **1. Enable Signal in `.env`:**
```bash
FEATURE_ENABLE_SIGNAL=true
```
**2. Restart Prism:**
```bash
docker compose down
docker compose up -d
```
**3. Link your Signal account:**
Visit <http://localhost:8080>, authenticate with your API_KEY, and scan the QR code from your Signal app:
**Settings → Linked Devices → Link New Device**
![QR code linking screen](assets/screenshots/2.webp) ![QR code linking screen](assets/screenshots/2.webp)
#### 3. Verify the setup Once linked, new apps will default to Signal delivery.
Once linked, you'll see the status dashboard: ### Telegram
![Healthy setup with linked account](assets/screenshots/3.webp) Send notifications through Telegram instead of WebPush.
With optional Proton Mail integration: **1. Create a Telegram bot:**
![Healthy setup with Proton Mail](assets/screenshots/4.webp) - Message [@BotFather](https://t.me/BotFather) on Telegram
- Send `/newbot` and follow the prompts
- Copy the bot token (looks like `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
### Development **2. Enable Telegram in `.env`:**
For local development, install Go and signal-cli:
```bash ```bash
git clone https://github.com/lone-cloud/prism.git FEATURE_ENABLE_TELEGRAM=true
cd prism TELEGRAM_BOT_TOKEN=your-bot-token-here
# Install development tools and signal-cli
make install-tools
# Run locally
make dev
``` ```
Then build and run with docker-compose.dev.yml: **3. Restart Prism:**
```bash ```bash
docker compose --profile protonmail -f docker-compose.dev.yml up -d docker compose down
docker compose up -d
``` ```
or just the proton-bridge: **4. Get your chat ID:**
- Message [@userinfobot](https://t.me/userinfobot) on Telegram
- Copy your Chat ID from the bot's response
**5. Add chat ID to `.env`:**
```bash ```bash
docker compose -f docker-compose.dev.yml up protonmail-bridge TELEGRAM_CHAT_ID=123456789
``` ```
**6. Restart Prism:**
```bash
docker compose down
docker compose up -d
```
All notifications will now be sent to your Telegram chat. Unlike Signal, Telegram doesn't create separate groups per app - all notifications go to the configured chat ID.
### Proton Mail
Receive notifications when new Proton Mail emails arrive.
> **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).
**1. Initialize the bridge:**
```bash
docker compose run --rm protonmail-bridge init
```
**2. Login at the `>>>` prompt:**
```bash
>>> login
# Enter your Proton Mail email
# Enter your password
# Enter your 2FA code
```
**3. Get IMAP credentials:**
```bash
>>> info
# Copy the Username and Password
>>> exit
```
**4. Add credentials to `.env`:**
```bash
FEATURE_ENABLE_PROTON=true
PROTON_IMAP_USERNAME=username-from-info-command
PROTON_IMAP_PASSWORD=password-from-info-command
```
**5. Start with Proton profile:**
```bash
docker compose --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
@ -245,10 +264,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: 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 - **WebPush** (default): Supports both encrypted WebPush (with VAPID signing and payload encryption) and plain HTTP webhooks
- **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` - **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` - **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. Each app can be independently configured to use either delivery method through the admin UI.

View file

@ -11,19 +11,33 @@ services:
- API_KEY=${API_KEY:-} - API_KEY=${API_KEY:-}
- VERBOSE_LOGGING=${VERBOSE_LOGGING:-false} - VERBOSE_LOGGING=${VERBOSE_LOGGING:-false}
- RATE_LIMIT=${RATE_LIMIT:-100} - RATE_LIMIT=${RATE_LIMIT:-100}
- FEATURE_ENABLE_SIGNAL=${FEATURE_ENABLE_SIGNAL:-false}
- SIGNAL_SOCKET=/home/signal/.signal-cli/socket
- FEATURE_ENABLE_PROTON=${FEATURE_ENABLE_PROTON:-false}
- PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-} - PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-}
- PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-} - PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-}
- PROTON_BRIDGE_HOST=protonmail-bridge - PROTON_BRIDGE_ADDR=${PROTON_BRIDGE_ADDR:-protonmail-bridge:143}
- PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143}
volumes: volumes:
- signal-data:/root/.local/share/signal-cli
- prism-data:/root/.local/share/prism - prism-data:/root/.local/share/prism
- signal-socket:/home/signal/.signal-cli:ro
restart: unless-stopped
signal-cli:
container_name: signal-cli
build:
context: .
dockerfile: Dockerfile.signal-cli
profiles: ['signal']
volumes:
- signal-data:/home/.local/share/signal-cli
- signal-socket:/home/signal/.signal-cli
- /tmp/signal-cli:/home/signal/.signal-cli
restart: unless-stopped restart: unless-stopped
protonmail-bridge: protonmail-bridge:
container_name: protonmail-bridge container_name: protonmail-bridge
image: shenxn/protonmail-bridge:build image: shenxn/protonmail-bridge:build
profiles: ['protonmail'] profiles: ['proton']
ports: ports:
- '127.0.0.1:143:143' - '127.0.0.1:143:143'
volumes: volumes:
@ -33,5 +47,6 @@ services:
volumes: volumes:
signal-data: signal-data:
signal-socket:
prism-data: prism-data:
proton-bridge-data: proton-bridge-data:

View file

@ -9,19 +9,33 @@ services:
- API_KEY=${API_KEY:-} - API_KEY=${API_KEY:-}
- VERBOSE_LOGGING=${VERBOSE_LOGGING:-false} - VERBOSE_LOGGING=${VERBOSE_LOGGING:-false}
- RATE_LIMIT=${RATE_LIMIT:-100} - RATE_LIMIT=${RATE_LIMIT:-100}
- FEATURE_ENABLE_SIGNAL=${FEATURE_ENABLE_SIGNAL:-false}
- SIGNAL_SOCKET=/home/signal/.signal-cli/socket
- FEATURE_ENABLE_PROTON=${FEATURE_ENABLE_PROTON:-false}
- PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-} - PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-}
- PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-} - PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-}
- PROTON_BRIDGE_HOST=protonmail-bridge - PROTON_BRIDGE_ADDR=${PROTON_BRIDGE_ADDR:-protonmail-bridge:143}
- PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143} - FEATURE_ENABLE_TELEGRAM=${FEATURE_ENABLE_TELEGRAM:-false}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
volumes: volumes:
- signal-data:/root/.local/share/signal-cli
- prism-data:/root/.local/share/prism - prism-data:/root/.local/share/prism
- signal-socket:/home/signal/.signal-cli:ro
restart: unless-stopped
signal-cli:
container_name: signal-cli
image: ghcr.io/lone-cloud/prism-signal-cli:latest
profiles: ['signal']
volumes:
- signal-data:/home/.local/share/signal-cli
- signal-socket:/home/signal/.signal-cli
restart: unless-stopped restart: unless-stopped
protonmail-bridge: protonmail-bridge:
container_name: protonmail-bridge container_name: protonmail-bridge
image: shenxn/protonmail-bridge:build image: shenxn/protonmail-bridge:build
profiles: ['protonmail'] profiles: ['proton']
volumes: volumes:
- proton-bridge-data:/root - proton-bridge-data:/root
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro - /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro
@ -29,5 +43,6 @@ services:
volumes: volumes:
signal-data: signal-data:
signal-socket:
prism-data: prism-data:
proton-bridge-data: proton-bridge-data:

17
go.mod
View file

@ -8,12 +8,27 @@ 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/mattn/go-sqlite3 v1.14.33 github.com/mattn/go-sqlite3 v1.14.33
github.com/mymmrac/telego v1.5.1
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0
) )
require ( require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
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/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
golang.org/x/crypto v0.31.0 // indirect github.com/grbit/go-json v0.11.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // 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
github.com/valyala/fastjson v1.6.7 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sys v0.39.0 // indirect
) )

56
go.sum
View file

@ -1,5 +1,18 @@
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
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/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=
@ -11,18 +24,53 @@ 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/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/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/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-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mymmrac/telego v1.5.1 h1:BnPPo158ABpHdS6xsTymLb8ut1gLwS927y87c+14mV8=
github.com/mymmrac/telego v1.5.1/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
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/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=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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/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=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 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.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.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.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.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/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 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.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=
@ -54,6 +102,8 @@ 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=
golang.org/x/sys v0.20.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/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -81,3 +131,7 @@ 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/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/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=

19
main.go
View file

@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
@ -29,22 +30,22 @@ func main() {
return return
} }
if err := runServer(); err != nil {
logger := util.NewLogger(false)
logger.Error("Fatal error", "error", err)
os.Exit(1)
}
}
func runServer() error {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
return fmt.Errorf("failed to load config: %w", err) fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
os.Exit(1)
} }
logger := util.NewLogger(cfg.VerboseLogging) logger := util.NewLogger(cfg.VerboseLogging)
logger.Info("Starting Prism", "version", version) logger.Info("Starting Prism", "version", version)
if err := runServer(cfg, logger); err != nil {
logger.Error("Fatal error", "error", err)
os.Exit(1)
}
}
func runServer(cfg *config.Config, logger *slog.Logger) error {
srv, err := server.New(cfg) srv, err := server.New(cfg)
if err != nil { if err != nil {
return fmt.Errorf("failed to create server: %w", err) return fmt.Errorf("failed to create server: %w", err)

View file

@ -3,41 +3,53 @@
--bg-secondary: #fdfdfd; --bg-secondary: #fdfdfd;
--text-primary: #000; --text-primary: #000;
--text-secondary: #666; --text-secondary: #666;
--text-on-color: #fafafa;
--border-color: rgba(0, 0, 0, 0.1); --border-color: rgba(0, 0, 0, 0.1);
--accent: #8159b8; --accent: #8159b8;
--success: #28a745; --success: #28a745;
--error: #dc3545; --error: #dc3545;
--spinner-track: #e0e0e0; --spinner-track: #e0e0e0;
--badge-signal-bg: #8159b8;
--badge-webhook-bg: rgba(40, 167, 69, 0.2);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { :root:not([data-theme="light"]):not([data-theme="dark"]) {
--bg-primary: #1a1a1a; --bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d; --bg-secondary: #2d2d2d;
--text-primary: #e0e0e0; --text-primary: #e0e0e0;
--text-secondary: #a0a0a0; --text-secondary: #a0a0a0;
--text-on-color: #fff;
--border-color: rgba(255, 255, 255, 0.1); --border-color: rgba(255, 255, 255, 0.1);
--accent: #a78bfa;
--success: #28a745; --success: #28a745;
--error: #ef4444; --error: #ef4444;
--spinner-track: #4a4a4a; --spinner-track: #4a4a4a;
--badge-signal-bg: #8159b8;
--badge-webhook-bg: #28a745;
} }
} }
[data-theme="dark"] { :root[data-theme="light"] {
--bg-primary: #f5f5f5;
--bg-secondary: #fdfdfd;
--text-primary: #000;
--text-secondary: #666;
--text-on-color: #fafafa;
--border-color: rgba(0, 0, 0, 0.1);
--accent: #8159b8;
--success: #28a745;
--error: #dc3545;
--spinner-track: #e0e0e0;
}
:root[data-theme="dark"] {
--bg-primary: #1a1a1a; --bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d; --bg-secondary: #2d2d2d;
--text-primary: #e0e0e0; --text-primary: #e0e0e0;
--text-secondary: #a0a0a0; --text-secondary: #a0a0a0;
--text-on-color: #fff;
--border-color: rgba(255, 255, 255, 0.1); --border-color: rgba(255, 255, 255, 0.1);
--accent: #a78bfa;
--success: #28a745; --success: #28a745;
--error: #ef4444; --error: #ef4444;
--spinner-track: #4a4a4a; --spinner-track: #4a4a4a;
--badge-signal-bg: #8159b8;
--badge-webhook-bg: #28a745;
} }
/* CSS Reset */ /* CSS Reset */
@ -87,7 +99,7 @@ h6 {
body { body {
font-family: system-ui; font-family: system-ui;
max-width: 50rem; max-width: 50rem;
margin: 1.25rem auto; margin: 1rem auto;
padding: 0 1.25rem 1.25rem; padding: 0 1.25rem 1.25rem;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
@ -96,15 +108,56 @@ body {
.card { .card {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 1.25rem; padding: 0;
margin-bottom: 1.25rem; margin-bottom: 1rem;
box-shadow: 0 0.125rem 0.25rem var(--border-color); box-shadow: 0 0.125rem 0.25rem var(--border-color);
} }
.card-header {
font-weight: 600;
font-size: 1.1em;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem;
cursor: pointer;
list-style: none;
user-select: none;
}
.card-header::-webkit-details-marker {
display: none;
}
.card-header::before {
content: "▶";
font-size: 0.7em;
margin-right: 0.5rem;
transition: transform 0.2s;
display: inline-block;
}
details[open] > .card-header::before {
transform: rotate(90deg);
}
.card #apps-list {
padding: 0 1.25rem 1.25rem 1.25rem;
}
h2 { h2 {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
a {
color: var(--accent);
text-decoration: underline;
}
a:hover {
opacity: 0.8;
}
.status { .status {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@ -119,12 +172,14 @@ h2 {
} }
.status-item:has(.tooltip), .status-item:has(.tooltip),
.channel-badge:has(.tooltip) { .channel-badge:has(.tooltip),
.integration-status:has(.tooltip) {
cursor: help; cursor: help;
} }
.status-item .tooltip, .status-item .tooltip,
.channel-badge .tooltip { .channel-badge .tooltip,
.integration-status .tooltip {
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
position: absolute; position: absolute;
@ -147,7 +202,8 @@ h2 {
} }
.status-item .tooltip::after, .status-item .tooltip::after,
.channel-badge .tooltip::after { .channel-badge .tooltip::after,
.integration-status .tooltip::after {
content: ""; content: "";
position: absolute; position: absolute;
top: 100%; top: 100%;
@ -158,14 +214,16 @@ h2 {
} }
.status-item:hover .tooltip, .status-item:hover .tooltip,
.channel-badge:hover .tooltip { .channel-badge:hover .tooltip,
.integration-status:hover .tooltip {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
} }
.theme-toggle { .theme-toggle {
display: block; position: fixed;
margin-left: auto; bottom: 1.5rem;
right: 1.5rem;
background: transparent; background: transparent;
border: none; border: none;
cursor: pointer; cursor: pointer;
@ -182,12 +240,12 @@ h2 {
.status-ok { .status-ok {
background: var(--success); background: var(--success);
color: white; color: var(--text-on-color);
} }
.status-error { .status-error {
background: var(--error); background: var(--error);
color: white; color: var(--text-on-color);
} }
.app-list { .app-list {
@ -235,13 +293,18 @@ h2 {
} }
.channel-signal { .channel-signal {
background: var(--badge-signal-bg); background: var(--accent);
color: white; color: var(--text-on-color);
} }
.channel-webhook { .channel-webpush {
background: var(--badge-webhook-bg); background: var(--success);
color: white; color: var(--text-on-color);
}
.channel-telegram {
background: #0088cc;
color: var(--text-on-color);
} }
.app-detail { .app-detail {
@ -268,7 +331,7 @@ h2 {
.btn-delete { .btn-delete {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
background: var(--error); background: var(--error);
color: white; color: var(--text-on-color);
border: none; border: none;
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
@ -284,7 +347,7 @@ h2 {
margin-top: 1rem; margin-top: 1rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: var(--text-secondary); background: var(--text-secondary);
color: white; color: var(--text-on-color);
border: none; border: none;
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
@ -312,30 +375,11 @@ h2 {
margin-left: 1.25rem; margin-left: 1.25rem;
} }
.qr-instructions {
font-size: 1.05em;
}
.qr-container {
margin-top: 1rem;
width: 18.75rem;
height: 18.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.qr-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.link-button { .link-button {
display: inline-block; display: inline-block;
padding: 0.625rem 1.25rem; padding: 0.625rem 1.25rem;
background: var(--accent); background: var(--accent);
color: white; color: var(--text-on-color);
text-decoration: none; text-decoration: none;
border-radius: 0.25rem; border-radius: 0.25rem;
margin-top: 0.625rem; margin-top: 0.625rem;
@ -375,3 +419,95 @@ h2 {
color: var(--text-secondary); color: var(--text-secondary);
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.integration-card {
background: var(--bg-secondary);
border-radius: 0.5rem;
margin-bottom: 1.25rem;
box-shadow: 0 0.125rem 0.25rem var(--border-color);
padding: 0;
}
.integration-container {
margin-bottom: 1.25rem;
}
.integration-header {
font-weight: 600;
font-size: 1.1em;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem;
cursor: pointer;
list-style: none;
user-select: none;
}
.integration-name {
font-size: 1.2em;
}
.integration-header::-webkit-details-marker {
display: none;
}
.integration-header::before {
content: "▶";
font-size: 0.7em;
margin-right: 0.5rem;
transition: transform 0.2s;
display: inline-block;
}
details[open] > .integration-header::before {
transform: rotate(90deg);
}
.integration-content {
padding: 0 1.25rem 1.25rem 1.25rem;
}
.integration-status {
padding: 0.25rem 0.625rem;
border-radius: 0.25rem;
font-size: 0.875em;
font-weight: 500;
position: relative;
}
.integration-status.connected,
.integration-status.available {
background: var(--success);
color: var(--text-on-color);
}
.integration-status.disconnected {
background: var(--error);
color: var(--text-on-color);
}
.integration-status.unlinked {
background: var(--text-secondary);
color: var(--text-on-color);
}
.link-instructions {
margin: 0.25rem 0;
padding-left: 1.25rem;
line-height: 1.8;
}
.qr-code-container {
margin-top: 0.5rem;
text-align: center;
}
.qr-code {
height: 22rem;
width: 22rem;
border: 2px solid var(--border-color);
border-radius: 0.5rem;
padding: 0.625rem;
background: var(--bg-secondar);
}

View file

@ -7,34 +7,15 @@
<meta name="theme-color" content="#8159b8"> <meta name="theme-color" content="#8159b8">
<link rel="icon" type="image/webp" href="/favicon.webp"> <link rel="icon" type="image/webp" href="/favicon.webp">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/index.css"> <link rel="stylesheet" href="/index.css?v={{.Version}}">
<script src="/htmx.min.js"></script> <script src="/htmx.min.js"></script>
<script src="/theme.js"></script> <script src="/theme.js?v={{.Version}}"></script>
</head> </head>
<body> <body>
<div class="card"> <details class="card" open>
<div id="health-status" <summary class="card-header">
hx-get="/fragment/health" <span class="integration-name">Registered Applications</span>
hx-trigger="load, every 10s"> </summary>
<div class="loading">
<div class="spinner"></div>
</div>
</div>
</div>
<div class="card">
<h2>Signal</h2>
<div id="signal-info"
hx-get="/fragment/signal-info"
hx-trigger="load">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
</div>
<div class="card">
<h2>Registered Applications</h2>
<div id="apps-list" <div id="apps-list"
hx-get="/fragment/apps" hx-get="/fragment/apps"
hx-trigger="load"> hx-trigger="load">
@ -42,6 +23,11 @@
<div class="spinner"></div> <div class="spinner"></div>
</div> </div>
</div> </div>
</details>
<div id="integrations"
hx-get="/fragment/integrations"
hx-trigger="load">
</div> </div>
<button id="theme-toggle" class="theme-toggle"></button> <button id="theme-toggle" class="theme-toggle"></button>

View file

@ -1,27 +0,0 @@
#!/bin/bash
set -e
VERSION="0.13.23"
INSTALL_DIR="$(pwd)/signal-cli"
echo "Installing signal-cli $VERSION..."
if [ -d "$INSTALL_DIR" ]; then
echo "signal-cli already installed at $INSTALL_DIR"
exit 0
fi
TARBALL="signal-cli-${VERSION}.tar.gz"
URL="https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/${TARBALL}"
echo "Downloading from $URL..."
curl -L -o "/tmp/$TARBALL" "$URL"
echo "Extracting..."
tar -xzf "/tmp/$TARBALL" -C "$(pwd)"
mv "signal-cli-${VERSION}" "$INSTALL_DIR"
rm "/tmp/$TARBALL"
echo "✓ signal-cli installed to $INSTALL_DIR"
echo "Add to PATH: export PATH=\"$INSTALL_DIR/bin:\$PATH\""

View file

@ -14,48 +14,40 @@ type Config struct {
DeviceName string DeviceName string
PrismEndpointPrefix string PrismEndpointPrefix string
SignalCLISocketPath string EnableSignal bool
SignalCLIDataPath string SignalSocket string
SignalCLIBinaryPath string
EnableProton bool
ProtonIMAPUsername string ProtonIMAPUsername string
ProtonIMAPPassword string ProtonIMAPPassword string
ProtonBridgeHost string ProtonBridgeAddr string
ProtonBridgePort int
ProtonPrismTopic string
IMAPInbox string EnableTelegram bool
IMAPSeenFlag string TelegramBotToken string
IMAPReconnectBaseDelay int TelegramChatID int64
IMAPMaxReconnectDelay int
IMAPMaxReconnectAttempts int
StoragePath string StoragePath string
} }
func Load() (*Config, error) { func Load() (*Config, error) {
cfg := &Config{ cfg := &Config{
Port: getEnvInt("PORT", 8080),
APIKey: os.Getenv("API_KEY"), APIKey: os.Getenv("API_KEY"),
Port: getEnvInt("PORT", 8080),
VerboseLogging: getEnvBool("VERBOSE_LOGGING", false), VerboseLogging: getEnvBool("VERBOSE_LOGGING", false),
RateLimit: getEnvInt("RATE_LIMIT", 100), RateLimit: getEnvInt("RATE_LIMIT", 100),
DeviceName: getEnvString("DEVICE_NAME", "Prism"),
DeviceName: getEnvString("DEVICE_NAME", "Prism"), EnableSignal: getEnvBool("FEATURE_ENABLE_SIGNAL", false),
SignalCLISocketPath: getEnvString("SIGNAL_CLI_SOCKET", "./data/signal-cli.sock"), SignalSocket: getEnvString("SIGNAL_SOCKET", "/run/signal-cli/socket"),
SignalCLIDataPath: getEnvString("SIGNAL_CLI_DATA", "./data/prism"),
SignalCLIBinaryPath: getEnvString("SIGNAL_CLI_BINARY", "./signal-cli/bin/signal-cli"),
EnableProton: getEnvBool("FEATURE_ENABLE_PROTON", false),
ProtonIMAPUsername: os.Getenv("PROTON_IMAP_USERNAME"), ProtonIMAPUsername: os.Getenv("PROTON_IMAP_USERNAME"),
ProtonIMAPPassword: os.Getenv("PROTON_IMAP_PASSWORD"), ProtonIMAPPassword: os.Getenv("PROTON_IMAP_PASSWORD"),
ProtonBridgeHost: getEnvString("PROTON_BRIDGE_HOST", "protonmail-bridge"), ProtonBridgeAddr: getEnvString("PROTON_BRIDGE_ADDR", "protonmail-bridge:143"),
ProtonBridgePort: getEnvInt("PROTON_BRIDGE_PORT", 143),
ProtonPrismTopic: "Proton Mail",
IMAPInbox: "INBOX", EnableTelegram: getEnvBool("FEATURE_ENABLE_TELEGRAM", false),
IMAPSeenFlag: "\\Seen", TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
IMAPReconnectBaseDelay: 10000, TelegramChatID: getEnvInt64("TELEGRAM_CHAT_ID", 0),
IMAPMaxReconnectDelay: 300000,
IMAPMaxReconnectAttempts: 50,
StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"), StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"),
} }
@ -77,7 +69,15 @@ func (c *Config) Validate() error {
} }
func (c *Config) IsProtonEnabled() bool { func (c *Config) IsProtonEnabled() bool {
return c.ProtonIMAPUsername != "" && c.ProtonIMAPPassword != "" return c.EnableProton && c.ProtonIMAPUsername != "" && c.ProtonIMAPPassword != "" && c.ProtonBridgeAddr != ""
}
func (c *Config) IsSignalEnabled() bool {
return c.EnableSignal
}
func (c *Config) IsTelegramEnabled() bool {
return c.EnableTelegram
} }
func getEnvString(key, defaultValue string) string { func getEnvString(key, defaultValue string) string {
@ -96,6 +96,15 @@ func getEnvInt(key string, defaultValue int) int {
return defaultValue return defaultValue
} }
func getEnvInt64(key string, defaultValue int64) int64 {
if value := os.Getenv(key); value != "" {
if i, err := strconv.ParseInt(value, 10, 64); err == nil {
return i
}
}
return defaultValue
}
func getEnvBool(key string, defaultValue bool) bool { func getEnvBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" { if value := os.Getenv(key); value != "" {
return value == "true" || value == "1" return value == "true" || value == "1"

View file

@ -0,0 +1,70 @@
package integration
import (
"context"
"log/slog"
"net/http"
"prism/service/config"
"prism/service/integration/proton"
"prism/service/integration/signal"
"prism/service/integration/telegram"
"prism/service/integration/webpush"
"prism/service/notification"
"github.com/go-chi/chi/v5"
)
type Integration interface {
RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler)
Start(ctx context.Context, logger *slog.Logger)
IsEnabled() bool
}
type Integrations struct {
Dispatcher *notification.Dispatcher
Signal *signal.Integration
integrations []Integration
}
func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logger) *Integrations {
signalIntegration := signal.NewIntegration(cfg, store, logger)
telegramIntegration := telegram.NewIntegration(cfg, store, logger)
dispatcher := notification.NewDispatcher(store, logger)
if signalSender := signalIntegration.GetSender(); signalSender != nil {
dispatcher.RegisterSender(notification.ChannelSignal, signalSender)
}
if telegramSender := telegramIntegration.GetSender(); telegramSender != nil {
dispatcher.RegisterSender(notification.ChannelTelegram, telegramSender)
}
dispatcher.RegisterSender(notification.ChannelWebPush, webpush.NewSender(logger))
integrations := []Integration{
signalIntegration,
telegramIntegration,
proton.NewIntegration(cfg, dispatcher, logger),
webpush.NewIntegration(store, logger),
}
return &Integrations{
Dispatcher: dispatcher,
Signal: signalIntegration,
integrations: integrations,
}
}
func (i *Integrations) Start(ctx context.Context, cfg *config.Config, logger *slog.Logger) {
for _, integration := range i.integrations {
if integration.IsEnabled() {
integration.Start(ctx, logger)
}
}
}
func RegisterAll(integrations *Integrations, router *chi.Mux, cfg *config.Config, store *notification.Store, logger *slog.Logger, authMiddleware func(string) func(http.Handler) http.Handler) {
auth := authMiddleware(cfg.APIKey)
for _, integration := range integrations.integrations {
integration.RegisterRoutes(router, auth)
}
}

View file

@ -9,7 +9,7 @@ import (
) )
func (m *Monitor) connect() error { func (m *Monitor) connect() error {
addr := fmt.Sprintf("%s:%d", m.cfg.ProtonBridgeHost, m.cfg.ProtonBridgePort) addr := m.cfg.ProtonBridgeAddr
options := &imapclient.Options{ options := &imapclient.Options{
UnilateralDataHandler: &imapclient.UnilateralDataHandler{ UnilateralDataHandler: &imapclient.UnilateralDataHandler{
@ -41,7 +41,7 @@ func (m *Monitor) connect() error {
} }
func (m *Monitor) monitor(ctx context.Context) error { func (m *Monitor) monitor(ctx context.Context) error {
selectCmd := m.client.Select(m.cfg.IMAPInbox, nil) selectCmd := m.client.Select(imapInbox, nil)
_, err := selectCmd.Wait() _, err := selectCmd.Wait()
if err != nil { if err != nil {
return fmt.Errorf("failed to select inbox: %w", err) return fmt.Errorf("failed to select inbox: %w", err)
@ -66,9 +66,7 @@ func (m *Monitor) monitor(ctx context.Context) error {
case <-m.newMessagesChan: case <-m.newMessagesChan:
idleCmd.Close() idleCmd.Close()
if err := m.sendNotification(); err != nil { m.sendNotification() // Errors already logged in sendNotification()
m.logger.Error("Failed to send notification", "error", err)
}
idleCmd, err = m.client.Idle() idleCmd, err = m.client.Idle()
if err != nil { if err != nil {

View file

@ -9,7 +9,7 @@ import (
) )
func (m *Monitor) sendNotification() error { func (m *Monitor) sendNotification() error {
selectData, err := m.client.Select(m.cfg.IMAPInbox, nil).Wait() selectData, err := m.client.Select(imapInbox, nil).Wait()
if err != nil { if err != nil {
m.logger.Error("Failed to select inbox", "error", err) m.logger.Error("Failed to select inbox", "error", err)
return err return err
@ -49,6 +49,14 @@ func (m *Monitor) sendNotification() error {
return nil return nil
} }
if msgData.Envelope.Date.Before(m.monitorStartTime) {
m.logger.Debug("Skipping notification for old email",
"subject", msgData.Envelope.Subject,
"date", msgData.Envelope.Date,
"monitorStart", m.monitorStartTime)
return nil
}
var from string var from string
if len(msgData.Envelope.From) > 0 { if len(msgData.Envelope.From) > 0 {
addr := msgData.Envelope.From[0] addr := msgData.Envelope.From[0]
@ -81,7 +89,7 @@ func (m *Monitor) sendNotification() error {
}, },
} }
if err := m.dispatcher.Send(m.cfg.ProtonPrismTopic, notif); err != nil { if err := m.dispatcher.Send(prismTopic, notif); err != nil {
m.logger.Error("Failed to send notification", "error", err) m.logger.Error("Failed to send notification", "error", err)
return err return err
} }

View file

@ -0,0 +1,120 @@
package proton
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
)
type Handlers struct {
monitor *Monitor
username string
logger *slog.Logger
}
func NewHandlers(monitor *Monitor, username string, logger *slog.Logger) *Handlers {
return &Handlers{
monitor: monitor,
username: username,
logger: logger,
}
}
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
if h.monitor == nil {
return // Proton not enabled
}
w.Header().Set("Content-Type", "text/html")
statusBadge := `<span class="integration-status disconnected">Unlinked</span>`
var content string
var openAttr string
if h.monitor.IsConnected() {
statusBadge = fmt.Sprintf(`<span class="integration-status connected">Linked<span class="tooltip">%s</span></span>`, h.username)
content = `
<p><strong>Unlink Instructions:</strong></p>
<ol class="link-instructions">
<li>Run: <code>docker compose run protonmail-bridge init</code></li>
<li>In the bridge CLI, use: <code>logout</code></li>
<li>Then: <code>exit</code> to close the bridge</li>
</ol>
`
openAttr = ""
} else {
content = `
<p><strong>Setup Instructions:</strong></p>
<ol class="link-instructions">
<li>Run: <code>docker compose run --rm protonmail-bridge init</code></li>
<li>At the prompt, use: <code>login</code> and enter your Proton Mail credentials</li>
<li>Run: <code>info</code> to get your IMAP username and password</li>
<li>Add credentials to <code>.env</code> and restart</li>
</ol>
<p class="text-muted">See <a href="https://github.com/lone-cloud/prism#proton-mail" target="_blank">full setup guide</a></p>
`
openAttr = " open"
}
html := fmt.Sprintf(`<details class="integration-card"%s>
<summary class="integration-header">
<span class="integration-name">Proton Mail</span>
%s
</summary>
<div class="integration-content">%s</div>
</details>`, openAttr, statusBadge, content)
_, _ = fmt.Fprint(w, html)
}
func (h *Handlers) IsEnabled() bool {
return h.monitor != nil
}
func (h *Handlers) GetMonitor() *Monitor {
return h.monitor
}
type markReadRequest struct {
UID uint32 `json:"uid"`
}
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"})
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"})
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"})
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"})
return
}
h.logger.Info("marked email as read", "uid", req.UID)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil {
h.logger.Error("failed to encode response", "error", err)
}
}

View file

@ -0,0 +1,51 @@
package proton
import (
"context"
"log/slog"
"net/http"
"prism/service/config"
"prism/service/notification"
"github.com/go-chi/chi/v5"
)
type Integration struct {
cfg *config.Config
dispatcher *notification.Dispatcher
logger *slog.Logger
handlers *Handlers
}
func NewIntegration(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Integration {
return &Integration{
cfg: cfg,
dispatcher: dispatcher,
logger: logger,
}
}
func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) {
p.handlers = RegisterRoutes(router, p.cfg, p.dispatcher, p.logger, auth)
}
func (p *Integration) Start(ctx context.Context, logger *slog.Logger) {
if p.handlers != nil && p.handlers.IsEnabled() {
logger.Info("Proton Mail configured", "status", "connecting in background", "bridge", p.cfg.ProtonBridgeAddr)
go func() {
monitor := p.handlers.GetMonitor()
if err := monitor.Start(ctx); err != nil && ctx.Err() == nil {
logger.Error("Proton monitor error", "error", err)
}
}()
}
}
func (p *Integration) IsEnabled() bool {
return p.cfg.IsProtonEnabled()
}
func (p *Integration) GetHandlers() *Handlers {
return p.handlers
}

View file

@ -11,6 +11,14 @@ import (
"github.com/emersion/go-imap/v2/imapclient" "github.com/emersion/go-imap/v2/imapclient"
) )
const (
imapInbox = "INBOX"
imapReconnectBaseDelay = 10 * time.Second
imapMaxReconnectDelay = 5 * time.Minute
imapMaxReconnectAttempts = 50
prismTopic = "Proton Mail"
)
type Monitor struct { type Monitor struct {
cfg *config.Config cfg *config.Config
dispatcher *notification.Dispatcher dispatcher *notification.Dispatcher
@ -31,7 +39,6 @@ func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger
func (m *Monitor) Start(ctx context.Context) error { func (m *Monitor) Start(ctx context.Context) error {
if !m.cfg.IsProtonEnabled() { if !m.cfg.IsProtonEnabled() {
m.logger.Info("Proton Mail monitoring disabled")
return nil return nil
} }
@ -44,7 +51,7 @@ func (m *Monitor) Start(ctx context.Context) error {
default: default:
if err := m.connect(); err != nil { if err := m.connect(); err != nil {
m.logger.Error("Failed to connect to IMAP", "error", err) m.logger.Error("Failed to connect to IMAP", "error", err)
time.Sleep(time.Duration(m.cfg.IMAPReconnectBaseDelay) * time.Millisecond) time.Sleep(imapReconnectBaseDelay)
continue continue
} }
@ -59,7 +66,7 @@ func (m *Monitor) Start(ctx context.Context) error {
m.client = nil m.client = nil
} }
time.Sleep(time.Duration(m.cfg.IMAPReconnectBaseDelay) * time.Millisecond) time.Sleep(imapReconnectBaseDelay)
} }
} }
} }

View file

@ -0,0 +1,29 @@
package proton
import (
"log/slog"
"net/http"
"prism/service/config"
"prism/service/notification"
"github.com/go-chi/chi/v5"
)
func RegisterRoutes(router *chi.Mux, cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) *Handlers {
if !cfg.IsProtonEnabled() {
return nil
}
monitor := NewMonitor(cfg, dispatcher, logger)
handlers := NewHandlers(monitor, cfg.ProtonIMAPUsername, logger)
router.With(authMiddleware).Get("/fragment/proton", handlers.HandleFragment)
router.Route("/api/proton-mail", func(r chi.Router) {
r.Use(authMiddleware)
r.Post("/mark-read", handlers.HandleMarkRead)
})
return handlers
}

View file

@ -10,11 +10,13 @@ import (
var rpcID int32 var rpcID int32
type Client struct { type Client struct {
socketPath string SocketPath string
} }
func NewClient(socketPath string) *Client { func NewClient(socketPath string) *Client {
return &Client{socketPath: socketPath} return &Client{
SocketPath: socketPath,
}
} }
type RPCRequest struct { type RPCRequest struct {
@ -37,7 +39,7 @@ type RPCError struct {
} }
func (c *Client) Call(method string, params map[string]interface{}) (json.RawMessage, error) { func (c *Client) Call(method string, params map[string]interface{}) (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)
} }
@ -79,7 +81,15 @@ type AccountInfo struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
} }
func (c *Client) GetLinkedAccount() (*AccountInfo, error) { type Account struct {
Number string
}
func (c *Client) GetLinkedAccount() (*Account, error) {
if c == nil {
return nil, nil
}
result, err := c.Call("listAccounts", nil) result, err := c.Call("listAccounts", nil)
if err != nil { if err != nil {
return nil, err return nil, err
@ -94,5 +104,67 @@ func (c *Client) GetLinkedAccount() (*AccountInfo, error) {
return nil, nil return nil, nil
} }
return &accounts[0], nil return &Account{Number: accounts[0].Number}, nil
}
// CreateGroup creates a Signal group using updateGroup JSON-RPC method
func (c *Client) CreateGroup(name string) (string, string, error) {
if c == nil {
return "", "", fmt.Errorf("signal client not initialized")
}
account, err := c.GetLinkedAccount()
if err != nil {
return "", "", fmt.Errorf("failed to get linked account: %w", err)
}
if account == nil {
return "", "", fmt.Errorf("no linked Signal account")
}
params := map[string]interface{}{
"name": name,
"member": []string{}, // Empty group
}
result, err := c.CallWithAccount("updateGroup", params, account.Number)
if err != nil {
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)
}
if response.GroupID == "" {
return "", "", fmt.Errorf("empty groupId in response")
}
return response.GroupID, account.Number, nil
}
// SendGroupMessage sends a message to a Signal group
func (c *Client) SendGroupMessage(groupID, message string) error {
if c == nil {
return fmt.Errorf("signal client not initialized")
}
account, err := c.GetLinkedAccount()
if err != nil {
return fmt.Errorf("failed to get account: %w", err)
}
if account == nil {
return fmt.Errorf("no linked account")
}
params := map[string]interface{}{
"groupId": groupID,
"message": message,
"notify-self": true,
}
_, err = c.CallWithAccount("send", params, account.Number)
return err
} }

View file

@ -0,0 +1,47 @@
package signal
import (
"fmt"
"strings"
)
func FormatPhoneNumber(number string) string {
if number == "" {
return number
}
if strings.HasPrefix(number, "+1") && len(number) == 12 {
return fmt.Sprintf("+1 (%s) %s-%s", number[2:5], number[5:8], number[8:])
}
if strings.HasPrefix(number, "+") && len(number) > 4 {
digits := number[1:]
var countryCode, rest string
for i := 1; i <= 3 && i < len(digits); i++ {
if digits[i] < '0' || digits[i] > '9' {
break
}
countryCode = digits[:i+1]
rest = digits[i+1:]
}
if len(rest) >= 3 {
var parts []string
for len(rest) > 0 {
size := 3
if len(rest) == 4 || len(rest) == 8 {
size = 4
}
if size > len(rest) {
size = len(rest)
}
parts = append(parts, rest[:size])
rest = rest[size:]
}
return "+" + countryCode + " " + strings.Join(parts, " ")
}
}
return number
}

View file

@ -0,0 +1,84 @@
package signal
import (
"fmt"
"net/http"
)
type Handlers struct {
client *Client
linkDevice *LinkDevice
}
func NewHandlers(client *Client, linkDevice *LinkDevice) *Handlers {
return &Handlers{
client: client,
linkDevice: linkDevice,
}
}
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
if h.client == nil {
return // Signal not enabled
}
w.Header().Set("Content-Type", "text/html")
account, _ := h.client.GetLinkedAccount()
var content string
var statusBadge string
var openAttr string
var pollAttrs string
if account != nil {
statusBadge = fmt.Sprintf(`<span class="integration-status connected">Linked<span class="tooltip">%s</span></span>`, FormatPhoneNumber(account.Number))
content = fmt.Sprintf(`
<p><strong>Unlink Instructions:</strong></p>
<ol class="link-instructions">
<li>Open Signal on your phone</li>
<li>Go to Settings Linked Devices</li>
<li>Find and remove <strong>%s</strong></li>
</ol>
`, h.linkDevice.deviceName)
openAttr = ""
pollAttrs = ""
} else {
statusBadge = `<span class="integration-status unlinked">Unlinked</span>`
qrCode, err := h.linkDevice.GenerateQR()
if err != nil {
content = fmt.Sprintf(`<p>Error generating QR code: %s</p>`, err)
} else {
content = fmt.Sprintf(`
<p><strong>Link your Signal (or <a href="https://molly.im" target="_blank" rel="noopener">Molly</a>) account:</strong></p>
<ol class="link-instructions">
<li>Open Signal on your phone</li>
<li>Go to Settings Linked Devices</li>
<li>Scan the QR code below</li>
</ol>
<div class="qr-code-container">
<img src="%s" alt="Signal QR Code" class="qr-code"/>
</div>
`, qrCode)
}
openAttr = " open"
pollAttrs = ` hx-get="/fragment/signal" hx-trigger="every 3s" hx-swap="outerHTML"`
}
html := fmt.Sprintf(`<details class="integration-card"%s%s>
<summary class="integration-header">
<span class="integration-name">Signal</span>
%s
</summary>
<div class="integration-content">%s</div>
</details>`, openAttr, pollAttrs, statusBadge, content)
_, _ = fmt.Fprint(w, html)
}
func (h *Handlers) IsEnabled() bool {
return h.client != nil
}
func (h *Handlers) GetClient() *Client {
return h.client
}

View file

@ -0,0 +1,58 @@
package signal
import (
"context"
"log/slog"
"net/http"
"prism/service/config"
"prism/service/notification"
"github.com/go-chi/chi/v5"
)
type Integration struct {
cfg *config.Config
handlers *Handlers
sender *Sender
}
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger) *Integration {
var sender *Sender
if cfg.IsSignalEnabled() {
client := NewClient(cfg.SignalSocket)
sender = NewSender(client, store, logger)
}
return &Integration{
cfg: cfg,
sender: sender,
}
}
func (s *Integration) GetSender() *Sender {
return s.sender
}
func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) {
s.handlers = RegisterRoutes(router, s.cfg, auth)
}
func (s *Integration) Start(ctx context.Context, logger *slog.Logger) {
if s.handlers != nil && s.handlers.IsEnabled() {
client := s.handlers.GetClient()
account, _ := client.GetLinkedAccount()
if account != nil {
logger.Info("Signal enabled", "status", "linked", "number", FormatPhoneNumber(account.Number))
} else {
logger.Info("Signal enabled", "status", "unlinked", "action", "visit admin UI to link")
}
}
}
func (s *Integration) IsEnabled() bool {
return s.cfg.IsSignalEnabled()
}
func (s *Integration) GetHandlers() *Handlers {
return s.handlers
}

View file

@ -1,21 +1,19 @@
package signal package signal
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"net/url" "net/url"
"time" "time"
) )
type LinkDevice struct { type LinkDevice struct {
client *Client client *Client
deviceName string deviceName string
qrCode string qrCode string
generatedAt time.Time deviceLinkUri string
ttl time.Duration generatedAt time.Time
ttl time.Duration
} }
func NewLinkDevice(client *Client, deviceName string) *LinkDevice { func NewLinkDevice(client *Client, deviceName string) *LinkDevice {
@ -31,10 +29,15 @@ type StartLinkResponse struct {
} }
func (l *LinkDevice) GenerateQR() (string, error) { func (l *LinkDevice) GenerateQR() (string, error) {
if l.client == nil {
return "", fmt.Errorf("signal client not initialized")
}
if l.qrCode != "" && time.Since(l.generatedAt) < l.ttl { if l.qrCode != "" && time.Since(l.generatedAt) < l.ttl {
return l.qrCode, nil return l.qrCode, nil
} }
// Call signal-cli's startLink JSON-RPC method directly
result, err := l.client.Call("startLink", nil) result, err := l.client.Call("startLink", nil)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to start link: %w", err) return "", fmt.Errorf("failed to start link: %w", err)
@ -50,31 +53,18 @@ func (l *LinkDevice) GenerateQR() (string, error) {
return "", fmt.Errorf("empty device link URI") return "", fmt.Errorf("empty device link URI")
} }
l.deviceLinkUri = uri
qrURL := fmt.Sprintf("https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=%s", url.QueryEscape(uri)) qrURL := fmt.Sprintf("https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=%s", url.QueryEscape(uri))
//nolint:gosec l.qrCode = qrURL
resp, err := http.Get(qrURL)
if err != nil {
return "", fmt.Errorf("failed to fetch QR code: %w", err)
}
defer resp.Body.Close()
qrData, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read QR code: %w", err)
}
base64Data := base64.StdEncoding.EncodeToString(qrData)
l.qrCode = fmt.Sprintf("data:image/png;base64,%s", base64Data)
l.generatedAt = time.Now() l.generatedAt = time.Now()
go l.finishLink()
go l.finishLink(uri)
return l.qrCode, nil return l.qrCode, nil
} }
func (l *LinkDevice) finishLink(uri string) { func (l *LinkDevice) finishLink() {
params := map[string]interface{}{ params := map[string]interface{}{
"deviceLinkUri": uri, "deviceLinkUri": l.deviceLinkUri,
"deviceName": l.deviceName, "deviceName": l.deviceName,
} }

View file

@ -0,0 +1,23 @@
package signal
import (
"net/http"
"prism/service/config"
"github.com/go-chi/chi/v5"
)
func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler) *Handlers {
if !cfg.IsSignalEnabled() {
return nil
}
client := NewClient(cfg.SignalSocket)
linkDevice := NewLinkDevice(client, cfg.DeviceName)
handlers := NewHandlers(client, linkDevice)
router.With(authMiddleware).Get("/fragment/signal", handlers.HandleFragment)
return handlers
}

View file

@ -0,0 +1,83 @@
package signal
import (
"fmt"
"log/slog"
"prism/service/notification"
)
type Sender struct {
client *Client
store *notification.Store
logger *slog.Logger
}
func NewSender(client *Client, store *notification.Store, logger *slog.Logger) *Sender {
return &Sender{
client: client,
store: store,
logger: logger,
}
}
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
if s.client == nil {
return fmt.Errorf("signal integration not enabled")
}
account, err := s.client.GetLinkedAccount()
if err != nil {
s.logger.Error("Failed to get linked account", "error", err)
return fmt.Errorf("failed to get linked account: %w", err)
}
if account == nil {
s.logger.Error("No linked Signal account found")
return fmt.Errorf("no linked Signal account")
}
var signalGroupID string
needsNewGroup := mapping.Signal == nil || mapping.Signal.GroupID == ""
if !needsNewGroup && mapping.Signal.Account != account.Number {
s.logger.Info("Signal account changed, recreating group",
"app", mapping.AppName,
"oldAccount", mapping.Signal.Account,
"newAccount", account.Number)
needsNewGroup = true
}
if needsNewGroup {
s.logger.Info("Creating new group", "app", mapping.AppName, "account", account.Number)
newGroupID, accountNumber, err := s.client.CreateGroup(mapping.AppName)
if err != nil {
s.logger.Error("Failed to create group", "app", mapping.AppName, "error", err)
return fmt.Errorf("failed to create group: %w", err)
}
signalGroupID = newGroupID
s.logger.Info("Created new group", "app", mapping.AppName, "groupID", signalGroupID, "account", accountNumber)
if err := s.store.UpdateSignal(mapping.AppName, &notification.SignalSubscription{
GroupID: signalGroupID,
Account: accountNumber,
}); err != nil {
s.logger.Warn("Failed to update signal subscription", "error", err)
}
} else {
signalGroupID = mapping.Signal.GroupID
}
message := notif.Message
if notif.Title != "" {
message = fmt.Sprintf("%s\n\n%s", notif.Title, notif.Message)
}
if err := s.client.SendGroupMessage(signalGroupID, message); err != nil {
s.logger.Error("Failed to send group message", "groupID", signalGroupID, "error", err)
return err
}
return nil
}

View file

@ -0,0 +1,55 @@
package telegram
import (
"context"
"fmt"
"github.com/mymmrac/telego"
"github.com/mymmrac/telego/telegoutil"
)
type Client struct {
bot *telego.Bot
}
func NewClient(token string) (*Client, error) {
if token == "" {
return nil, nil
}
bot, err := telego.NewBot(token)
if err != nil {
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
}
return &Client{bot: bot}, nil
}
func (c *Client) GetMe() (*telego.User, error) {
if c == nil || c.bot == nil {
return nil, fmt.Errorf("telegram client not initialized")
}
return c.bot.GetMe(context.Background())
}
func (c *Client) SendMessage(chatID int64, text string) error {
if c == nil || c.bot == nil {
return fmt.Errorf("telegram client not initialized")
}
msg := telegoutil.Message(
telegoutil.ID(chatID),
text,
).WithParseMode(telego.ModeHTML)
_, err := c.bot.SendMessage(context.Background(), msg)
return err
}
func (c *Client) IsAvailable() bool {
if c == nil || c.bot == nil {
return false
}
_, err := c.bot.GetMe(context.Background())
return err == nil
}

View file

@ -0,0 +1,89 @@
package telegram
import (
"fmt"
"net/http"
)
type Handlers struct {
client *Client
chatID int64
}
func NewHandlers(client *Client, chatID int64) *Handlers {
return &Handlers{
client: client,
chatID: chatID,
}
}
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
var content string
var statusBadge string
var openAttr string
if h.client == nil {
statusBadge = `<span class="integration-status disconnected">Not Configured</span>`
content = `
<p><strong>Setup Instructions:</strong></p>
<ol class="link-instructions">
<li>Message <a href="https://t.me/BotFather" target="_blank">@BotFather</a> on Telegram</li>
<li>Send <code>/newbot</code> and follow the prompts</li>
<li>Copy the bot token and add to <code>.env</code>: <code>TELEGRAM_BOT_TOKEN=your-token</code></li>
<li>Message <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> to get your Chat ID</li>
<li>Add to <code>.env</code>: <code>TELEGRAM_CHAT_ID=your-chat-id</code></li>
<li>Restart Prism</li>
</ol>
<p class="text-muted">See <a href="https://github.com/lone-cloud/prism#telegram" target="_blank">full setup guide</a></p>
`
openAttr = " open"
} else {
bot, err := h.client.GetMe()
if err != nil {
statusBadge = `<span class="integration-status disconnected">Error</span>`
content = fmt.Sprintf(`<p>Error: %s</p>`, err)
openAttr = " open"
} else if h.chatID == 0 {
statusBadge = fmt.Sprintf(`<span class="integration-status disconnected">Needs Chat ID<span class="tooltip">@%s</span></span>`, bot.Username)
content = `
<p><strong>Complete Setup:</strong></p>
<ol class="link-instructions">
<li>Message <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> on Telegram to get your Chat ID</li>
<li>Add to <code>.env</code>: <code>TELEGRAM_CHAT_ID=your-chat-id</code></li>
<li>Restart Prism</li>
</ol>
`
openAttr = " open"
} else {
statusBadge = fmt.Sprintf(`<span class="integration-status connected">Linked<span class="tooltip">@%s</span></span>`, bot.Username)
content = `
<p><strong>Unlink Instructions:</strong></p>
<ol class="link-instructions">
<li>Remove <code>TELEGRAM_BOT_TOKEN</code> and <code>TELEGRAM_CHAT_ID</code> from <code>.env</code></li>
<li>Restart: <code>docker compose restart prism</code></li>
</ol>
`
openAttr = ""
}
}
html := fmt.Sprintf(`<details class="integration-card"%s>
<summary class="integration-header">
<span class="integration-name">Telegram</span>
%s
</summary>
<div class="integration-content">%s</div>
</details>`, openAttr, statusBadge, content)
_, _ = fmt.Fprint(w, html)
}
func (h *Handlers) IsEnabled() bool {
return true
}
func (h *Handlers) GetClient() *Client {
return h.client
}

View file

@ -0,0 +1,62 @@
package telegram
import (
"context"
"log/slog"
"net/http"
"prism/service/config"
"prism/service/notification"
"github.com/go-chi/chi/v5"
)
type Integration struct {
cfg *config.Config
handlers *Handlers
sender *Sender
}
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger) *Integration {
client, err := NewClient(cfg.TelegramBotToken)
if err != nil {
logger.Error("Failed to create telegram client", "error", err)
}
var sender *Sender
if client != nil {
sender = NewSender(client, store, logger, cfg.TelegramChatID)
}
handlers := NewHandlers(client, cfg.TelegramChatID)
return &Integration{
cfg: cfg,
handlers: handlers,
sender: sender,
}
}
func (t *Integration) GetSender() *Sender {
return t.sender
}
func (t *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) {
RegisterRoutes(router, t.handlers, auth)
}
func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
if t.handlers != nil && t.handlers.IsEnabled() {
client := t.handlers.GetClient()
bot, err := client.GetMe()
if err != nil {
logger.Error("Telegram bot error", "error", err)
} else {
logger.Info("Telegram enabled", "bot", bot.Username, "id", bot.ID)
}
}
}
func (t *Integration) IsEnabled() bool {
return t.cfg.IsTelegramEnabled()
}

View file

@ -0,0 +1,15 @@
package telegram
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler) {
if handlers == nil {
return
}
router.With(auth).Get("/fragment/telegram", handlers.HandleFragment)
}

View file

@ -0,0 +1,46 @@
package telegram
import (
"fmt"
"log/slog"
"prism/service/notification"
)
type Sender struct {
client *Client
store *notification.Store
logger *slog.Logger
DefaultChatID int64
}
func NewSender(client *Client, store *notification.Store, logger *slog.Logger, defaultChatID int64) *Sender {
return &Sender{
client: client,
store: store,
logger: logger,
DefaultChatID: defaultChatID,
}
}
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
if s.client == nil {
return fmt.Errorf("telegram integration not enabled")
}
if s.DefaultChatID == 0 {
return fmt.Errorf("no telegram chat configured (set TELEGRAM_CHAT_ID in .env)")
}
message := notif.Message
if notif.Title != "" {
message = fmt.Sprintf("<b>%s</b>\n%s", notif.Title, notif.Message)
}
if err := s.client.SendMessage(s.DefaultChatID, message); err != nil {
s.logger.Error("Failed to send telegram message", "chatID", s.DefaultChatID, "error", err)
return err
}
return nil
}

View file

@ -1,7 +1,8 @@
package server package webpush
import ( import (
"encoding/json" "encoding/json"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
@ -10,7 +11,19 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type registerWebPushRequest struct { type Handlers struct {
store *notification.Store
logger *slog.Logger
}
func NewHandlers(store *notification.Store, logger *slog.Logger) *Handlers {
return &Handlers{
store: store,
logger: logger,
}
}
type registerRequest struct {
AppName string `json:"appName"` AppName string `json:"appName"`
PushEndpoint string `json:"pushEndpoint"` PushEndpoint string `json:"pushEndpoint"`
P256dh *string `json:"p256dh,omitempty"` P256dh *string `json:"p256dh,omitempty"`
@ -18,8 +31,8 @@ type registerWebPushRequest struct {
VapidPrivateKey *string `json:"vapidPrivateKey,omitempty"` VapidPrivateKey *string `json:"vapidPrivateKey,omitempty"`
} }
func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
var req registerWebPushRequest var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest) http.Error(w, "Invalid request body", http.StatusBadRequest)
return return
@ -28,20 +41,20 @@ func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) {
if req.AppName == "" || req.PushEndpoint == "" { if req.AppName == "" || req.PushEndpoint == "" {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "appName and pushEndpoint are required"}) //nolint:errcheck _ = 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") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Invalid pushEndpoint URL"}) //nolint:errcheck _ = json.NewEncoder(w).Encode(map[string]string{"error": "Invalid pushEndpoint URL"})
return return
} }
existing, err := s.store.GetApp(req.AppName) existing, err := h.store.GetApp(req.AppName)
if err != nil { if err != nil {
s.logger.Error("Failed to check existing app", "error", err) h.logger.Error("Failed to check existing app", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
@ -61,20 +74,20 @@ func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) {
} }
if existing != nil { if existing != nil {
if err := s.store.UpdateWebPush(req.AppName, webPush); err != nil { if err := h.store.UpdateWebPush(req.AppName, webPush); err != nil {
s.logger.Error("Failed to update webpush endpoint", "error", err) h.logger.Error("Failed to update webpush endpoint", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
s.logger.Info("Updated webpush for existing endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint) h.logger.Info("Updated webpush for existing endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint)
} else { } else {
channel := notification.ChannelWebPush channel := notification.ChannelWebPush
if err := s.store.Register(req.AppName, &channel, nil, webPush); err != nil { if err := h.store.Register(req.AppName, &channel, nil, webPush); err != nil {
s.logger.Error("Failed to register webpush endpoint", "error", err) h.logger.Error("Failed to register webpush endpoint", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
s.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)
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -82,32 +95,28 @@ func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) {
"appName": req.AppName, "appName": req.AppName,
"channel": "webpush", "channel": "webpush",
} }
if err := json.NewEncoder(w).Encode(response); err != nil { _ = json.NewEncoder(w).Encode(response)
s.logger.Error("Failed to encode response", "error", err)
}
} }
func (s *Server) handleWebPushUnregister(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) {
appName := chi.URLParam(r, "appName") appName := chi.URLParam(r, "appName")
if appName == "" { if appName == "" {
http.Error(w, "appName is required", http.StatusBadRequest) http.Error(w, "appName is required", http.StatusBadRequest)
return return
} }
if err := s.store.ClearWebPush(appName); err != nil { if err := h.store.ClearWebPush(appName); err != nil {
s.logger.Error("Failed to clear webpush endpoint", "error", err) h.logger.Error("Failed to clear webpush endpoint", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
s.logger.Info("Cleared webpush subscription", "app", appName) h.logger.Info("Cleared webpush subscription", "app", appName)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
response := map[string]string{ response := map[string]string{
"status": "unregistered", "status": "unregistered",
"appName": appName, "appName": appName,
} }
if err := json.NewEncoder(w).Encode(response); err != nil { _ = json.NewEncoder(w).Encode(response)
s.logger.Error("Failed to encode response", "error", err)
}
} }

View file

@ -0,0 +1,33 @@
package webpush
import (
"context"
"log/slog"
"net/http"
"prism/service/notification"
"github.com/go-chi/chi/v5"
)
type Integration struct {
store *notification.Store
logger *slog.Logger
}
func NewIntegration(store *notification.Store, logger *slog.Logger) *Integration {
return &Integration{
store: store,
logger: logger,
}
}
func (w *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) {
RegisterRoutes(router, w.store, w.logger, auth)
}
func (w *Integration) Start(ctx context.Context, logger *slog.Logger) {}
func (w *Integration) IsEnabled() bool {
return true
}

View file

@ -0,0 +1,20 @@
package webpush
import (
"log/slog"
"net/http"
"prism/service/notification"
"github.com/go-chi/chi/v5"
)
func RegisterRoutes(router *chi.Mux, store *notification.Store, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) {
handlers := NewHandlers(store, logger)
router.Route("/webpush/app", func(r chi.Router) {
r.Use(authMiddleware)
r.Post("/", handlers.HandleRegister)
r.Delete("/{appName}", handlers.HandleUnregister)
})
}

View file

@ -0,0 +1,73 @@
package webpush
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"prism/service/notification"
webpush "github.com/SherClockHolmes/webpush-go"
)
type Sender struct {
logger *slog.Logger
}
func NewSender(logger *slog.Logger) *Sender {
return &Sender{
logger: logger,
}
}
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
if mapping.WebPush == nil {
return fmt.Errorf("no push endpoint configured for %s", mapping.AppName)
}
payload, err := json.Marshal(notif)
if err != nil {
return fmt.Errorf("failed to marshal notification: %w", err)
}
if mapping.WebPush.HasEncryption() {
subscription := &webpush.Subscription{
Endpoint: mapping.WebPush.Endpoint,
Keys: webpush.Keys{
P256dh: mapping.WebPush.P256dh,
Auth: mapping.WebPush.Auth,
},
}
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)
}
s.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)
}
s.logger.Debug("Sent plain webhook notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint)
}
return nil
}

View file

@ -1,31 +1,58 @@
package notification package notification
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http"
"prism/service/signal"
webpush "github.com/SherClockHolmes/webpush-go"
) )
type Dispatcher struct { type NotificationSender interface {
store *Store Send(mapping *Mapping, notif Notification) error
signalClient *signal.Client
logger *slog.Logger
} }
func NewDispatcher(store *Store, signalClient *signal.Client, logger *slog.Logger) *Dispatcher { type Dispatcher struct {
store *Store
senders map[Channel]NotificationSender
logger *slog.Logger
}
func NewDispatcher(store *Store, logger *slog.Logger) *Dispatcher {
return &Dispatcher{ return &Dispatcher{
store: store, store: store,
signalClient: signalClient, senders: make(map[Channel]NotificationSender),
logger: logger, logger: logger,
} }
} }
func (d *Dispatcher) RegisterSender(channel Channel, sender NotificationSender) {
d.senders[channel] = sender
}
func (d *Dispatcher) HasSignal() bool {
_, ok := d.senders[ChannelSignal]
return ok
}
func (d *Dispatcher) HasTelegram() bool {
_, ok := d.senders[ChannelTelegram]
return ok
}
func (d *Dispatcher) GetAvailableChannels() []Channel {
var channels []Channel
if d.HasSignal() {
channels = append(channels, ChannelSignal)
}
if d.HasTelegram() {
channels = append(channels, ChannelTelegram)
}
channels = append(channels, ChannelWebPush)
return channels
}
func (d *Dispatcher) IsValidChannel(channel Channel) bool {
return channel.IsAvailable(d.HasSignal(), d.HasTelegram())
}
func (d *Dispatcher) Send(appName string, notif Notification) error { func (d *Dispatcher) Send(appName string, notif Notification) error {
mapping, err := d.store.GetApp(appName) mapping, err := d.store.GetApp(appName)
if err != nil { if err != nil {
@ -36,7 +63,8 @@ func (d *Dispatcher) Send(appName string, notif Notification) error {
if mapping == nil { if mapping == nil {
d.logger.Info("Registering new app", "app", appName) d.logger.Info("Registering new app", "app", appName)
if err := d.store.RegisterDefault(appName); err != nil { availableChannels := d.GetAvailableChannels()
if err := d.store.RegisterDefault(appName, availableChannels); err != nil {
d.logger.Error("Failed to register app", "app", appName, "error", err) d.logger.Error("Failed to register app", "app", appName, "error", err)
return fmt.Errorf("failed to register app: %w", err) return fmt.Errorf("failed to register app: %w", err)
} }
@ -48,176 +76,11 @@ func (d *Dispatcher) Send(appName string, notif Notification) error {
} }
} }
switch mapping.Channel { sender, ok := d.senders[mapping.Channel]
case ChannelWebPush: if !ok {
return d.sendWebPush(mapping, notif) d.logger.Error("No sender registered for channel", "channel", mapping.Channel)
case ChannelSignal: return fmt.Errorf("no sender for channel: %s", mapping.Channel)
return d.sendSignal(mapping, notif)
default:
d.logger.Error("Unknown channel type", "channel", mapping.Channel)
return fmt.Errorf("unknown channel: %s", mapping.Channel)
} }
}
return sender.Send(mapping, notif)
func (d *Dispatcher) sendSignal(mapping *Mapping, notif Notification) error {
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")
}
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, string, error) {
account, err := d.signalClient.GetLinkedAccount()
if err != nil {
return "", "", fmt.Errorf("failed to get linked account: %w", err)
}
if account == nil {
return "", "", fmt.Errorf("no linked Signal account")
}
params := map[string]interface{}{
"name": appName,
"member": []string{},
}
result, err := d.signalClient.CallWithAccount("updateGroup", params, account.Number)
if err != nil {
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)
}
if response.GroupID == "" {
return "", "", fmt.Errorf("empty groupId in response")
}
return response.GroupID, account.Number, nil
}
func (d *Dispatcher) sendGroupMessage(groupID string, notif Notification) error {
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")
}
message := notif.Message
if notif.Title != "" {
message = fmt.Sprintf("%s\n%s", notif.Title, notif.Message)
}
params := map[string]interface{}{
"groupId": groupID,
"message": message,
"notify-self": true,
}
_, err = d.signalClient.CallWithAccount("send", params, account.Number)
if err != nil {
d.logger.Error("Signal send API call failed", "error", err)
}
return err
}
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)
if err != nil {
return fmt.Errorf("failed to marshal notification: %w", err)
}
if mapping.WebPush.HasEncryption() {
subscription := &webpush.Subscription{
Endpoint: mapping.WebPush.Endpoint,
Keys: webpush.Keys{
P256dh: mapping.WebPush.P256dh,
Auth: mapping.WebPush.Auth,
},
}
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)
}
return nil
} }

View file

@ -16,10 +16,24 @@ type Notification struct {
type Channel string type Channel string
const ( const (
ChannelSignal Channel = "signal" ChannelSignal Channel = "signal"
ChannelWebPush Channel = "webpush" ChannelWebPush Channel = "webpush"
ChannelTelegram Channel = "telegram"
) )
func (c Channel) IsAvailable(signalEnabled bool, telegramEnabled bool) bool {
switch c {
case ChannelWebPush:
return true
case ChannelSignal:
return signalEnabled
case ChannelTelegram:
return telegramEnabled
default:
return false
}
}
type WebPushSubscription struct { type WebPushSubscription struct {
Endpoint string Endpoint string
P256dh string P256dh string

View file

@ -42,7 +42,7 @@ func (s *Store) createTables() error {
appName TEXT PRIMARY KEY, appName TEXT PRIMARY KEY,
signalGroupId TEXT, signalGroupId TEXT,
signalAccount TEXT, signalAccount TEXT,
channel TEXT NOT NULL DEFAULT 'signal', channel TEXT NOT NULL DEFAULT 'webpush',
pushEndpoint TEXT, pushEndpoint TEXT,
p256dh TEXT, p256dh TEXT,
auth TEXT, auth TEXT,
@ -75,7 +75,7 @@ func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscri
vapidPrivateKey = &webPush.VapidPrivateKey vapidPrivateKey = &webPush.VapidPrivateKey
} }
ch := ChannelSignal ch := ChannelWebPush
if channel != nil { if channel != nil {
ch = *channel ch = *channel
} }
@ -96,8 +96,15 @@ func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscri
return err return err
} }
func (s *Store) RegisterDefault(appName string) error { func (s *Store) RegisterDefault(appName string, availableChannels []Channel) error {
return s.Register(appName, nil, nil, nil) var channel Channel
if len(availableChannels) > 0 {
channel = availableChannels[0]
} else {
channel = ChannelWebPush
}
return s.Register(appName, &channel, nil, nil)
} }
func (s *Store) GetApp(appName string) (*Mapping, error) { func (s *Store) GetApp(appName string) (*Mapping, error) {

View file

@ -0,0 +1,18 @@
package server
import (
"net/http"
)
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := struct {
Version string
}{
Version: s.version,
}
if err := s.indexTmpl.Execute(w, data); err != nil {
s.logger.Error("failed to execute index template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

View file

@ -39,7 +39,7 @@ func (s *Server) handleToggleChannelAction(w http.ResponseWriter, r *http.Reques
return return
} }
if channel != string(notification.ChannelSignal) && channel != string(notification.ChannelWebPush) { if !s.dispatcher.IsValidChannel(notification.Channel(channel)) {
http.Error(w, "Invalid channel", http.StatusBadRequest) http.Error(w, "Invalid channel", http.StatusBadRequest)
return return
} }

View file

@ -45,7 +45,7 @@ func (s *Server) handleCreateMapping(w http.ResponseWriter, r *http.Request) {
} }
if req.Channel == "" { if req.Channel == "" {
req.Channel = notification.ChannelSignal req.Channel = notification.ChannelWebPush
} }
if req.Channel == notification.ChannelWebPush && req.PushEndpoint == nil { if req.Channel == notification.ChannelWebPush && req.PushEndpoint == nil {

View file

@ -6,117 +6,8 @@ import (
"net/url" "net/url"
"prism/service/notification" "prism/service/notification"
"prism/service/signal"
"prism/service/util"
) )
func (s *Server) handleFragmentHealth(w http.ResponseWriter, r *http.Request) {
linked := s.getLinkedAccount()
signalOk := s.signalDaemon.IsRunning()
hasProton := s.cfg.IsProtonEnabled()
w.Header().Set("Content-Type", "text/html")
statusClass := "status-error"
statusText := "Disconnected and Unlinked"
var tooltip string
if signalOk && linked != nil {
statusClass = "status-ok"
statusText = "Connected and Linked"
tooltip = fmt.Sprintf(`<span class="tooltip">%s</span>`, util.FormatPhoneNumber(linked.Number))
} else if signalOk {
statusText = "Connected and Unlinked"
}
html := fmt.Sprintf(`<div class="status">
<div class="status-item %s">Signal: %s%s</div>`, statusClass, statusText, tooltip)
if hasProton {
protonStatus := "Disconnected"
protonClass := "status-error"
protonTooltip := ""
if s.protonMonitor.IsConnected() {
protonStatus = "Connected"
protonClass = "status-ok"
protonTooltip = fmt.Sprintf(`<span class="tooltip">%s</span>`, s.cfg.ProtonIMAPUsername)
}
html += fmt.Sprintf(`
<div class="status-item %s">Proton Mail: %s%s</div>`, protonClass, protonStatus, protonTooltip)
}
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
}
func (s *Server) handleFragmentSignalInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, s.getSignalInfoHTML()) //nolint:errcheck
}
func (s *Server) getSignalInfoHTML() string {
if s.getLinkedAccount() != nil {
return fmt.Sprintf(`<div id="signal-info"><details class="unlink-details">
<summary class="unlink-summary">Unlink and remove device</summary>
<div class="unlink-instructions">
<ol>
<li>Open Signal app <strong>Settings Linked Devices</strong></li>
<li>Find <strong>"%s"</strong> and tap it</li>
<li>Tap <strong>"Unlink Device"</strong></li>
</ol>
</div>
</details></div>`, s.cfg.DeviceName)
}
return fmt.Sprintf(`<div id="signal-info" hx-get="/fragment/signal-info" hx-trigger="every 3s">%s</div>`, s.getQRCodeHTML())
}
func (s *Server) getLinkedAccount() *signal.AccountInfo {
client := signal.NewClient(s.cfg.SignalCLISocketPath)
account, _ := client.GetLinkedAccount() //nolint:errcheck // Nil account is valid, indicates not linked
return account
}
func (s *Server) getQRCodeHTML() string {
if s.getLinkedAccount() != nil {
return `<p>Account already linked</p>`
}
qrCode, err := s.linkDevice.GenerateQR()
if err != nil {
s.logger.Error("Failed to generate QR code", "error", err)
return `<p>Signal daemon is starting up, please refresh in a few seconds...</p>`
}
return fmt.Sprintf(`<p>Scan this QR code with your Signal app:</p>
<p class="qr-instructions"><strong>Settings Linked Devices Link New Device</strong></p>
<div class="qr-container">
<img src="%s" class="qr-image" alt="QR Code" />
</div>`, qrCode)
}
func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
mappings, err := s.store.GetAllMappings() mappings, err := s.store.GetAllMappings()
if err != nil { if err != nil {
@ -131,35 +22,57 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
func (s *Server) getAppsListHTML(mappings []notification.Mapping) string { func (s *Server) getAppsListHTML(mappings []notification.Mapping) string {
if len(mappings) == 0 { if len(mappings) == 0 {
return `<p>No apps registered</p>` return `<p>No apps registered yet. See <a href="https://github.com/lone-cloud/prism?tab=readme-ov-file#real-world-examples" target="_blank" rel="noopener noreferrer">real-world examples</a> to get started.</p>`
} }
signalLinked := false
if s.integrations.Signal != nil {
handlers := s.integrations.Signal.GetHandlers()
if handlers != nil {
account, _ := handlers.GetClient().GetLinkedAccount()
signalLinked = account != nil
}
}
telegramConfigured := s.cfg.IsTelegramEnabled() && s.cfg.TelegramChatID != 0
var html string var html string
html += `<ul class="app-list">` html += `<ul class="app-list">`
for _, m := range mappings { for _, m := range mappings {
isSignal := m.Channel == notification.ChannelSignal isSignal := m.Channel == notification.ChannelSignal
isWebPush := m.Channel == notification.ChannelWebPush isWebPush := m.Channel == notification.ChannelWebPush
channelBadge := "Signal" isTelegram := m.Channel == notification.ChannelTelegram
channelBadge := ""
channelTooltip := "" channelTooltip := ""
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)
}
if isSignal && m.Signal != nil && m.Signal.GroupID != "" { if isSignal && m.Signal != nil && m.Signal.GroupID != "" {
channelBadge = "Signal"
channelTooltip = fmt.Sprintf(`<span class="tooltip">Group ID: %s</span>`, m.Signal.GroupID) channelTooltip = fmt.Sprintf(`<span class="tooltip">Group ID: %s</span>`, m.Signal.GroupID)
} else if isWebPush && m.WebPush != nil && m.WebPush.Endpoint != "" {
channelBadge = "WebPush"
channelTooltip = fmt.Sprintf(`<span class="tooltip">%s</span>`, m.WebPush.Endpoint)
} else if isTelegram && telegramConfigured {
channelBadge = "Telegram"
} else {
channelBadge = "Not Configured"
if isSignal {
channelTooltip = `<span class="tooltip">No Signal group created yet. Will auto-create on first notification.</span>`
} else if isTelegram {
channelTooltip = `<span class="tooltip">Set TELEGRAM_CHAT_ID in .env</span>`
} else {
channelTooltip = `<span class="tooltip">No WebPush endpoint registered.</span>`
}
} }
itemHTML := fmt.Sprintf(`<li class="app-item"> itemHTML := fmt.Sprintf(`<li class="app-item">
<div class="app-info"> <div class="app-info">
<div class="app-name"><strong>%s</strong></div> <div class="app-name"><strong>%s</strong></div>
<div class="app-channel"> <div class="app-channel">`, m.AppName)
<span class="channel-badge channel-%s">%s%s</span>`, m.AppName, m.Channel, channelBadge, channelTooltip)
if channelBadge != "Not Configured" {
itemHTML += fmt.Sprintf(`<span class="channel-badge channel-%s">%s%s</span>`, m.Channel, channelBadge, channelTooltip)
} else {
itemHTML += fmt.Sprintf(`<span class="channel-badge" style="background: var(--error); color: var(--text-on-color);">%s%s</span>`, channelBadge, channelTooltip)
}
if m.WebPush != nil && isWebPush { if m.WebPush != nil && isWebPush {
if u, err := url.Parse(m.WebPush.Endpoint); err == nil { if u, err := url.Parse(m.WebPush.Endpoint); err == nil {
@ -169,18 +82,29 @@ func (s *Server) getAppsListHTML(mappings []notification.Mapping) string {
itemHTML += `</div></div><div class="app-actions">` itemHTML += `</div></div><div class="app-actions">`
if m.WebPush != nil { var channelOptions string
webpushLabel := "WebPush" optionCount := 0
if !m.WebPush.HasEncryption() {
webpushLabel = "Webhook" if signalLinked {
} channelOptions += fmt.Sprintf(`<option value="signal"%s>Signal</option>`, map[bool]string{true: " selected", false: ""}[isSignal])
optionCount++
}
if telegramConfigured {
channelOptions += fmt.Sprintf(`<option value="telegram"%s>Telegram</option>`, map[bool]string{true: " selected", false: ""}[isTelegram])
optionCount++
}
if m.WebPush != nil && m.WebPush.Endpoint != "" {
channelOptions += fmt.Sprintf(`<option value="webpush"%s>WebPush</option>`, map[bool]string{true: " selected", false: ""}[isWebPush])
optionCount++
}
if optionCount > 1 {
itemHTML += fmt.Sprintf(`<form style="display: inline;"> itemHTML += fmt.Sprintf(`<form style="display: inline;">
<input type="hidden" name="app" value="%s" /> <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"> <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> %s
<option value="webpush"%s>%s</option>
</select> </select>
</form>`, m.AppName, map[bool]string{true: " selected", false: ""}[isSignal], map[bool]string{true: " selected", false: ""}[isWebPush], webpushLabel) </form>`, m.AppName, channelOptions)
} }
itemHTML += fmt.Sprintf(`<button class="btn-delete" hx-delete="/action/app/%s" hx-target="#apps-list" hx-swap="innerHTML">Delete</button></div></li>`, m.AppName) itemHTML += fmt.Sprintf(`<button class="btn-delete" hx-delete="/action/app/%s" hx-target="#apps-list" hx-swap="innerHTML">Delete</button></div></li>`, m.AppName)

View file

@ -0,0 +1,26 @@
package server
import (
"fmt"
"net/http"
)
func (s *Server) handleFragmentIntegrations(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
html := ""
if s.cfg.IsSignalEnabled() {
html += `<div id="signal-integration" class="integration-container" hx-get="/fragment/signal" hx-trigger="load"><div class="loading"><div class="spinner"></div></div></div>`
}
if s.cfg.IsProtonEnabled() {
html += `<div id="proton-integration" class="integration-container" hx-get="/fragment/proton" hx-trigger="load"><div class="loading"><div class="spinner"></div></div></div>`
}
if s.cfg.IsTelegramEnabled() {
html += `<div id="telegram-integration" class="integration-container" hx-get="/fragment/telegram" hx-trigger="load"><div class="loading"><div class="spinner"></div></div></div>`
}
_, _ = fmt.Fprint(w, html)
}

View file

@ -1,42 +0,0 @@
package server
import (
"encoding/json"
"net/http"
)
type markReadRequest struct {
UID uint32 `json:"uid"`
}
func (s *Server) handleProtonMarkRead(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"}) //nolint:errcheck
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"}) //nolint:errcheck
return
}
if err := s.protonMonitor.MarkAsRead(req.UID); err != nil {
s.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"}) //nolint:errcheck
return
}
s.logger.Info("marked email as read", "uid", req.UID)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil {
s.logger.Error("failed to encode response", "error", err)
}
}

View file

@ -11,6 +11,10 @@ import (
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
var noisyPaths = map[string]bool{
"/.well-known/appspecific/com.chrome.devtools.json": true,
}
func authMiddleware(apiKey string) func(http.Handler) http.Handler { func authMiddleware(apiKey string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -32,13 +36,15 @@ func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r) next.ServeHTTP(wrapped, r)
logger.Debug("HTTP request", if !noisyPaths[r.URL.Path] {
"method", r.Method, logger.Debug("HTTP request",
"path", r.URL.Path, "method", r.Method,
"status", wrapped.statusCode, "path", r.URL.Path,
"duration", time.Since(start), "status", wrapped.statusCode,
"ip", util.GetClientIP(r), "duration", time.Since(start),
) "ip", util.GetClientIP(r),
)
}
}) })
} }
} }
@ -49,7 +55,9 @@ type responseWriter struct {
} }
func (rw *responseWriter) WriteHeader(code int) { func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code if rw.statusCode == http.StatusOK {
rw.statusCode = code
}
rw.ResponseWriter.WriteHeader(code) rw.ResponseWriter.WriteHeader(code)
} }
@ -58,7 +66,7 @@ func securityHeadersMiddleware() func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; object-src 'none'") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://api.qrserver.com; form-action 'self'; frame-ancestors 'none'; object-src 'none'")
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })

View file

@ -3,14 +3,16 @@ package server
import ( import (
"context" "context"
"fmt" "fmt"
"html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"strings"
"time" "time"
"prism/service/config" "prism/service/config"
"prism/service/integration"
"prism/service/notification" "prism/service/notification"
"prism/service/proton"
"prism/service/signal"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -18,18 +20,16 @@ import (
) )
type Server struct { type Server struct {
cfg *config.Config cfg *config.Config
store *notification.Store store *notification.Store
dispatcher *notification.Dispatcher dispatcher *notification.Dispatcher
protonMonitor *proton.Monitor integrations *integration.Integrations
signalDaemon *signal.Daemon logger *slog.Logger
linkDevice *signal.LinkDevice router *chi.Mux
logger *slog.Logger httpServer *http.Server
router *chi.Mux startTime time.Time
httpServer *http.Server version string
startTime time.Time indexTmpl *template.Template
lastSignalLinked *bool // Track signal linked status for change detection
lastAppsCount *int // Track apps count for change detection
} }
func New(cfg *config.Config) (*Server, error) { func New(cfg *config.Config) (*Server, error) {
@ -40,23 +40,27 @@ func New(cfg *config.Config) (*Server, error) {
return nil, fmt.Errorf("failed to create store: %w", err) return nil, fmt.Errorf("failed to create store: %w", err)
} }
signalClient := signal.NewClient(cfg.SignalCLISocketPath) integrations := integration.Initialize(cfg, store, logger)
dispatcher := notification.NewDispatcher(store, signalClient, logger)
protonMonitor := proton.NewMonitor(cfg, dispatcher, logger) version := "dev"
if versionBytes, err := os.ReadFile("VERSION"); err == nil {
version = strings.TrimSpace(string(versionBytes))
}
signalDaemon := signal.NewDaemon(cfg.SignalCLIBinaryPath, cfg.SignalCLIDataPath, cfg.SignalCLISocketPath) tmpl, err := template.ParseFiles("public/index.html")
linkDevice := signal.NewLinkDevice(signalClient, cfg.DeviceName) if err != nil {
return nil, fmt.Errorf("failed to parse index template: %w", err)
}
s := &Server{ s := &Server{
cfg: cfg, cfg: cfg,
store: store, store: store,
dispatcher: dispatcher, dispatcher: integrations.Dispatcher,
protonMonitor: protonMonitor, integrations: integrations,
signalDaemon: signalDaemon, logger: logger,
linkDevice: linkDevice, startTime: time.Now(),
logger: logger, version: version,
startTime: time.Now(), indexTmpl: tmpl,
} }
s.setupRoutes() s.setupRoutes()
@ -76,14 +80,13 @@ func (s *Server) setupRoutes() {
r.Use(middleware.StripSlashes) r.Use(middleware.StripSlashes)
r.Use(rateLimitMiddleware(s.cfg.RateLimit)) r.Use(rateLimitMiddleware(s.cfg.RateLimit))
r.Get("/", s.handleIndex)
r.Handle("/*", http.StripPrefix("/", http.FileServer(http.Dir("./public")))) r.Handle("/*", http.StripPrefix("/", http.FileServer(http.Dir("./public"))))
r.Route("/fragment", func(r chi.Router) { integration.RegisterAll(s.integrations, r, s.cfg, s.store, s.logger, authMiddleware)
r.Use(authMiddleware(s.cfg.APIKey))
r.Get("/health", s.handleFragmentHealth) r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/apps", s.handleFragmentApps)
r.Get("/signal-info", s.handleFragmentSignalInfo) r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/integrations", s.handleFragmentIntegrations)
r.Get("/apps", s.handleFragmentApps)
})
r.Route("/action", func(r chi.Router) { r.Route("/action", func(r chi.Router) {
r.Use(authMiddleware(s.cfg.APIKey)) r.Use(authMiddleware(s.cfg.APIKey))
@ -100,33 +103,13 @@ func (s *Server) setupRoutes() {
r.Get("/stats", s.handleGetStats) r.Get("/stats", s.handleGetStats)
}) })
r.Route("/webpush/app", func(r chi.Router) {
r.Use(authMiddleware(s.cfg.APIKey))
r.Post("/", s.handleWebPushRegister)
r.Delete("/{appName}", s.handleWebPushUnregister)
})
r.Route("/api/proton-mail", func(r chi.Router) {
r.Use(authMiddleware(s.cfg.APIKey))
r.Post("/mark-read", s.handleProtonMarkRead)
})
r.With(authMiddleware(s.cfg.APIKey)).Post("/{topic}", s.handleNtfyPublish) r.With(authMiddleware(s.cfg.APIKey)).Post("/{topic}", s.handleNtfyPublish)
s.router = r s.router = r
} }
func (s *Server) Start(ctx context.Context) error { func (s *Server) Start(ctx context.Context) error {
s.logger.Info("Starting Signal daemon") s.integrations.Start(ctx, s.cfg, s.logger)
if err := s.signalDaemon.Start(); err != nil {
return fmt.Errorf("failed to start signal daemon: %w", err)
}
go func() {
if err := s.protonMonitor.Start(ctx); err != nil && err != context.Canceled {
s.logger.Error("Proton monitor error", "error", err)
}
}()
addr := fmt.Sprintf(":%d", s.cfg.Port) addr := fmt.Sprintf(":%d", s.cfg.Port)
s.httpServer = &http.Server{ s.httpServer = &http.Server{

View file

@ -1,96 +0,0 @@
package signal
import (
"fmt"
"os"
"os/exec"
"syscall"
"time"
)
type Daemon struct {
binaryPath string
dataPath string
socketPath string
cmd *exec.Cmd
}
func NewDaemon(binaryPath, dataPath, socketPath string) *Daemon {
return &Daemon{
binaryPath: binaryPath,
dataPath: dataPath,
socketPath: socketPath,
}
}
func (d *Daemon) Start() error {
if d.IsRunning() {
return nil
}
_ = os.Remove(d.socketPath)
d.cmd = exec.Command(
d.binaryPath,
"--config", d.dataPath,
"daemon",
"--socket", d.socketPath,
"--send-read-receipts",
)
d.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := d.cmd.Start(); err != nil {
return fmt.Errorf("failed to start daemon: %w", err)
}
timeout := time.After(10 * time.Second)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-timeout:
_ = d.Stop() //nolint:errcheck
return fmt.Errorf("daemon failed to start within timeout")
case <-ticker.C:
if _, err := os.Stat(d.socketPath); err == nil {
return nil
}
}
}
}
func (d *Daemon) Stop() error {
if d.cmd == nil || d.cmd.Process == nil {
return nil
}
if err := d.cmd.Process.Signal(syscall.SIGTERM); err != nil {
return fmt.Errorf("failed to send SIGTERM: %w", err)
}
done := make(chan error, 1)
go func() {
done <- d.cmd.Wait()
}()
select {
case <-time.After(5 * time.Second):
_ = d.cmd.Process.Kill()
case <-done:
}
_ = os.Remove(d.socketPath)
return nil
}
func (d *Daemon) IsRunning() bool {
if _, err := os.Stat(d.socketPath); os.IsNotExist(err) {
return false
}
client := NewClient(d.socketPath)
_, err := client.Call("listAccounts", nil)
return err == nil
}

View file

@ -6,49 +6,6 @@ import (
"time" "time"
) )
func FormatPhoneNumber(number string) string {
if number == "" {
return number
}
// US/Canada: +1 (234) 567-8901
if strings.HasPrefix(number, "+1") && len(number) == 12 {
return fmt.Sprintf("+1 (%s) %s-%s", number[2:5], number[5:8], number[8:])
}
// International: +XX XXXX XXXX
if strings.HasPrefix(number, "+") && len(number) > 4 {
digits := number[1:]
var countryCode, rest string
for i := 1; i <= 3 && i < len(digits); i++ {
if digits[i] < '0' || digits[i] > '9' {
break
}
countryCode = digits[:i+1]
rest = digits[i+1:]
}
if len(rest) >= 3 {
var parts []string
for len(rest) > 0 {
size := 3
if len(rest) == 4 || len(rest) == 8 {
size = 4
}
if size > len(rest) {
size = len(rest)
}
parts = append(parts, rest[:size])
rest = rest[size:]
}
return "+" + countryCode + " " + strings.Join(parts, " ")
}
}
return number
}
func FormatUptime(d time.Duration) string { func FormatUptime(d time.Duration) string {
days := int(d.Hours()) / 24 days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24 hours := int(d.Hours()) % 24

View file

@ -18,33 +18,31 @@ const (
) )
type ColorHandler struct { type ColorHandler struct {
handler slog.Handler w io.Writer
w io.Writer level slog.Level
} }
func NewColorHandler(w io.Writer, opts *slog.HandlerOptions) *ColorHandler { func NewColorHandler(w io.Writer, opts *slog.HandlerOptions) *ColorHandler {
level := slog.LevelInfo
if opts != nil && opts.Level != nil {
level = opts.Level.Level()
}
return &ColorHandler{ return &ColorHandler{
handler: slog.NewTextHandler(w, opts), w: w,
w: w, level: level,
} }
} }
func (h *ColorHandler) Enabled(ctx context.Context, level slog.Level) bool { func (h *ColorHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.handler.Enabled(ctx, level) return level >= h.level
} }
func (h *ColorHandler) WithAttrs(attrs []slog.Attr) slog.Handler { func (h *ColorHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &ColorHandler{ return h
handler: h.handler.WithAttrs(attrs),
w: h.w,
}
} }
func (h *ColorHandler) WithGroup(name string) slog.Handler { func (h *ColorHandler) WithGroup(name string) slog.Handler {
return &ColorHandler{ return h
handler: h.handler.WithGroup(name),
w: h.w,
}
} }
func (h *ColorHandler) Handle(ctx context.Context, r slog.Record) error { func (h *ColorHandler) Handle(ctx context.Context, r slog.Record) error {