using biome for html/cs/js formatting and linting, simplify app to run with no services, re-implement proton and signal implementations to be much better, configure all integration in web UI instead of .env

This commit is contained in:
Egor 2026-02-09 01:19:47 -08:00
parent 5d99180862
commit 897df27e00
54 changed files with 2221 additions and 1131 deletions

View file

@ -5,7 +5,7 @@ tmp_dir = "tmp"
bin = "./tmp/main" bin = "./tmp/main"
cmd = "go build -o ./tmp/main ." cmd = "go build -o ./tmp/main ."
include_ext = ["go", "html", "css", "js"] include_ext = ["go", "html", "css", "js"]
exclude_dir = ["tmp", "data", "signal-cli"] exclude_dir = ["tmp", "data"]
delay = 1000 delay = 1000
[log] [log]

View file

@ -1,4 +1,3 @@
signal-cli/
data/ data/
*.db *.db
prism prism

View file

@ -1,47 +1,21 @@
# Required: API key for authentication # Required: API key for authentication
# API_KEY=your-secret-key-here # API_KEY=your-secret-key-here
# Optional: Feature flags to enable/disable integrations # Optional Configuration
# FEATURE_ENABLE_SIGNAL=false
# FEATURE_ENABLE_PROTON=false
# FEATURE_ENABLE_TELEGRAM=false
# Advanced Configuration # Optional: Enable integrations (default: false - only enable what you need)
# ENABLE_SIGNAL=true
# ENABLE_TELEGRAM=true
# ENABLE_PROTON=true
# Optional: Server port (default: 8080)
# Optional: Server port
# PORT=8080 # PORT=8080
# Optional: Rate limit for requests (per 15 minute window) # Optional: Rate limit for requests (default: 100 per 15 minute window)
# RATE_LIMIT=100 # RATE_LIMIT=100
# Optional: Device name shown in Signal app's linked devices list # Optional: Enable verbose logging (default: false)
# DEVICE_NAME=Prism # VERBOSE_LOGGING=false
# Optional: Enable verbose logging # Optional: Database storage path (default: ./data/prism.db)
# VERBOSE_LOGGING=true # STORAGE_PATH=./data/prism.db
# 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
# 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

View file

@ -2,6 +2,7 @@
## Code Style ## Code Style
- **NO DOCUMENTATION FILES**: Never create README, GUIDE, HOWTO, or any other documentation markdown files unless explicitly requested. Code changes only.
- **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. - **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. - **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. - **Only comment WHY, not WHAT**: If you must add a comment, explain WHY something is done, not WHAT is being done.

View file

@ -45,5 +45,8 @@ jobs:
version: v2.8.0 version: v2.8.0
args: --timeout=5m args: --timeout=5m
- name: Check frontend
run: npx @biomejs/biome@latest check .
- name: Build - name: Build
run: go build -v . run: go build -v .

View file

@ -1,34 +0,0 @@
name: Release Signal CLI
on:
workflow_dispatch:
permissions:
contents: read
packages: write
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

1
.gitignore vendored
View file

@ -1,4 +1,3 @@
signal-cli/
data/ data/
*.db *.db
.env .env

View file

@ -14,15 +14,26 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-w -s -X main.version=$(cat VERSION 2>/dev/null || echo dev) -X main.commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \ -ldflags="-w -s -X main.version=$(cat VERSION 2>/dev/null || echo dev) -X main.commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \
-o prism . -o prism .
FROM alpine:3.23 FROM debian:trixie-slim
RUN apk --no-cache add ca-certificates ARG TARGETARCH
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates wget curl && \
curl -L -o signal-cli.gz \
https://media.projektzentrisch.de/temp/signal-cli/signal-cli_ubuntu2004_${TARGETARCH}.gz && \
gunzip signal-cli.gz && \
mv signal-cli /usr/local/bin/signal-cli && \
chmod +x /usr/local/bin/signal-cli && \
apt-get remove -y curl && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=builder /build/prism . COPY --from=builder /build/prism .
RUN adduser -D -u 1000 prism && \ RUN useradd -m -u 1000 prism && \
mkdir -p /app/data && \ mkdir -p /app/data && \
chown -R prism:prism /app chown -R prism:prism /app

View file

@ -1,10 +0,0 @@
FROM alpine:3.23
RUN apk add --no-cache signal-cli su-exec && \
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 && \
echo -e '#!/bin/sh\nrm -f /home/signal/.signal-cli/socket\nchown -R signal:signal /home/signal/.signal-cli\nexec su-exec signal signal-cli --config /home/.local/share/signal-cli daemon --socket /home/signal/.signal-cli/socket' > /entrypoint.sh && \
chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -1,4 +1,4 @@
.PHONY: all build build-linux run dev fmt lint vet clean install-tools deps check-updates update update-all docker-build docker-run docker-down release .PHONY: all build build-linux run dev lint fix vet clean install-tools deps check-updates update update-all docker-build docker-run docker-down release
BINARY_NAME=prism BINARY_NAME=prism
VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev") VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev")
@ -6,7 +6,7 @@ COMMIT?=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
GOBIN?=$(shell command -v go >/dev/null 2>&1 && go env GOPATH || echo "${HOME}/go")/bin GOBIN?=$(shell command -v go >/dev/null 2>&1 && go env GOPATH || echo "${HOME}/go")/bin
export PATH := $(GOBIN):$(PATH) export PATH := $(GOBIN):$(PATH)
all: fmt lint build all: fix build
build: build:
go build -ldflags="-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o $(BINARY_NAME) . go build -ldflags="-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o $(BINARY_NAME) .
@ -18,15 +18,11 @@ dev:
@which air > /dev/null || (echo "Installing air..." && go install github.com/air-verse/air@latest) @which air > /dev/null || (echo "Installing air..." && go install github.com/air-verse/air@latest)
air air
fmt: fix:
gofmt -s -w . gofmt -s -w .
goimports -w . goimports -w .
lint:
golangci-lint run
fix:
golangci-lint run --fix golangci-lint run --fix
npx @biomejs/biome@latest check --write --unsafe .
vet: vet:
go vet ./... go vet ./...
@ -39,6 +35,24 @@ install-tools:
@echo "Installing Go tools to $(GOBIN)..." @echo "Installing Go tools to $(GOBIN)..."
go install golang.org/x/tools/cmd/goimports@latest go install golang.org/x/tools/cmd/goimports@latest
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v2.8.0 curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v2.8.0
@echo "Installing signal-cli native binary..."
@ARCH=$$(uname -m); \
case $$ARCH in \
x86_64) SIGNAL_ARCH=amd64 ;; \
aarch64) SIGNAL_ARCH=arm64 ;; \
*) echo "Unsupported architecture: $$ARCH"; exit 1 ;; \
esac; \
TMP_DIR=$$(mktemp -d); \
cd $$TMP_DIR && \
curl -L -o signal-cli.gz \
https://media.projektzentrisch.de/temp/signal-cli/signal-cli_ubuntu2004_$${SIGNAL_ARCH}.gz && \
gunzip signal-cli.gz && \
sudo mv signal-cli /usr/local/bin/signal-cli && \
sudo chmod +x /usr/local/bin/signal-cli && \
cd - && \
rm -rf $$TMP_DIR && \
signal-cli --version && \
echo "signal-cli installed successfully to /usr/local/bin/signal-cli"
deps: deps:
go mod download go mod download
@ -55,21 +69,9 @@ update-all:
go get -u all go get -u all
go mod tidy go mod tidy
docker-build:
docker build -t prism:$(VERSION) .
docker-up: docker-up:
docker compose -f docker-compose.dev.yml up -d docker compose -f docker-compose.dev.yml up -d
docker-down:
docker compose -f docker-compose.dev.yml down
docker-up-proton:
docker compose -f docker-compose.dev.yml up -d protonmail-bridge
docker-up-signal:
docker compose -f docker-compose.dev.yml up -d signal-cli
release: release:
@if [ ! -f VERSION ]; then \ @if [ ! -f VERSION ]; then \
echo "Error: VERSION file not found"; \ echo "Error: VERSION file not found"; \
@ -84,7 +86,3 @@ release:
release-dev: release-dev:
@echo "Triggering dev Docker image build..." @echo "Triggering dev Docker image build..."
gh workflow run release-dev.yml gh workflow run release-dev.yml
release-signal:
@echo "Triggering signal-cli image release via GitHub Actions..."
gh workflow run release-signal-cli.yml

212
README.md
View file

@ -4,155 +4,149 @@
# Prism # Prism
**Self-hosted notification gateway** **Self-hosted notification gateway with email monitoring**
[Setup](#setup) • [Real-World Examples](#real-world-examples) [Setup](#setup) • [Integrations](#integrations) • [Examples](#real-world-examples)
</div> </div>
<!-- markdownlint-enable MD033 --> <!-- markdownlint-enable MD033 -->
Prism is a self-hosted notification gateway. Prism can receive messages and route them to Signal, Telegram or WebPush URLs. Messages can be sent via Webhooks or from an optional Proton Mail integration. Prism is a self-hosted notification gateway. Prism can receive messages and route them to Signal, Telegram or WebPush URLs. Messages can be sent via webhooks or monitored from a Proton Mail account integration.
## Setup ## Setup
### Docker (Recommended)
```bash ```bash
# Download docker-compose.yml # Create .env file
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/docker-compose.yml
# Download .env.example
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example
mv .env.example .env
# Configure your API key (eg. admin password)
cp .env.example .env
nano .env # Set API_KEY=your-secret-key-here nano .env # Set API_KEY=your-secret-key-here
# Start Prism # Run Prism
docker compose up -d docker run -d \
--name prism \
-p 8080:8080 \
-v prism-data:/app/data \
--env-file .env \
ghcr.io/lone-cloud/prism:latest
``` ```
Prism is now running at <http://localhost:8080>. Enable optional integrations below for custom functionality. ### Binary (Alternative)
```bash
# Download latest release
curl -L -O https://github.com/lone-cloud/prism/releases/latest/download/prism-linux-amd64
chmod +x prism-linux-amd64
mv prism-linux-amd64 prism
# Create .env file
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example
mv .env.example .env
nano .env # Set API_KEY=your-secret-key-here
# Run Prism
./prism
```
Prism is now running at <http://localhost:8080>.
![Prism Dashboard](assets/screenshots/dashboard.webp)
## Integrations ## Integrations
All integrations are optional. Enable only what you need. All integrations are configured through the web UI - no environment variables or command-line setup needed!
Authenticate using your `API_KEY` as the password (username can be anything).
### Signal ### Signal
Send notifications through Signal groups. Send notifications through Signal Messenger.
Each registered app will route messages to private Signal groups matching the app name.
**1. Enable Signal in `.env`:** **Setup:**
```bash 1. Visit <http://localhost:8080> and authenticate with your API_KEY
FEATURE_ENABLE_SIGNAL=true 2. Expand the Signal integration card
``` 3. Click "Link Device"
4. Scan the QR code with Signal on your phone:
- Open Signal → Settings → Linked Devices → Link New Device
- Scan the displayed QR code
5. Your device will link automatically
**2. Start Prism with the Signal service:** All notifications will be sent via Signal.
```bash **Note:** If running the binary directly (not Docker), you'll need [signal-cli](https://github.com/AsamK/signal-cli/releases) installed and in your PATH. Docker images include signal-cli automatically.
docker compose --profile signal 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**
Once linked, new apps will default to Signal delivery.
### Telegram ### Telegram
Send notifications through Telegram instead. Send notifications through a Telegram bot.
Unlike the Signal integration, Telegram relies on creating a Telegram bot as it will serve as the notifications messenger.
**1. Create a Telegram bot:** **Setup:**
- Message [@BotFather](https://t.me/BotFather) on Telegram 1. Create a bot:
- Send `/newbot` and follow the prompts - Message [@BotFather](https://t.me/BotFather) on Telegram
- Copy the bot token (looks like `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`) - Send `/newbot` and follow the prompts
- Copy the bot token
**2. Enable Telegram in `.env`:** 2. Get your Chat ID:
- Message [@userinfobot](https://t.me/userinfobot) on Telegram
- Copy your Chat ID from the response
```bash 3. Configure in Prism:
FEATURE_ENABLE_TELEGRAM=true - Visit <http://localhost:8080> and authenticate with your API_KEY
TELEGRAM_BOT_TOKEN=your-bot-token-here - Expand the Telegram integration card
``` - Enter your bot token and chat ID
- Click "Configure"
**3. Get your chat ID:** All notifications will be sent to your Telegram chat.
- Message [@userinfobot](https://t.me/userinfobot) on Telegram
- Copy your Chat ID from the bot's response
**4. Add chat ID to `.env`:**
```bash
TELEGRAM_CHAT_ID=123456789
```
**5. Start Prism:**
```bash
# Telegram runs in the main Prism service (no profile needed)
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 ### Proton Mail
Receive notifications when new Proton Mail emails arrive. Monitor a Proton Mail account and forward new emails as notifications through Signal or Telegram.
Unlike other integrations, this one will generate new messages to be delivered by one of the configured transports.
Note that using this integration requires a paid Proton Mail account to be able to use the Proton Mail Bridge that this integration relies on.
Also note that the Proton Mail Bridge is RAM hungry.
> **Note:** The default image (`shenxn/protonmail-bridge:build`) used by Prism compiles from source and supports all architectures. For x86_64 only, you can use `shenxn/protonmail-bridge:latest` (smaller, faster). **Features:**
**1. Initialize the Proton Mail Bridge:** - Monitors inbox for new emails in real-time
- Supports 2FA-enabled accounts
- Auto-creates "Proton Mail" app for received emails
- Secure credential storage with AES-256-GCM encryption
- Automatic token refresh - no re-authentication needed
```bash **Setup:**
docker compose run --rm protonmail-bridge init
```
**2. Login at the `>>>` prompt:** 1. Visit <http://localhost:8080> and authenticate with your API_KEY
2. Configure Signal or Telegram first (required for routing)
3. Expand the Proton Mail integration card
4. Enter your Proton Mail credentials:
- Email address
- Password
- 2FA code (if enabled)
5. Click "Link"
```bash Proton Mail will connect and begin monitoring. New emails will appear as notifications from the "Proton Mail" app.
>>> login
# Enter your Proton Mail email
# Enter your password
# Enter your 2FA code
```
**3. Get IMAP credentials:** **Note:** Prism uses the official Proton Mail API (same as the Proton Bridge). Credentials are encrypted and stored locally. Tokens refresh automatically in the background.
```bash ### WebPush
>>> info
# Copy the Username and Password
>>> exit
```
**4. Add credentials to `.env`:** Send notifications directly to your browser.
```bash **Setup:**
FEATURE_ENABLE_PROTON=true
PROTON_IMAP_USERNAME=username-from-info-command
PROTON_IMAP_PASSWORD=password-from-info-command
```
**5. Start Prism with Proton Mail:** 1. Visit <http://localhost:8080> and authenticate with your API_KEY
2. Allow browser notifications when prompted
3. Apps without Signal or Telegram configured will automatically use WebPush
```bash You'll receive browser notifications when messages arrive.
docker compose --profile proton up -d
```
## Real-World Examples ## Real-World Examples
### Proton Mail Notifications ### Email Monitoring
Receive Signal notifications when new emails arrive in your Proton Mail inbox. Receive instant Signal or Telegram notifications when new emails arrive in your Proton Mail inbox.
Prism monitors a Proton Mail account via the local bridge and forwards email alerts through Signal. This relies on the same technology that a third-party email client like Thunderbird would be using to integrate with Proton Mail. Prism monitors your Proton Mail account using the official Proton API and forwards new emails as notifications. Perfect for monitoring important accounts without constantly checking email.
### Home Assistant Alerts ### Home Assistant Alerts
@ -184,7 +178,9 @@ Reboot your Home Assistant system and you'll then be able to send Signal notific
#### POST /{appName} #### POST /{appName}
Send a notification. Compatible with ntfy format. Send a notification to a specific app. Messages are routed based on your app configuration in the web UI.
JSON format:
```bash ```bash
curl -X POST http://localhost:8080/my-app \ curl -X POST http://localhost:8080/my-app \
@ -193,7 +189,7 @@ curl -X POST http://localhost:8080/my-app \
-d '{"title": "Alert", "message": "Something happened"}' -d '{"title": "Alert", "message": "Something happened"}'
``` ```
Or ntfy-style: Plain text (ntfy-compatible):
```bash ```bash
curl -X POST http://localhost:8080/my-app \ curl -X POST http://localhost:8080/my-app \
@ -201,6 +197,11 @@ curl -X POST http://localhost:8080/my-app \
-d "Simple message text" -d "Simple message text"
``` ```
**App Routing:**
- Configure which integration(s) receive messages from each app via the web UI
- Apps can route to Signal, Telegram, WebPush, or multiple destinations
- Special apps like "Proton Mail" are created automatically
### WebPush/Webhook Management ### WebPush/Webhook Management
#### POST /webpush/app #### POST /webpush/app
@ -245,8 +246,6 @@ curl -X DELETE http://localhost:8080/webpush/app/my-app \
## Monitoring ## Monitoring
The health of the system can be viewed in the same admin UI used for linking Signal. Prism uses [basic access authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) - provide your `API_KEY` as the password (username can be anything).
### Health Endpoints ### Health Endpoints
#### GET /health #### GET /health
@ -267,5 +266,14 @@ curl http://localhost:8080/api/health \
``` ```
```json ```json
{"version":"1.0.0","uptime":"3s","signal":{"linked":true},"proton":{"linked":true},"telegram":{"linked":true}} {
"version": "0.2.0",
"uptime": "2h15m",
"signal": {"linked": true, "account": "+1234567890"},
"telegram": {"linked": true, "account": "123456789"},
"proton": {"linked": true, "account": "user@proton.me"}
}
``` ```

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

40
biome.json Normal file
View file

@ -0,0 +1,40 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": [
"**/public/**/*.{js,css,html}",
"!**/public/htmx.min.js",
"!**/service/**/templates/**/*.html"
]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "always"
}
},
"css": {
"formatter": {
"enabled": true
},
"linter": {
"enabled": true
}
}
}

View file

@ -1,53 +0,0 @@
services:
prism:
container_name: prism
image: ghcr.io/lone-cloud/prism:dev
ports:
- '8080:8080'
environment:
- PORT=8080
- API_KEY=${API_KEY:-}
- VERBOSE_LOGGING=${VERBOSE_LOGGING:-false}
- RATE_LIMIT=${RATE_LIMIT:-100}
- DEVICE_NAME=${DEVICE_NAME:-Prism}
- STORAGE_PATH=${STORAGE_PATH:-./data/prism.db}
- 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_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:
- prism-data:/app/data
- 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
- /tmp/signal-cli:/home/signal/.signal-cli
restart: unless-stopped
protonmail-bridge:
container_name: protonmail-bridge
image: shenxn/protonmail-bridge:build
profiles: ['proton']
ports:
- '127.0.0.1:143:143'
volumes:
- proton-bridge-data:/root
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro
restart: unless-stopped
volumes:
signal-data:
signal-socket:
prism-data:
proton-bridge-data:

View file

@ -1,50 +0,0 @@
services:
prism:
container_name: prism
image: ghcr.io/lone-cloud/prism:latest
ports:
- '8080:8080'
environment:
- PORT=8080
- API_KEY=${API_KEY:-}
- VERBOSE_LOGGING=${VERBOSE_LOGGING:-false}
- RATE_LIMIT=${RATE_LIMIT:-100}
- DEVICE_NAME=${DEVICE_NAME:-Prism}
- STORAGE_PATH=${STORAGE_PATH:-./data/prism.db}
- 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_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:
- prism-data:/app/data
- 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: ['proton']
volumes:
- proton-bridge-data:/root
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro
restart: unless-stopped
volumes:
signal-data:
signal-socket:
prism-data:
proton-bridge-data:

13
go.mod
View file

@ -4,23 +4,25 @@ go 1.25.6
require ( require (
github.com/SherClockHolmes/webpush-go v1.4.0 github.com/SherClockHolmes/webpush-go v1.4.0
github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/emersion/hydroxide v0.2.31
github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/chi/v5 v5.2.5
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mymmrac/telego v1.5.1 github.com/mymmrac/telego v1.5.1
golang.org/x/crypto v0.47.0
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0
modernc.org/sqlite v1.44.3 modernc.org/sqlite v1.44.3
) )
require ( require (
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/grbit/go-json v0.11.0 // indirect github.com/grbit/go-json v0.11.0 // indirect
@ -33,10 +35,9 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect
github.com/valyala/fastjson v1.6.7 // indirect github.com/valyala/fastjson v1.6.7 // indirect
golang.org/x/arch v0.23.0 // indirect golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.41.0 // indirect
modernc.org/gc/v3 v3.1.2 // indirect modernc.org/gc/v3 v3.1.2 // indirect
modernc.org/libc v1.67.7 // indirect modernc.org/libc v1.67.7 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

22
go.sum
View file

@ -1,3 +1,5 @@
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
@ -8,6 +10,8 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= 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 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -15,12 +19,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 h1:7aCSuwTBzg7BCPRRaBJD0weKZYdeAykOrY6ktpx8Vvc=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63/go.mod h1:eRwwJnuLVFtYTC+AI2JDJTMcuQUTYhBIK4I6bC5tpqw=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= github.com/emersion/hydroxide v0.2.31 h1:ofPKtEpD+AtE2oKJhcQKhUjubQS8+AHIjCuO3aeLBsM=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= github.com/emersion/hydroxide v0.2.31/go.mod h1:jhoMVyP0z2GACrmFkL0ppcWKt2LbC02auADSSsB4vH4=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
@ -74,8 +76,8 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@ -122,8 +124,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=

View file

@ -3,42 +3,27 @@
--bg-secondary: #fdfdfd; --bg-secondary: #fdfdfd;
--text-primary: #000; --text-primary: #000;
--text-secondary: #666; --text-secondary: #666;
--text-on-color: #fafafa; --text-on-color: #fff;
--border-color: rgba(0, 0, 0, 0.1); --border-color: rgba(0, 0, 0, 0.1);
--accent: #8159b8; --accent: #00b4d8;
--success: #28a745; --success: #06d6a0;
--error: #dc3545; --error: #ef476f;
--spinner-track: #e0e0e0; --spinner-track: #e0e0e0;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]):not([data-theme="dark"]) { :root:not([data-theme="light"]) {
--bg-primary: #1a1a1a; --bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d; --bg-secondary: #2d2d2d;
--text-primary: #e0e0e0; --text-primary: #e0e0e0;
--text-secondary: #a0a0a0; --text-secondary: #a0a0a0;
--text-on-color: #fff; --text-on-color: #fff;
--border-color: rgba(255, 255, 255, 0.1); --border-color: rgba(255, 255, 255, 0.1);
--accent: #a78bfa; --accent: #60c5ff;
--success: #28a745;
--error: #ef4444;
--spinner-track: #4a4a4a; --spinner-track: #4a4a4a;
} }
} }
: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"] { :root[data-theme="dark"] {
--bg-primary: #1a1a1a; --bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d; --bg-secondary: #2d2d2d;
@ -46,9 +31,7 @@
--text-secondary: #a0a0a0; --text-secondary: #a0a0a0;
--text-on-color: #fff; --text-on-color: #fff;
--border-color: rgba(255, 255, 255, 0.1); --border-color: rgba(255, 255, 255, 0.1);
--accent: #a78bfa; --accent: #60c5ff;
--success: #28a745;
--error: #ef4444;
--spinner-track: #4a4a4a; --spinner-track: #4a4a4a;
} }
@ -141,7 +124,7 @@ details[open] > .card-header::before {
transform: rotate(90deg); transform: rotate(90deg);
} }
.card #apps-list { .card-content {
padding: 0 1.25rem 1.25rem 1.25rem; padding: 0 1.25rem 1.25rem 1.25rem;
} }
@ -163,8 +146,7 @@ a:hover {
cursor: help; cursor: help;
} }
.channel-badge .tooltip, .tooltip {
.integration-status .tooltip {
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
position: absolute; position: absolute;
@ -186,8 +168,7 @@ a:hover {
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2); box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2);
} }
.channel-badge .tooltip::after, .tooltip::after {
.integration-status .tooltip::after {
content: ""; content: "";
position: absolute; position: absolute;
top: 100%; top: 100%;
@ -197,8 +178,7 @@ a:hover {
border-top-color: var(--bg-secondary); border-top-color: var(--bg-secondary);
} }
.channel-badge:hover .tooltip, :hover > .tooltip {
.integration-status:hover .tooltip {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
} }
@ -312,44 +292,12 @@ a:hover {
border: none; border: none;
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
transition: filter 0.2s; transition: filter 0.2s ease;
white-space: nowrap; white-space: nowrap;
} }
.btn-delete:hover { .btn-delete:hover {
filter: brightness(0.9); filter: brightness(0.85);
}
.btn-cancel {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: var(--text-secondary);
color: var(--text-on-color);
border: none;
border-radius: 0.25rem;
cursor: pointer;
transition: filter 0.2s;
}
.btn-cancel:hover {
filter: brightness(0.9);
}
.link-button {
display: inline-block;
padding: 0.625rem 1.25rem;
background: var(--accent);
color: var(--text-on-color);
text-decoration: none;
border-radius: 0.25rem;
margin-top: 0.625rem;
border: none;
cursor: pointer;
transition: filter 0.2s;
}
.link-button:hover {
filter: brightness(0.9);
} }
.loading { .loading {
@ -452,26 +400,122 @@ details[open] > .integration-header::before {
line-height: 1.8; line-height: 1.8;
} }
.qr-code-container {
margin-top: 0.5rem;
text-align: center;
}
.qr-code {
height: 22rem;
width: 22rem;
border: 0.125rem solid var(--border-color);
border-radius: 0.5rem;
padding: 0.625rem;
background: var(--bg-secondary);
}
.channel-not-configured { .channel-not-configured {
background: var(--error); background: var(--error);
color: var(--text-on-color); color: var(--text-on-color);
} }
.text-muted { .auth-form {
color: var(--text-secondary); margin-top: 1rem;
font-size: 0.875rem; max-width: 400px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--text-primary);
}
.form-group input[type="text"],
.form-group input[type="email"],
.form-group input[type="password"] {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 1rem;
}
.btn-primary,
.btn-secondary,
.btn-danger {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: filter 0.2s ease;
}
.btn-primary:hover,
.btn-secondary:hover,
.btn-danger:hover {
filter: brightness(0.85);
}
.btn-primary:disabled,
.btn-secondary:disabled,
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
color: var(--text-on-color);
}
.btn-secondary {
background: var(--text-secondary);
color: var(--text-on-color);
}
.btn-danger {
background: var(--error);
color: var(--text-on-color);
}
.auth-status {
margin-top: 1rem;
padding: 0.75rem;
border-radius: 0.25rem;
display: none;
}
.auth-status.success {
display: block;
background: var(--success);
color: var(--text-on-color);
}
.auth-status.error {
display: block;
background: var(--error);
color: var(--text-on-color);
}
.auth-status.info {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--accent);
color: var(--text-on-color);
}
.qr-container {
margin-top: 1rem;
}
.qr-code-wrapper {
background: var(--text-on-color);
padding: 1rem;
text-align: center;
display: inline-block;
max-width: 100%;
border-radius: 0.5rem;
}
.qr-code-wrapper img {
width: 100%;
max-width: 25rem;
height: auto;
display: block;
} }

View file

@ -10,13 +10,14 @@
<link rel="stylesheet" href="/index.css?v={{.Version}}"> <link rel="stylesheet" href="/index.css?v={{.Version}}">
<script src="/htmx.min.js"></script> <script src="/htmx.min.js"></script>
<script src="/theme.js?v={{.Version}}"></script> <script src="/theme.js?v={{.Version}}"></script>
<script src="/integration.js?v={{.Version}}"></script>
</head> </head>
<body> <body>
<details class="card" open> <details class="card" open>
<summary class="card-header"> <summary class="card-header">
<span class="integration-name">Registered Applications</span> <span class="integration-name">Registered Applications</span>
</summary> </summary>
<div id="apps-list" <div id="apps-list" class="card-content"
hx-get="/fragment/apps" hx-get="/fragment/apps"
hx-trigger="load"> hx-trigger="load">
<div class="loading"> <div class="loading">

219
public/integration.js Normal file
View file

