mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
re-architect to a new integration system, ensure that signal is optional, adding telegram support
This commit is contained in:
parent
ba4c245189
commit
303e093e89
55 changed files with 1928 additions and 912 deletions
43
.env.example
43
.env.example
|
|
@ -1,30 +1,47 @@
|
|||
# Required: API key for authentication
|
||||
# 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
|
||||
# Default: 8080
|
||||
# PORT=8080
|
||||
|
||||
# Optional: Rate limit for requests (per 15 minute window)
|
||||
# Default: 100 (requests per 15 minutes)
|
||||
# RATE_LIMIT=100
|
||||
|
||||
# Optional: Device name shown in Signal app's linked devices list
|
||||
# Default: Prism
|
||||
# DEVICE_NAME=Prism Dev
|
||||
# DEVICE_NAME=Prism
|
||||
|
||||
# Optional: Enable verbose logging
|
||||
# Default: false
|
||||
# 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
|
||||
# Get credentials by running: docker compose run --rm protonmail-bridge init
|
||||
# Then use 'login' and 'info' commands to get IMAP username/password
|
||||
# Signal Integration (requires FEATURE_ENABLE_SIGNAL=true)
|
||||
|
||||
# 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_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
35
.github/copilot-instructions.md
vendored
Normal 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
|
||||
|
||||
30
.github/workflows/release-signal-cli.yml
vendored
Normal file
30
.github/workflows/release-signal-cli.yml
vendored
Normal 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
|
||||
13
.github/workflows/release.yml
vendored
13
.github/workflows/release.yml
vendored
|
|
@ -1,14 +1,7 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version tag (e.g., 0.1.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
release:
|
||||
|
|
@ -29,11 +22,7 @@ jobs:
|
|||
- name: Extract version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "tag=${{ inputs.version }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "tag=$(cat VERSION)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -4,3 +4,5 @@ data/
|
|||
.env
|
||||
/prism
|
||||
prism-*
|
||||
tmp
|
||||
.VSCodeCounter/
|
||||
|
|
|
|||
12
Dockerfile
12
Dockerfile
|
|
@ -9,13 +9,14 @@ RUN go mod download
|
|||
|
||||
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)" \
|
||||
-o prism .
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates signal-cli openjdk21-jre
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
|
@ -23,14 +24,11 @@ COPY --from=builder /build/prism .
|
|||
COPY public ./public
|
||||
|
||||
RUN adduser -D -u 1000 prism && \
|
||||
mkdir -p /var/run/signal-cli /app/data && \
|
||||
chown -R prism:prism /app /var/run/signal-cli
|
||||
mkdir -p /app/data && \
|
||||
chown -R prism:prism /app
|
||||
|
||||
USER prism
|
||||
|
||||
ENV SIGNAL_CLI_BINARY=signal-cli
|
||||
ENV SIGNAL_CLI_SOCKET=/var/run/signal-cli/socket
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["./prism"]
|
||||
|
|
|
|||
12
Dockerfile.signal-cli
Normal file
12
Dockerfile.signal-cli
Normal 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"]
|
||||
18
Makefile
18
Makefile
|
|
@ -3,7 +3,7 @@
|
|||
BINARY_NAME=prism
|
||||
VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev")
|
||||
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)
|
||||
|
||||
all: fmt lint build
|
||||
|
|
@ -39,8 +39,6 @@ install-tools:
|
|||
@echo "Installing Go tools to $(GOBIN)..."
|
||||
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
|
||||
@echo "Installing signal-cli..."
|
||||
@bash scripts/install-signal-cli.sh
|
||||
|
||||
deps:
|
||||
go mod download
|
||||
|
|
@ -59,7 +57,13 @@ docker-down:
|
|||
docker compose -f docker-compose.dev.yml down
|
||||
|
||||
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:
|
||||
@if [ ! -f VERSION ]; then \
|
||||
|
|
@ -70,4 +74,8 @@ release:
|
|||
echo "Releasing v$$VERSION..."; \
|
||||
git tag -a "v$$VERSION" -m "Release 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
191
README.md
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# 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)
|
||||
|
||||
|
|
@ -12,128 +12,147 @@
|
|||
|
||||
<!-- 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
|
||||
|
||||
### 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
|
||||
# Download docker-compose.yml
|
||||
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/docker-compose.yml
|
||||
|
||||
docker compose run --rm protonmail-bridge init
|
||||
```
|
||||
|
||||
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)
|
||||
# Download .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
|
||||
nano .env
|
||||
nano .env # Set API_KEY=your-secret-key-here
|
||||
|
||||
# Start Prism server
|
||||
# Start Prism
|
||||
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.
|
||||
|
||||

|
||||
### 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**
|
||||
|
||||

|
||||
|
||||
#### 3. Verify the setup
|
||||
Once linked, new apps will default to Signal delivery.
|
||||
|
||||
Once linked, you'll see the status dashboard:
|
||||
### Telegram
|
||||
|
||||

|
||||
Send notifications through Telegram instead of WebPush.
|
||||
|
||||
With optional Proton Mail integration:
|
||||
**1. Create a Telegram bot:**
|
||||
|
||||

|
||||
- Message [@BotFather](https://t.me/BotFather) on Telegram
|
||||
- Send `/newbot` and follow the prompts
|
||||
- Copy the bot token (looks like `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
|
||||
|
||||
### Development
|
||||
|
||||
For local development, install Go and signal-cli:
|
||||
**2. Enable Telegram in `.env`:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/lone-cloud/prism.git
|
||||
cd prism
|
||||
|
||||
# Install development tools and signal-cli
|
||||
make install-tools
|
||||
|
||||
# Run locally
|
||||
make dev
|
||||
FEATURE_ENABLE_TELEGRAM=true
|
||||
TELEGRAM_BOT_TOKEN=your-bot-token-here
|
||||
```
|
||||
|
||||
Then build and run with docker-compose.dev.yml:
|
||||
**3. Restart Prism:**
|
||||
|
||||
```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
|
||||
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
|
||||
|
||||
### 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:
|
||||
|
||||
- **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**: Supports both encrypted WebPush (with VAPID signing and payload encryption) and plain HTTP webhooks
|
||||
- **WebPush** (default): Supports both encrypted WebPush (with VAPID signing and payload encryption) and plain HTTP webhooks
|
||||
- **Encrypted WebPush**: Full WebPush protocol with end-to-end encryption - requires `appName`, `pushEndpoint`, `p256dh`, `auth`, and `vapidPrivateKey`
|
||||
- **Plain webhooks**: Simple JSON POST to any HTTP endpoint - only requires `appName` and `pushEndpoint`
|
||||
- **Signal groups** (optional): Uses [signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api) to create a Signal group for each app and send notifications as messages
|
||||
|
||||
Each app can be independently configured to use either delivery method through the admin UI.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,19 +11,33 @@ services:
|
|||
- API_KEY=${API_KEY:-}
|
||||
- VERBOSE_LOGGING=${VERBOSE_LOGGING:-false}
|
||||
- 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_PASSWORD=${PROTON_IMAP_PASSWORD:-}
|
||||
- PROTON_BRIDGE_HOST=protonmail-bridge
|
||||
- PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143}
|
||||
- PROTON_BRIDGE_ADDR=${PROTON_BRIDGE_ADDR:-protonmail-bridge:143}
|
||||
volumes:
|
||||
- signal-data:/root/.local/share/signal-cli
|
||||
- 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
|
||||
|
||||
protonmail-bridge:
|
||||
container_name: protonmail-bridge
|
||||
image: shenxn/protonmail-bridge:build
|
||||
profiles: ['protonmail']
|
||||
profiles: ['proton']
|
||||
ports:
|
||||
- '127.0.0.1:143:143'
|
||||
volumes:
|
||||
|
|
@ -33,5 +47,6 @@ services:
|
|||
|
||||
volumes:
|
||||
signal-data:
|
||||
signal-socket:
|
||||
prism-data:
|
||||
proton-bridge-data:
|
||||
|
|
|
|||
|
|
@ -9,19 +9,33 @@ services:
|
|||
- API_KEY=${API_KEY:-}
|
||||
- VERBOSE_LOGGING=${VERBOSE_LOGGING:-false}
|
||||
- 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_PASSWORD=${PROTON_IMAP_PASSWORD:-}
|
||||
- PROTON_BRIDGE_HOST=protonmail-bridge
|
||||
- PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143}
|
||||
- PROTON_BRIDGE_ADDR=${PROTON_BRIDGE_ADDR:-protonmail-bridge:143}
|
||||
- FEATURE_ENABLE_TELEGRAM=${FEATURE_ENABLE_TELEGRAM:-false}
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
||||
volumes:
|
||||
- signal-data:/root/.local/share/signal-cli
|
||||
- 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
|
||||
|
||||
protonmail-bridge:
|
||||
container_name: protonmail-bridge
|
||||
image: shenxn/protonmail-bridge:build
|
||||
profiles: ['protonmail']
|
||||
profiles: ['proton']
|
||||
volumes:
|
||||
- proton-bridge-data:/root
|
||||
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro
|
||||
|
|
@ -29,5 +43,6 @@ services:
|
|||
|
||||
volumes:
|
||||
signal-data:
|
||||
signal-socket:
|
||||
prism-data:
|
||||
proton-bridge-data:
|
||||
|
|
|
|||
17
go.mod
17
go.mod
|
|
@ -8,12 +8,27 @@ require (
|
|||
github.com/go-chi/chi/v5 v5.2.4
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/mymmrac/telego v1.5.1
|
||||
golang.org/x/time v0.14.0
|
||||
)
|
||||
|
||||
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-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
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
56
go.sum
|
|
@ -1,5 +1,18 @@
|
|||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
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/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk=
|
||||
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/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
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/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/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=
|
||||
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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/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.8.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.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.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/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=
|
||||
|
|
@ -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.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
19
main.go
19
main.go
|
|
@ -3,6 +3,7 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
|
@ -29,22 +30,22 @@ func main() {
|
|||
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()
|
||||
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.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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create server: %w", err)
|
||||
|
|
|
|||
228
public/index.css
228
public/index.css
|
|
@ -3,41 +3,53 @@
|
|||
--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;
|
||||
--badge-signal-bg: #8159b8;
|
||||
--badge-webhook-bg: rgba(40, 167, 69, 0.2);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
:root:not([data-theme="light"]):not([data-theme="dark"]) {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-on-color: #fff;
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--accent: #a78bfa;
|
||||
--success: #28a745;
|
||||
--error: #ef4444;
|
||||
--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-secondary: #2d2d2d;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-on-color: #fff;
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--accent: #a78bfa;
|
||||
--success: #28a745;
|
||||
--error: #ef4444;
|
||||
--spinner-track: #4a4a4a;
|
||||
--badge-signal-bg: #8159b8;
|
||||
--badge-webhook-bg: #28a745;
|
||||
}
|
||||
|
||||
/* CSS Reset */
|
||||
|
|
@ -87,7 +99,7 @@ h6 {
|
|||
body {
|
||||
font-family: system-ui;
|
||||
max-width: 50rem;
|
||||
margin: 1.25rem auto;
|
||||
margin: 1rem auto;
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
|
|
@ -96,15 +108,56 @@ body {
|
|||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0;
|
||||
margin-bottom: 1rem;
|
||||
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 {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
|
@ -119,12 +172,14 @@ h2 {
|
|||
}
|
||||
|
||||
.status-item:has(.tooltip),
|
||||
.channel-badge:has(.tooltip) {
|
||||
.channel-badge:has(.tooltip),
|
||||
.integration-status:has(.tooltip) {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.status-item .tooltip,
|
||||
.channel-badge .tooltip {
|
||||
.channel-badge .tooltip,
|
||||
.integration-status .tooltip {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
|
|
@ -147,7 +202,8 @@ h2 {
|
|||
}
|
||||
|
||||
.status-item .tooltip::after,
|
||||
.channel-badge .tooltip::after {
|
||||
.channel-badge .tooltip::after,
|
||||
.integration-status .tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
|
|
@ -158,14 +214,16 @@ h2 {
|
|||
}
|
||||
|
||||
.status-item:hover .tooltip,
|
||||
.channel-badge:hover .tooltip {
|
||||
.channel-badge:hover .tooltip,
|
||||
.integration-status:hover .tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
|
@ -182,12 +240,12 @@ h2 {
|
|||
|
||||
.status-ok {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.app-list {
|
||||
|
|
@ -235,13 +293,18 @@ h2 {
|
|||
}
|
||||
|
||||
.channel-signal {
|
||||
background: var(--badge-signal-bg);
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.channel-webhook {
|
||||
background: var(--badge-webhook-bg);
|
||||
color: white;
|
||||
.channel-webpush {
|
||||
background: var(--success);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.channel-telegram {
|
||||
background: #0088cc;
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.app-detail {
|
||||
|
|
@ -268,7 +331,7 @@ h2 {
|
|||
.btn-delete {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--error);
|
||||
color: white;
|
||||
color: var(--text-on-color);
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
|
|
@ -284,7 +347,7 @@ h2 {
|
|||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--text-secondary);
|
||||
color: white;
|
||||
color: var(--text-on-color);
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
|
|
@ -312,30 +375,11 @@ h2 {
|
|||
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 {
|
||||
display: inline-block;
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
color: var(--text-on-color);
|
||||
text-decoration: none;
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 0.625rem;
|
||||
|
|
@ -375,3 +419,95 @@ h2 {
|
|||
color: var(--text-secondary);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,34 +7,15 @@
|
|||
<meta name="theme-color" content="#8159b8">
|
||||
<link rel="icon" type="image/webp" href="/favicon.webp">
|
||||
<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="/theme.js"></script>
|
||||
<script src="/theme.js?v={{.Version}}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div id="health-status"
|
||||
hx-get="/fragment/health"
|
||||
hx-trigger="load, every 10s">
|
||||
<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>
|
||||
<details class="card" open>
|
||||
<summary class="card-header">
|
||||
<span class="integration-name">Registered Applications</span>
|
||||
</summary>
|
||||
<div id="apps-list"
|
||||
hx-get="/fragment/apps"
|
||||
hx-trigger="load">
|
||||
|
|
@ -42,6 +23,11 @@
|
|||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="integrations"
|
||||
hx-get="/fragment/integrations"
|
||||
hx-trigger="load">
|
||||
</div>
|
||||
|
||||
<button id="theme-toggle" class="theme-toggle"></button>
|
||||
|
|
|
|||
|
|
@ -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\""
|
||||
|
|
@ -14,48 +14,40 @@ type Config struct {
|
|||
|
||||
DeviceName string
|
||||
PrismEndpointPrefix string
|
||||
SignalCLISocketPath string
|
||||
SignalCLIDataPath string
|
||||
SignalCLIBinaryPath string
|
||||
EnableSignal bool
|
||||
SignalSocket string
|
||||
|
||||
EnableProton bool
|
||||
ProtonIMAPUsername string
|
||||
ProtonIMAPPassword string
|
||||
ProtonBridgeHost string
|
||||
ProtonBridgePort int
|
||||
ProtonPrismTopic string
|
||||
ProtonBridgeAddr string
|
||||
|
||||
IMAPInbox string
|
||||
IMAPSeenFlag string
|
||||
IMAPReconnectBaseDelay int
|
||||
IMAPMaxReconnectDelay int
|
||||
IMAPMaxReconnectAttempts int
|
||||
EnableTelegram bool
|
||||
TelegramBotToken string
|
||||
TelegramChatID int64
|
||||
|
||||
StoragePath string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
Port: getEnvInt("PORT", 8080),
|
||||
APIKey: os.Getenv("API_KEY"),
|
||||
Port: getEnvInt("PORT", 8080),
|
||||
VerboseLogging: getEnvBool("VERBOSE_LOGGING", false),
|
||||
RateLimit: getEnvInt("RATE_LIMIT", 100),
|
||||
|
||||
DeviceName: getEnvString("DEVICE_NAME", "Prism"),
|
||||
SignalCLISocketPath: getEnvString("SIGNAL_CLI_SOCKET", "./data/signal-cli.sock"),
|
||||
SignalCLIDataPath: getEnvString("SIGNAL_CLI_DATA", "./data/prism"),
|
||||
SignalCLIBinaryPath: getEnvString("SIGNAL_CLI_BINARY", "./signal-cli/bin/signal-cli"),
|
||||
|
||||
EnableSignal: getEnvBool("FEATURE_ENABLE_SIGNAL", false),
|
||||
SignalSocket: getEnvString("SIGNAL_SOCKET", "/run/signal-cli/socket"),
|
||||
|
||||
EnableProton: getEnvBool("FEATURE_ENABLE_PROTON", false),
|
||||
ProtonIMAPUsername: os.Getenv("PROTON_IMAP_USERNAME"),
|
||||
ProtonIMAPPassword: os.Getenv("PROTON_IMAP_PASSWORD"),
|
||||
ProtonBridgeHost: getEnvString("PROTON_BRIDGE_HOST", "protonmail-bridge"),
|
||||
ProtonBridgePort: getEnvInt("PROTON_BRIDGE_PORT", 143),
|
||||
ProtonPrismTopic: "Proton Mail",
|
||||
ProtonBridgeAddr: getEnvString("PROTON_BRIDGE_ADDR", "protonmail-bridge:143"),
|
||||
|
||||
IMAPInbox: "INBOX",
|
||||
IMAPSeenFlag: "\\Seen",
|
||||
IMAPReconnectBaseDelay: 10000,
|
||||
IMAPMaxReconnectDelay: 300000,
|
||||
IMAPMaxReconnectAttempts: 50,
|
||||
EnableTelegram: getEnvBool("FEATURE_ENABLE_TELEGRAM", false),
|
||||
TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
|
||||
TelegramChatID: getEnvInt64("TELEGRAM_CHAT_ID", 0),
|
||||
|
||||
StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"),
|
||||
}
|
||||
|
|
@ -77,7 +69,15 @@ func (c *Config) Validate() error {
|
|||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -96,6 +96,15 @@ func getEnvInt(key string, defaultValue int) int {
|
|||
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 {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value == "true" || value == "1"
|
||||
|
|
|
|||
70
service/integration/integration.go
Normal file
70
service/integration/integration.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import (
|
|||
)
|
||||
|
||||
func (m *Monitor) connect() error {
|
||||
addr := fmt.Sprintf("%s:%d", m.cfg.ProtonBridgeHost, m.cfg.ProtonBridgePort)
|
||||
addr := m.cfg.ProtonBridgeAddr
|
||||
|
||||
options := &imapclient.Options{
|
||||
UnilateralDataHandler: &imapclient.UnilateralDataHandler{
|
||||
|
|
@ -41,7 +41,7 @@ func (m *Monitor) connect() 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()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select inbox: %w", err)
|
||||
|
|
@ -66,9 +66,7 @@ func (m *Monitor) monitor(ctx context.Context) error {
|
|||
case <-m.newMessagesChan:
|
||||
idleCmd.Close()
|
||||
|
||||
if err := m.sendNotification(); err != nil {
|
||||
m.logger.Error("Failed to send notification", "error", err)
|
||||
}
|
||||
m.sendNotification() // Errors already logged in sendNotification()
|
||||
|
||||
idleCmd, err = m.client.Idle()
|
||||
if err != nil {
|
||||
|
|
@ -9,7 +9,7 @@ import (
|
|||
)
|
||||
|
||||
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 {
|
||||
m.logger.Error("Failed to select inbox", "error", err)
|
||||
return err
|
||||
|
|
@ -49,6 +49,14 @@ func (m *Monitor) sendNotification() error {
|
|||
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
|
||||
if len(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)
|
||||
return err
|
||||
}
|
||||
120
service/integration/proton/handlers.go
Normal file
120
service/integration/proton/handlers.go
Normal 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)
|
||||
}
|
||||
}
|
||||
51
service/integration/proton/integration.go
Normal file
51
service/integration/proton/integration.go
Normal 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
|
||||
}
|
||||
|
|
@ -11,6 +11,14 @@ import (
|
|||
"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 {
|
||||
cfg *config.Config
|
||||
dispatcher *notification.Dispatcher
|
||||
|
|
@ -31,7 +39,6 @@ func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger
|
|||
|
||||
func (m *Monitor) Start(ctx context.Context) error {
|
||||
if !m.cfg.IsProtonEnabled() {
|
||||
m.logger.Info("Proton Mail monitoring disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -44,7 +51,7 @@ func (m *Monitor) Start(ctx context.Context) error {
|
|||
default:
|
||||
if err := m.connect(); err != nil {
|
||||
m.logger.Error("Failed to connect to IMAP", "error", err)
|
||||
time.Sleep(time.Duration(m.cfg.IMAPReconnectBaseDelay) * time.Millisecond)
|
||||
time.Sleep(imapReconnectBaseDelay)
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +66,7 @@ func (m *Monitor) Start(ctx context.Context) error {
|
|||
m.client = nil
|
||||
}
|
||||
|
||||
time.Sleep(time.Duration(m.cfg.IMAPReconnectBaseDelay) * time.Millisecond)
|
||||
time.Sleep(imapReconnectBaseDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
29
service/integration/proton/routes.go
Normal file
29
service/integration/proton/routes.go
Normal 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
|
||||
}
|
||||
|
|
@ -10,11 +10,13 @@ import (
|
|||
var rpcID int32
|
||||
|
||||
type Client struct {
|
||||
socketPath string
|
||||
SocketPath string
|
||||
}
|
||||
|
||||
func NewClient(socketPath string) *Client {
|
||||
return &Client{socketPath: socketPath}
|
||||
return &Client{
|
||||
SocketPath: socketPath,
|
||||
}
|
||||
}
|
||||
|
||||
type RPCRequest struct {
|
||||
|
|
@ -37,7 +39,7 @@ type RPCError struct {
|
|||
}
|
||||
|
||||
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 {
|
||||
return nil, fmt.Errorf("failed to connect: %w", err)
|
||||
}
|
||||
|
|
@ -79,7 +81,15 @@ type AccountInfo struct {
|
|||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -94,5 +104,67 @@ func (c *Client) GetLinkedAccount() (*AccountInfo, error) {
|
|||
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
|
||||
}
|
||||
47
service/integration/signal/format.go
Normal file
47
service/integration/signal/format.go
Normal 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
|
||||
}
|
||||
84
service/integration/signal/handlers.go
Normal file
84
service/integration/signal/handlers.go
Normal 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
|
||||
}
|
||||
58
service/integration/signal/integration.go
Normal file
58
service/integration/signal/integration.go
Normal 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
|
||||
}
|
||||
|
|
@ -1,11 +1,8 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -14,6 +11,7 @@ type LinkDevice struct {
|
|||
client *Client
|
||||
deviceName string
|
||||
qrCode string
|
||||
deviceLinkUri string
|
||||
generatedAt time.Time
|
||||
ttl time.Duration
|
||||
}
|
||||
|
|
@ -31,10 +29,15 @@ type StartLinkResponse struct {
|
|||
}
|
||||
|
||||
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 {
|
||||
return l.qrCode, nil
|
||||
}
|
||||
|
||||
// Call signal-cli's startLink JSON-RPC method directly
|
||||
result, err := l.client.Call("startLink", nil)
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
l.deviceLinkUri = uri
|
||||
qrURL := fmt.Sprintf("https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=%s", url.QueryEscape(uri))
|
||||
//nolint:gosec
|
||||
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.qrCode = qrURL
|
||||
l.generatedAt = time.Now()
|
||||
|
||||
go l.finishLink(uri)
|
||||
go l.finishLink()
|
||||
|
||||
return l.qrCode, nil
|
||||
}
|
||||
|
||||
func (l *LinkDevice) finishLink(uri string) {
|
||||
func (l *LinkDevice) finishLink() {
|
||||
params := map[string]interface{}{
|
||||
"deviceLinkUri": uri,
|
||||
"deviceLinkUri": l.deviceLinkUri,
|
||||
"deviceName": l.deviceName,
|
||||
}
|
||||
|
||||
23
service/integration/signal/routes.go
Normal file
23
service/integration/signal/routes.go
Normal 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
|
||||
}
|
||||
83
service/integration/signal/sender.go
Normal file
83
service/integration/signal/sender.go
Normal 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, ¬ification.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
|
||||
}
|
||||
55
service/integration/telegram/client.go
Normal file
55
service/integration/telegram/client.go
Normal 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
|
||||
}
|
||||
89
service/integration/telegram/handlers.go
Normal file
89
service/integration/telegram/handlers.go
Normal 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
|
||||
}
|
||||
62
service/integration/telegram/integration.go
Normal file
62
service/integration/telegram/integration.go
Normal 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()
|
||||
}
|
||||
15
service/integration/telegram/routes.go
Normal file
15
service/integration/telegram/routes.go
Normal 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)
|
||||
}
|
||||
46
service/integration/telegram/sender.go
Normal file
46
service/integration/telegram/sender.go
Normal 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
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
package server
|
||||
package webpush
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
|
|
@ -10,7 +11,19 @@ import (
|
|||
"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"`
|
||||
PushEndpoint string `json:"pushEndpoint"`
|
||||
P256dh *string `json:"p256dh,omitempty"`
|
||||
|
|
@ -18,8 +31,8 @@ type registerWebPushRequest struct {
|
|||
VapidPrivateKey *string `json:"vapidPrivateKey,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerWebPushRequest
|
||||
func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
|
|
@ -28,20 +41,20 @@ func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) {
|
|||
if req.AppName == "" || req.PushEndpoint == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "appName and pushEndpoint are required"}) //nolint:errcheck
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "appName and pushEndpoint are required"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := url.Parse(req.PushEndpoint); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Invalid pushEndpoint URL"}) //nolint:errcheck
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Invalid pushEndpoint URL"})
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := s.store.GetApp(req.AppName)
|
||||
existing, err := h.store.GetApp(req.AppName)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
|
@ -61,20 +74,20 @@ func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if existing != nil {
|
||||
if err := s.store.UpdateWebPush(req.AppName, webPush); err != nil {
|
||||
s.logger.Error("Failed to update webpush endpoint", "error", err)
|
||||
if err := h.store.UpdateWebPush(req.AppName, webPush); err != nil {
|
||||
h.logger.Error("Failed to update webpush endpoint", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.logger.Info("Updated webpush for existing endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint)
|
||||
h.logger.Info("Updated webpush for existing endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint)
|
||||
} else {
|
||||
channel := notification.ChannelWebPush
|
||||
if err := s.store.Register(req.AppName, &channel, nil, webPush); err != nil {
|
||||
s.logger.Error("Failed to register webpush endpoint", "error", err)
|
||||
if err := h.store.Register(req.AppName, &channel, nil, webPush); err != nil {
|
||||
h.logger.Error("Failed to register webpush endpoint", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
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")
|
||||
|
|
@ -82,32 +95,28 @@ func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) {
|
|||
"appName": req.AppName,
|
||||
"channel": "webpush",
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
s.logger.Error("Failed to encode response", "error", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
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")
|
||||
if appName == "" {
|
||||
http.Error(w, "appName is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.ClearWebPush(appName); err != nil {
|
||||
s.logger.Error("Failed to clear webpush endpoint", "error", err)
|
||||
if err := h.store.ClearWebPush(appName); err != nil {
|
||||
h.logger.Error("Failed to clear webpush endpoint", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Info("Cleared webpush subscription", "app", appName)
|
||||
h.logger.Info("Cleared webpush subscription", "app", appName)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
response := map[string]string{
|
||||
"status": "unregistered",
|
||||
"appName": appName,
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
s.logger.Error("Failed to encode response", "error", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
33
service/integration/webpush/integration.go
Normal file
33
service/integration/webpush/integration.go
Normal 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
|
||||
}
|
||||
20
service/integration/webpush/routes.go
Normal file
20
service/integration/webpush/routes.go
Normal 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)
|
||||
})
|
||||
}
|
||||
73
service/integration/webpush/sender.go
Normal file
73
service/integration/webpush/sender.go
Normal 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
|
||||
}
|
||||
|
|
@ -1,31 +1,58 @@
|
|||
package notification
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"prism/service/signal"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
)
|
||||
|
||||
type NotificationSender interface {
|
||||
Send(mapping *Mapping, notif Notification) error
|
||||
}
|
||||
|
||||
type Dispatcher struct {
|
||||
store *Store
|
||||
signalClient *signal.Client
|
||||
senders map[Channel]NotificationSender
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewDispatcher(store *Store, signalClient *signal.Client, logger *slog.Logger) *Dispatcher {
|
||||
func NewDispatcher(store *Store, logger *slog.Logger) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
store: store,
|
||||
signalClient: signalClient,
|
||||
senders: make(map[Channel]NotificationSender),
|
||||
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 {
|
||||
mapping, err := d.store.GetApp(appName)
|
||||
if err != nil {
|
||||
|
|
@ -36,7 +63,8 @@ func (d *Dispatcher) Send(appName string, notif Notification) error {
|
|||
if mapping == nil {
|
||||
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)
|
||||
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 {
|
||||
case ChannelWebPush:
|
||||
return d.sendWebPush(mapping, notif)
|
||||
case ChannelSignal:
|
||||
return d.sendSignal(mapping, notif)
|
||||
default:
|
||||
d.logger.Error("Unknown channel type", "channel", mapping.Channel)
|
||||
return fmt.Errorf("unknown channel: %s", mapping.Channel)
|
||||
}
|
||||
sender, ok := d.senders[mapping.Channel]
|
||||
if !ok {
|
||||
d.logger.Error("No sender registered for channel", "channel", mapping.Channel)
|
||||
return fmt.Errorf("no sender for channel: %s", mapping.Channel)
|
||||
}
|
||||
|
||||
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
|
||||
return sender.Send(mapping, notif)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,22 @@ type Channel string
|
|||
const (
|
||||
ChannelSignal Channel = "signal"
|
||||
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 {
|
||||
Endpoint string
|
||||
P256dh string
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ func (s *Store) createTables() error {
|
|||
appName TEXT PRIMARY KEY,
|
||||
signalGroupId TEXT,
|
||||
signalAccount TEXT,
|
||||
channel TEXT NOT NULL DEFAULT 'signal',
|
||||
channel TEXT NOT NULL DEFAULT 'webpush',
|
||||
pushEndpoint TEXT,
|
||||
p256dh TEXT,
|
||||
auth TEXT,
|
||||
|
|
@ -75,7 +75,7 @@ func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscri
|
|||
vapidPrivateKey = &webPush.VapidPrivateKey
|
||||
}
|
||||
|
||||
ch := ChannelSignal
|
||||
ch := ChannelWebPush
|
||||
if channel != nil {
|
||||
ch = *channel
|
||||
}
|
||||
|
|
@ -96,8 +96,15 @@ func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscri
|
|||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RegisterDefault(appName string) error {
|
||||
return s.Register(appName, nil, nil, nil)
|
||||
func (s *Store) RegisterDefault(appName string, availableChannels []Channel) error {
|
||||
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) {
|
||||
|
|
|
|||
18
service/server/handler_index.go
Normal file
18
service/server/handler_index.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ func (s *Server) handleToggleChannelAction(w http.ResponseWriter, r *http.Reques
|
|||
return
|
||||
}
|
||||
|
||||
if channel != string(notification.ChannelSignal) && channel != string(notification.ChannelWebPush) {
|
||||
if !s.dispatcher.IsValidChannel(notification.Channel(channel)) {
|
||||
http.Error(w, "Invalid channel", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ func (s *Server) handleCreateMapping(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
if req.Channel == "" {
|
||||
req.Channel = notification.ChannelSignal
|
||||
req.Channel = notification.ChannelWebPush
|
||||
}
|
||||
|
||||
if req.Channel == notification.ChannelWebPush && req.PushEndpoint == nil {
|
||||
|
|
|
|||
|
|
@ -6,117 +6,8 @@ import (
|
|||
"net/url"
|
||||
|
||||
"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 = ¤tLinked
|
||||
}
|
||||
|
||||
// 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 = ¤tAppsCount
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = 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) {
|
||||
mappings, err := s.store.GetAllMappings()
|
||||
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 {
|
||||
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
|
||||
html += `<ul class="app-list">`
|
||||
for _, m := range mappings {
|
||||
isSignal := m.Channel == notification.ChannelSignal
|
||||
isWebPush := m.Channel == notification.ChannelWebPush
|
||||
channelBadge := "Signal"
|
||||
isTelegram := m.Channel == notification.ChannelTelegram
|
||||
channelBadge := ""
|
||||
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 != "" {
|
||||
channelBadge = "Signal"
|
||||
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">
|
||||
<div class="app-info">
|
||||
<div class="app-name"><strong>%s</strong></div>
|
||||
<div class="app-channel">
|
||||
<span class="channel-badge channel-%s">%s%s</span>`, m.AppName, m.Channel, channelBadge, channelTooltip)
|
||||
<div class="app-channel">`, m.AppName)
|
||||
|
||||
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 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">`
|
||||
|
||||
if m.WebPush != nil {
|
||||
webpushLabel := "WebPush"
|
||||
if !m.WebPush.HasEncryption() {
|
||||
webpushLabel = "Webhook"
|
||||
var channelOptions string
|
||||
optionCount := 0
|
||||
|
||||
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;">
|
||||
<input type="hidden" name="app" value="%s" />
|
||||
<select class="channel-select" name="channel" hx-post="/action/toggle-channel" hx-target="#apps-list" hx-swap="innerHTML" hx-include="closest form">
|
||||
<option value="signal"%s>Signal</option>
|
||||
<option value="webpush"%s>%s</option>
|
||||
%s
|
||||
</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)
|
||||
|
|
|
|||
26
service/server/handlers_integrations.go
Normal file
26
service/server/handlers_integrations.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,10 @@ import (
|
|||
"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 {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -32,6 +36,7 @@ func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
|
|||
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
if !noisyPaths[r.URL.Path] {
|
||||
logger.Debug("HTTP request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
|
|
@ -39,6 +44,7 @@ func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
|
|||
"duration", time.Since(start),
|
||||
"ip", util.GetClientIP(r),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -49,7 +55,9 @@ type responseWriter struct {
|
|||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
if rw.statusCode == http.StatusOK {
|
||||
rw.statusCode = 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) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@ package server
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"prism/service/config"
|
||||
"prism/service/integration"
|
||||
"prism/service/notification"
|
||||
"prism/service/proton"
|
||||
"prism/service/signal"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
|
@ -21,15 +23,13 @@ type Server struct {
|
|||
cfg *config.Config
|
||||
store *notification.Store
|
||||
dispatcher *notification.Dispatcher
|
||||
protonMonitor *proton.Monitor
|
||||
signalDaemon *signal.Daemon
|
||||
linkDevice *signal.LinkDevice
|
||||
integrations *integration.Integrations
|
||||
logger *slog.Logger
|
||||
router *chi.Mux
|
||||
httpServer *http.Server
|
||||
startTime time.Time
|
||||
lastSignalLinked *bool // Track signal linked status for change detection
|
||||
lastAppsCount *int // Track apps count for change detection
|
||||
version string
|
||||
indexTmpl *template.Template
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
signalClient := signal.NewClient(cfg.SignalCLISocketPath)
|
||||
dispatcher := notification.NewDispatcher(store, signalClient, logger)
|
||||
integrations := integration.Initialize(cfg, store, 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)
|
||||
linkDevice := signal.NewLinkDevice(signalClient, cfg.DeviceName)
|
||||
tmpl, err := template.ParseFiles("public/index.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse index template: %w", err)
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
dispatcher: dispatcher,
|
||||
protonMonitor: protonMonitor,
|
||||
signalDaemon: signalDaemon,
|
||||
linkDevice: linkDevice,
|
||||
dispatcher: integrations.Dispatcher,
|
||||
integrations: integrations,
|
||||
logger: logger,
|
||||
startTime: time.Now(),
|
||||
version: version,
|
||||
indexTmpl: tmpl,
|
||||
}
|
||||
|
||||
s.setupRoutes()
|
||||
|
|
@ -76,14 +80,13 @@ func (s *Server) setupRoutes() {
|
|||
r.Use(middleware.StripSlashes)
|
||||
r.Use(rateLimitMiddleware(s.cfg.RateLimit))
|
||||
|
||||
r.Get("/", s.handleIndex)
|
||||
r.Handle("/*", http.StripPrefix("/", http.FileServer(http.Dir("./public"))))
|
||||
|
||||
r.Route("/fragment", func(r chi.Router) {
|
||||
r.Use(authMiddleware(s.cfg.APIKey))
|
||||
r.Get("/health", s.handleFragmentHealth)
|
||||
r.Get("/signal-info", s.handleFragmentSignalInfo)
|
||||
r.Get("/apps", s.handleFragmentApps)
|
||||
})
|
||||
integration.RegisterAll(s.integrations, r, s.cfg, s.store, s.logger, authMiddleware)
|
||||
|
||||
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/apps", s.handleFragmentApps)
|
||||
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/integrations", s.handleFragmentIntegrations)
|
||||
|
||||
r.Route("/action", func(r chi.Router) {
|
||||
r.Use(authMiddleware(s.cfg.APIKey))
|
||||
|
|
@ -100,33 +103,13 @@ func (s *Server) setupRoutes() {
|
|||
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)
|
||||
|
||||
s.router = r
|
||||
}
|
||||
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
s.logger.Info("Starting Signal daemon")
|
||||
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)
|
||||
}
|
||||
}()
|
||||
s.integrations.Start(ctx, s.cfg, s.logger)
|
||||
|
||||
addr := fmt.Sprintf(":%d", s.cfg.Port)
|
||||
s.httpServer = &http.Server{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -6,49 +6,6 @@ import (
|
|||
"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 {
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
|
|
|
|||
|
|
@ -18,33 +18,31 @@ const (
|
|||
)
|
||||
|
||||
type ColorHandler struct {
|
||||
handler slog.Handler
|
||||
w io.Writer
|
||||
level slog.Level
|
||||
}
|
||||
|
||||
func NewColorHandler(w io.Writer, opts *slog.HandlerOptions) *ColorHandler {
|
||||
level := slog.LevelInfo
|
||||
if opts != nil && opts.Level != nil {
|
||||
level = opts.Level.Level()
|
||||
}
|
||||
return &ColorHandler{
|
||||
handler: slog.NewTextHandler(w, opts),
|
||||
w: w,
|
||||
level: level,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return &ColorHandler{
|
||||
handler: h.handler.WithAttrs(attrs),
|
||||
w: h.w,
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ColorHandler) WithGroup(name string) slog.Handler {
|
||||
return &ColorHandler{
|
||||
handler: h.handler.WithGroup(name),
|
||||
w: h.w,
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ColorHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue