diff --git a/.air.toml b/.air.toml index 1b562a7..e838e18 100644 --- a/.air.toml +++ b/.air.toml @@ -5,7 +5,7 @@ tmp_dir = "tmp" bin = "./tmp/main" cmd = "go build -o ./tmp/main ." include_ext = ["go", "html", "css", "js"] - exclude_dir = ["tmp", "data", "signal-cli"] + exclude_dir = ["tmp", "data"] delay = 1000 [log] diff --git a/.dockerignore b/.dockerignore index cab792c..0f8089b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,3 @@ -signal-cli/ data/ *.db prism diff --git a/.env.example b/.env.example index 4bd78be..41147e2 100644 --- a/.env.example +++ b/.env.example @@ -1,47 +1,21 @@ -# Required: API key for authentication +# Required: API key for authentication # API_KEY=your-secret-key-here -# Optional: Feature flags to enable/disable integrations -# FEATURE_ENABLE_SIGNAL=false -# FEATURE_ENABLE_PROTON=false -# FEATURE_ENABLE_TELEGRAM=false +# Optional Configuration -# Advanced Configuration +# Optional: Enable integrations (default: false - only enable what you need) +# ENABLE_SIGNAL=true +# ENABLE_TELEGRAM=true +# ENABLE_PROTON=true - -# Optional: Server port +# Optional: Server port (default: 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 -# Optional: Device name shown in Signal app's linked devices list -# DEVICE_NAME=Prism +# Optional: Enable verbose logging (default: false) +# VERBOSE_LOGGING=false -# Optional: Enable verbose logging -# VERBOSE_LOGGING=true - - -# 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 +# Optional: Database storage path (default: ./data/prism.db) +# STORAGE_PATH=./data/prism.db diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e1032d0..4f1233a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,6 +2,7 @@ ## 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. - **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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbdd67b..99a28f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,5 +45,8 @@ jobs: version: v2.8.0 args: --timeout=5m + - name: Check frontend + run: npx @biomejs/biome@latest check . + - name: Build run: go build -v . diff --git a/.github/workflows/release-signal-cli.yml b/.github/workflows/release-signal-cli.yml deleted file mode 100644 index c719686..0000000 --- a/.github/workflows/release-signal-cli.yml +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore index 4f210fc..7520b7c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -signal-cli/ data/ *.db .env diff --git a/Dockerfile b/Dockerfile index 9c8d346..44b3000 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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)" \ -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 COPY --from=builder /build/prism . -RUN adduser -D -u 1000 prism && \ +RUN useradd -m -u 1000 prism && \ mkdir -p /app/data && \ chown -R prism:prism /app diff --git a/Dockerfile.signal-cli b/Dockerfile.signal-cli deleted file mode 100644 index 5c01698..0000000 --- a/Dockerfile.signal-cli +++ /dev/null @@ -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"] diff --git a/Makefile b/Makefile index ef67c0f..2626119 100644 --- a/Makefile +++ b/Makefile @@ -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 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 export PATH := $(GOBIN):$(PATH) -all: fmt lint build +all: fix build build: 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) air -fmt: +fix: gofmt -s -w . goimports -w . - -lint: - golangci-lint run - -fix: golangci-lint run --fix + npx @biomejs/biome@latest check --write --unsafe . vet: go vet ./... @@ -39,6 +35,24 @@ install-tools: @echo "Installing Go tools to $(GOBIN)..." go install golang.org/x/tools/cmd/goimports@latest curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v2.8.0 + @echo "Installing signal-cli 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: go mod download @@ -55,21 +69,9 @@ update-all: go get -u all go mod tidy -docker-build: - docker build -t prism:$(VERSION) . - docker-up: 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: @if [ ! -f VERSION ]; then \ echo "Error: VERSION file not found"; \ @@ -84,7 +86,3 @@ release: release-dev: @echo "Triggering dev Docker image build..." gh workflow run release-dev.yml - -release-signal: - @echo "Triggering signal-cli image release via GitHub Actions..." - gh workflow run release-signal-cli.yml \ No newline at end of file diff --git a/README.md b/README.md index 500377a..b05e2ed 100644 --- a/README.md +++ b/README.md @@ -4,155 +4,149 @@ # 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) -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 +### Docker (Recommended) + ```bash -# Download docker-compose.yml -curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/docker-compose.yml - -# Download .env.example +# Create .env file curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example - -# Configure your API key (eg. admin password) -cp .env.example .env +mv .env.example .env nano .env # Set API_KEY=your-secret-key-here -# Start Prism -docker compose up -d +# Run Prism +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 . 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 . + +![Prism Dashboard](assets/screenshots/dashboard.webp) ## 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 -Send notifications through Signal groups. -Each registered app will route messages to private Signal groups matching the app name. +Send notifications through Signal Messenger. -**1. Enable Signal in `.env`:** +**Setup:** -```bash -FEATURE_ENABLE_SIGNAL=true -``` +1. Visit and authenticate with your API_KEY +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 -docker compose --profile signal up -d -``` - -**3. Link your Signal account:** - -Visit , 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. +**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. ### Telegram -Send notifications through Telegram instead. -Unlike the Signal integration, Telegram relies on creating a Telegram bot as it will serve as the notifications messenger. +Send notifications through a Telegram bot. -**1. Create a Telegram bot:** +**Setup:** -- Message [@BotFather](https://t.me/BotFather) on Telegram -- Send `/newbot` and follow the prompts -- Copy the bot token (looks like `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`) +1. Create a bot: + - Message [@BotFather](https://t.me/BotFather) on Telegram + - 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 -FEATURE_ENABLE_TELEGRAM=true -TELEGRAM_BOT_TOKEN=your-bot-token-here -``` +3. Configure in Prism: + - Visit and authenticate with your API_KEY + - Expand the Telegram integration card + - Enter your bot token and chat ID + - Click "Configure" -**3. Get your chat ID:** - -- 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. +All notifications will be sent to your Telegram chat. ### Proton Mail -Receive notifications when new Proton Mail emails arrive. -Unlike other integrations, this one will generate new messages to be delivered by one of the configured transports. -Note that using this integration requires a paid Proton Mail account to be able to use the Proton Mail Bridge that this integration relies on. -Also note that the Proton Mail Bridge is RAM hungry. +Monitor a Proton Mail account and forward new emails as notifications through Signal or Telegram. -> **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 -docker compose run --rm protonmail-bridge init -``` +**Setup:** -**2. Login at the `>>>` prompt:** +1. Visit 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 ->>> login -# Enter your Proton Mail email -# Enter your password -# Enter your 2FA code -``` +Proton Mail will connect and begin monitoring. New emails will appear as notifications from the "Proton Mail" app. -**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 ->>> info -# Copy the Username and Password ->>> exit -``` +### WebPush -**4. Add credentials to `.env`:** +Send notifications directly to your browser. -```bash -FEATURE_ENABLE_PROTON=true -PROTON_IMAP_USERNAME=username-from-info-command -PROTON_IMAP_PASSWORD=password-from-info-command -``` +**Setup:** -**5. Start Prism with Proton Mail:** +1. Visit and authenticate with your API_KEY +2. Allow browser notifications when prompted +3. Apps without Signal or Telegram configured will automatically use WebPush -```bash -docker compose --profile proton up -d -``` +You'll receive browser notifications when messages arrive. ## 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 @@ -184,7 +178,9 @@ Reboot your Home Assistant system and you'll then be able to send Signal notific #### 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 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"}' ``` -Or ntfy-style: +Plain text (ntfy-compatible): ```bash curl -X POST http://localhost:8080/my-app \ @@ -201,6 +197,11 @@ curl -X POST http://localhost:8080/my-app \ -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 #### POST /webpush/app @@ -245,8 +246,6 @@ curl -X DELETE http://localhost:8080/webpush/app/my-app \ ## 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 #### GET /health @@ -267,5 +266,14 @@ curl http://localhost:8080/api/health \ ``` ```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"} +} ``` + + + diff --git a/assets/screenshots/dashboard.webp b/assets/screenshots/dashboard.webp new file mode 100644 index 0000000..d890476 Binary files /dev/null and b/assets/screenshots/dashboard.webp differ diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..126979f --- /dev/null +++ b/biome.json @@ -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 + } + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index ef7ed49..0000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index c5a7506..0000000 --- a/docker-compose.yml +++ /dev/null @@ -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: diff --git a/go.mod b/go.mod index fc5d889..4fc2ee7 100644 --- a/go.mod +++ b/go.mod @@ -4,23 +4,25 @@ go 1.25.6 require ( 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/joho/godotenv v1.5.1 github.com/mymmrac/telego v1.5.1 + golang.org/x/crypto v0.47.0 golang.org/x/time v0.14.0 modernc.org/sqlite v1.44.3 ) require ( + github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/emersion/go-message v0.18.2 // indirect - github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.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/fasthttp v1.69.0 // indirect github.com/valyala/fastjson v1.6.7 // indirect - golang.org/x/arch v0.23.0 // indirect - golang.org/x/crypto v0.47.0 // indirect + golang.org/x/arch v0.24.0 // 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/libc v1.67.7 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 6b007a8..5108929 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= 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/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= 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/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= 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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= -github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= -github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= -github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= -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/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 h1:7aCSuwTBzg7BCPRRaBJD0weKZYdeAykOrY6ktpx8Vvc= +github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63/go.mod h1:eRwwJnuLVFtYTC+AI2JDJTMcuQUTYhBIK4I6bC5tpqw= +github.com/emersion/hydroxide v0.2.31 h1:ofPKtEpD+AtE2oKJhcQKhUjubQS8+AHIjCuO3aeLBsM= +github.com/emersion/hydroxide v0.2.31/go.mod h1:jhoMVyP0z2GACrmFkL0ppcWKt2LbC02auADSSsB4vH4= 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/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= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= -golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= +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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 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.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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +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/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= diff --git a/public/index.css b/public/index.css index 2935e91..7117dca 100644 --- a/public/index.css +++ b/public/index.css @@ -3,42 +3,27 @@ --bg-secondary: #fdfdfd; --text-primary: #000; --text-secondary: #666; - --text-on-color: #fafafa; + --text-on-color: #fff; --border-color: rgba(0, 0, 0, 0.1); - --accent: #8159b8; - --success: #28a745; - --error: #dc3545; + --accent: #00b4d8; + --success: #06d6a0; + --error: #ef476f; --spinner-track: #e0e0e0; } @media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]):not([data-theme="dark"]) { + :root:not([data-theme="light"]) { --bg-primary: #1a1a1a; --bg-secondary: #2d2d2d; --text-primary: #e0e0e0; --text-secondary: #a0a0a0; --text-on-color: #fff; --border-color: rgba(255, 255, 255, 0.1); - --accent: #a78bfa; - --success: #28a745; - --error: #ef4444; + --accent: #60c5ff; --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"] { --bg-primary: #1a1a1a; --bg-secondary: #2d2d2d; @@ -46,9 +31,7 @@ --text-secondary: #a0a0a0; --text-on-color: #fff; --border-color: rgba(255, 255, 255, 0.1); - --accent: #a78bfa; - --success: #28a745; - --error: #ef4444; + --accent: #60c5ff; --spinner-track: #4a4a4a; } @@ -141,7 +124,7 @@ details[open] > .card-header::before { transform: rotate(90deg); } -.card #apps-list { +.card-content { padding: 0 1.25rem 1.25rem 1.25rem; } @@ -163,8 +146,7 @@ a:hover { cursor: help; } -.channel-badge .tooltip, -.integration-status .tooltip { +.tooltip { visibility: hidden; opacity: 0; position: absolute; @@ -186,8 +168,7 @@ a:hover { box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2); } -.channel-badge .tooltip::after, -.integration-status .tooltip::after { +.tooltip::after { content: ""; position: absolute; top: 100%; @@ -197,8 +178,7 @@ a:hover { border-top-color: var(--bg-secondary); } -.channel-badge:hover .tooltip, -.integration-status:hover .tooltip { +:hover > .tooltip { visibility: visible; opacity: 1; } @@ -312,44 +292,12 @@ a:hover { border: none; border-radius: 0.25rem; cursor: pointer; - transition: filter 0.2s; + transition: filter 0.2s ease; white-space: nowrap; } .btn-delete:hover { - filter: brightness(0.9); -} - -.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); + filter: brightness(0.85); } .loading { @@ -452,26 +400,122 @@ details[open] > .integration-header::before { 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 { background: var(--error); color: var(--text-on-color); } -.text-muted { - color: var(--text-secondary); - font-size: 0.875rem; +.auth-form { + margin-top: 1rem; + 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; } diff --git a/public/index.html b/public/index.html index 7c2eb83..9a05be9 100644 --- a/public/index.html +++ b/public/index.html @@ -10,13 +10,14 @@ +
Registered Applications -
diff --git a/public/integration.js b/public/integration.js new file mode 100644 index 0000000..49e5897 --- /dev/null +++ b/public/integration.js @@ -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 = `

Error: ${error.error || 'Failed to generate link'}

`; + 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 = '

Linked! Refreshing...

'; + setTimeout(() => location.reload(), 1000); + } + } catch (err) { + console.error('Status check failed:', err); + } + }, 2000); + } catch (err) { + qrContainer.innerHTML = `

Error: ${err.message}

`; + 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}`); + } + } +}); diff --git a/public/theme.js b/public/theme.js index 0e809fd..48efa5f 100644 --- a/public/theme.js +++ b/public/theme.js @@ -6,38 +6,38 @@ currentIndex = themes.indexOf(savedTheme); if (currentIndex === -1) currentIndex = 0; function applyTheme(theme) { - if (theme === 'system') { - document.documentElement.removeAttribute('data-theme'); - } else { - document.documentElement.setAttribute('data-theme', theme); - } - localStorage.setItem('theme', theme); + if (theme === 'system') { + document.documentElement.removeAttribute('data-theme'); + } else { + document.documentElement.setAttribute('data-theme', theme); + } + localStorage.setItem('theme', theme); } window.cycleTheme = () => { - currentIndex = (currentIndex + 1) % themes.length; - const newTheme = themes[currentIndex]; - applyTheme(newTheme); - updateButtonText(newTheme); + currentIndex = (currentIndex + 1) % themes.length; + const newTheme = themes[currentIndex]; + applyTheme(newTheme); + updateButtonText(newTheme); }; function updateButtonText(theme) { - const btn = document.getElementById('theme-toggle'); - if (btn) { - btn.textContent = theme === 'system' ? '🌓' : theme === 'light' ? '☀️' : '🌙'; - } + const btn = document.getElementById('theme-toggle'); + if (btn) { + btn.textContent = theme === 'system' ? '🌓' : theme === 'light' ? '☀️' : '🌙'; + } } applyTheme(savedTheme); if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - updateButtonText(themes[currentIndex]); - const btn = document.getElementById('theme-toggle'); - if (btn) btn.addEventListener('click', window.cycleTheme); - }); + document.addEventListener('DOMContentLoaded', () => { + updateButtonText(themes[currentIndex]); + const btn = document.getElementById('theme-toggle'); + if (btn) btn.addEventListener('click', window.cycleTheme); + }); } else { - updateButtonText(themes[currentIndex]); - const btn = document.getElementById('theme-toggle'); - if (btn) btn.addEventListener('click', window.cycleTheme); + updateButtonText(themes[currentIndex]); + const btn = document.getElementById('theme-toggle'); + if (btn) btn.addEventListener('click', window.cycleTheme); } diff --git a/service/config/config.go b/service/config/config.go index 3a549ad..ed4e094 100644 --- a/service/config/config.go +++ b/service/config/config.go @@ -7,22 +7,14 @@ import ( ) type Config struct { - TelegramChatID int64 - Port int - RateLimit int - APIKey string - DeviceName string - PrismEndpointPrefix string - SignalSocket string - ProtonIMAPUsername string - ProtonIMAPPassword string - ProtonBridgeAddr string - TelegramBotToken string - StoragePath string - VerboseLogging bool - EnableSignal bool - EnableProton bool - EnableTelegram bool + Port int + RateLimit int + APIKey string + StoragePath string + VerboseLogging bool + EnableSignal bool + EnableTelegram bool + EnableProton bool } func Load() (*Config, error) { @@ -31,25 +23,12 @@ func Load() (*Config, error) { Port: getEnvInt("PORT", 8080), VerboseLogging: getEnvBool("VERBOSE_LOGGING", false), 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 { return nil, err } @@ -64,18 +43,6 @@ func (c *Config) Validate() error { 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 { if value := os.Getenv(key); value != "" { return value @@ -92,15 +59,6 @@ func getEnvInt(key string, defaultValue int) int { return defaultValue } -func getEnvInt64(key string, defaultValue int64) int64 { - if value := os.Getenv(key); value != "" { - if i, err := strconv.ParseInt(value, 10, 64); err == nil { - return i - } - } - return defaultValue -} - func getEnvBool(key string, defaultValue bool) bool { if value := os.Getenv(key); value != "" { return value == "true" || value == "1" diff --git a/service/credentials/credentials.go b/service/credentials/credentials.go new file mode 100644 index 0000000..0ba955d --- /dev/null +++ b/service/credentials/credentials.go @@ -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() +} diff --git a/service/integration/embed.go b/service/integration/embed.go new file mode 100644 index 0000000..e731ca5 --- /dev/null +++ b/service/integration/embed.go @@ -0,0 +1,10 @@ +package integration + +import "embed" + +//go:embed templates/*.html +var templates embed.FS + +func GetTemplates() embed.FS { + return templates +} diff --git a/service/integration/integration.go b/service/integration/integration.go index 40d9e4c..f4d94f9 100644 --- a/service/integration/integration.go +++ b/service/integration/integration.go @@ -2,6 +2,9 @@ package integration import ( "context" + "database/sql" + "fmt" + "html/template" "log/slog" "net/http" @@ -17,7 +20,7 @@ import ( ) 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) IsEnabled() bool } @@ -26,35 +29,72 @@ type Integrations struct { Dispatcher *notification.Dispatcher Signal *signal.Integration Telegram *telegram.Integration + Proton *proton.Integration integrations []Integration } -func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmplRenderer *util.TemplateRenderer) *Integrations { - signalIntegration := signal.NewIntegration(cfg, store, logger, tmplRenderer) - telegramIntegration := telegram.NewIntegration(cfg, store, logger, tmplRenderer) +func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logger, baseTmpl *template.Template) (*Integrations, *template.Template, error) { + fragmentTmpl := baseTmpl + 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) - if signalSender := signalIntegration.GetSender(); signalSender != nil { - dispatcher.RegisterSender(notification.ChannelSignal, signalSender) - } - if telegramSender := telegramIntegration.GetSender(); telegramSender != nil { - dispatcher.RegisterSender(notification.ChannelTelegram, telegramSender) - } - dispatcher.RegisterSender(notification.ChannelWebPush, webpush.NewSender(logger)) + var integrations []Integration - integrations := []Integration{ - signalIntegration, - telegramIntegration, - proton.NewIntegration(cfg, dispatcher, logger, tmplRenderer), - webpush.NewIntegration(store, logger), + var protonIntegration *proton.Integration + + if cfg.EnableSignal { + signalIntegration = signal.NewIntegration(cfg, store, logger, tmplRenderer) + if signalSender := signalIntegration.GetSender(); signalSender != nil { + 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 { + dispatcher.RegisterSender(notification.ChannelTelegram, telegramSender) + } + integrations = append(integrations, telegramIntegration) + } + + 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{ Dispatcher: dispatcher, Signal: signalIntegration, Telegram: telegramIntegration, + Proton: protonIntegration, integrations: integrations, - } + }, fragmentTmpl, nil } 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) { auth := authMiddleware(cfg.APIKey) + db := store.GetDB() for _, integration := range integrations.integrations { - integration.RegisterRoutes(router, auth) + integration.RegisterRoutes(router, auth, db, cfg.APIKey, logger) } } diff --git a/service/integration/proton/connection.go b/service/integration/proton/connection.go deleted file mode 100644 index 228873a..0000000 --- a/service/integration/proton/connection.go +++ /dev/null @@ -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) - } - } - } -} diff --git a/service/integration/proton/email.go b/service/integration/proton/email.go deleted file mode 100644 index c162e6d..0000000 --- a/service/integration/proton/email.go +++ /dev/null @@ -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 -} diff --git a/service/integration/proton/handlers.go b/service/integration/proton/handlers.go index bc4a3fa..e17c039 100644 --- a/service/integration/proton/handlers.go +++ b/service/integration/proton/handlers.go @@ -1,11 +1,13 @@ package proton import ( + "database/sql" "encoding/json" "html/template" "log/slog" "net/http" + "prism/service/credentials" "prism/service/util" ) @@ -14,6 +16,8 @@ type Handlers struct { username string logger *slog.Logger tmpl *util.TemplateRenderer + db *sql.DB + apiKey string } type ProtonContentData struct { @@ -36,31 +40,61 @@ func NewHandlers(monitor *Monitor, username string, logger *slog.Logger, tmpl *u username: username, logger: logger, tmpl: tmpl, + db: nil, + apiKey: "", } } -func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) { - if h.monitor == nil { - return +func (h *Handlers) SetDB(db *sql.DB, apiKey string) { + h.db = db + 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") + email, hasCredentials := h.LoadFreshCredentials() + var contentData ProtonContentData var integData IntegrationData integData.Name = "Proton Mail" - if h.monitor.IsConnected() { - integData.StatusClass = "connected" - integData.StatusText = "Linked" - integData.StatusTooltip = h.username - integData.Open = false - contentData.Connected = true - } else { + if !hasCredentials { integData.StatusClass = "disconnected" integData.StatusText = "Unlinked" + integData.StatusTooltip = "Enter credentials to link" 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) @@ -88,7 +122,7 @@ func (h *Handlers) GetMonitor() *Monitor { } type markReadRequest struct { - UID uint32 `json:"uid"` + UID string `json:"uid"` } 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 } - if req.UID == 0 { + if req.UID == "" { util.JSONError(w, "uid (number) is required", http.StatusBadRequest) return } @@ -121,3 +155,38 @@ func (h *Handlers) HandleMarkRead(w http.ResponseWriter, r *http.Request) { 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) + } +} diff --git a/service/integration/proton/integration.go b/service/integration/proton/integration.go index 33e5d11..75cfeff 100644 --- a/service/integration/proton/integration.go +++ b/service/integration/proton/integration.go @@ -2,10 +2,12 @@ package proton import ( "context" + "database/sql" "log/slog" "net/http" "prism/service/config" + "prism/service/credentials" "prism/service/notification" "prism/service/util" @@ -18,35 +20,64 @@ type Integration struct { logger *slog.Logger handlers *Handlers 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{ cfg: cfg, dispatcher: dispatcher, logger: logger, + handlers: handlers, tmpl: tmpl, + monitor: monitor, + db: db, + apiKey: apiKey, } } -func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) { - p.handlers = RegisterRoutes(router, p.cfg, p.dispatcher, p.logger, auth, p.tmpl) +func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) { + 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) { - if p.handlers != nil && p.handlers.IsEnabled() { - logger.Info("Proton Mail configured", "status", "connecting in background", "bridge", p.cfg.ProtonBridgeAddr) - go func() { - monitor := p.handlers.GetMonitor() - if err := monitor.Start(ctx); err != nil && ctx.Err() == nil { - logger.Error("Proton monitor error", "error", err) - } - }() + credStore, err := credentials.NewStore(p.db, p.apiKey) + if err != nil { + logger.Error("Failed to initialize credentials store for Proton", "error", err) + return } + + 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 { - return p.cfg.IsProtonEnabled() + return p.cfg.EnableProton } func (p *Integration) GetHandlers() *Handlers { diff --git a/service/integration/proton/monitor.go b/service/integration/proton/monitor.go index 543fd70..c10bb8d 100644 --- a/service/integration/proton/monitor.go +++ b/service/integration/proton/monitor.go @@ -2,75 +2,347 @@ package proton import ( "context" + "fmt" "log/slog" "time" "prism/service/config" + "prism/service/credentials" "prism/service/notification" - "github.com/emersion/go-imap/v2/imapclient" + "github.com/emersion/hydroxide/protonmail" ) const ( - imapInbox = "INBOX" - imapReconnectBaseDelay = 10 * time.Second - imapMaxReconnectDelay = 5 * time.Minute - imapMaxReconnectAttempts = 50 - prismTopic = "Proton Mail" + pollInterval = 30 * time.Second + prismTopic = "Proton Mail" ) type Monitor struct { cfg *config.Config dispatcher *notification.Dispatcher logger *slog.Logger - client *imapclient.Client - monitorStartTime time.Time - newMessagesChan chan struct{} + credStore *credentials.Store + client *protonmail.Client + eventID string + unseenMessageIDs map[string]time.Time + startTime time.Time } func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Monitor { return &Monitor{ - cfg: cfg, - dispatcher: dispatcher, - logger: logger, - newMessagesChan: make(chan struct{}, 10), + cfg: cfg, + dispatcher: dispatcher, + logger: logger, } } -func (m *Monitor) Start(ctx context.Context) error { - if !m.cfg.IsProtonEnabled() { +func (m *Monitor) Start(ctx context.Context, credStore *credentials.Store) error { + creds, err := credStore.GetProton() + if err != nil { + m.logger.Debug("Proton credentials not configured", "error", err) 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 { select { case <-ctx.Done(): - return ctx.Err() - default: - if err := m.connect(); err != nil { - m.logger.Error("Failed to connect to IMAP", "error", err) - time.Sleep(imapReconnectBaseDelay) - continue + return + case <-ticker.C: + if err := m.checkEvents(); err != nil { + m.logger.Error("Failed to check events", "error", err) } - - if err := m.monitor(ctx); err != nil { - m.logger.Error("Monitor error", "error", err) - } - - if m.client != nil { - if err := m.client.Logout(); err != nil { - m.logger.Error("Logout failed", "error", err) - } - m.client = nil - } - - time.Sleep(imapReconnectBaseDelay) + case <-cleanupTicker.C: + m.cleanupOldMessages() } } } +func (m *Monitor) checkEvents() error { + if m.client == nil { + return nil + } + + event, err := m.client.GetEvent(m.eventID) + if err != nil { + return err + } + + if event.ID != m.eventID { + m.processMessageEvents(event.Messages) + 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) + } + } + + 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 { 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}) +} diff --git a/service/integration/proton/routes.go b/service/integration/proton/routes.go index b9dd030..aa787a6 100644 --- a/service/integration/proton/routes.go +++ b/service/integration/proton/routes.go @@ -1,14 +1,18 @@ package proton import ( + "context" + "database/sql" "embed" + "encoding/json" "log/slog" "net/http" - "prism/service/config" + "prism/service/credentials" "prism/service/notification" "prism/service/util" + "github.com/emersion/hydroxide/protonmail" "github.com/go-chi/chi/v5" ) @@ -19,20 +23,164 @@ func GetTemplates() embed.FS { 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 { - if !cfg.IsProtonEnabled() { - return nil +type authHandler struct { + db *sql.DB + 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) - handlers := NewHandlers(monitor, cfg.ProtonIMAPUsername, logger, tmpl) + if req.Email == "" || req.Password == "" { + 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) { - r.Use(authMiddleware) - r.Post("/mark-read", handlers.HandleMarkRead) - }) + auth, err := c.Auth(req.Email, req.Password, authInfo) + if err != nil { + 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) + }) } diff --git a/service/integration/proton/templates/proton-content.html b/service/integration/proton/templates/proton-content.html index eaaa2d9..c44e169 100644 --- a/service/integration/proton/templates/proton-content.html +++ b/service/integration/proton/templates/proton-content.html @@ -1,17 +1,22 @@ {{if .Connected}} -

Unlink Instructions:

- + {{else}} -

Setup Instructions:

- -

See full setup guide

+

Link Proton Account:

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
{{end}} + diff --git a/service/integration/signal/client.go b/service/integration/signal/client.go index 720805b..b1fe983 100644 --- a/service/integration/signal/client.go +++ b/service/integration/signal/client.go @@ -1,114 +1,148 @@ package signal import ( + "bytes" "encoding/json" "fmt" - "net" - "sync/atomic" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" ) -var rpcID int32 +const ( + DefaultDeviceName = "Prism" + DefaultConfigPath = ".local/share/signal-cli" +) 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{ - SocketPath: socketPath, + ConfigPath: configPath, + enabled: enabled, } } -type RPCRequest struct { - JSONRPC string `json:"jsonrpc"` - 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"` +func (c *Client) IsEnabled() bool { + return c.enabled } type Account struct { 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) { - if c == nil { + if c == nil || !c.enabled { 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 { return nil, err } - var accounts []AccountInfo - if err := json.Unmarshal(result, &accounts); err != nil { + var accountsData struct { + Accounts []struct { + Number string `json:"number"` + UUID string `json:"uuid"` + } `json:"accounts"` + } + + if err := json.Unmarshal(data, &accountsData); err != nil { return nil, err } - if len(accounts) == 0 { + if len(accountsData.Accounts) == 0 { + c.accountCache = nil + c.accountCacheTime = time.Now() 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) { - if c == nil { + if c == nil || !c.enabled { 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") } - params := map[string]any{ - "name": name, - "member": []string{}, - } - - result, err := c.CallWithAccount("updateGroup", params, account.Number) + output, err := c.exec("-o", "json", "-a", account.Number, "updateGroup", "-n", name) if err != nil { - return "", "", err + return "", "", fmt.Errorf("failed to create group: %w", err) } var response struct { GroupID string `json:"groupId"` } - if err := json.Unmarshal(result, &response); err != nil { - return "", "", fmt.Errorf("failed to parse updateGroup response: %w", err) + + lines := bytes.Split(output, []byte("\n")) + 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 + } } - if response.GroupID == "" { - return "", "", fmt.Errorf("empty groupId in response") - } - - return response.GroupID, account.Number, nil + return "", "", fmt.Errorf("failed to parse group creation response") } func (c *Client) SendGroupMessage(groupID, message string) error { - if c == nil { + if c == nil || !c.enabled { return fmt.Errorf("signal client not initialized") } account, err := c.GetLinkedAccount() 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 { - return fmt.Errorf("no linked account") + return fmt.Errorf("no linked Signal account") } - params := map[string]any{ - "groupId": groupID, - "message": message, - "notifySelf": true, - } - - _, err = c.CallWithAccount("send", params, account.Number) + _, err = c.exec("-a", account.Number, "send", "-g", groupID, "--notify-self", "-m", message) 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") + } +} diff --git a/service/integration/signal/handlers.go b/service/integration/signal/handlers.go index bc81f93..93cbe9b 100644 --- a/service/integration/signal/handlers.go +++ b/service/integration/signal/handlers.go @@ -1,6 +1,7 @@ package signal import ( + "encoding/json" "html/template" "log/slog" "net/http" @@ -9,10 +10,9 @@ import ( ) type Handlers struct { - client *Client - linkDevice *LinkDevice - tmpl *util.TemplateRenderer - logger *slog.Logger + client *Client + tmpl *util.TemplateRenderer + logger *slog.Logger } type SignalContentData struct { @@ -32,49 +32,47 @@ type IntegrationData struct { 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{ - client: client, - linkDevice: linkDevice, - tmpl: tmpl, - logger: logger, + client: client, + tmpl: tmpl, + logger: logger, } } func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) { - if h.client == nil { - return - } - w.Header().Set("Content-Type", "text/html") - account, _ := h.client.GetLinkedAccount() - var contentData SignalContentData var integData IntegrationData integData.Name = "Signal" - if account != nil { - integData.StatusClass = "connected" - integData.StatusText = "Linked" - integData.StatusTooltip = FormatPhoneNumber(account.Number) - integData.Open = false - integData.PollAttrs = "" - - contentData.Linked = true - contentData.DeviceName = h.linkDevice.deviceName - } else { - integData.StatusClass = "unlinked" - integData.StatusText = "Unlinked" + 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 - integData.PollAttrs = "" - - contentData.Linked = false - qrCode, err := h.linkDevice.GenerateQR() + 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 { - contentData.QRCode = qrCode + integData.StatusClass = "connected" + integData.StatusText = "Linked" + integData.StatusTooltip = FormatPhoneNumber(account.Number) + integData.Open = false + contentData.Linked = true + contentData.DeviceName = FormatPhoneNumber(account.Number) } } @@ -95,9 +93,64 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) { } func (h *Handlers) IsEnabled() bool { - return h.client != nil + return h.client != nil && h.client.IsEnabled() } func (h *Handlers) GetClient() *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, + }) + } +} diff --git a/service/integration/signal/integration.go b/service/integration/signal/integration.go index bc64cfc..b8e0968 100644 --- a/service/integration/signal/integration.go +++ b/service/integration/signal/integration.go @@ -2,6 +2,7 @@ package signal import ( "context" + "database/sql" "log/slog" "net/http" @@ -14,6 +15,7 @@ import ( type Integration struct { cfg *config.Config + client *Client handlers *Handlers sender *Sender 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 { - var sender *Sender - if cfg.IsSignalEnabled() { - client := NewClient(cfg.SignalSocket) - sender = NewSender(client, store, logger) - } + client := NewClient() + sender := NewSender(client, store, logger) return &Integration{ cfg: cfg, + client: client, sender: sender, tmpl: tmpl, logger: logger, @@ -38,8 +38,8 @@ func (s *Integration) GetSender() *Sender { return s.sender } -func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) { - s.handlers = RegisterRoutes(router, s.cfg, auth, s.tmpl, s.logger) +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.client) } 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 { 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 { - return s.cfg.IsSignalEnabled() + if s.handlers == nil { + return false + } + return s.handlers.IsEnabled() } func (s *Integration) GetHandlers() *Handlers { diff --git a/service/integration/signal/link.go b/service/integration/signal/link.go deleted file mode 100644 index 46bf4ae..0000000 --- a/service/integration/signal/link.go +++ /dev/null @@ -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{} - } -} diff --git a/service/integration/signal/routes.go b/service/integration/signal/routes.go index 73162c2..45a7efa 100644 --- a/service/integration/signal/routes.go +++ b/service/integration/signal/routes.go @@ -18,16 +18,12 @@ func GetTemplates() embed.FS { return templates } -func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers { - if !cfg.IsSignalEnabled() { - return nil - } - - client := NewClient(cfg.SignalSocket) - linkDevice := NewLinkDevice(client, cfg.DeviceName) - handlers := NewHandlers(client, linkDevice, tmpl, logger) +func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler, tmpl *util.TemplateRenderer, logger *slog.Logger, client *Client) *Handlers { + handlers := NewHandlers(client, tmpl, logger) 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 } diff --git a/service/integration/signal/templates/signal-content.html b/service/integration/signal/templates/signal-content.html index 587da9e..430068e 100644 --- a/service/integration/signal/templates/signal-content.html +++ b/service/integration/signal/templates/signal-content.html @@ -1,26 +1,26 @@ {{if .Linked}} -

Unlink Instructions:

+

To unlink:

{{else}} {{if .Error}} -

Error generating QR code: {{.Error}}

+

{{.Error}}

{{else}} -

Link your Signal (or Molly) account:

- -
- Signal QR Code + + {{end}} {{end}} diff --git a/service/integration/telegram/handlers.go b/service/integration/telegram/handlers.go index a626611..4cf2bfc 100644 --- a/service/integration/telegram/handlers.go +++ b/service/integration/telegram/handlers.go @@ -1,10 +1,13 @@ package telegram import ( + "database/sql" "html/template" "log/slog" "net/http" + "strconv" + "prism/service/credentials" "prism/service/util" ) @@ -13,6 +16,8 @@ type Handlers struct { chatID int64 tmpl *util.TemplateRenderer logger *slog.Logger + db *sql.DB + apiKey string } type TelegramContentData struct { @@ -37,29 +42,73 @@ func NewHandlers(client *Client, chatID int64, tmpl *util.TemplateRenderer, logg chatID: chatID, tmpl: tmpl, 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) { w.Header().Set("Content-Type", "text/html") + client, chatID, _ := h.loadFreshCredentials() + var contentData TelegramContentData var integData IntegrationData integData.Name = "Telegram" - if h.client == nil { + if client == nil { integData.StatusClass = "disconnected" - integData.StatusText = "Not Configured" + integData.StatusText = "Unlinked" + integData.StatusTooltip = "Enter bot token to link" integData.Open = true contentData.NotConfigured = true } else { - bot, err := h.client.GetMe() + bot, err := client.GetMe() if err != nil { integData.StatusClass = "disconnected" integData.StatusText = "Error" + integData.StatusTooltip = err.Error() integData.Open = true contentData.Error = err.Error() - } else if h.chatID == 0 { + } else if chatID == 0 { integData.StatusClass = "disconnected" integData.StatusText = "Needs Chat ID" integData.StatusTooltip = "@" + bot.Username @@ -94,5 +143,11 @@ func (h *Handlers) IsEnabled() bool { } func (h *Handlers) GetClient() *Client { - return h.client + client, _, _ := h.loadFreshCredentials() + return client +} + +func (h *Handlers) GetChatID() int64 { + _, chatID, _ := h.loadFreshCredentials() + return chatID } diff --git a/service/integration/telegram/integration.go b/service/integration/telegram/integration.go index 43ced9b..2039420 100644 --- a/service/integration/telegram/integration.go +++ b/service/integration/telegram/integration.go @@ -2,10 +2,13 @@ package telegram import ( "context" + "database/sql" "log/slog" "net/http" + "strconv" "prism/service/config" + "prism/service/credentials" "prism/service/notification" "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 { - client, err := NewClient(cfg.TelegramBotToken) - if err != nil { - logger.Error("Failed to create telegram client", "error", err) - } - + var client *Client + var chatID int64 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{ cfg: cfg, @@ -48,22 +67,31 @@ func (t *Integration) GetHandlers() *Handlers { return t.handlers } -func (t *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) { - RegisterRoutes(router, t.handlers, auth) +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, db, apiKey, logger) } func (t *Integration) Start(ctx context.Context, logger *slog.Logger) { - if t.handlers != nil && t.handlers.IsEnabled() { - client := t.handlers.GetClient() - bot, err := client.GetMe() - if err != nil { - logger.Error("Telegram bot error", "error", err) - } else { - logger.Info("Telegram enabled", "bot", bot.Username, "id", bot.ID) - } + client := t.handlers.GetClient() + if client == nil { + logger.Info("Telegram not configured", "action", "visit admin UI to configure") + return + } + + bot, err := client.GetMe() + if err != nil { + logger.Error("Telegram bot error", "error", err) + return + } + + 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 { - return t.cfg.IsTelegramEnabled() + return t.handlers != nil && t.handlers.GetClient() != nil } diff --git a/service/integration/telegram/routes.go b/service/integration/telegram/routes.go index 06a5c57..7dfd064 100644 --- a/service/integration/telegram/routes.go +++ b/service/integration/telegram/routes.go @@ -1,9 +1,15 @@ package telegram import ( + "database/sql" "embed" + "encoding/json" + "log/slog" "net/http" + "prism/service/credentials" + "prism/service/util" + "github.com/go-chi/chi/v5" ) @@ -14,10 +20,75 @@ func GetTemplates() embed.FS { 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 { return } + handlers.SetDB(db, apiKey) + + authH := &authHandler{db: db, apiKey: apiKey, logger: logger} + 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) } diff --git a/service/integration/telegram/sender.go b/service/integration/telegram/sender.go index b7dd45c..ec0f741 100644 --- a/service/integration/telegram/sender.go +++ b/service/integration/telegram/sender.go @@ -37,7 +37,7 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica message = fmt.Sprintf("%s\n%s", notif.Title, notif.Message) } - fullMessage := fmt.Sprintf("📱 %s\n\n%s", mapping.AppName, message) + fullMessage := fmt.Sprintf("%s\n\n%s", mapping.AppName, message) if err := s.client.SendMessage(s.DefaultChatID, fullMessage); err != nil { s.logger.Error("Failed to send telegram message", "chatID", s.DefaultChatID, "error", err) diff --git a/service/integration/telegram/templates/telegram-content.html b/service/integration/telegram/templates/telegram-content.html index 646fc3c..199cc13 100644 --- a/service/integration/telegram/templates/telegram-content.html +++ b/service/integration/telegram/templates/telegram-content.html @@ -1,27 +1,39 @@ {{if .NotConfigured}} -

Setup Instructions:

+

Setup Telegram Bot:

-

See full setup guide

+
+
+ + +
+
+ + +
+ +
+
{{else if .Error}}

Error: {{.Error}}

+ {{else if .NeedsChatID}}

Complete Setup:

- +

Bot is configured, but Chat ID is missing.

+
+
+ + +
+ +
+
{{else}} -

Unlink Instructions:

- + {{end}} + diff --git a/service/integration/templates/integrations.html b/service/integration/templates/integrations.html new file mode 100644 index 0000000..a0f2fb5 --- /dev/null +++ b/service/integration/templates/integrations.html @@ -0,0 +1,17 @@ +{{if .EnableSignal}} +
+
+
+{{end}} + +{{if .EnableTelegram}} +
+
+
+{{end}} + +{{if .EnableProton}} +
+
+
+{{end}} diff --git a/service/integration/webpush/integration.go b/service/integration/webpush/integration.go index 75595d9..6fb3514 100644 --- a/service/integration/webpush/integration.go +++ b/service/integration/webpush/integration.go @@ -2,6 +2,7 @@ package webpush import ( "context" + "database/sql" "log/slog" "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) } diff --git a/service/notification/dispatcher.go b/service/notification/dispatcher.go index 32b41ed..4f166a4 100644 --- a/service/notification/dispatcher.go +++ b/service/notification/dispatcher.go @@ -56,6 +56,11 @@ func (d *Dispatcher) IsValidChannel(channel Channel) bool { 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 { mapping, err := d.store.GetApp(appName) 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 { - maxRetries := 3 - baseDelay := 100 * time.Millisecond + maxRetries := 10 + baseDelay := 500 * time.Millisecond var lastErr error for attempt := 0; attempt < maxRetries; attempt++ { diff --git a/service/notification/notification.go b/service/notification/notification.go index 2d72afd..3a7b180 100644 --- a/service/notification/notification.go +++ b/service/notification/notification.go @@ -2,6 +2,7 @@ package notification type Action struct { ID string `json:"id"` + Label string `json:"label"` Endpoint string `json:"endpoint"` Method string `json:"method"` Data map[string]any `json:"data,omitempty"` @@ -10,6 +11,7 @@ type Action struct { type Notification struct { Title string `json:"title,omitempty"` Message string `json:"message"` + Tag string `json:"tag,omitempty"` Actions []Action `json:"actions,omitempty"` } diff --git a/service/notification/store.go b/service/notification/store.go index c9a1b1e..ef8ae56 100644 --- a/service/notification/store.go +++ b/service/notification/store.go @@ -62,6 +62,10 @@ func (s *Store) Close() error { 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 { var signalGroupID, signalAccount *string if signal != nil { diff --git a/service/server/handlers_admin.go b/service/server/handlers_admin.go index 1450fc1..22ac218 100644 --- a/service/server/handlers_admin.go +++ b/service/server/handlers_admin.go @@ -133,7 +133,6 @@ func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) { "mappingsCount": len(mappings), "signalCount": countByChannel(mappings, notification.ChannelSignal), "webpushCount": countByChannel(mappings, notification.ChannelWebPush), - "protonEnabled": s.cfg.IsProtonEnabled(), } w.Header().Set("Content-Type", "application/json") diff --git a/service/server/handlers_fragment.go b/service/server/handlers_fragment.go index 1b1134d..b8bddbc 100644 --- a/service/server/handlers_fragment.go +++ b/service/server/handlers_fragment.go @@ -41,17 +41,31 @@ func (s *Server) buildAppListData(mappings []notification.Mapping) []AppListItem 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)) for _, m := range mappings { - item := s.buildAppListItem(m, signalLinked, telegramConfigured) + item := s.buildAppListItem(m, signalLinked, telegramLinked, telegramInfo) items = append(items, item) } 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 isWebPush := m.Channel == notification.ChannelWebPush 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 { item.Hostname = u.Hostname() } - } else if isTelegram && telegramConfigured { + } else if isTelegram && telegramLinked { item.ChannelBadge = m.Channel.Label() + item.Tooltip = telegramInfo item.ChannelConfigured = true } else { - item.ChannelBadge = "Not Configured" + item.ChannelBadge = "Unlinked" item.ChannelConfigured = false if isSignal { 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 } @@ -98,14 +113,14 @@ func (s *Server) buildChannelOptions(m notification.Mapping, signalLinked, teleg var options []SelectOption - if signalLinked { + if s.integrations.Signal != nil { options = append(options, SelectOption{ Value: notification.ChannelSignal.String(), Label: notification.ChannelSignal.Label(), Selected: isSignal, }) } - if telegramConfigured { + if s.integrations.Telegram != nil { options = append(options, SelectOption{ Value: notification.ChannelTelegram.String(), Label: notification.ChannelTelegram.Label(), diff --git a/service/server/handlers_health.go b/service/server/handlers_health.go index 55cf5a5..3b08443 100644 --- a/service/server/handlers_health.go +++ b/service/server/handlers_health.go @@ -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() { telegramClient := s.integrations.Telegram.GetHandlers().GetClient() 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") if err := json.NewEncoder(w).Encode(resp); err != nil { s.logger.Error("Failed to encode health response", "error", err) diff --git a/service/server/handlers_integrations.go b/service/server/handlers_integrations.go index 8181e2f..bc84165 100644 --- a/service/server/handlers_integrations.go +++ b/service/server/handlers_integrations.go @@ -1,26 +1,20 @@ package server import ( - "fmt" + "bytes" "net/http" + + "prism/service/util" ) func (s *Server) handleFragmentIntegrations(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - html := "" - - if s.cfg.IsSignalEnabled() { - html += `
` + var buf bytes.Buffer + if err := s.fragmentTmpl.ExecuteTemplate(&buf, "integrations.html", s.cfg); err != nil { + util.LogAndError(w, s.logger, "Internal server error", http.StatusInternalServerError, err) + return } - if s.cfg.IsProtonEnabled() { - html += `
` - } - - if s.cfg.IsTelegramEnabled() { - html += `
` - } - - _, _ = fmt.Fprint(w, html) + w.Write(buf.Bytes()) } diff --git a/service/server/server.go b/service/server/server.go index 3d52b4a..89a2441 100644 --- a/service/server/server.go +++ b/service/server/server.go @@ -14,9 +14,6 @@ import ( "prism/service/config" "prism/service/integration" - "prism/service/integration/proton" - "prism/service/integration/signal" - "prism/service/integration/telegram" "prism/service/notification" "prism/service/util" @@ -55,21 +52,11 @@ func New(cfg *config.Config, publicAssets embed.FS) (*Server, error) { if err != nil { 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 := integration.Initialize(cfg, store, logger, templateRenderer) + integrations, fragmentTmpl, err := integration.Initialize(cfg, store, logger, fragmentTmpl) + if err != nil { + return nil, err + } version := "dev" if versionBytes, err := os.ReadFile("VERSION"); err == nil { diff --git a/service/server/templates/app-list.html b/service/server/templates/app-list.html index b54ace8..7a203b0 100644 --- a/service/server/templates/app-list.html +++ b/service/server/templates/app-list.html @@ -32,7 +32,7 @@ {{end}} - +
{{end}}