@ -0,0 +1,219 @@
document.addEventListener('submit', async (e) => {
if (e.target.id === 'telegram-auth-form') {
e.preventDefault();
const form = e.target;
const status = document.getElementById('telegram-auth-status');
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
try {
const formData = new FormData(form);
const response = await fetch('/api/telegram/auth', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
bot_token: formData.get('bot_token'),
chat_id: formData.get('chat_id'),
}),
});
if (response.ok) {
location.reload();
} else {
const error = await response.json();
status.textContent = `Error: ${error.error || 'Failed to save'}`;
status.className = 'auth-status error';
btn.disabled = false;
}
} catch (err) {
status.textContent = `Error: ${err.message}`;
status.className = 'auth-status error';
btn.disabled = false;
}
}
if (e.target.id === 'telegram-chatid-form') {
e.preventDefault();
const form = e.target;
const status = document.getElementById('telegram-chatid-status');
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
try {
const formData = new FormData(form);
const botToken = form.dataset.botToken;
const response = await fetch('/api/telegram/auth', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
bot_token: botToken,
chat_id: formData.get('chat_id'),
}),
});
if (response.ok) {
location.reload();
} else {
const error = await response.json();
status.textContent = `Error: ${error.error || 'Failed to save'}`;
status.className = 'auth-status error';
btn.disabled = false;
}
} catch (err) {
status.textContent = `Error: ${err.message}`;
status.className = 'auth-status error';
btn.disabled = false;
}
}
if (e.target.id === 'proton-auth-form') {
e.preventDefault();
const form = e.target;
const status = document.getElementById('proton-auth-status');
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
try {
const formData = new FormData(form);
const response = await fetch('/api/proton/auth', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: formData.get('email'),
password: formData.get('password'),
totp: formData.get('totp'),
}),
});
if (response.ok) {
location.reload();
} else {
const error = await response.json();
status.textContent = `Error: ${error.error || 'Authentication failed'}`;
status.className = 'auth-status error';
btn.disabled = false;
}
} catch (err) {
status.textContent = `Error: ${err.message}`;
status.className = 'auth-status error';
btn.disabled = false;
}
}
});
let signalLinkingPoll = null;
document.addEventListener('click', async (e) => {
if (e.target.id === 'signal-link-btn') {
const btn = e.target;
const qrContainer = document.getElementById('signal-qr-container');
const qrCode = document.getElementById('signal-qr-code');
btn.style.display = 'none';
qrContainer.style.display = 'none';
try {
const response = await fetch('/api/signal/link', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ device_name: 'Prism' }),
});
if (!response.ok) {
const error = await response.json();
qrContainer.innerHTML = `<p class="channel-not-configured">Error: ${error.error || 'Failed to generate link'}</p>`;
qrContainer.style.display = 'block';
btn.style.display = 'inline-block';
return;
}
const data = await response.json();
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(data.qr_code)}`;
qrCode.src = qrUrl;
qrContainer.style.display = 'block';
signalLinkingPoll = setInterval(async () => {
try {
const statusResp = await fetch('/api/signal/status');
const statusData = await statusResp.json();
if (statusData.linked) {
clearInterval(signalLinkingPoll);
qrContainer.innerHTML = '<p class="auth-status success">Linked! Refreshing...</p>';
setTimeout(() => location.reload(), 1000);
}
} catch (err) {
console.error('Status check failed:', err);
}
}, 2000);
} catch (err) {
qrContainer.innerHTML = `<p class="channel-not-configured">Error: ${err.message}</p>`;
qrContainer.style.display = 'block';
btn.style.display = 'inline-block';
}
}
if (e.target.classList.contains('reload-btn')) {
location.reload();
}
if (e.target.classList.contains('delete-telegram-btn')) {
e.preventDefault();
if (!confirm('Unlink Telegram integration?')) {
return;
}
const btn = e.target;
btn.disabled = true;
try {
const response = await fetch('/api/telegram/auth', {
method: 'DELETE',
});
if (response.ok) {
location.reload();
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Failed to unlink integration'}`);
btn.disabled = false;
}
} catch (err) {
alert(`Error: ${err.message}`);
btn.disabled = false;
}
}
if (e.target.classList.contains('delete-proton-btn')) {
if (!confirm('Unlink Proton Mail integration?')) {
return;
}
try {
const response = await fetch('/api/proton/auth', {
method: 'DELETE',
});
if (response.ok) {
location.reload();
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Failed to unlink integration'}`);
}
} catch (err) {
alert(`Error: ${err.message}`);
}
}
});

View file

@ -7,22 +7,14 @@ import (
) )
type Config struct { type Config struct {
TelegramChatID int64
Port int Port int
RateLimit int RateLimit int
APIKey string APIKey string
DeviceName string
PrismEndpointPrefix string
SignalSocket string
ProtonIMAPUsername string
ProtonIMAPPassword string
ProtonBridgeAddr string
TelegramBotToken string
StoragePath string StoragePath string
VerboseLogging bool VerboseLogging bool
EnableSignal bool EnableSignal bool
EnableProton bool
EnableTelegram bool EnableTelegram bool
EnableProton bool
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@ -31,25 +23,12 @@ func Load() (*Config, error) {
Port: getEnvInt("PORT", 8080), Port: getEnvInt("PORT", 8080),
VerboseLogging: getEnvBool("VERBOSE_LOGGING", false), VerboseLogging: getEnvBool("VERBOSE_LOGGING", false),
RateLimit: getEnvInt("RATE_LIMIT", 100), RateLimit: getEnvInt("RATE_LIMIT", 100),
DeviceName: getEnvString("DEVICE_NAME", "Prism"),
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"),
ProtonBridgeAddr: getEnvString("PROTON_BRIDGE_ADDR", "protonmail-bridge:143"),
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"), StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"),
EnableSignal: getEnvBool("ENABLE_SIGNAL", false),
EnableTelegram: getEnvBool("ENABLE_TELEGRAM", false),
EnableProton: getEnvBool("ENABLE_PROTON", false),
} }
cfg.PrismEndpointPrefix = fmt.Sprintf("[%s:", cfg.DeviceName)
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return nil, err return nil, err
} }
@ -64,18 +43,6 @@ func (c *Config) Validate() error {
return nil return nil
} }
func (c *Config) IsProtonEnabled() bool {
return c.EnableProton && c.ProtonIMAPUsername != "" && c.ProtonIMAPPassword != "" && c.ProtonBridgeAddr != ""
}
func (c *Config) IsSignalEnabled() bool {
return c.EnableSignal
}
func (c *Config) IsTelegramEnabled() bool {
return c.EnableTelegram
}
func getEnvString(key, defaultValue string) string { func getEnvString(key, defaultValue string) string {
if value := os.Getenv(key); value != "" { if value := os.Getenv(key); value != "" {
return value return value
@ -92,15 +59,6 @@ func getEnvInt(key string, defaultValue int) int {
return defaultValue return defaultValue
} }
func getEnvInt64(key string, defaultValue int64) int64 {
if value := os.Getenv(key); value != "" {
if i, err := strconv.ParseInt(value, 10, 64); err == nil {
return i
}
}
return defaultValue
}
func getEnvBool(key string, defaultValue bool) bool { func getEnvBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" { if value := os.Getenv(key); value != "" {
return value == "true" || value == "1" return value == "true" || value == "1"

View file

@ -0,0 +1,293 @@
package credentials
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"strings"
"golang.org/x/crypto/scrypt"
)
type IntegrationType string
const (
IntegrationSignal IntegrationType = "signal"
IntegrationProton IntegrationType = "proton"
IntegrationTelegram IntegrationType = "telegram"
)
type ProtonCredentials struct {
Email string `json:"email"`
Password string `json:"password,omitempty"`
UID string `json:"uid,omitempty"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
Scope string `json:"scope,omitempty"`
KeySalts map[string][]byte `json:"key_salts,omitempty"`
State *ProtonState `json:"state,omitempty"`
}
type ProtonState struct {
LastEventID string `json:"last_event_id"`
}
type TelegramCredentials struct {
BotToken string `json:"bot_token"`
ChatID string `json:"chat_id"`
}
type SignalCredentials struct {
Linked bool `json:"linked"`
PhoneNumber string `json:"phone_number"`
}
type Store struct {
db *sql.DB
encryptionKey []byte
logger *slog.Logger
}
func NewStore(db *sql.DB, masterPassword string) (*Store, error) {
return NewStoreWithLogger(db, masterPassword, slog.Default())
}
func NewStoreWithLogger(db *sql.DB, masterPassword string, logger *slog.Logger) (*Store, error) {
key, err := deriveKey(masterPassword)
if err != nil {
return nil, fmt.Errorf("failed to derive encryption key: %w", err)
}
store := &Store{
db: db,
encryptionKey: key,
logger: logger,
}
if err := store.createTable(); err != nil {
return nil, err
}
if err := store.CheckIntegrity(); err != nil {
if strings.Contains(err.Error(), "corrupted") {
logger.Warn("Credentials corrupted (API_KEY likely changed), clearing all integration credentials", "error", err)
if clearErr := store.ClearAll(); clearErr != nil {
logger.Error("Failed to clear corrupted credentials", "error", clearErr)
} else {
logger.Info("Cleared all integration credentials - please reconfigure integrations")
}
}
}
return store, nil
}
func (s *Store) createTable() error {
query := `
CREATE TABLE IF NOT EXISTS integration_credentials (
integration_type TEXT PRIMARY KEY,
credentials_encrypted BLOB NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`
_, err := s.db.Exec(query)
return err
}
func deriveKey(password string) ([]byte, error) {
salt := []byte("prism-integration-salt-v1")
return scrypt.Key([]byte(password), salt, 32768, 8, 1, 32)
}
func (s *Store) encrypt(plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(s.encryptionKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
func (s *Store) decrypt(ciphertext []byte) ([]byte, error) {
block, err := aes.NewCipher(s.encryptionKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(ciphertext) < gcm.NonceSize() {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
func (s *Store) SaveProton(creds *ProtonCredentials) error {
return s.saveCredentials(IntegrationProton, creds)
}
func (s *Store) GetProton() (*ProtonCredentials, error) {
var creds ProtonCredentials
err := s.getCredentials(IntegrationProton, &creds)
if err != nil {
return nil, err
}
return &creds, nil
}
func (s *Store) SaveTelegram(creds *TelegramCredentials) error {
return s.saveCredentials(IntegrationTelegram, creds)
}
func (s *Store) GetTelegram() (*TelegramCredentials, error) {
var creds TelegramCredentials
err := s.getCredentials(IntegrationTelegram, &creds)
if err != nil {
return nil, err
}
return &creds, nil
}
func (s *Store) SaveSignal(creds *SignalCredentials) error {
return s.saveCredentials(IntegrationSignal, creds)
}
func (s *Store) GetSignal() (*SignalCredentials, error) {
var creds SignalCredentials
err := s.getCredentials(IntegrationSignal, &creds)
if err != nil {
return nil, err
}
return &creds, nil
}
func (s *Store) saveCredentials(integrationType IntegrationType, credentials interface{}) error {
jsonData, err := json.Marshal(credentials)
if err != nil {
return fmt.Errorf("failed to marshal credentials: %w", err)
}
encrypted, err := s.encrypt(jsonData)
if err != nil {
return fmt.Errorf("failed to encrypt credentials: %w", err)
}
query := `
INSERT INTO integration_credentials (integration_type, credentials_encrypted, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(integration_type) DO UPDATE SET
credentials_encrypted = excluded.credentials_encrypted,
updated_at = CURRENT_TIMESTAMP
`
_, err = s.db.Exec(query, string(integrationType), encrypted)
return err
}
func (s *Store) getCredentials(integrationType IntegrationType, dest interface{}) error {
query := `
SELECT credentials_encrypted
FROM integration_credentials
WHERE integration_type = ? AND enabled = 1
`
var encrypted []byte
err := s.db.QueryRow(query, string(integrationType)).Scan(&encrypted)
if err == sql.ErrNoRows {
return fmt.Errorf("integration %s not configured", integrationType)
}
if err != nil {
return err
}
decrypted, err := s.decrypt(encrypted)
if err != nil {
return fmt.Errorf("failed to decrypt credentials: %w", err)
}
if err := json.Unmarshal(decrypted, dest); err != nil {
return fmt.Errorf("failed to unmarshal credentials: %w", err)
}
return nil
}
func (s *Store) DeleteIntegration(integrationType IntegrationType) error {
query := `DELETE FROM integration_credentials WHERE integration_type = ?`
_, err := s.db.Exec(query, string(integrationType))
return err
}
func (s *Store) SetEnabled(integrationType IntegrationType, enabled bool) error {
query := `UPDATE integration_credentials SET enabled = ? WHERE integration_type = ?`
_, err := s.db.Exec(query, enabled, string(integrationType))
return err
}
func (s *Store) IsEnabled(integrationType IntegrationType) (bool, error) {
query := `SELECT enabled FROM integration_credentials WHERE integration_type = ?`
var enabled bool
err := s.db.QueryRow(query, string(integrationType)).Scan(&enabled)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return enabled, nil
}
func (s *Store) ClearAll() error {
query := `DELETE FROM integration_credentials`
_, err := s.db.Exec(query)
return err
}
func (s *Store) CheckIntegrity() error {
query := `SELECT integration_type, credentials_encrypted FROM integration_credentials`
rows, err := s.db.Query(query)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var integrationType string
var encrypted []byte
if err := rows.Scan(&integrationType, &encrypted); err != nil {
return err
}
if _, err := s.decrypt(encrypted); err != nil {
return fmt.Errorf("credentials corrupted for %s (likely API_KEY changed): %w", integrationType, err)
}
}
return rows.Err()
}

View file

@ -0,0 +1,10 @@
package integration
import "embed"
//go:embed templates/*.html
var templates embed.FS
func GetTemplates() embed.FS {
return templates
}

View file

@ -2,6 +2,9 @@ package integration
import ( import (
"context" "context"
"database/sql"
"fmt"
"html/template"
"log/slog" "log/slog"
"net/http" "net/http"
@ -17,7 +20,7 @@ import (
) )
type Integration interface { type Integration interface {
RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger)
Start(ctx context.Context, logger *slog.Logger) Start(ctx context.Context, logger *slog.Logger)
IsEnabled() bool IsEnabled() bool
} }
@ -26,35 +29,72 @@ type Integrations struct {
Dispatcher *notification.Dispatcher Dispatcher *notification.Dispatcher
Signal *signal.Integration Signal *signal.Integration
Telegram *telegram.Integration Telegram *telegram.Integration
Proton *proton.Integration
integrations []Integration integrations []Integration
} }
func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmplRenderer *util.TemplateRenderer) *Integrations { func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logger, baseTmpl *template.Template) (*Integrations, *template.Template, error) {
signalIntegration := signal.NewIntegration(cfg, store, logger, tmplRenderer) fragmentTmpl := baseTmpl
telegramIntegration := telegram.NewIntegration(cfg, store, logger, tmplRenderer) var err error
fragmentTmpl, err = fragmentTmpl.ParseFS(GetTemplates(), "templates/*.html")
if err != nil {
return nil, nil, fmt.Errorf("failed to parse integration templates: %w", err)
}
fragmentTmpl, err = fragmentTmpl.ParseFS(signal.GetTemplates(), "templates/*.html")
if err != nil {
return nil, nil, fmt.Errorf("failed to parse signal templates: %w", err)
}
fragmentTmpl, err = fragmentTmpl.ParseFS(telegram.GetTemplates(), "templates/*.html")
if err != nil {
return nil, nil, fmt.Errorf("failed to parse telegram templates: %w", err)
}
fragmentTmpl, err = fragmentTmpl.ParseFS(proton.GetTemplates(), "templates/*.html")
if err != nil {
return nil, nil, fmt.Errorf("failed to parse proton templates: %w", err)
}
tmplRenderer := util.NewTemplateRenderer(fragmentTmpl)
var signalIntegration *signal.Integration
var telegramIntegration *telegram.Integration
dispatcher := notification.NewDispatcher(store, logger) dispatcher := notification.NewDispatcher(store, logger)
var integrations []Integration
var protonIntegration *proton.Integration
if cfg.EnableSignal {
signalIntegration = signal.NewIntegration(cfg, store, logger, tmplRenderer)
if signalSender := signalIntegration.GetSender(); signalSender != nil { if signalSender := signalIntegration.GetSender(); signalSender != nil {
dispatcher.RegisterSender(notification.ChannelSignal, signalSender) dispatcher.RegisterSender(notification.ChannelSignal, signalSender)
} }
integrations = append(integrations, signalIntegration)
}
if cfg.EnableTelegram {
telegramIntegration = telegram.NewIntegration(cfg, store, logger, tmplRenderer)
if telegramSender := telegramIntegration.GetSender(); telegramSender != nil { if telegramSender := telegramIntegration.GetSender(); telegramSender != nil {
dispatcher.RegisterSender(notification.ChannelTelegram, telegramSender) dispatcher.RegisterSender(notification.ChannelTelegram, telegramSender)
} }
dispatcher.RegisterSender(notification.ChannelWebPush, webpush.NewSender(logger)) integrations = append(integrations, telegramIntegration)
integrations := []Integration{
signalIntegration,
telegramIntegration,
proton.NewIntegration(cfg, dispatcher, logger, tmplRenderer),
webpush.NewIntegration(store, logger),
} }
if cfg.EnableProton {
protonIntegration = proton.NewIntegration(cfg, dispatcher, logger, tmplRenderer, store.GetDB(), cfg.APIKey)
integrations = append(integrations, protonIntegration)
}
dispatcher.RegisterSender(notification.ChannelWebPush, webpush.NewSender(logger))
integrations = append(integrations, webpush.NewIntegration(store, logger))
return &Integrations{ return &Integrations{
Dispatcher: dispatcher, Dispatcher: dispatcher,
Signal: signalIntegration, Signal: signalIntegration,
Telegram: telegramIntegration, Telegram: telegramIntegration,
Proton: protonIntegration,
integrations: integrations, integrations: integrations,
} }, fragmentTmpl, nil
} }
func (i *Integrations) Start(ctx context.Context, cfg *config.Config, logger *slog.Logger) { func (i *Integrations) Start(ctx context.Context, cfg *config.Config, logger *slog.Logger) {
@ -67,7 +107,8 @@ func (i *Integrations) Start(ctx context.Context, cfg *config.Config, logger *sl
func RegisterAll(integrations *Integrations, router *chi.Mux, cfg *config.Config, store *notification.Store, logger *slog.Logger, authMiddleware func(string) func(http.Handler) http.Handler) { 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) auth := authMiddleware(cfg.APIKey)
db := store.GetDB()
for _, integration := range integrations.integrations { for _, integration := range integrations.integrations {
integration.RegisterRoutes(router, auth) integration.RegisterRoutes(router, auth, db, cfg.APIKey, logger)
} }
} }

View file

@ -1,77 +0,0 @@
package proton
import (
"context"
"fmt"
"time"
"github.com/emersion/go-imap/v2/imapclient"
)
func (m *Monitor) connect() error {
addr := m.cfg.ProtonBridgeAddr
options := &imapclient.Options{
UnilateralDataHandler: &imapclient.UnilateralDataHandler{
Mailbox: func(data *imapclient.UnilateralDataMailbox) {
if data.NumMessages != nil {
select {
case m.newMessagesChan <- struct{}{}:
default:
m.logger.Warn("New messages channel full, skipping signal")
}
}
},
},
}
c, err := imapclient.DialInsecure(addr, options)
if err != nil {
return fmt.Errorf("failed to dial: %w", err)
}
if err := c.Login(m.cfg.ProtonIMAPUsername, m.cfg.ProtonIMAPPassword).Wait(); err != nil {
c.Close()
return fmt.Errorf("failed to login: %w", err)
}
m.client = c
m.logger.Info("Connected to Proton Bridge")
return nil
}
func (m *Monitor) monitor(ctx context.Context) error {
selectCmd := m.client.Select(imapInbox, nil)
_, err := selectCmd.Wait()
if err != nil {
return fmt.Errorf("failed to select inbox: %w", err)
}
if m.monitorStartTime.IsZero() {
m.monitorStartTime = time.Now()
m.logger.Info("Proton Mail monitor start time set", "time", m.monitorStartTime)
}
idleCmd, err := m.client.Idle()
if err != nil {
return fmt.Errorf("failed to start idle: %w", err)
}
for {
select {
case <-ctx.Done():
idleCmd.Close()
return ctx.Err()
case <-m.newMessagesChan:
idleCmd.Close()
m.sendNotification()
idleCmd, err = m.client.Idle()
if err != nil {
return fmt.Errorf("failed to restart idle: %w", err)
}
}
}
}

View file

@ -1,130 +0,0 @@
package proton
import (
"fmt"
"prism/service/notification"
"github.com/emersion/go-imap/v2"
)
func (m *Monitor) sendNotification() error {
selectData, err := m.client.Select(imapInbox, nil).Wait()
if err != nil {
m.logger.Error("Failed to select inbox", "error", err)
return err
}
if selectData.NumMessages == 0 {
m.logger.Warn("No messages in mailbox")
return nil
}
latestSeq := selectData.NumMessages
seqSet := imap.SeqSetNum(latestSeq)
fetchOptions := &imap.FetchOptions{
Envelope: true,
UID: true,
}
fetchCmd := m.client.Fetch(seqSet, fetchOptions)
defer fetchCmd.Close()
msg := fetchCmd.Next()
if msg == nil {
m.logger.Warn("No message found to send notification")
return nil
}
msgData, err := msg.Collect()
if err != nil {
m.logger.Error("Failed to collect message", "error", err)
return err
}
if msgData.Envelope == nil {
m.logger.Warn("Message has no envelope")
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]
if addr.Name != "" {
from = addr.Name
} else {
from = addr.Addr()
}
} else {
from = "Unknown sender"
}
subject := msgData.Envelope.Subject
if subject == "" {
subject = "No subject"
}
notif := notification.Notification{
Title: from,
Message: subject,
Actions: []notification.Action{
{
ID: "mark-read",
Endpoint: "/api/proton-mail/mark-read",
Method: "POST",
Data: map[string]any{
"uid": msgData.UID,
},
},
},
}
if err := m.dispatcher.Send(prismTopic, notif); err != nil {
m.logger.Error("Failed to send notification", "error", err)
return err
}
m.logger.Info("Sent ProtonMail notification", "from", from, "subject", subject, "uid", msgData.UID)
return nil
}
func (m *Monitor) MarkAsRead(uid uint32) error {
if m.client == nil {
return fmt.Errorf("not connected")
}
uidSet := imap.UIDSet{}
uidSet.AddNum(imap.UID(uid))
storeFlags := &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagSeen},
Silent: false,
}
storeCmd := m.client.Store(uidSet, storeFlags, nil)
for {
msg := storeCmd.Next()
if msg == nil {
break
}
}
if err := storeCmd.Close(); err != nil {
return fmt.Errorf("failed to mark message as read: %w", err)
}
m.logger.Info("Marked message as read", "uid", uid)
return nil
}

View file

@ -1,11 +1,13 @@
package proton package proton
import ( import (
"database/sql"
"encoding/json" "encoding/json"
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"prism/service/credentials"
"prism/service/util" "prism/service/util"
) )
@ -14,6 +16,8 @@ type Handlers struct {
username string username string
logger *slog.Logger logger *slog.Logger
tmpl *util.TemplateRenderer tmpl *util.TemplateRenderer
db *sql.DB
apiKey string
} }
type ProtonContentData struct { type ProtonContentData struct {
@ -36,31 +40,61 @@ func NewHandlers(monitor *Monitor, username string, logger *slog.Logger, tmpl *u
username: username, username: username,
logger: logger, logger: logger,
tmpl: tmpl, tmpl: tmpl,
db: nil,
apiKey: "",
} }
} }
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) { func (h *Handlers) SetDB(db *sql.DB, apiKey string) {
if h.monitor == nil { h.db = db
return h.apiKey = apiKey
}
func (h *Handlers) LoadFreshCredentials() (string, bool) {
if h.db == nil || h.apiKey == "" {
return "", false
} }
credStore, err := credentials.NewStore(h.db, h.apiKey)
if err != nil {
return "", false
}
creds, err := credStore.GetProton()
if err != nil || creds == nil {
return "", false
}
return creds.Email, true
}
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
email, hasCredentials := h.LoadFreshCredentials()
var contentData ProtonContentData var contentData ProtonContentData
var integData IntegrationData var integData IntegrationData
integData.Name = "Proton Mail" integData.Name = "Proton Mail"
if h.monitor.IsConnected() { if !hasCredentials {
integData.StatusClass = "connected"
integData.StatusText = "Linked"
integData.StatusTooltip = h.username
integData.Open = false
contentData.Connected = true
} else {
integData.StatusClass = "disconnected" integData.StatusClass = "disconnected"
integData.StatusText = "Unlinked" integData.StatusText = "Unlinked"
integData.StatusTooltip = "Enter credentials to link"
integData.Open = true integData.Open = true
contentData.Connected = false } else if h.monitor == nil || !h.monitor.IsConnected() {
integData.StatusClass = "disconnected"
integData.StatusText = "Connecting…"
integData.StatusTooltip = email
integData.Open = false
integData.PollAttrs = `hx-get="/fragment/integrations/proton" hx-trigger="every 2s"`
contentData.Connected = true
} else {
integData.StatusClass = "connected"
integData.StatusText = "Linked"
integData.StatusTooltip = email
integData.Open = false
contentData.Connected = true
} }
content, err := h.tmpl.RenderHTML("proton-content.html", contentData) content, err := h.tmpl.RenderHTML("proton-content.html", contentData)
@ -88,7 +122,7 @@ func (h *Handlers) GetMonitor() *Monitor {
} }
type markReadRequest struct { type markReadRequest struct {
UID uint32 `json:"uid"` UID string `json:"uid"`
} }
func (h *Handlers) HandleMarkRead(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
@ -98,7 +132,7 @@ func (h *Handlers) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.UID == 0 { if req.UID == "" {
util.JSONError(w, "uid (number) is required", http.StatusBadRequest) util.JSONError(w, "uid (number) is required", http.StatusBadRequest)
return return
} }
@ -121,3 +155,38 @@ func (h *Handlers) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
h.logger.Error("failed to encode response", "error", err) h.logger.Error("failed to encode response", "error", err)
} }
} }
type archiveRequest struct {
UID string `json:"uid"`
}
func (h *Handlers) HandleArchive(w http.ResponseWriter, r *http.Request) {
var req archiveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
util.JSONError(w, "invalid request body", http.StatusBadRequest)
return
}
if req.UID == "" {
util.JSONError(w, "uid is required", http.StatusBadRequest)
return
}
if h.monitor == nil {
util.JSONError(w, "Proton integration not enabled", http.StatusBadRequest)
return
}
if err := h.monitor.Archive(req.UID); err != nil {
h.logger.Error("failed to archive email", "uid", req.UID, "error", err)
util.JSONError(w, "failed to archive", http.StatusInternalServerError)
return
}
h.logger.Info("archived email", "uid", req.UID)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil {
h.logger.Error("failed to encode response", "error", err)
}
}

View file

@ -2,10 +2,12 @@ package proton
import ( import (
"context" "context"
"database/sql"
"log/slog" "log/slog"
"net/http" "net/http"
"prism/service/config" "prism/service/config"
"prism/service/credentials"
"prism/service/notification" "prism/service/notification"
"prism/service/util" "prism/service/util"
@ -18,35 +20,64 @@ type Integration struct {
logger *slog.Logger logger *slog.Logger
handlers *Handlers handlers *Handlers
tmpl *util.TemplateRenderer tmpl *util.TemplateRenderer
monitor *Monitor
db *sql.DB
apiKey string
} }
func NewIntegration(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration { func NewIntegration(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger, tmpl *util.TemplateRenderer, db *sql.DB, apiKey string) *Integration {
monitor := NewMonitor(cfg, dispatcher, logger)
handlers := NewHandlers(monitor, "", logger, tmpl)
return &Integration{ return &Integration{
cfg: cfg, cfg: cfg,
dispatcher: dispatcher, dispatcher: dispatcher,
logger: logger, logger: logger,
handlers: handlers,
tmpl: tmpl, tmpl: tmpl,
monitor: monitor,
db: db,
apiKey: apiKey,
} }
} }
func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) { func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
p.handlers = RegisterRoutes(router, p.cfg, p.dispatcher, p.logger, auth, p.tmpl) credStore, err := credentials.NewStore(db, apiKey)
if err == nil {
if creds, err := credStore.GetProton(); err == nil {
p.handlers.username = creds.Email
}
}
RegisterRoutes(router, p.handlers, auth, db, apiKey, logger, p)
} }
func (p *Integration) Start(ctx context.Context, logger *slog.Logger) { func (p *Integration) Start(ctx context.Context, logger *slog.Logger) {
if p.handlers != nil && p.handlers.IsEnabled() { credStore, err := credentials.NewStore(p.db, p.apiKey)
logger.Info("Proton Mail configured", "status", "connecting in background", "bridge", p.cfg.ProtonBridgeAddr) if err != nil {
go func() { logger.Error("Failed to initialize credentials store for Proton", "error", err)
monitor := p.handlers.GetMonitor() return
if err := monitor.Start(ctx); err != nil && ctx.Err() == nil {
logger.Error("Proton monitor error", "error", err)
} }
}()
creds, err := credStore.GetProton()
if err != nil {
logger.Debug("Proton credentials not configured", "error", err)
return
} }
if err := p.monitor.Start(ctx, credStore); err != nil {
logger.Error("Failed to start Proton monitor", "error", err)
return
}
if p.handlers != nil {
p.handlers.username = creds.Email
}
logger.Info("Proton Mail enabled", "email", creds.Email)
} }
func (p *Integration) IsEnabled() bool { func (p *Integration) IsEnabled() bool {
return p.cfg.IsProtonEnabled() return p.cfg.EnableProton
} }
func (p *Integration) GetHandlers() *Handlers { func (p *Integration) GetHandlers() *Handlers {

View file

@ -2,20 +2,19 @@ package proton
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"time" "time"
"prism/service/config" "prism/service/config"
"prism/service/credentials"
"prism/service/notification" "prism/service/notification"
"github.com/emersion/go-imap/v2/imapclient" "github.com/emersion/hydroxide/protonmail"
) )
const ( const (
imapInbox = "INBOX" pollInterval = 30 * time.Second
imapReconnectBaseDelay = 10 * time.Second
imapMaxReconnectDelay = 5 * time.Minute
imapMaxReconnectAttempts = 50
prismTopic = "Proton Mail" prismTopic = "Proton Mail"
) )
@ -23,9 +22,11 @@ type Monitor struct {
cfg *config.Config cfg *config.Config
dispatcher *notification.Dispatcher dispatcher *notification.Dispatcher
logger *slog.Logger logger *slog.Logger
client *imapclient.Client credStore *credentials.Store
monitorStartTime time.Time client *protonmail.Client
newMessagesChan chan struct{} eventID string
unseenMessageIDs map[string]time.Time
startTime time.Time
} }
func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Monitor { func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Monitor {
@ -33,44 +34,315 @@ func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger
cfg: cfg, cfg: cfg,
dispatcher: dispatcher, dispatcher: dispatcher,
logger: logger, logger: logger,
newMessagesChan: make(chan struct{}, 10),
} }
} }
func (m *Monitor) Start(ctx context.Context) error { func (m *Monitor) Start(ctx context.Context, credStore *credentials.Store) error {
if !m.cfg.IsProtonEnabled() { creds, err := credStore.GetProton()
if err != nil {
m.logger.Debug("Proton credentials not configured", "error", err)
return nil return nil
} }
m.logger.Info("Starting Proton Mail monitor") m.credStore = credStore
m.logger.Info("Starting Proton Mail monitor", "email", creds.Email)
c := &protonmail.Client{
RootURL: "https://mail.proton.me/api",
AppVersion: "Other",
}
var auth *protonmail.Auth
if creds.UID != "" && creds.AccessToken != "" && creds.RefreshToken != "" {
auth = &protonmail.Auth{
UID: creds.UID,
AccessToken: creds.AccessToken,
RefreshToken: creds.RefreshToken,
Scope: creds.Scope,
}
_, err = c.Unlock(auth, creds.KeySalts, creds.Password)
if err != nil {
m.logger.Error("Failed to unlock keys - password may have changed", "error", err)
if deleteErr := credStore.DeleteIntegration(credentials.IntegrationProton); deleteErr != nil {
m.logger.Error("Failed to clear invalid credentials", "error", deleteErr)
}
return fmt.Errorf("failed to unlock keys (password changed?): %v", err)
}
m.logger.Info("Restored Proton session from stored tokens")
} else if creds.Password != "" {
authInfo, err := c.AuthInfo(creds.Email)
if err != nil {
return err
}
authResult, err := c.Auth(creds.Email, creds.Password, authInfo)
if err != nil {
return err
}
auth = authResult
keySalts, err := c.ListKeySalts()
if err != nil {
return fmt.Errorf("failed to get key salts: %v", err)
}
_, err = c.Unlock(auth, keySalts, creds.Password)
if err != nil {
m.logger.Error("Failed to unlock keys", "error", err)
if deleteErr := credStore.DeleteIntegration(credentials.IntegrationProton); deleteErr != nil {
m.logger.Error("Failed to clear invalid credentials", "error", deleteErr)
}
return fmt.Errorf("failed to unlock keys: %v", err)
}
creds.KeySalts = keySalts
if err := credStore.SaveProton(creds); err != nil {
m.logger.Warn("Failed to cache key salts", "error", err)
}
m.logger.Info("Authenticated and unlocked Proton session")
} else {
return fmt.Errorf("no valid credentials found - need password or tokens")
}
c.ReAuth = func() error {
newAuth, err := c.AuthRefresh(auth)
if err != nil {
m.logger.Error("Token refresh failed", "error", err)
return err
}
_, err = c.Unlock(newAuth, creds.KeySalts, creds.Password)
if err != nil {
m.logger.Error("Token refresh failed - cannot unlock keys", "error", err)
return err
}
auth = newAuth
updatedCreds, err := m.credStore.GetProton()
if err != nil {
m.logger.Warn("Failed to get credentials for token update", "error", err)
return nil
}
updatedCreds.UID = newAuth.UID
updatedCreds.AccessToken = newAuth.AccessToken
updatedCreds.RefreshToken = newAuth.RefreshToken
updatedCreds.Scope = newAuth.Scope
if err := m.credStore.SaveProton(updatedCreds); err != nil {
m.logger.Warn("Failed to save refreshed tokens", "error", err)
} else {
m.logger.Info("Proton tokens refreshed and saved")
}
return nil
}
m.client = c
m.startTime = time.Now()
if creds.State != nil {
m.eventID = creds.State.LastEventID
m.logger.Info("Restored Proton state", "eventID", m.eventID)
} else {
m.eventID = auth.EventID
if err := m.saveState(creds); err != nil {
m.logger.Warn("Failed to save initial state", "error", err)
}
m.logger.Info("Initialized Proton state", "eventID", m.eventID)
}
m.unseenMessageIDs = make(map[string]time.Time)
go m.pollEvents(ctx)
return nil
}
func (m *Monitor) pollEvents(ctx context.Context) {
ticker := time.NewTicker(pollInterval)
cleanupTicker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
defer cleanupTicker.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return
default: case <-ticker.C:
if err := m.connect(); err != nil { if err := m.checkEvents(); err != nil {
m.logger.Error("Failed to connect to IMAP", "error", err) m.logger.Error("Failed to check events", "error", err)
time.Sleep(imapReconnectBaseDelay) }
continue case <-cleanupTicker.C:
m.cleanupOldMessages()
}
}
}
func (m *Monitor) checkEvents() error {
if m.client == nil {
return nil
} }
if err := m.monitor(ctx); err != nil { event, err := m.client.GetEvent(m.eventID)
m.logger.Error("Monitor error", "error", err) if err != nil {
return err
} }
if m.client != nil { if event.ID != m.eventID {
if err := m.client.Logout(); err != nil { m.processMessageEvents(event.Messages)
m.logger.Error("Logout failed", "error", err) m.eventID = event.ID
creds, err := m.credStore.GetProton()
if err != nil {
return err
}
if err := m.saveState(creds); err != nil {
m.logger.Warn("Failed to save state", "error", err)
} }
m.client = nil
} }
time.Sleep(imapReconnectBaseDelay) return nil
}
func (m *Monitor) processMessageEvents(events []*protonmail.EventMessage) {
for _, evt := range events {
switch evt.Action {
case protonmail.EventCreate:
if evt.Created != nil {
msg := evt.Created
if msg.Unread == 1 && hasLabel(msg, protonmail.LabelInbox) && msg.Time.Time().After(m.startTime) {
if _, seen := m.unseenMessageIDs[msg.ID]; !seen {
m.unseenMessageIDs[msg.ID] = time.Now()
m.sendNotification(msg)
} }
} }
}
case protonmail.EventUpdate, protonmail.EventUpdateFlags:
if evt.Updated != nil && evt.Updated.Unread != nil && *evt.Updated.Unread == 0 {
if _, wasSent := m.unseenMessageIDs[evt.ID]; wasSent {
m.clearNotification(evt.ID)
}
delete(m.unseenMessageIDs, evt.ID)
}
case protonmail.EventDelete:
if _, wasSent := m.unseenMessageIDs[evt.ID]; wasSent {
m.clearNotification(evt.ID)
}
delete(m.unseenMessageIDs, evt.ID)
}
}
}
func (m *Monitor) cleanupOldMessages() {
cutoff := time.Now().Add(-24 * time.Hour)
for msgID, notifiedAt := range m.unseenMessageIDs {
if notifiedAt.Before(cutoff) {
delete(m.unseenMessageIDs, msgID)
}
}
if len(m.unseenMessageIDs) > 0 {
m.logger.Debug("Cleaned up old message IDs", "remaining", len(m.unseenMessageIDs))
}
}
func (m *Monitor) saveState(creds *credentials.ProtonCredentials) error {
creds.State = &credentials.ProtonState{
LastEventID: m.eventID,
}
return m.credStore.SaveProton(creds)
}
func hasLabel(msg *protonmail.Message, labelID string) bool {
for _, id := range msg.LabelIDs {
if id == labelID {
return true
}
}
return false
}
func (m *Monitor) sendNotification(msg *protonmail.Message) {
from := "Unknown"
if msg.Sender != nil {
if msg.Sender.Name != "" {
from = msg.Sender.Name
} else {
from = msg.Sender.Address
}
}
subject := msg.Subject
if subject == "" {
subject = "(No subject)"
}
notif := notification.Notification{
Title: from,
Message: subject,
Tag: "proton-" + msg.ID,
Actions: []notification.Action{
{
ID: "archive",
Label: "Archive",
Endpoint: "/api/proton/archive",
Method: "POST",
Data: map[string]any{
"uid": msg.ID,
},
},
{
ID: "mark-read",
Label: "Mark as Read",
Endpoint: "/api/proton/mark-read",
Method: "POST",
Data: map[string]any{
"uid": msg.ID,
},
},
},
}
if err := m.dispatcher.Send(prismTopic, notif); err != nil {
m.logger.Error("Failed to send notification", "error", err)
} else {
m.logger.Info("Sent notification", "from", from, "subject", subject, "msgID", msg.ID)
}
}
func (m *Monitor) clearNotification(msgID string) {
notif := notification.Notification{
Tag: "proton-" + msgID,
Title: "",
Message: "",
}
if err := m.dispatcher.Send(prismTopic, notif); err != nil {
m.logger.Error("Failed to clear notification", "error", err, "msgID", msgID)
} else {
m.logger.Debug("Cleared notification", "msgID", msgID)
}
} }
func (m *Monitor) IsConnected() bool { func (m *Monitor) IsConnected() bool {
return m.client != nil return m.client != nil
} }
func (m *Monitor) MarkAsRead(msgID string) error {
if m.client == nil {
return nil
}
return m.client.MarkMessagesRead([]string{msgID})
}
func (m *Monitor) Archive(msgID string) error {
if m.client == nil {
return nil
}
return m.client.UnlabelMessages(protonmail.LabelInbox, []string{msgID})
}

View file

@ -1,14 +1,18 @@
package proton package proton
import ( import (
"context"
"database/sql"
"embed" "embed"
"encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"prism/service/config" "prism/service/credentials"
"prism/service/notification" "prism/service/notification"
"prism/service/util" "prism/service/util"
"github.com/emersion/hydroxide/protonmail"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@ -19,20 +23,164 @@ func GetTemplates() embed.FS {
return templates return templates
} }
func RegisterRoutes(router *chi.Mux, cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler, tmpl *util.TemplateRenderer) *Handlers { type authHandler struct {
if !cfg.IsProtonEnabled() { db *sql.DB
return nil apiKey string
logger *slog.Logger
integration *Integration
dispatcher *notification.Dispatcher
}
type protonAuthRequest struct {
Email string `json:"email"`
Password string `json:"password"`
TOTP string `json:"totp"`
}
func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
var req protonAuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
util.JSONError(w, "Invalid request body", http.StatusBadRequest)
return
} }
monitor := NewMonitor(cfg, dispatcher, logger) if req.Email == "" || req.Password == "" {
handlers := NewHandlers(monitor, cfg.ProtonIMAPUsername, logger, tmpl) util.JSONError(w, "email and password are required", http.StatusBadRequest)
return
}
router.With(authMiddleware).Get("/fragment/proton", handlers.HandleFragment) c := &protonmail.Client{
RootURL: "https://mail.proton.me/api",
AppVersion: "Other",
}
authInfo, err := c.AuthInfo(req.Email)
if err != nil {
h.logger.Error("Failed to get auth info", "error", err)
util.JSONError(w, "Failed to get auth info", http.StatusInternalServerError)
return
}
router.Route("/api/proton-mail", func(r chi.Router) { auth, err := c.Auth(req.Email, req.Password, authInfo)
r.Use(authMiddleware) if err != nil {
r.Post("/mark-read", handlers.HandleMarkRead) h.logger.Error("Authentication failed", "error", err)
}) util.JSONError(w, "Incorrect login credentials. Please try again", http.StatusBadRequest)
return
}
return handlers if auth.TwoFactor.Enabled != 0 {
if req.TOTP == "" {
util.JSONError(w, "2FA enabled: TOTP code required", http.StatusBadRequest)
return
}
if auth.TwoFactor.TOTP != 1 {
util.JSONError(w, "Only TOTP is supported as a 2FA method", http.StatusBadRequest)
return
}
scope, err := c.AuthTOTP(req.TOTP)
if err != nil {
h.logger.Error("TOTP verification failed", "error", err)
util.JSONError(w, "Invalid 2FA code. Please try again", http.StatusBadRequest)
return
}
auth.Scope = scope
}
credStore, err := credentials.NewStore(h.db, h.apiKey)
if err != nil {
h.logger.Error("Failed to initialize credentials store", "error", err)
util.JSONError(w, "Internal server error", http.StatusInternalServerError)
return
}
keySalts, err := c.ListKeySalts()
if err != nil {
h.logger.Error("Failed to get key salts", "error", err)
util.JSONError(w, "Failed to complete authentication", http.StatusInternalServerError)
return
}
creds := &credentials.ProtonCredentials{
Email: req.Email,
Password: req.Password,
UID: auth.UID,
AccessToken: auth.AccessToken,
RefreshToken: auth.RefreshToken,
Scope: auth.Scope,
KeySalts: keySalts,
}
if err := credStore.SaveProton(creds); err != nil {
h.logger.Error("Failed to save Proton credentials", "error", err)
util.JSONError(w, "Failed to save credentials", http.StatusInternalServerError)
return
}
if h.dispatcher != nil {
if err := h.dispatcher.RegisterApp(prismTopic); err != nil {
h.logger.Warn("Failed to auto-create Proton Mail app mapping", "error", err)
} else {
h.logger.Info("Created Proton Mail app mapping")
}
}
if h.integration != nil {
go func() {
ctx := context.Background()
if err := h.integration.monitor.Start(ctx, credStore); err != nil {
h.logger.Error("Failed to start Proton monitor after auth", "error", err)
} else {
h.logger.Info("Proton monitor started after authentication")
if h.integration.handlers != nil {
h.integration.handlers.username = req.Email
}
}
}()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
credStore, err := credentials.NewStore(h.db, h.apiKey)
if err != nil {
h.logger.Error("Failed to initialize credentials store", "error", err)
util.JSONError(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := credStore.DeleteIntegration(credentials.IntegrationProton); err != nil {
h.logger.Error("Failed to delete integration", "error", err)
util.JSONError(w, "Failed to delete integration", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
}
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger, integration *Integration) {
if handlers == nil {
return
}
handlers.SetDB(db, apiKey)
authH := &authHandler{
db: db,
apiKey: apiKey,
logger: logger,
integration: integration,
dispatcher: integration.dispatcher,
}
router.With(auth).Get("/fragment/proton", handlers.HandleFragment)
router.Route("/api/proton", func(r chi.Router) {
r.Use(auth)
r.Post("/mark-read", handlers.HandleMarkRead)
r.Post("/archive", handlers.HandleArchive)
r.Post("/auth", authH.handleAuth)
r.Delete("/auth", authH.handleDelete)
})
} }

View file

@ -1,17 +1,22 @@
{{if .Connected}} {{if .Connected}}
<p><strong>Unlink Instructions:</strong></p> <button class="btn-danger delete-proton-btn">Unlink</button>
<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>
{{else}} {{else}}
<p><strong>Setup Instructions:</strong></p> <p><strong>Link Proton Account:</strong></p>
<ol class="link-instructions"> <form id="proton-auth-form" class="auth-form">
<li>Run: <code>docker compose run --rm protonmail-bridge init</code></li> <div class="form-group">
<li>At the prompt, use: <code>login</code> and enter your Proton Mail credentials</li> <label for="proton-email">Email:</label>
<li>Run: <code>info</code> to get your IMAP username and password</li> <input type="email" id="proton-email" name="email" placeholder="your-email@proton.me" required>
<li>Add credentials to <code>.env</code> and restart</li> </div>
</ol> <div class="form-group">
<p class="text-muted">See <a href="https://github.com/lone-cloud/prism#proton-mail" target="_blank">full setup guide</a></p> <label for="proton-password">Password:</label>
<input type="password" id="proton-password" name="password" placeholder="Your Proton password" required>
</div>
<div class="form-group">
<label for="proton-totp">2FA Code (optional):</label>
<input type="text" id="proton-totp" name="totp" placeholder="123456" pattern="[0-9]{6}">
</div>
<button type="submit" class="btn-primary">Link</button>
<div id="proton-auth-status" class="auth-status"></div>
</form>
{{end}} {{end}}

View file

@ -1,114 +1,148 @@
package signal package signal
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net" "io"
"sync/atomic" "os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
) )
var rpcID int32 const (
DefaultDeviceName = "Prism"
DefaultConfigPath = ".local/share/signal-cli"
)
type Client struct { type Client struct {
SocketPath string ConfigPath string
enabled bool
accountCache *Account
accountCacheTime time.Time
accountCacheMu sync.RWMutex
} }
func NewClient(socketPath string) *Client { func NewClient() *Client {
configPath := filepath.Join(os.Getenv("HOME"), DefaultConfigPath)
enabled := false
if _, err := exec.LookPath("signal-cli"); err == nil {
enabled = true
}
return &Client{ return &Client{
SocketPath: socketPath, ConfigPath: configPath,
enabled: enabled,
} }
} }
type RPCRequest struct { func (c *Client) IsEnabled() bool {
JSONRPC string `json:"jsonrpc"` return c.enabled
ID int32 `json:"id"`
Method string `json:"method"`
Params map[string]any `json:"params"`
}
type RPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int32 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (c *Client) Call(method string, params map[string]any) (json.RawMessage, error) {
conn, err := net.Dial("unix", c.SocketPath)
if err != nil {
return nil, fmt.Errorf("failed to connect: %w", err)
}
defer conn.Close()
request := RPCRequest{
JSONRPC: "2.0",
ID: atomic.AddInt32(&rpcID, 1),
Method: method,
Params: params,
}
if err := json.NewEncoder(conn).Encode(&request); err != nil {
return nil, fmt.Errorf("failed to encode request: %w", err)
}
var response RPCResponse
if err := json.NewDecoder(conn).Decode(&response); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if response.Error != nil {
return nil, fmt.Errorf("signal-cli error: %s", response.Error.Message)
}
return response.Result, nil
}
func (c *Client) CallWithAccount(method string, params map[string]any, account string) (json.RawMessage, error) {
if params == nil {
params = make(map[string]any)
}
params["account"] = account
return c.Call(method, params)
}
type AccountInfo struct {
Number string `json:"number"`
Name string `json:"name,omitempty"`
} }
type Account struct { type Account struct {
Number string Number string
UUID string
}
func (c *Client) exec(args ...string) ([]byte, error) {
if !c.enabled {
return nil, fmt.Errorf("signal-cli not found in PATH")
}
baseArgs := []string{"--config", c.ConfigPath, "--output=json"}
cmd := exec.Command("signal-cli", append(baseArgs, args...)...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if stderr.Len() > 0 {
return nil, fmt.Errorf("signal-cli: %s", strings.TrimSpace(stderr.String()))
}
return nil, err
}
return stdout.Bytes(), nil
} }
func (c *Client) GetLinkedAccount() (*Account, error) { func (c *Client) GetLinkedAccount() (*Account, error) {
if c == nil { if c == nil || !c.enabled {
return nil, nil return nil, nil
} }
result, err := c.Call("listAccounts", nil) c.accountCacheMu.RLock()
if time.Since(c.accountCacheTime) < 30*time.Second {
cached := c.accountCache
c.accountCacheMu.RUnlock()
return cached, nil
}
c.accountCacheMu.RUnlock()
c.accountCacheMu.Lock()
defer c.accountCacheMu.Unlock()
if time.Since(c.accountCacheTime) < 30*time.Second {
return c.accountCache, nil
}
accountsFile := filepath.Join(c.ConfigPath, "data", "accounts.json")
if _, err := os.Stat(accountsFile); os.IsNotExist(err) {
c.accountCache = nil
c.accountCacheTime = time.Now()
return nil, nil
}
data, err := os.ReadFile(accountsFile)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var accounts []AccountInfo var accountsData struct {
if err := json.Unmarshal(result, &accounts); err != nil { Accounts []struct {
Number string `json:"number"`
UUID string `json:"uuid"`
} `json:"accounts"`
}
if err := json.Unmarshal(data, &accountsData); err != nil {
return nil, err return nil, err
} }
if len(accounts) == 0 { if len(accountsData.Accounts) == 0 {
c.accountCache = nil
c.accountCacheTime = time.Now()
return nil, nil return nil, nil
} }
return &Account{Number: accounts[0].Number}, nil account := &Account{
Number: accountsData.Accounts[0].Number,
UUID: accountsData.Accounts[0].UUID,
}
cmd := exec.Command("signal-cli", "-a", account.Number, "receive", "--timeout", "0")
output, err := cmd.CombinedOutput()
if err != nil {
errStr := strings.ToLower(string(output))
if strings.Contains(errStr, "not registered") || strings.Contains(errStr, "authorization failed") {
c.accountCache = nil
c.accountCacheTime = time.Now()
return nil, nil
}
}
c.accountCache = account
c.accountCacheTime = time.Now()
return account, nil
} }
func (c *Client) CreateGroup(name string) (string, string, error) { func (c *Client) CreateGroup(name string) (string, string, error) {
if c == nil { if c == nil || !c.enabled {
return "", "", fmt.Errorf("signal client not initialized") return "", "", fmt.Errorf("signal client not initialized")
} }
@ -120,49 +154,157 @@ func (c *Client) CreateGroup(name string) (string, string, error) {
return "", "", fmt.Errorf("no linked Signal account") return "", "", fmt.Errorf("no linked Signal account")
} }
params := map[string]any{ output, err := c.exec("-o", "json", "-a", account.Number, "updateGroup", "-n", name)
"name": name,
"member": []string{},
}
result, err := c.CallWithAccount("updateGroup", params, account.Number)
if err != nil { if err != nil {
return "", "", err return "", "", fmt.Errorf("failed to create group: %w", err)
} }
var response struct { var response struct {
GroupID string `json:"groupId"` 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 == "" { lines := bytes.Split(output, []byte("\n"))
return "", "", fmt.Errorf("empty groupId in response") for _, line := range lines {
if len(line) == 0 {
continue
} }
if err := json.Unmarshal(line, &response); err == nil && response.GroupID != "" {
return response.GroupID, account.Number, nil return response.GroupID, account.Number, nil
}
}
return "", "", fmt.Errorf("failed to parse group creation response")
} }
func (c *Client) SendGroupMessage(groupID, message string) error { func (c *Client) SendGroupMessage(groupID, message string) error {
if c == nil { if c == nil || !c.enabled {
return fmt.Errorf("signal client not initialized") return fmt.Errorf("signal client not initialized")
} }
account, err := c.GetLinkedAccount() account, err := c.GetLinkedAccount()
if err != nil { if err != nil {
return fmt.Errorf("failed to get account: %w", err) return fmt.Errorf("failed to get linked account: %w", err)
} }
if account == nil { if account == nil {
return fmt.Errorf("no linked account") return fmt.Errorf("no linked Signal account")
} }
params := map[string]any{ _, err = c.exec("-a", account.Number, "send", "-g", groupID, "--notify-self", "-m", message)
"groupId": groupID,
"message": message,
"notifySelf": true,
}
_, err = c.CallWithAccount("send", params, account.Number)
return err return err
} }
func (c *Client) LinkDevice(deviceName string) (string, error) {
if c == nil || !c.enabled {
return "", fmt.Errorf("signal-cli not found in PATH")
}
if deviceName == "" {
deviceName = DefaultDeviceName
}
if err := os.MkdirAll(c.ConfigPath, 0755); err != nil {
return "", fmt.Errorf("failed to create config directory: %w", err)
}
dataDir := filepath.Join(c.ConfigPath, "data")
if entries, err := os.ReadDir(c.ConfigPath); err == nil {
for _, entry := range entries {
if entry.IsDir() && strings.HasPrefix(entry.Name(), "+") {
accountDir := filepath.Join(c.ConfigPath, entry.Name())
os.RemoveAll(accountDir)
}
}
}
os.RemoveAll(dataDir)
cmd := exec.Command("signal-cli", "--config", c.ConfigPath, "link", "-n", deviceName)
stdout, err := cmd.StdoutPipe()
if err != nil {
return "", fmt.Errorf("failed to create stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return "", fmt.Errorf("failed to create stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("failed to start link command: %w", err)
}
var output bytes.Buffer
buf := make([]byte, 8192)
done := make(chan string, 1)
errChan := make(chan error, 1)
go func() {
for {
n, err := stdout.Read(buf)
if n > 0 {
output.Write(buf[:n])
text := output.String()
if idx := strings.Index(text, "sgnl://linkdevice"); idx != -1 {
end := strings.IndexAny(text[idx:], " \n\r\t")
var url string
if end == -1 {
url = text[idx:]
} else {
url = text[idx : idx+end]
}
done <- strings.TrimSpace(url)
return
}
}
if err != nil {
if err != io.EOF {
errChan <- err
}
return
}
}
}()
go func() {
for {
n, err := stderr.Read(buf)
if n > 0 {
output.Write(buf[:n])
text := output.String()
if idx := strings.Index(text, "sgnl://linkdevice"); idx != -1 {
end := strings.IndexAny(text[idx:], " \n\r\t")
var url string
if end == -1 {
url = text[idx:]
} else {
url = text[idx : idx+end]
}
done <- strings.TrimSpace(url)
return
}
}
if err != nil {
return
}
}
}()
select {
case url := <-done:
go func() {
io.Copy(io.Discard, stdout)
io.Copy(io.Discard, stderr)
cmd.Wait()
}()
return url, nil
case err := <-errChan:
cmd.Process.Kill()
return "", err
case <-time.After(5 * time.Minute):
cmd.Process.Kill()
return "", fmt.Errorf("timeout waiting for QR code URL")
}
}

View file

@ -1,6 +1,7 @@
package signal package signal
import ( import (
"encoding/json"
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
@ -10,7 +11,6 @@ import (
type Handlers struct { type Handlers struct {
client *Client client *Client
linkDevice *LinkDevice
tmpl *util.TemplateRenderer tmpl *util.TemplateRenderer
logger *slog.Logger logger *slog.Logger
} }
@ -32,49 +32,47 @@ type IntegrationData struct {
PollAttrs string PollAttrs string
} }
func NewHandlers(client *Client, linkDevice *LinkDevice, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers { func NewHandlers(client *Client, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers {
return &Handlers{ return &Handlers{
client: client, client: client,
linkDevice: linkDevice,
tmpl: tmpl, tmpl: tmpl,
logger: logger, logger: logger,
} }
} }
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
if h.client == nil {
return
}
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
account, _ := h.client.GetLinkedAccount()
var contentData SignalContentData var contentData SignalContentData
var integData IntegrationData var integData IntegrationData
integData.Name = "Signal" integData.Name = "Signal"
if account != nil { if h.client == nil || !h.client.IsEnabled() {
integData.StatusClass = "disconnected"
integData.StatusText = "Not Available"
integData.StatusTooltip = "signal-cli not found in PATH"
integData.Open = true
contentData.Error = "signal-cli not found. Download from: https://github.com/AsamK/signal-cli/releases"
} else {
account, err := h.client.GetLinkedAccount()
if err != nil {
integData.StatusClass = "disconnected"
integData.StatusText = "Error"
integData.StatusTooltip = err.Error()
integData.Open = true
contentData.Error = err.Error()
} else if account == nil {
integData.StatusClass = "disconnected"
integData.StatusText = "Unlinked"
integData.StatusTooltip = "Click to link device with Signal"
integData.Open = true
} else {
integData.StatusClass = "connected" integData.StatusClass = "connected"
integData.StatusText = "Linked" integData.StatusText = "Linked"
integData.StatusTooltip = FormatPhoneNumber(account.Number) integData.StatusTooltip = FormatPhoneNumber(account.Number)
integData.Open = false integData.Open = false
integData.PollAttrs = ""
contentData.Linked = true contentData.Linked = true
contentData.DeviceName = h.linkDevice.deviceName contentData.DeviceName = FormatPhoneNumber(account.Number)
} else {
integData.StatusClass = "unlinked"
integData.StatusText = "Unlinked"
integData.Open = true
integData.PollAttrs = ""
contentData.Linked = false
qrCode, err := h.linkDevice.GenerateQR()
if err != nil {
contentData.Error = err.Error()
} else {
contentData.QRCode = qrCode
} }
} }
@ -95,9 +93,64 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handlers) IsEnabled() bool { func (h *Handlers) IsEnabled() bool {
return h.client != nil return h.client != nil && h.client.IsEnabled()
} }
func (h *Handlers) GetClient() *Client { func (h *Handlers) GetClient() *Client {
return h.client return h.client
} }
func (h *Handlers) HandleLinkDevice(w http.ResponseWriter, r *http.Request) {
if h.client == nil || !h.client.IsEnabled() {
util.JSONError(w, "signal-cli not available", http.StatusServiceUnavailable)
return
}
var req struct {
DeviceName string `json:"device_name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
req.DeviceName = DefaultDeviceName
}
qrCode, err := h.client.LinkDevice(req.DeviceName)
if err != nil {
h.logger.Error("Failed to generate link code", "error", err)
util.JSONError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"qr_code": qrCode,
"status": "linking",
})
}
func (h *Handlers) HandleLinkStatus(w http.ResponseWriter, r *http.Request) {
if h.client == nil || !h.client.IsEnabled() {
util.JSONError(w, "signal-cli not available", http.StatusServiceUnavailable)
return
}
account, err := h.client.GetLinkedAccount()
if err != nil {
h.logger.Error("Failed to get linked account", "error", err)
util.JSONError(w, err.Error(), http.StatusInternalServerError)
return
}
h.logger.Debug("Link status check", "linked", account != nil, "account", account)
w.Header().Set("Content-Type", "application/json")
if account != nil {
json.NewEncoder(w).Encode(map[string]interface{}{
"linked": true,
"phone_number": account.Number,
})
} else {
json.NewEncoder(w).Encode(map[string]interface{}{
"linked": false,
})
}
}

View file

@ -2,6 +2,7 @@ package signal
import ( import (
"context" "context"
"database/sql"
"log/slog" "log/slog"
"net/http" "net/http"
@ -14,6 +15,7 @@ import (
type Integration struct { type Integration struct {
cfg *config.Config cfg *config.Config
client *Client
handlers *Handlers handlers *Handlers
sender *Sender sender *Sender
tmpl *util.TemplateRenderer tmpl *util.TemplateRenderer
@ -21,13 +23,11 @@ type Integration struct {
} }
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration { func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
var sender *Sender client := NewClient()
if cfg.IsSignalEnabled() { sender := NewSender(client, store, logger)
client := NewClient(cfg.SignalSocket)
sender = NewSender(client, store, logger)
}
return &Integration{ return &Integration{
cfg: cfg, cfg: cfg,
client: client,
sender: sender, sender: sender,
tmpl: tmpl, tmpl: tmpl,
logger: logger, logger: logger,
@ -38,8 +38,8 @@ func (s *Integration) GetSender() *Sender {
return s.sender return s.sender
} }
func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) { func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
s.handlers = RegisterRoutes(router, s.cfg, auth, s.tmpl, s.logger) s.handlers = RegisterRoutes(router, s.cfg, auth, s.tmpl, s.logger, s.client)
} }
func (s *Integration) Start(ctx context.Context, logger *slog.Logger) { func (s *Integration) Start(ctx context.Context, logger *slog.Logger) {
@ -51,11 +51,16 @@ func (s *Integration) Start(ctx context.Context, logger *slog.Logger) {
} else { } else {
logger.Info("Signal enabled", "status", "unlinked", "action", "visit admin UI to link") logger.Info("Signal enabled", "status", "unlinked", "action", "visit admin UI to link")
} }
} else {
logger.Info("Signal disabled", "reason", "signal-cli not found in PATH")
} }
} }
func (s *Integration) IsEnabled() bool { func (s *Integration) IsEnabled() bool {
return s.cfg.IsSignalEnabled() if s.handlers == nil {
return false
}
return s.handlers.IsEnabled()
} }
func (s *Integration) GetHandlers() *Handlers { func (s *Integration) GetHandlers() *Handlers {

View file

@ -1,75 +0,0 @@
package signal
import (
"encoding/json"
"fmt"
"net/url"
"time"
)
type LinkDevice struct {
client *Client
deviceName string
qrCode string
deviceLinkUri string
generatedAt time.Time
ttl time.Duration
}
func NewLinkDevice(client *Client, deviceName string) *LinkDevice {
return &LinkDevice{
client: client,
deviceName: deviceName,
ttl: 10 * time.Minute,
}
}
type StartLinkResponse struct {
DeviceLinkURI string `json:"deviceLinkUri"`
}
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
}
result, err := l.client.Call("startLink", nil)
if err != nil {
return "", fmt.Errorf("failed to start link: %w", err)
}
var response StartLinkResponse
if err := json.Unmarshal(result, &response); err != nil {
return "", fmt.Errorf("failed to parse link response: %w", err)
}
uri := response.DeviceLinkURI
if uri == "" {
return "", fmt.Errorf("empty device link URI")
}
l.deviceLinkUri = uri
qrURL := fmt.Sprintf("https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=%s", url.QueryEscape(uri))
l.qrCode = qrURL
l.generatedAt = time.Now()
go l.finishLink()
return l.qrCode, nil
}
func (l *LinkDevice) finishLink() {
params := map[string]any{
"deviceLinkUri": l.deviceLinkUri,
"deviceName": l.deviceName,
}
_, err := l.client.Call("finishLink", params)
if err == nil {
l.qrCode = ""
l.generatedAt = time.Time{}
}
}

View file

@ -18,16 +18,12 @@ func GetTemplates() embed.FS {
return templates return templates
} }
func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers { func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler, tmpl *util.TemplateRenderer, logger *slog.Logger, client *Client) *Handlers {
if !cfg.IsSignalEnabled() { handlers := NewHandlers(client, tmpl, logger)
return nil
}
client := NewClient(cfg.SignalSocket)
linkDevice := NewLinkDevice(client, cfg.DeviceName)
handlers := NewHandlers(client, linkDevice, tmpl, logger)
router.With(authMiddleware).Get("/fragment/signal", handlers.HandleFragment) router.With(authMiddleware).Get("/fragment/signal", handlers.HandleFragment)
router.With(authMiddleware).Post("/api/signal/link", handlers.HandleLinkDevice)
router.With(authMiddleware).Get("/api/signal/status", handlers.HandleLinkStatus)
return handlers return handlers
} }

View file

@ -1,26 +1,26 @@
{{if .Linked}} {{if .Linked}}
<p><strong>Unlink Instructions:</strong></p> <p><strong>To unlink:</strong></p>
<ol class="link-instructions"> <ol class="link-instructions">
<li>Open Signal on your phone</li> <li>Open Signal on your phone</li>
<li>Go to Settings → Linked Devices</li> <li>Go to Settings → Linked Devices</li>
<li>Find and remove <strong>{{.DeviceName}}</strong></li> <li>Find and remove this device</li>
</ol> </ol>
{{else}} {{else}}
{{if .Error}} {{if .Error}}
<p>Error generating QR code: {{.Error}}</p> <p class="channel-not-configured">{{.Error}}</p>
{{else}} {{else}}
<p><strong>Link your Signal (or <a href="https://molly.im" target="_blank" rel="noopener">Molly</a>) account:</strong></p> <button id="signal-link-btn" class="btn-primary">Link Device</button>
<ol class="link-instructions"> <div id="signal-qr-container" class="qr-container" style="display:none;">
<p><strong>Scan this QR code with Signal:</strong></p>
<ol class="link-instructions">
<li>Open Signal on your phone</li> <li>Open Signal on your phone</li>
<li>Go to Settings → Linked Devices</li> <li>Go to Settings → Linked Devices</li>
<li>Tap "+" or "Link New Device"</li>
<li>Scan the QR code below</li> <li>Scan the QR code below</li>
</ol> </ol>
<div class="qr-code-container" <div class="qr-code-wrapper">
hx-get="/fragment/signal" <img id="signal-qr-code" alt="Signal QR Code">
hx-trigger="every 3s" </div>
hx-swap="outerHTML"
hx-target="closest .integration-card">
<img src="{{.QRCode}}" alt="Signal QR Code" class="qr-code"/>
</div> </div>
{{end}} {{end}}
{{end}} {{end}}

View file

@ -1,10 +1,13 @@
package telegram package telegram
import ( import (
"database/sql"
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv"
"prism/service/credentials"
"prism/service/util" "prism/service/util"
) )
@ -13,6 +16,8 @@ type Handlers struct {
chatID int64 chatID int64
tmpl *util.TemplateRenderer tmpl *util.TemplateRenderer
logger *slog.Logger logger *slog.Logger
db *sql.DB
apiKey string
} }
type TelegramContentData struct { type TelegramContentData struct {
@ -37,29 +42,73 @@ func NewHandlers(client *Client, chatID int64, tmpl *util.TemplateRenderer, logg
chatID: chatID, chatID: chatID,
tmpl: tmpl, tmpl: tmpl,
logger: logger, logger: logger,
db: nil,
apiKey: "",
} }
} }
func (h *Handlers) SetDB(db *sql.DB, apiKey string) {
h.db = db
h.apiKey = apiKey
}
func (h *Handlers) loadFreshCredentials() (*Client, int64, bool) {
if h.db == nil || h.apiKey == "" {
return nil, 0, false
}
credStore, err := credentials.NewStore(h.db, h.apiKey)
if err != nil {
return nil, 0, false
}
creds, err := credStore.GetTelegram()
if err != nil || creds == nil {
return nil, 0, false
}
client, err := NewClient(creds.BotToken)
if err != nil {
h.logger.Error("Failed to create Telegram client", "error", err)
return nil, 0, false
}
var chatID int64
if creds.ChatID != "" {
chatID, err = strconv.ParseInt(creds.ChatID, 10, 64)
if err != nil {
h.logger.Error("Failed to parse chat ID", "error", err)
return client, 0, true
}
}
return client, chatID, true
}
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
client, chatID, _ := h.loadFreshCredentials()
var contentData TelegramContentData var contentData TelegramContentData
var integData IntegrationData var integData IntegrationData
integData.Name = "Telegram" integData.Name = "Telegram"
if h.client == nil { if client == nil {
integData.StatusClass = "disconnected" integData.StatusClass = "disconnected"
integData.StatusText = "Not Configured" integData.StatusText = "Unlinked"
integData.StatusTooltip = "Enter bot token to link"
integData.Open = true integData.Open = true
contentData.NotConfigured = true contentData.NotConfigured = true
} else { } else {
bot, err := h.client.GetMe() bot, err := client.GetMe()
if err != nil { if err != nil {
integData.StatusClass = "disconnected" integData.StatusClass = "disconnected"
integData.StatusText = "Error" integData.StatusText = "Error"
integData.StatusTooltip = err.Error()
integData.Open = true integData.Open = true
contentData.Error = err.Error() contentData.Error = err.Error()
} else if h.chatID == 0 { } else if chatID == 0 {
integData.StatusClass = "disconnected" integData.StatusClass = "disconnected"
integData.StatusText = "Needs Chat ID" integData.StatusText = "Needs Chat ID"
integData.StatusTooltip = "@" + bot.Username integData.StatusTooltip = "@" + bot.Username
@ -94,5 +143,11 @@ func (h *Handlers) IsEnabled() bool {
} }
func (h *Handlers) GetClient() *Client { func (h *Handlers) GetClient() *Client {
return h.client client, _, _ := h.loadFreshCredentials()
return client
}
func (h *Handlers) GetChatID() int64 {
_, chatID, _ := h.loadFreshCredentials()
return chatID
} }

View file

@ -2,10 +2,13 @@ package telegram
import ( import (
"context" "context"
"database/sql"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv"
"prism/service/config" "prism/service/config"
"prism/service/credentials"
"prism/service/notification" "prism/service/notification"
"prism/service/util" "prism/service/util"
@ -20,17 +23,33 @@ type Integration struct {
} }
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration { func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
client, err := NewClient(cfg.TelegramBotToken) var client *Client
if err != nil { var chatID int64
logger.Error("Failed to create telegram client", "error", err)
}
var sender *Sender var sender *Sender
if client != nil {
sender = NewSender(client, store, logger, cfg.TelegramChatID) credStore, err := credentials.NewStoreWithLogger(store.GetDB(), cfg.APIKey, logger)
if err != nil {
logger.Warn("Failed to initialize credentials store for Telegram", "error", err)
} else {
creds, err := credStore.GetTelegram()
if err == nil && creds != nil {
logger.Info("Loading Telegram credentials from database")
client, err = NewClient(creds.BotToken)
if err != nil {
logger.Error("Failed to create Telegram client", "error", err)
client = nil
}
if creds.ChatID != "" {
chatID, _ = strconv.ParseInt(creds.ChatID, 10, 64)
}
}
} }
handlers := NewHandlers(client, cfg.TelegramChatID, tmpl, logger) if client != nil {
sender = NewSender(client, store, logger, chatID)
}
handlers := NewHandlers(client, chatID, tmpl, logger)
return &Integration{ return &Integration{
cfg: cfg, cfg: cfg,
@ -48,22 +67,31 @@ func (t *Integration) GetHandlers() *Handlers {
return t.handlers return t.handlers
} }
func (t *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) { func (t *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
RegisterRoutes(router, t.handlers, auth) RegisterRoutes(router, t.handlers, auth, db, apiKey, logger)
} }
func (t *Integration) Start(ctx context.Context, logger *slog.Logger) { func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
if t.handlers != nil && t.handlers.IsEnabled() {
client := t.handlers.GetClient() client := t.handlers.GetClient()
if client == nil {
logger.Info("Telegram not configured", "action", "visit admin UI to configure")
return
}
bot, err := client.GetMe() bot, err := client.GetMe()
if err != nil { if err != nil {
logger.Error("Telegram bot error", "error", err) logger.Error("Telegram bot error", "error", err)
} else { return
logger.Info("Telegram enabled", "bot", bot.Username, "id", bot.ID)
} }
chatID := t.handlers.chatID
if chatID == 0 {
logger.Info("Telegram bot connected", "bot", bot.Username, "status", "needs_chat_id")
} else {
logger.Info("Telegram enabled", "bot", bot.Username, "chat_id", chatID)
} }
} }
func (t *Integration) IsEnabled() bool { func (t *Integration) IsEnabled() bool {
return t.cfg.IsTelegramEnabled() return t.handlers != nil && t.handlers.GetClient() != nil
} }

View file

@ -1,9 +1,15 @@
package telegram package telegram
import ( import (
"database/sql"
"embed" "embed"
"encoding/json"
"log/slog"
"net/http" "net/http"
"prism/service/credentials"
"prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@ -14,10 +20,75 @@ func GetTemplates() embed.FS {
return templates return templates
} }
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler) { type authHandler struct {
db *sql.DB
apiKey string
logger *slog.Logger
}
type telegramAuthRequest struct {
BotToken string `json:"bot_token"`
ChatID string `json:"chat_id"`
}
func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
var req telegramAuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
util.JSONError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.BotToken == "" || req.ChatID == "" {
util.JSONError(w, "bot_token and chat_id are required", http.StatusBadRequest)
return
}
credStore, err := credentials.NewStore(h.db, h.apiKey)
if err != nil {
util.LogAndError(w, h.logger, "Failed to initialize credentials store", http.StatusInternalServerError, err)
return
}
creds := &credentials.TelegramCredentials{
BotToken: req.BotToken,
ChatID: req.ChatID,
}
if err := credStore.SaveTelegram(creds); err != nil {
util.LogAndError(w, h.logger, "Failed to save Telegram credentials", http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
credStore, err := credentials.NewStore(h.db, h.apiKey)
if err != nil {
util.LogAndError(w, h.logger, "Failed to initialize credentials store", http.StatusInternalServerError, err)
return
}
if err := credStore.DeleteIntegration(credentials.IntegrationTelegram); err != nil {
util.LogAndError(w, h.logger, "Failed to delete integration", http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
}
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
if handlers == nil { if handlers == nil {
return return
} }
handlers.SetDB(db, apiKey)
authH := &authHandler{db: db, apiKey: apiKey, logger: logger}
router.With(auth).Get("/fragment/telegram", handlers.HandleFragment) router.With(auth).Get("/fragment/telegram", handlers.HandleFragment)
router.With(auth).Post("/api/telegram/auth", authH.handleAuth)
router.With(auth).Delete("/api/telegram/auth", authH.handleDelete)
} }

View file

@ -37,7 +37,7 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
message = fmt.Sprintf("<b>%s</b>\n%s", notif.Title, notif.Message) message = fmt.Sprintf("<b>%s</b>\n%s", notif.Title, notif.Message)
} }
fullMessage := fmt.Sprintf("📱 <b>%s</b>\n\n%s", mapping.AppName, message) fullMessage := fmt.Sprintf("<b>%s</b>\n\n%s", mapping.AppName, message)
if err := s.client.SendMessage(s.DefaultChatID, fullMessage); err != nil { if err := s.client.SendMessage(s.DefaultChatID, fullMessage); err != nil {
s.logger.Error("Failed to send telegram message", "chatID", s.DefaultChatID, "error", err) s.logger.Error("Failed to send telegram message", "chatID", s.DefaultChatID, "error", err)

View file

@ -1,27 +1,39 @@
{{if .NotConfigured}} {{if .NotConfigured}}
<p><strong>Setup Instructions:</strong></p> <p><strong>Setup Telegram Bot:</strong></p>
<ol class="link-instructions"> <ol class="link-instructions">
<li>Message <a href="https://t.me/BotFather" target="_blank">@BotFather</a> on Telegram</li> <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>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>Copy the bot token from BotFather</li>
<li>Message <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> to get your Chat ID</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>Enter both below:</li>
<li>Restart Prism</li>
</ol> </ol>
<p class="text-muted">See <a href="https://github.com/lone-cloud/prism#telegram" target="_blank">full setup guide</a></p> <form id="telegram-auth-form" class="auth-form">
<div class="form-group">
<label for="telegram-bot-token">Bot Token:</label>
<input type="text" id="telegram-bot-token" name="bot_token" placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz" required>
</div>
<div class="form-group">
<label for="telegram-chat-id">Chat ID:</label>
<input type="text" id="telegram-chat-id" name="chat_id" placeholder="123456789" required>
</div>
<button type="submit" class="btn-primary">Configure</button>
<div id="telegram-auth-status" class="auth-status"></div>
</form>
{{else if .Error}} {{else if .Error}}
<p>Error: {{.Error}}</p> <p>Error: {{.Error}}</p>
<button class="btn-secondary reload-btn">Retry</button>
{{else if .NeedsChatID}} {{else if .NeedsChatID}}
<p><strong>Complete Setup:</strong></p> <p><strong>Complete Setup:</strong></p>
<ol class="link-instructions"> <p>Bot is configured, but Chat ID is missing.</p>
<li>Message <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> on Telegram to get your Chat ID</li> <form id="telegram-chatid-form" class="auth-form" data-bot-token="{{.BotToken}}">
<li>Add to <code>.env</code>: <code>TELEGRAM_CHAT_ID=your-chat-id</code></li> <div class="form-group">
<li>Restart Prism</li> <label for="telegram-chat-id-only">Chat ID:</label>
</ol> <input type="text" id="telegram-chat-id-only" name="chat_id" placeholder="Get from @userinfobot" required>
</div>
<button type="submit" class="btn-primary">Save Chat ID</button>
<div id="telegram-chatid-status" class="auth-status"></div>
</form>
{{else}} {{else}}
<p><strong>Unlink Instructions:</strong></p> <button class="btn-danger delete-telegram-btn">Unlink</button>
<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>
{{end}} {{end}}

View file

@ -0,0 +1,17 @@
{{if .EnableSignal}}
<div id="signal-integration" class="integration-container" hx-get="/fragment/signal" hx-trigger="load">
<div class="loading"><div class="spinner"></div></div>
</div>
{{end}}
{{if .EnableTelegram}}
<div id="telegram-integration" class="integration-container" hx-get="/fragment/telegram" hx-trigger="load">
<div class="loading"><div class="spinner"></div></div>
</div>
{{end}}
{{if .EnableProton}}
<div id="proton-integration" class="integration-container" hx-get="/fragment/proton" hx-trigger="load">
<div class="loading"><div class="spinner"></div></div>
</div>
{{end}}

View file

@ -2,6 +2,7 @@ package webpush
import ( import (
"context" "context"
"database/sql"
"log/slog" "log/slog"
"net/http" "net/http"
@ -22,7 +23,7 @@ func NewIntegration(store *notification.Store, logger *slog.Logger) *Integration
} }
} }
func (w *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) { func (w *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
RegisterRoutes(router, w.store, w.logger, auth) RegisterRoutes(router, w.store, w.logger, auth)
} }

View file

@ -56,6 +56,11 @@ func (d *Dispatcher) IsValidChannel(channel Channel) bool {
return channel.IsAvailable(d.HasSignal(), d.HasTelegram()) return channel.IsAvailable(d.HasSignal(), d.HasTelegram())
} }
func (d *Dispatcher) RegisterApp(appName string) error {
availableChannels := d.GetAvailableChannels()
return d.store.RegisterDefault(appName, availableChannels)
}
func (d *Dispatcher) Send(appName string, notif Notification) error { func (d *Dispatcher) Send(appName string, notif Notification) error {
mapping, err := d.store.GetApp(appName) mapping, err := d.store.GetApp(appName)
if err != nil { if err != nil {
@ -90,8 +95,8 @@ func (d *Dispatcher) Send(appName string, notif Notification) error {
} }
func (d *Dispatcher) sendWithRetry(sender NotificationSender, mapping *Mapping, notif Notification, appName string) error { func (d *Dispatcher) sendWithRetry(sender NotificationSender, mapping *Mapping, notif Notification, appName string) error {
maxRetries := 3 maxRetries := 10
baseDelay := 100 * time.Millisecond baseDelay := 500 * time.Millisecond
var lastErr error var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ { for attempt := 0; attempt < maxRetries; attempt++ {

View file

@ -2,6 +2,7 @@ package notification
type Action struct { type Action struct {
ID string `json:"id"` ID string `json:"id"`
Label string `json:"label"`
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint"`
Method string `json:"method"` Method string `json:"method"`
Data map[string]any `json:"data,omitempty"` Data map[string]any `json:"data,omitempty"`
@ -10,6 +11,7 @@ type Action struct {
type Notification struct { type Notification struct {
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Message string `json:"message"` Message string `json:"message"`
Tag string `json:"tag,omitempty"`
Actions []Action `json:"actions,omitempty"` Actions []Action `json:"actions,omitempty"`
} }

View file

@ -62,6 +62,10 @@ func (s *Store) Close() error {
return s.db.Close() return s.db.Close()
} }
func (s *Store) GetDB() *sql.DB {
return s.db
}
func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscription, webPush *WebPushSubscription) error { func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscription, webPush *WebPushSubscription) error {
var signalGroupID, signalAccount *string var signalGroupID, signalAccount *string
if signal != nil { if signal != nil {

View file

@ -133,7 +133,6 @@ func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
"mappingsCount": len(mappings), "mappingsCount": len(mappings),
"signalCount": countByChannel(mappings, notification.ChannelSignal), "signalCount": countByChannel(mappings, notification.ChannelSignal),
"webpushCount": countByChannel(mappings, notification.ChannelWebPush), "webpushCount": countByChannel(mappings, notification.ChannelWebPush),
"protonEnabled": s.cfg.IsProtonEnabled(),
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View file

@ -41,17 +41,31 @@ func (s *Server) buildAppListData(mappings []notification.Mapping) []AppListItem
signalLinked = account != nil signalLinked = account != nil
} }
} }
telegramConfigured := s.cfg.IsTelegramEnabled() && s.cfg.TelegramChatID != 0
telegramLinked := false
var telegramInfo string
if s.integrations.Telegram != nil {
handlers := s.integrations.Telegram.GetHandlers()
if handlers != nil && handlers.GetClient() != nil {
chatID := handlers.GetChatID()
telegramLinked = chatID != 0
if telegramLinked {
if bot, err := handlers.GetClient().GetMe(); err == nil {
telegramInfo = "@" + bot.Username
}
}
}
}
items := make([]AppListItem, 0, len(mappings)) items := make([]AppListItem, 0, len(mappings))
for _, m := range mappings { for _, m := range mappings {
item := s.buildAppListItem(m, signalLinked, telegramConfigured) item := s.buildAppListItem(m, signalLinked, telegramLinked, telegramInfo)
items = append(items, item) items = append(items, item)
} }
return items return items
} }
func (s *Server) buildAppListItem(m notification.Mapping, signalLinked, telegramConfigured bool) AppListItem { func (s *Server) buildAppListItem(m notification.Mapping, signalLinked, telegramLinked bool, telegramInfo string) AppListItem {
isSignal := m.Channel == notification.ChannelSignal isSignal := m.Channel == notification.ChannelSignal
isWebPush := m.Channel == notification.ChannelWebPush isWebPush := m.Channel == notification.ChannelWebPush
isTelegram := m.Channel == notification.ChannelTelegram isTelegram := m.Channel == notification.ChannelTelegram
@ -72,11 +86,12 @@ func (s *Server) buildAppListItem(m notification.Mapping, signalLinked, telegram
if u, err := url.Parse(m.WebPush.Endpoint); err == nil { if u, err := url.Parse(m.WebPush.Endpoint); err == nil {
item.Hostname = u.Hostname() item.Hostname = u.Hostname()
} }
} else if isTelegram && telegramConfigured { } else if isTelegram && telegramLinked {
item.ChannelBadge = m.Channel.Label() item.ChannelBadge = m.Channel.Label()
item.Tooltip = telegramInfo
item.ChannelConfigured = true item.ChannelConfigured = true
} else { } else {
item.ChannelBadge = "Not Configured" item.ChannelBadge = "Unlinked"
item.ChannelConfigured = false item.ChannelConfigured = false
if isSignal { if isSignal {
item.Tooltip = "No Signal group created yet. Will auto-create on first notification." item.Tooltip = "No Signal group created yet. Will auto-create on first notification."
@ -87,7 +102,7 @@ func (s *Server) buildAppListItem(m notification.Mapping, signalLinked, telegram
} }
} }
item.ChannelOptions = s.buildChannelOptions(m, signalLinked, telegramConfigured) item.ChannelOptions = s.buildChannelOptions(m, signalLinked, telegramLinked)
return item return item
} }
@ -98,14 +113,14 @@ func (s *Server) buildChannelOptions(m notification.Mapping, signalLinked, teleg
var options []SelectOption var options []SelectOption
if signalLinked { if s.integrations.Signal != nil {
options = append(options, SelectOption{ options = append(options, SelectOption{
Value: notification.ChannelSignal.String(), Value: notification.ChannelSignal.String(),
Label: notification.ChannelSignal.Label(), Label: notification.ChannelSignal.Label(),
Selected: isSignal, Selected: isSignal,
}) })
} }
if telegramConfigured { if s.integrations.Telegram != nil {
options = append(options, SelectOption{ options = append(options, SelectOption{
Value: notification.ChannelTelegram.String(), Value: notification.ChannelTelegram.String(),
Label: notification.ChannelTelegram.Label(), Label: notification.ChannelTelegram.Label(),

View file

@ -48,13 +48,6 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
} }
} }
if s.cfg.IsProtonEnabled() {
resp.Proton = &integrationHealth{
Linked: true,
Account: s.cfg.ProtonIMAPUsername,
}
}
if s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled() { if s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled() {
telegramClient := s.integrations.Telegram.GetHandlers().GetClient() telegramClient := s.integrations.Telegram.GetHandlers().GetClient()
if telegramClient != nil { if telegramClient != nil {
@ -68,6 +61,19 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
} }
} }
if s.integrations.Proton != nil && s.integrations.Proton.IsEnabled() {
protonHandlers := s.integrations.Proton.GetHandlers()
if protonHandlers != nil && protonHandlers.IsEnabled() {
email, hasCredentials := protonHandlers.LoadFreshCredentials()
if hasCredentials {
resp.Proton = &integrationHealth{
Linked: true,
Account: email,
}
}
}
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil { if err := json.NewEncoder(w).Encode(resp); err != nil {
s.logger.Error("Failed to encode health response", "error", err) s.logger.Error("Failed to encode health response", "error", err)

View file

@ -1,26 +1,20 @@
package server package server
import ( import (
"fmt" "bytes"
"net/http" "net/http"
"prism/service/util"
) )
func (s *Server) handleFragmentIntegrations(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFragmentIntegrations(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
html := "" var buf bytes.Buffer
if err := s.fragmentTmpl.ExecuteTemplate(&buf, "integrations.html", s.cfg); err != nil {
if s.cfg.IsSignalEnabled() { util.LogAndError(w, s.logger, "Internal server error", http.StatusInternalServerError, err)
html += `<div id="signal-integration" class="integration-container" hx-get="/fragment/signal" hx-trigger="load"><div class="loading"><div class="spinner"></div></div></div>` return
} }
if s.cfg.IsProtonEnabled() { w.Write(buf.Bytes())
html += `<div id="proton-integration" class="integration-container" hx-get="/fragment/proton" hx-trigger="load"><div class="loading"><div class="spinner"></div></div></div>`
}
if s.cfg.IsTelegramEnabled() {
html += `<div id="telegram-integration" class="integration-container" hx-get="/fragment/telegram" hx-trigger="load"><div class="loading"><div class="spinner"></div></div></div>`
}
_, _ = fmt.Fprint(w, html)
} }

View file

@ -14,9 +14,6 @@ import (
"prism/service/config" "prism/service/config"
"prism/service/integration" "prism/service/integration"
"prism/service/integration/proton"
"prism/service/integration/signal"
"prism/service/integration/telegram"
"prism/service/notification" "prism/service/notification"
"prism/service/util" "prism/service/util"
@ -55,21 +52,11 @@ func New(cfg *config.Config, publicAssets embed.FS) (*Server, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse fragment templates: %w", err) return nil, fmt.Errorf("failed to parse fragment templates: %w", err)
} }
fragmentTmpl, err = fragmentTmpl.ParseFS(signal.GetTemplates(), "templates/*.html")
if err != nil {
return nil, fmt.Errorf("failed to parse signal templates: %w", err)
}
fragmentTmpl, err = fragmentTmpl.ParseFS(telegram.GetTemplates(), "templates/*.html")
if err != nil {
return nil, fmt.Errorf("failed to parse telegram templates: %w", err)
}
fragmentTmpl, err = fragmentTmpl.ParseFS(proton.GetTemplates(), "templates/*.html")
if err != nil {
return nil, fmt.Errorf("failed to parse proton templates: %w", err)
}
templateRenderer := util.NewTemplateRenderer(fragmentTmpl) integrations, fragmentTmpl, err := integration.Initialize(cfg, store, logger, fragmentTmpl)
integrations := integration.Initialize(cfg, store, logger, templateRenderer) if err != nil {
return nil, err
}
version := "dev" version := "dev"
if versionBytes, err := os.ReadFile("VERSION"); err == nil { if versionBytes, err := os.ReadFile("VERSION"); err == nil {

View file

@ -32,7 +32,7 @@
</select> </select>
</form> </form>
{{end}} {{end}}
<button class="btn-delete" hx-delete="/action/app/{{.AppName}}" hx-target="#apps-list" hx-swap="innerHTML">Delete</button> <button class="btn-delete" hx-delete="/action/app/{{.AppName}}" hx-target="#apps-list" hx-swap="innerHTML" hx-confirm="Delete {{.AppName}}?">Delete</button>
</div> </div>
</li> </li>
{{end}} {{end}}