diff --git a/.env.example b/.env.example index 6e91c82..4bd78be 100644 --- a/.env.example +++ b/.env.example @@ -1,30 +1,47 @@ # Required: API key for authentication # API_KEY=your-secret-key-here +# Optional: Feature flags to enable/disable integrations +# FEATURE_ENABLE_SIGNAL=false +# FEATURE_ENABLE_PROTON=false +# FEATURE_ENABLE_TELEGRAM=false + +# Advanced Configuration + + # Optional: Server port -# Default: 8080 # PORT=8080 # Optional: Rate limit for requests (per 15 minute window) -# Default: 100 (requests per 15 minutes) # RATE_LIMIT=100 # Optional: Device name shown in Signal app's linked devices list -# Default: Prism -# DEVICE_NAME=Prism Dev +# DEVICE_NAME=Prism # Optional: Enable verbose logging -# Default: false # VERBOSE_LOGGING=true -# Optional: signal-cli version to install/use -# Default: 0.13.23 -# SIGNAL_CLI_VERSION=0.13.23 -# Optional: Proton Mail integration - receive email notifications via Signal -# Get credentials by running: docker compose run --rm protonmail-bridge init -# Then use 'login' and 'info' commands to get IMAP username/password +# Signal Integration (requires FEATURE_ENABLE_SIGNAL=true) + +# Optional: Path to the signal-cli daemon Unix socket +# SIGNAL_SOCKET=/run/signal-cli/socket + + +# Proton Mail Integration (requires FEATURE_ENABLE_PROTON=true) + +# Required: IMAP credentials for Proton Mail Bridge # PROTON_IMAP_USERNAME=your-email@proton.me # PROTON_IMAP_PASSWORD=bridge-generated-password -# PROTON_BRIDGE_HOST=protonmail-bridge -# PROTON_BRIDGE_PORT=143 \ No newline at end of file + +# 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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..e1032d0 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,35 @@ +# Copilot Instructions + +## Code Style + +- **NO USELESS COMMENTS**: Don't add comments that just restate what the code does. If the code needs a comment to be understood, refactor it to be clearer instead. +- **Self-documenting code**: Use clear variable and function names. The code should explain itself. +- **Only comment WHY, not WHAT**: If you must add a comment, explain WHY something is done, not WHAT is being done. + +### Bad Comments (Don't Do This) + +```go +// Check what channels are actually available +signalLinked := false + +// Build channel selector with only available options +var channelOptions string +``` + +### Good Comments (Rare, Only When Necessary) + +```go +// HACK: QR service has 60s timeout, cache to avoid repeated calls +if time.Since(l.generatedAt) < l.ttl { + return l.qrCode, nil +} +``` + +## General Rules + +- Code must be idiomatic and follow Go best practices +- Keep functions focused and small +- Use clear, descriptive names +- Prefer composition over inheritance +- Handle errors properly, don't ignore them + diff --git a/.github/workflows/release-signal-cli.yml b/.github/workflows/release-signal-cli.yml new file mode 100644 index 0000000..94e1b76 --- /dev/null +++ b/.github/workflows/release-signal-cli.yml @@ -0,0 +1,30 @@ +name: Release Signal CLI + +on: + workflow_dispatch: + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push signal-cli Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile.signal-cli + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/lone-cloud/prism-signal-cli:latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e28598..f040021 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,7 @@ name: Release on: - push: - tags: ['v*'] workflow_dispatch: - inputs: - version: - description: 'Version tag (e.g., 0.1.0)' - required: true - type: string jobs: release: @@ -29,11 +22,7 @@ jobs: - name: Extract version id: version run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "tag=${{ inputs.version }}" >> $GITHUB_OUTPUT - else - echo "tag=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - fi + echo "tag=$(cat VERSION)" >> $GITHUB_OUTPUT - name: Build and push Docker image uses: docker/build-push-action@v6 diff --git a/.gitignore b/.gitignore index 09af0b8..4f210fc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ data/ .env /prism prism-* +tmp +.VSCodeCounter/ diff --git a/Dockerfile b/Dockerfile index df66dd7..f4a0402 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,13 +9,14 @@ RUN go mod download COPY . . -RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo \ +RUN CGO_ENABLED=1 GOOS=linux go build \ + -trimpath \ -ldflags="-w -s -X main.version=$(cat VERSION 2>/dev/null || echo dev) -X main.commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \ -o prism . FROM alpine:latest -RUN apk --no-cache add ca-certificates signal-cli openjdk21-jre +RUN apk --no-cache add ca-certificates WORKDIR /app @@ -23,14 +24,11 @@ COPY --from=builder /build/prism . COPY public ./public RUN adduser -D -u 1000 prism && \ - mkdir -p /var/run/signal-cli /app/data && \ - chown -R prism:prism /app /var/run/signal-cli + mkdir -p /app/data && \ + chown -R prism:prism /app USER prism -ENV SIGNAL_CLI_BINARY=signal-cli -ENV SIGNAL_CLI_SOCKET=/var/run/signal-cli/socket - EXPOSE 8080 CMD ["./prism"] diff --git a/Dockerfile.signal-cli b/Dockerfile.signal-cli new file mode 100644 index 0000000..43b696c --- /dev/null +++ b/Dockerfile.signal-cli @@ -0,0 +1,12 @@ +FROM alpine:latest + +RUN apk add --no-cache signal-cli openjdk21-jre bash su-exec + +RUN adduser -D -u 1000 signal && \ + mkdir -p /home/signal/.signal-cli /home/.local/share/signal-cli && \ + chown -R signal:signal /home/signal /home/.local/share/signal-cli + +RUN printf '#!/bin/bash\nset -e\nmkdir -p /home/signal/.signal-cli\nchown signal:signal /home/signal/.signal-cli\nchmod 755 /home/signal/.signal-cli\nexec su-exec signal signal-cli --config /home/.local/share/signal-cli daemon --socket /home/signal/.signal-cli/socket\n' > /entrypoint.sh && \ + chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/Makefile b/Makefile index 5a0bae4..bcfdd1d 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ BINARY_NAME=prism VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev") COMMIT?=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") -GOBIN?=$(shell go env GOPATH)/bin +GOBIN?=$(shell command -v go >/dev/null 2>&1 && go env GOPATH || echo "${HOME}/go")/bin export PATH := $(GOBIN):$(PATH) all: fmt lint build @@ -39,8 +39,6 @@ install-tools: @echo "Installing Go tools to $(GOBIN)..." go install golang.org/x/tools/cmd/goimports@latest curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v2.8.0 - @echo "Installing signal-cli..." - @bash scripts/install-signal-cli.sh deps: go mod download @@ -59,7 +57,13 @@ docker-down: docker compose -f docker-compose.dev.yml down docker-up-proton: - docker compose -f docker-compose.dev.yml --profile protonmail up -d + docker compose -f docker-compose.dev.yml up -d --build protonmail-bridge + +docker-up-signal: + docker compose -f docker-compose.dev.yml up -d --build signal-cli + +docker-up-telegram: + docker compose -f docker-compose.dev.yml up -d --build telegram-bot release: @if [ ! -f VERSION ]; then \ @@ -70,4 +74,8 @@ release: echo "Releasing v$$VERSION..."; \ git tag -a "v$$VERSION" -m "Release v$$VERSION"; \ git push origin "v$$VERSION"; \ - echo "Tag pushed. GitHub Actions will build and push Docker image." + gh workflow run release.yml + +release-signal: + @echo "Triggering signal-cli image release via GitHub Actions..." + gh workflow run release-signal-cli.yml \ No newline at end of file diff --git a/README.md b/README.md index 350d8c0..6b1391a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Prism -**Self-hosted notification gateway using Signal and WebPush for transport** +**Self-hosted notification gateway using WebPush and optional Signal for transport** [Setup](#setup) • [Real-World Examples](#real-world-examples) • [Architecture](#architecture) @@ -12,128 +12,147 @@ -Prism is a self-hosted notification gateway that receives HTTP requests and routes them through Signal groups or WebPush apps. Route notifications through Signal to avoid exposing unique network fingerprints, or forward them to your own WebPush apps for custom handling. - +Prism is a self-hosted notification gateway that receives HTTP requests and routes them through WebPush apps or optionally through Signal groups. Route notifications through Signal to avoid exposing unique network fingerprints, or forward them to your own WebPush apps for custom handling. ## Setup -### 1. Proton Mail Integration - -A Proton Mail Bridge is optionally available if you want to receive push notifications for incoming emails. - -> **Note:** The default Proton Mail Bridge image uses `shenxn/protonmail-bridge:build` which compiles from source and supports multiple architectures. For x86_64 systems, you can use `shenxn/protonmail-bridge:latest` (pre-built binary, smaller and faster). For ARM devices (Raspberry Pi), stick with `:build`. - -To receive Proton Mail notifications via Signal: - -1. **Initialize Proton Mail Bridge** (one-time setup): - ```bash # Download docker-compose.yml curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/docker-compose.yml -docker compose run --rm protonmail-bridge init -``` - -2.**Login to Proton Mail Bridge**: - -- At the `>>>` prompt, run: `login` -- Enter your email -- Enter your password -- Enter your 2FA code - -3.**Get IMAP credentials**: - -- Run: `info` -- Copy the Username and Password shown -- Run: `exit` to quit - -4.**Add credentials to .env**: - -```bash -# Add these to your .env file -PROTON_IMAP_USERNAME=bridge-username-from-info-command -PROTON_IMAP_PASSWORD=bridge-generated-password-from-info-command -``` - -5.**Start all services with Proton Mail**: - -```bash -docker compose --profile protonmail up -d -``` - -Your phone will now receive Signal notifications when Proton Mail receives new emails. - -Note that the bridge will first need to sync all of your old emails before you can start getting new email notifications which may take a while, but this is a one-time setup. - -### 2. Install Prism Server - -```bash -# Download docker-compose.yml -curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/docker-compose.yml - -# Download .env.example (optional) +# Download .env.example curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example -# Configure Prism server through environment variables (optional) +# Configure your API key cp .env.example .env -nano .env +nano .env # Set API_KEY=your-secret-key-here -# Start Prism server +# Start Prism docker compose up -d - ``` -### 3. Link Your Signal Account +Prism is now running at . By default, all notifications use WebPush (encrypted push notifications or plain HTTP webhooks). Enable optional integrations below for Signal or Telegram delivery, or Proton Mail monitoring. -Visit and link your Signal account (one-time setup): +## Integrations -#### 1. Authenticate with your API_KEY +All integrations are optional. Enable only what you need. -![Admin login screen](assets/screenshots/1.webp) +### Signal -#### 2. Scan the QR code from your Signal app +Send notifications through Signal groups instead of WebPush. -Go to **Settings → Linked Devices → Link New Device** in Signal. +**1. Enable Signal in `.env`:** + +```bash +FEATURE_ENABLE_SIGNAL=true +``` + +**2. Restart Prism:** + +```bash +docker compose down +docker compose up -d +``` + +**3. Link your Signal account:** + +Visit , authenticate with your API_KEY, and scan the QR code from your Signal app: + +**Settings → Linked Devices → Link New Device** ![QR code linking screen](assets/screenshots/2.webp) -#### 3. Verify the setup +Once linked, new apps will default to Signal delivery. -Once linked, you'll see the status dashboard: +### Telegram -![Healthy setup with linked account](assets/screenshots/3.webp) +Send notifications through Telegram instead of WebPush. -With optional Proton Mail integration: +**1. Create a Telegram bot:** -![Healthy setup with Proton Mail](assets/screenshots/4.webp) +- Message [@BotFather](https://t.me/BotFather) on Telegram +- Send `/newbot` and follow the prompts +- Copy the bot token (looks like `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`) -### Development - -For local development, install Go and signal-cli: +**2. Enable Telegram in `.env`:** ```bash -git clone https://github.com/lone-cloud/prism.git -cd prism - -# Install development tools and signal-cli -make install-tools - -# Run locally -make dev +FEATURE_ENABLE_TELEGRAM=true +TELEGRAM_BOT_TOKEN=your-bot-token-here ``` -Then build and run with docker-compose.dev.yml: +**3. Restart Prism:** ```bash -docker compose --profile protonmail -f docker-compose.dev.yml up -d +docker compose down +docker compose up -d ``` -or just the proton-bridge: +**4. Get your chat ID:** + +- Message [@userinfobot](https://t.me/userinfobot) on Telegram +- Copy your Chat ID from the bot's response + +**5. Add chat ID to `.env`:** ```bash -docker compose -f docker-compose.dev.yml up protonmail-bridge +TELEGRAM_CHAT_ID=123456789 ``` +**6. Restart Prism:** + +```bash +docker compose down +docker compose up -d +``` + +All notifications will now be sent to your Telegram chat. Unlike Signal, Telegram doesn't create separate groups per app - all notifications go to the configured chat ID. + +### Proton Mail + +Receive notifications when new Proton Mail emails arrive. + +> **Note:** The default image (`shenxn/protonmail-bridge:build`) compiles from source and supports all architectures. For x86_64 only, you can use `shenxn/protonmail-bridge:latest` (smaller, faster). + +**1. Initialize the bridge:** + +```bash +docker compose run --rm protonmail-bridge init +``` + +**2. Login at the `>>>` prompt:** + +```bash +>>> login +# Enter your Proton Mail email +# Enter your password +# Enter your 2FA code +``` + +**3. Get IMAP credentials:** + +```bash +>>> info +# Copy the Username and Password +>>> exit +``` + +**4. Add credentials to `.env`:** + +```bash +FEATURE_ENABLE_PROTON=true +PROTON_IMAP_USERNAME=username-from-info-command +PROTON_IMAP_PASSWORD=password-from-info-command +``` + +**5. Start with Proton profile:** + +```bash +docker compose --profile proton up -d +``` + +Prism will now forward Proton Mail notifications to your configured channel (Signal, Telegram, or WebPush). + ## Real-World Examples ### Proton Mail Notifications @@ -245,10 +264,10 @@ For API-based monitoring, call `/api/health` which returns JSON: Prism accepts notifications via HTTP POST requests and routes them based on your configured delivery method: -- **Signal groups**: Uses [signal-cli](https://github.com/AsamK/signal-cli) to create a Signal group for each app and send notifications as messages -- **WebPush**: Supports both encrypted WebPush (with VAPID signing and payload encryption) and plain HTTP webhooks +- **WebPush** (default): Supports both encrypted WebPush (with VAPID signing and payload encryption) and plain HTTP webhooks - **Encrypted WebPush**: Full WebPush protocol with end-to-end encryption - requires `appName`, `pushEndpoint`, `p256dh`, `auth`, and `vapidPrivateKey` - **Plain webhooks**: Simple JSON POST to any HTTP endpoint - only requires `appName` and `pushEndpoint` +- **Signal groups** (optional): Uses [signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api) to create a Signal group for each app and send notifications as messages Each app can be independently configured to use either delivery method through the admin UI. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 22ddba1..740482c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -11,19 +11,33 @@ services: - API_KEY=${API_KEY:-} - VERBOSE_LOGGING=${VERBOSE_LOGGING:-false} - RATE_LIMIT=${RATE_LIMIT:-100} + - FEATURE_ENABLE_SIGNAL=${FEATURE_ENABLE_SIGNAL:-false} + - SIGNAL_SOCKET=/home/signal/.signal-cli/socket + - FEATURE_ENABLE_PROTON=${FEATURE_ENABLE_PROTON:-false} - PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-} - PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-} - - PROTON_BRIDGE_HOST=protonmail-bridge - - PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143} + - PROTON_BRIDGE_ADDR=${PROTON_BRIDGE_ADDR:-protonmail-bridge:143} volumes: - - signal-data:/root/.local/share/signal-cli - prism-data:/root/.local/share/prism + - signal-socket:/home/signal/.signal-cli:ro + restart: unless-stopped + + signal-cli: + container_name: signal-cli + build: + context: . + dockerfile: Dockerfile.signal-cli + profiles: ['signal'] + volumes: + - signal-data:/home/.local/share/signal-cli + - signal-socket:/home/signal/.signal-cli + - /tmp/signal-cli:/home/signal/.signal-cli restart: unless-stopped protonmail-bridge: container_name: protonmail-bridge image: shenxn/protonmail-bridge:build - profiles: ['protonmail'] + profiles: ['proton'] ports: - '127.0.0.1:143:143' volumes: @@ -33,5 +47,6 @@ services: volumes: signal-data: + signal-socket: prism-data: proton-bridge-data: diff --git a/docker-compose.yml b/docker-compose.yml index f13f787..01aad02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,19 +9,33 @@ services: - API_KEY=${API_KEY:-} - VERBOSE_LOGGING=${VERBOSE_LOGGING:-false} - RATE_LIMIT=${RATE_LIMIT:-100} + - FEATURE_ENABLE_SIGNAL=${FEATURE_ENABLE_SIGNAL:-false} + - SIGNAL_SOCKET=/home/signal/.signal-cli/socket + - FEATURE_ENABLE_PROTON=${FEATURE_ENABLE_PROTON:-false} - PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-} - PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-} - - PROTON_BRIDGE_HOST=protonmail-bridge - - PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143} + - PROTON_BRIDGE_ADDR=${PROTON_BRIDGE_ADDR:-protonmail-bridge:143} + - FEATURE_ENABLE_TELEGRAM=${FEATURE_ENABLE_TELEGRAM:-false} + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-} + - TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-} volumes: - - signal-data:/root/.local/share/signal-cli - prism-data:/root/.local/share/prism + - signal-socket:/home/signal/.signal-cli:ro + restart: unless-stopped + + signal-cli: + container_name: signal-cli + image: ghcr.io/lone-cloud/prism-signal-cli:latest + profiles: ['signal'] + volumes: + - signal-data:/home/.local/share/signal-cli + - signal-socket:/home/signal/.signal-cli restart: unless-stopped protonmail-bridge: container_name: protonmail-bridge image: shenxn/protonmail-bridge:build - profiles: ['protonmail'] + profiles: ['proton'] volumes: - proton-bridge-data:/root - /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro @@ -29,5 +43,6 @@ services: volumes: signal-data: + signal-socket: prism-data: proton-bridge-data: diff --git a/go.mod b/go.mod index 9dc4a89..399053e 100644 --- a/go.mod +++ b/go.mod @@ -8,12 +8,27 @@ require ( github.com/go-chi/chi/v5 v5.2.4 github.com/joho/godotenv v1.5.1 github.com/mattn/go-sqlite3 v1.14.33 + github.com/mymmrac/telego v1.5.1 golang.org/x/time v0.14.0 ) require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/emersion/go-message v0.18.1 // indirect github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect - golang.org/x/crypto v0.31.0 // indirect + github.com/grbit/go-json v0.11.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.69.0 // indirect + github.com/valyala/fastjson v1.6.7 // indirect + golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/sys v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index 73566cc..08908f2 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,18 @@ github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-imap/v2 v2.0.0-beta.7 h1:lNznYWa5uhMrngnSYEklzCeye4DBq9TEJ+pr0K593+8= github.com/emersion/go-imap/v2 v2.0.0-beta.7/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk= github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E= @@ -11,18 +24,53 @@ github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfup github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= +github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mymmrac/telego v1.5.1 h1:BnPPo158ABpHdS6xsTymLb8ut1gLwS927y87c+14mV8= +github.com/mymmrac/telego v1.5.1/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= +github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= +github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= +github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -54,6 +102,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -81,3 +131,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 7aec5cb..4cb6418 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "log/slog" "os" "os/signal" "syscall" @@ -29,22 +30,22 @@ func main() { return } - if err := runServer(); err != nil { - logger := util.NewLogger(false) - logger.Error("Fatal error", "error", err) - os.Exit(1) - } -} - -func runServer() error { cfg, err := config.Load() if err != nil { - return fmt.Errorf("failed to load config: %w", err) + fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err) + os.Exit(1) } logger := util.NewLogger(cfg.VerboseLogging) logger.Info("Starting Prism", "version", version) + if err := runServer(cfg, logger); err != nil { + logger.Error("Fatal error", "error", err) + os.Exit(1) + } +} + +func runServer(cfg *config.Config, logger *slog.Logger) error { srv, err := server.New(cfg) if err != nil { return fmt.Errorf("failed to create server: %w", err) diff --git a/public/index.css b/public/index.css index e6aeb33..8ce242a 100644 --- a/public/index.css +++ b/public/index.css @@ -3,41 +3,53 @@ --bg-secondary: #fdfdfd; --text-primary: #000; --text-secondary: #666; + --text-on-color: #fafafa; --border-color: rgba(0, 0, 0, 0.1); --accent: #8159b8; --success: #28a745; --error: #dc3545; --spinner-track: #e0e0e0; - --badge-signal-bg: #8159b8; - --badge-webhook-bg: rgba(40, 167, 69, 0.2); } @media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { + :root:not([data-theme="light"]):not([data-theme="dark"]) { --bg-primary: #1a1a1a; --bg-secondary: #2d2d2d; --text-primary: #e0e0e0; --text-secondary: #a0a0a0; + --text-on-color: #fff; --border-color: rgba(255, 255, 255, 0.1); + --accent: #a78bfa; --success: #28a745; --error: #ef4444; --spinner-track: #4a4a4a; - --badge-signal-bg: #8159b8; - --badge-webhook-bg: #28a745; } } -[data-theme="dark"] { +:root[data-theme="light"] { + --bg-primary: #f5f5f5; + --bg-secondary: #fdfdfd; + --text-primary: #000; + --text-secondary: #666; + --text-on-color: #fafafa; + --border-color: rgba(0, 0, 0, 0.1); + --accent: #8159b8; + --success: #28a745; + --error: #dc3545; + --spinner-track: #e0e0e0; +} + +:root[data-theme="dark"] { --bg-primary: #1a1a1a; --bg-secondary: #2d2d2d; --text-primary: #e0e0e0; --text-secondary: #a0a0a0; + --text-on-color: #fff; --border-color: rgba(255, 255, 255, 0.1); + --accent: #a78bfa; --success: #28a745; --error: #ef4444; --spinner-track: #4a4a4a; - --badge-signal-bg: #8159b8; - --badge-webhook-bg: #28a745; } /* CSS Reset */ @@ -87,7 +99,7 @@ h6 { body { font-family: system-ui; max-width: 50rem; - margin: 1.25rem auto; + margin: 1rem auto; padding: 0 1.25rem 1.25rem; background: var(--bg-primary); color: var(--text-primary); @@ -96,15 +108,56 @@ body { .card { background: var(--bg-secondary); border-radius: 0.5rem; - padding: 1.25rem; - margin-bottom: 1.25rem; + padding: 0; + margin-bottom: 1rem; box-shadow: 0 0.125rem 0.25rem var(--border-color); } +.card-header { + font-weight: 600; + font-size: 1.1em; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.25rem; + cursor: pointer; + list-style: none; + user-select: none; +} + +.card-header::-webkit-details-marker { + display: none; +} + +.card-header::before { + content: "▶"; + font-size: 0.7em; + margin-right: 0.5rem; + transition: transform 0.2s; + display: inline-block; +} + +details[open] > .card-header::before { + transform: rotate(90deg); +} + +.card #apps-list { + padding: 0 1.25rem 1.25rem 1.25rem; +} + h2 { margin-bottom: 1.25rem; } +a { + color: var(--accent); + text-decoration: underline; +} + +a:hover { + opacity: 0.8; +} + .status { display: flex; gap: 1rem; @@ -119,12 +172,14 @@ h2 { } .status-item:has(.tooltip), -.channel-badge:has(.tooltip) { +.channel-badge:has(.tooltip), +.integration-status:has(.tooltip) { cursor: help; } .status-item .tooltip, -.channel-badge .tooltip { +.channel-badge .tooltip, +.integration-status .tooltip { visibility: hidden; opacity: 0; position: absolute; @@ -147,7 +202,8 @@ h2 { } .status-item .tooltip::after, -.channel-badge .tooltip::after { +.channel-badge .tooltip::after, +.integration-status .tooltip::after { content: ""; position: absolute; top: 100%; @@ -158,14 +214,16 @@ h2 { } .status-item:hover .tooltip, -.channel-badge:hover .tooltip { +.channel-badge:hover .tooltip, +.integration-status:hover .tooltip { visibility: visible; opacity: 1; } .theme-toggle { - display: block; - margin-left: auto; + position: fixed; + bottom: 1.5rem; + right: 1.5rem; background: transparent; border: none; cursor: pointer; @@ -182,12 +240,12 @@ h2 { .status-ok { background: var(--success); - color: white; + color: var(--text-on-color); } .status-error { background: var(--error); - color: white; + color: var(--text-on-color); } .app-list { @@ -235,13 +293,18 @@ h2 { } .channel-signal { - background: var(--badge-signal-bg); - color: white; + background: var(--accent); + color: var(--text-on-color); } -.channel-webhook { - background: var(--badge-webhook-bg); - color: white; +.channel-webpush { + background: var(--success); + color: var(--text-on-color); +} + +.channel-telegram { + background: #0088cc; + color: var(--text-on-color); } .app-detail { @@ -268,7 +331,7 @@ h2 { .btn-delete { padding: 0.375rem 0.75rem; background: var(--error); - color: white; + color: var(--text-on-color); border: none; border-radius: 0.25rem; cursor: pointer; @@ -284,7 +347,7 @@ h2 { margin-top: 1rem; padding: 0.5rem 1rem; background: var(--text-secondary); - color: white; + color: var(--text-on-color); border: none; border-radius: 0.25rem; cursor: pointer; @@ -312,30 +375,11 @@ h2 { margin-left: 1.25rem; } -.qr-instructions { - font-size: 1.05em; -} - -.qr-container { - margin-top: 1rem; - width: 18.75rem; - height: 18.75rem; - display: flex; - align-items: center; - justify-content: center; -} - -.qr-image { - width: 100%; - height: 100%; - object-fit: contain; -} - .link-button { display: inline-block; padding: 0.625rem 1.25rem; background: var(--accent); - color: white; + color: var(--text-on-color); text-decoration: none; border-radius: 0.25rem; margin-top: 0.625rem; @@ -375,3 +419,95 @@ h2 { color: var(--text-secondary); margin-top: 0.5rem; } + +.integration-card { + background: var(--bg-secondary); + border-radius: 0.5rem; + margin-bottom: 1.25rem; + box-shadow: 0 0.125rem 0.25rem var(--border-color); + padding: 0; +} + +.integration-container { + margin-bottom: 1.25rem; +} + +.integration-header { + font-weight: 600; + font-size: 1.1em; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.25rem; + cursor: pointer; + list-style: none; + user-select: none; +} + +.integration-name { + font-size: 1.2em; +} + +.integration-header::-webkit-details-marker { + display: none; +} + +.integration-header::before { + content: "▶"; + font-size: 0.7em; + margin-right: 0.5rem; + transition: transform 0.2s; + display: inline-block; +} + +details[open] > .integration-header::before { + transform: rotate(90deg); +} + +.integration-content { + padding: 0 1.25rem 1.25rem 1.25rem; +} + +.integration-status { + padding: 0.25rem 0.625rem; + border-radius: 0.25rem; + font-size: 0.875em; + font-weight: 500; + position: relative; +} + +.integration-status.connected, +.integration-status.available { + background: var(--success); + color: var(--text-on-color); +} + +.integration-status.disconnected { + background: var(--error); + color: var(--text-on-color); +} + +.integration-status.unlinked { + background: var(--text-secondary); + color: var(--text-on-color); +} + +.link-instructions { + margin: 0.25rem 0; + padding-left: 1.25rem; + line-height: 1.8; +} + +.qr-code-container { + margin-top: 0.5rem; + text-align: center; +} + +.qr-code { + height: 22rem; + width: 22rem; + border: 2px solid var(--border-color); + border-radius: 0.5rem; + padding: 0.625rem; + background: var(--bg-secondar); +} diff --git a/public/index.html b/public/index.html index d835461..7c2eb83 100644 --- a/public/index.html +++ b/public/index.html @@ -7,34 +7,15 @@ - + - + -
-
-
-
-
-
-
- -
-

Signal

-
-
-
-
-
-
- -
-

Registered Applications

+
+ + Registered Applications +
@@ -42,6 +23,11 @@
+ + +
diff --git a/scripts/install-signal-cli.sh b/scripts/install-signal-cli.sh deleted file mode 100755 index d3a57c1..0000000 --- a/scripts/install-signal-cli.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash -set -e - -VERSION="0.13.23" -INSTALL_DIR="$(pwd)/signal-cli" - -echo "Installing signal-cli $VERSION..." - -if [ -d "$INSTALL_DIR" ]; then - echo "signal-cli already installed at $INSTALL_DIR" - exit 0 -fi - -TARBALL="signal-cli-${VERSION}.tar.gz" -URL="https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/${TARBALL}" - -echo "Downloading from $URL..." -curl -L -o "/tmp/$TARBALL" "$URL" - -echo "Extracting..." -tar -xzf "/tmp/$TARBALL" -C "$(pwd)" -mv "signal-cli-${VERSION}" "$INSTALL_DIR" - -rm "/tmp/$TARBALL" - -echo "✓ signal-cli installed to $INSTALL_DIR" -echo "Add to PATH: export PATH=\"$INSTALL_DIR/bin:\$PATH\"" diff --git a/service/config/config.go b/service/config/config.go index a2103f0..505fb92 100644 --- a/service/config/config.go +++ b/service/config/config.go @@ -14,48 +14,40 @@ type Config struct { DeviceName string PrismEndpointPrefix string - SignalCLISocketPath string - SignalCLIDataPath string - SignalCLIBinaryPath string + EnableSignal bool + SignalSocket string + EnableProton bool ProtonIMAPUsername string ProtonIMAPPassword string - ProtonBridgeHost string - ProtonBridgePort int - ProtonPrismTopic string + ProtonBridgeAddr string - IMAPInbox string - IMAPSeenFlag string - IMAPReconnectBaseDelay int - IMAPMaxReconnectDelay int - IMAPMaxReconnectAttempts int + EnableTelegram bool + TelegramBotToken string + TelegramChatID int64 StoragePath string } func Load() (*Config, error) { cfg := &Config{ - Port: getEnvInt("PORT", 8080), APIKey: os.Getenv("API_KEY"), + Port: getEnvInt("PORT", 8080), VerboseLogging: getEnvBool("VERBOSE_LOGGING", false), RateLimit: getEnvInt("RATE_LIMIT", 100), + DeviceName: getEnvString("DEVICE_NAME", "Prism"), - DeviceName: getEnvString("DEVICE_NAME", "Prism"), - SignalCLISocketPath: getEnvString("SIGNAL_CLI_SOCKET", "./data/signal-cli.sock"), - SignalCLIDataPath: getEnvString("SIGNAL_CLI_DATA", "./data/prism"), - SignalCLIBinaryPath: getEnvString("SIGNAL_CLI_BINARY", "./signal-cli/bin/signal-cli"), + EnableSignal: getEnvBool("FEATURE_ENABLE_SIGNAL", false), + SignalSocket: getEnvString("SIGNAL_SOCKET", "/run/signal-cli/socket"), + EnableProton: getEnvBool("FEATURE_ENABLE_PROTON", false), ProtonIMAPUsername: os.Getenv("PROTON_IMAP_USERNAME"), ProtonIMAPPassword: os.Getenv("PROTON_IMAP_PASSWORD"), - ProtonBridgeHost: getEnvString("PROTON_BRIDGE_HOST", "protonmail-bridge"), - ProtonBridgePort: getEnvInt("PROTON_BRIDGE_PORT", 143), - ProtonPrismTopic: "Proton Mail", + ProtonBridgeAddr: getEnvString("PROTON_BRIDGE_ADDR", "protonmail-bridge:143"), - IMAPInbox: "INBOX", - IMAPSeenFlag: "\\Seen", - IMAPReconnectBaseDelay: 10000, - IMAPMaxReconnectDelay: 300000, - IMAPMaxReconnectAttempts: 50, + EnableTelegram: getEnvBool("FEATURE_ENABLE_TELEGRAM", false), + TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"), + TelegramChatID: getEnvInt64("TELEGRAM_CHAT_ID", 0), StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"), } @@ -77,7 +69,15 @@ func (c *Config) Validate() error { } func (c *Config) IsProtonEnabled() bool { - return c.ProtonIMAPUsername != "" && c.ProtonIMAPPassword != "" + return c.EnableProton && c.ProtonIMAPUsername != "" && c.ProtonIMAPPassword != "" && c.ProtonBridgeAddr != "" +} + +func (c *Config) IsSignalEnabled() bool { + return c.EnableSignal +} + +func (c *Config) IsTelegramEnabled() bool { + return c.EnableTelegram } func getEnvString(key, defaultValue string) string { @@ -96,6 +96,15 @@ func getEnvInt(key string, defaultValue int) int { return defaultValue } +func getEnvInt64(key string, defaultValue int64) int64 { + if value := os.Getenv(key); value != "" { + if i, err := strconv.ParseInt(value, 10, 64); err == nil { + return i + } + } + return defaultValue +} + func getEnvBool(key string, defaultValue bool) bool { if value := os.Getenv(key); value != "" { return value == "true" || value == "1" diff --git a/service/integration/integration.go b/service/integration/integration.go new file mode 100644 index 0000000..5738370 --- /dev/null +++ b/service/integration/integration.go @@ -0,0 +1,70 @@ +package integration + +import ( + "context" + "log/slog" + "net/http" + + "prism/service/config" + "prism/service/integration/proton" + "prism/service/integration/signal" + "prism/service/integration/telegram" + "prism/service/integration/webpush" + "prism/service/notification" + + "github.com/go-chi/chi/v5" +) + +type Integration interface { + RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) + Start(ctx context.Context, logger *slog.Logger) + IsEnabled() bool +} + +type Integrations struct { + Dispatcher *notification.Dispatcher + Signal *signal.Integration + integrations []Integration +} + +func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logger) *Integrations { + signalIntegration := signal.NewIntegration(cfg, store, logger) + telegramIntegration := telegram.NewIntegration(cfg, store, logger) + dispatcher := notification.NewDispatcher(store, logger) + + if signalSender := signalIntegration.GetSender(); signalSender != nil { + dispatcher.RegisterSender(notification.ChannelSignal, signalSender) + } + if telegramSender := telegramIntegration.GetSender(); telegramSender != nil { + dispatcher.RegisterSender(notification.ChannelTelegram, telegramSender) + } + dispatcher.RegisterSender(notification.ChannelWebPush, webpush.NewSender(logger)) + + integrations := []Integration{ + signalIntegration, + telegramIntegration, + proton.NewIntegration(cfg, dispatcher, logger), + webpush.NewIntegration(store, logger), + } + + return &Integrations{ + Dispatcher: dispatcher, + Signal: signalIntegration, + integrations: integrations, + } +} + +func (i *Integrations) Start(ctx context.Context, cfg *config.Config, logger *slog.Logger) { + for _, integration := range i.integrations { + if integration.IsEnabled() { + integration.Start(ctx, logger) + } + } +} + +func RegisterAll(integrations *Integrations, router *chi.Mux, cfg *config.Config, store *notification.Store, logger *slog.Logger, authMiddleware func(string) func(http.Handler) http.Handler) { + auth := authMiddleware(cfg.APIKey) + for _, integration := range integrations.integrations { + integration.RegisterRoutes(router, auth) + } +} diff --git a/service/proton/connection.go b/service/integration/proton/connection.go similarity index 85% rename from service/proton/connection.go rename to service/integration/proton/connection.go index 965e0aa..61d90ab 100644 --- a/service/proton/connection.go +++ b/service/integration/proton/connection.go @@ -9,7 +9,7 @@ import ( ) func (m *Monitor) connect() error { - addr := fmt.Sprintf("%s:%d", m.cfg.ProtonBridgeHost, m.cfg.ProtonBridgePort) + addr := m.cfg.ProtonBridgeAddr options := &imapclient.Options{ UnilateralDataHandler: &imapclient.UnilateralDataHandler{ @@ -41,7 +41,7 @@ func (m *Monitor) connect() error { } func (m *Monitor) monitor(ctx context.Context) error { - selectCmd := m.client.Select(m.cfg.IMAPInbox, nil) + selectCmd := m.client.Select(imapInbox, nil) _, err := selectCmd.Wait() if err != nil { return fmt.Errorf("failed to select inbox: %w", err) @@ -66,9 +66,7 @@ func (m *Monitor) monitor(ctx context.Context) error { case <-m.newMessagesChan: idleCmd.Close() - if err := m.sendNotification(); err != nil { - m.logger.Error("Failed to send notification", "error", err) - } + m.sendNotification() // Errors already logged in sendNotification() idleCmd, err = m.client.Idle() if err != nil { diff --git a/service/proton/email.go b/service/integration/proton/email.go similarity index 86% rename from service/proton/email.go rename to service/integration/proton/email.go index abd09c3..e33cecb 100644 --- a/service/proton/email.go +++ b/service/integration/proton/email.go @@ -9,7 +9,7 @@ import ( ) func (m *Monitor) sendNotification() error { - selectData, err := m.client.Select(m.cfg.IMAPInbox, nil).Wait() + selectData, err := m.client.Select(imapInbox, nil).Wait() if err != nil { m.logger.Error("Failed to select inbox", "error", err) return err @@ -49,6 +49,14 @@ func (m *Monitor) sendNotification() error { return nil } + if msgData.Envelope.Date.Before(m.monitorStartTime) { + m.logger.Debug("Skipping notification for old email", + "subject", msgData.Envelope.Subject, + "date", msgData.Envelope.Date, + "monitorStart", m.monitorStartTime) + return nil + } + var from string if len(msgData.Envelope.From) > 0 { addr := msgData.Envelope.From[0] @@ -81,7 +89,7 @@ func (m *Monitor) sendNotification() error { }, } - if err := m.dispatcher.Send(m.cfg.ProtonPrismTopic, notif); err != nil { + if err := m.dispatcher.Send(prismTopic, notif); err != nil { m.logger.Error("Failed to send notification", "error", err) return err } diff --git a/service/integration/proton/handlers.go b/service/integration/proton/handlers.go new file mode 100644 index 0000000..3c253ff --- /dev/null +++ b/service/integration/proton/handlers.go @@ -0,0 +1,120 @@ +package proton + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" +) + +type Handlers struct { + monitor *Monitor + username string + logger *slog.Logger +} + +func NewHandlers(monitor *Monitor, username string, logger *slog.Logger) *Handlers { + return &Handlers{ + monitor: monitor, + username: username, + logger: logger, + } +} + +func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) { + if h.monitor == nil { + return // Proton not enabled + } + + w.Header().Set("Content-Type", "text/html") + + statusBadge := `Unlinked` + var content string + var openAttr string + + if h.monitor.IsConnected() { + statusBadge = fmt.Sprintf(`Linked%s`, h.username) + content = ` +

Unlink Instructions:

+ + ` + openAttr = "" + } else { + content = ` +

Setup Instructions:

+ +

See full setup guide

+ ` + openAttr = " open" + } + + html := fmt.Sprintf(`
+ + Proton Mail + %s + +
%s
+
`, openAttr, statusBadge, content) + + _, _ = fmt.Fprint(w, html) +} + +func (h *Handlers) IsEnabled() bool { + return h.monitor != nil +} + +func (h *Handlers) GetMonitor() *Monitor { + return h.monitor +} + +type markReadRequest struct { + UID uint32 `json:"uid"` +} + +func (h *Handlers) HandleMarkRead(w http.ResponseWriter, r *http.Request) { + var req markReadRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"}) + return + } + + if req.UID == 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "uid (number) is required"}) + return + } + + if h.monitor == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "Proton integration not enabled"}) + return + } + + if err := h.monitor.MarkAsRead(req.UID); err != nil { + h.logger.Error("failed to mark email as read", "uid", req.UID, "error", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "failed to mark as read"}) + return + } + + h.logger.Info("marked email as read", "uid", req.UID) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil { + h.logger.Error("failed to encode response", "error", err) + } +} diff --git a/service/integration/proton/integration.go b/service/integration/proton/integration.go new file mode 100644 index 0000000..88a8bee --- /dev/null +++ b/service/integration/proton/integration.go @@ -0,0 +1,51 @@ +package proton + +import ( + "context" + "log/slog" + "net/http" + + "prism/service/config" + "prism/service/notification" + + "github.com/go-chi/chi/v5" +) + +type Integration struct { + cfg *config.Config + dispatcher *notification.Dispatcher + logger *slog.Logger + handlers *Handlers +} + +func NewIntegration(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Integration { + return &Integration{ + cfg: cfg, + dispatcher: dispatcher, + logger: logger, + } +} + +func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) { + p.handlers = RegisterRoutes(router, p.cfg, p.dispatcher, p.logger, auth) +} + +func (p *Integration) Start(ctx context.Context, logger *slog.Logger) { + if p.handlers != nil && p.handlers.IsEnabled() { + logger.Info("Proton Mail configured", "status", "connecting in background", "bridge", p.cfg.ProtonBridgeAddr) + go func() { + monitor := p.handlers.GetMonitor() + if err := monitor.Start(ctx); err != nil && ctx.Err() == nil { + logger.Error("Proton monitor error", "error", err) + } + }() + } +} + +func (p *Integration) IsEnabled() bool { + return p.cfg.IsProtonEnabled() +} + +func (p *Integration) GetHandlers() *Handlers { + return p.handlers +} diff --git a/service/proton/monitor.go b/service/integration/proton/monitor.go similarity index 82% rename from service/proton/monitor.go rename to service/integration/proton/monitor.go index af060e8..543fd70 100644 --- a/service/proton/monitor.go +++ b/service/integration/proton/monitor.go @@ -11,6 +11,14 @@ import ( "github.com/emersion/go-imap/v2/imapclient" ) +const ( + imapInbox = "INBOX" + imapReconnectBaseDelay = 10 * time.Second + imapMaxReconnectDelay = 5 * time.Minute + imapMaxReconnectAttempts = 50 + prismTopic = "Proton Mail" +) + type Monitor struct { cfg *config.Config dispatcher *notification.Dispatcher @@ -31,7 +39,6 @@ func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger func (m *Monitor) Start(ctx context.Context) error { if !m.cfg.IsProtonEnabled() { - m.logger.Info("Proton Mail monitoring disabled") return nil } @@ -44,7 +51,7 @@ func (m *Monitor) Start(ctx context.Context) error { default: if err := m.connect(); err != nil { m.logger.Error("Failed to connect to IMAP", "error", err) - time.Sleep(time.Duration(m.cfg.IMAPReconnectBaseDelay) * time.Millisecond) + time.Sleep(imapReconnectBaseDelay) continue } @@ -59,7 +66,7 @@ func (m *Monitor) Start(ctx context.Context) error { m.client = nil } - time.Sleep(time.Duration(m.cfg.IMAPReconnectBaseDelay) * time.Millisecond) + time.Sleep(imapReconnectBaseDelay) } } } diff --git a/service/integration/proton/routes.go b/service/integration/proton/routes.go new file mode 100644 index 0000000..a34e32f --- /dev/null +++ b/service/integration/proton/routes.go @@ -0,0 +1,29 @@ +package proton + +import ( + "log/slog" + "net/http" + + "prism/service/config" + "prism/service/notification" + + "github.com/go-chi/chi/v5" +) + +func RegisterRoutes(router *chi.Mux, cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) *Handlers { + if !cfg.IsProtonEnabled() { + return nil + } + + monitor := NewMonitor(cfg, dispatcher, logger) + handlers := NewHandlers(monitor, cfg.ProtonIMAPUsername, logger) + + router.With(authMiddleware).Get("/fragment/proton", handlers.HandleFragment) + + router.Route("/api/proton-mail", func(r chi.Router) { + r.Use(authMiddleware) + r.Post("/mark-read", handlers.HandleMarkRead) + }) + + return handlers +} diff --git a/service/signal/client.go b/service/integration/signal/client.go similarity index 52% rename from service/signal/client.go rename to service/integration/signal/client.go index 8d82749..2344087 100644 --- a/service/signal/client.go +++ b/service/integration/signal/client.go @@ -10,11 +10,13 @@ import ( var rpcID int32 type Client struct { - socketPath string + SocketPath string } func NewClient(socketPath string) *Client { - return &Client{socketPath: socketPath} + return &Client{ + SocketPath: socketPath, + } } type RPCRequest struct { @@ -37,7 +39,7 @@ type RPCError struct { } func (c *Client) Call(method string, params map[string]interface{}) (json.RawMessage, error) { - conn, err := net.Dial("unix", c.socketPath) + conn, err := net.Dial("unix", c.SocketPath) if err != nil { return nil, fmt.Errorf("failed to connect: %w", err) } @@ -79,7 +81,15 @@ type AccountInfo struct { Name string `json:"name,omitempty"` } -func (c *Client) GetLinkedAccount() (*AccountInfo, error) { +type Account struct { + Number string +} + +func (c *Client) GetLinkedAccount() (*Account, error) { + if c == nil { + return nil, nil + } + result, err := c.Call("listAccounts", nil) if err != nil { return nil, err @@ -94,5 +104,67 @@ func (c *Client) GetLinkedAccount() (*AccountInfo, error) { return nil, nil } - return &accounts[0], nil + return &Account{Number: accounts[0].Number}, nil +} + +// CreateGroup creates a Signal group using updateGroup JSON-RPC method +func (c *Client) CreateGroup(name string) (string, string, error) { + if c == nil { + return "", "", fmt.Errorf("signal client not initialized") + } + + account, err := c.GetLinkedAccount() + if err != nil { + return "", "", fmt.Errorf("failed to get linked account: %w", err) + } + if account == nil { + return "", "", fmt.Errorf("no linked Signal account") + } + + params := map[string]interface{}{ + "name": name, + "member": []string{}, // Empty group + } + + result, err := c.CallWithAccount("updateGroup", params, account.Number) + if err != nil { + return "", "", err + } + + var response struct { + GroupID string `json:"groupId"` + } + if err := json.Unmarshal(result, &response); err != nil { + return "", "", fmt.Errorf("failed to parse updateGroup response: %w", err) + } + + if response.GroupID == "" { + return "", "", fmt.Errorf("empty groupId in response") + } + + return response.GroupID, account.Number, nil +} + +// SendGroupMessage sends a message to a Signal group +func (c *Client) SendGroupMessage(groupID, message string) error { + if c == nil { + return fmt.Errorf("signal client not initialized") + } + + account, err := c.GetLinkedAccount() + if err != nil { + return fmt.Errorf("failed to get account: %w", err) + } + if account == nil { + return fmt.Errorf("no linked account") + } + + params := map[string]interface{}{ + "groupId": groupID, + "message": message, + "notify-self": true, + } + + _, err = c.CallWithAccount("send", params, account.Number) + return err } diff --git a/service/integration/signal/format.go b/service/integration/signal/format.go new file mode 100644 index 0000000..db79e46 --- /dev/null +++ b/service/integration/signal/format.go @@ -0,0 +1,47 @@ +package signal + +import ( + "fmt" + "strings" +) + +func FormatPhoneNumber(number string) string { + if number == "" { + return number + } + + if strings.HasPrefix(number, "+1") && len(number) == 12 { + return fmt.Sprintf("+1 (%s) %s-%s", number[2:5], number[5:8], number[8:]) + } + + if strings.HasPrefix(number, "+") && len(number) > 4 { + digits := number[1:] + var countryCode, rest string + + for i := 1; i <= 3 && i < len(digits); i++ { + if digits[i] < '0' || digits[i] > '9' { + break + } + countryCode = digits[:i+1] + rest = digits[i+1:] + } + + if len(rest) >= 3 { + var parts []string + for len(rest) > 0 { + size := 3 + if len(rest) == 4 || len(rest) == 8 { + size = 4 + } + if size > len(rest) { + size = len(rest) + } + parts = append(parts, rest[:size]) + rest = rest[size:] + } + return "+" + countryCode + " " + strings.Join(parts, " ") + } + } + + return number +} diff --git a/service/integration/signal/handlers.go b/service/integration/signal/handlers.go new file mode 100644 index 0000000..e14d97f --- /dev/null +++ b/service/integration/signal/handlers.go @@ -0,0 +1,84 @@ +package signal + +import ( + "fmt" + "net/http" +) + +type Handlers struct { + client *Client + linkDevice *LinkDevice +} + +func NewHandlers(client *Client, linkDevice *LinkDevice) *Handlers { + return &Handlers{ + client: client, + linkDevice: linkDevice, + } +} + +func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) { + if h.client == nil { + return // Signal not enabled + } + + w.Header().Set("Content-Type", "text/html") + + account, _ := h.client.GetLinkedAccount() + var content string + var statusBadge string + var openAttr string + var pollAttrs string + + if account != nil { + statusBadge = fmt.Sprintf(`Linked%s`, FormatPhoneNumber(account.Number)) + content = fmt.Sprintf(` +

Unlink Instructions:

+ + `, h.linkDevice.deviceName) + openAttr = "" + pollAttrs = "" + } else { + statusBadge = `Unlinked` + qrCode, err := h.linkDevice.GenerateQR() + if err != nil { + content = fmt.Sprintf(`

Error generating QR code: %s

`, err) + } else { + content = fmt.Sprintf(` +

Link your Signal (or Molly) account:

+ +
+ Signal QR Code +
+ `, qrCode) + } + openAttr = " open" + pollAttrs = ` hx-get="/fragment/signal" hx-trigger="every 3s" hx-swap="outerHTML"` + } + + html := fmt.Sprintf(`
+ + Signal + %s + +
%s
+
`, openAttr, pollAttrs, statusBadge, content) + + _, _ = fmt.Fprint(w, html) +} + +func (h *Handlers) IsEnabled() bool { + return h.client != nil +} + +func (h *Handlers) GetClient() *Client { + return h.client +} diff --git a/service/integration/signal/integration.go b/service/integration/signal/integration.go new file mode 100644 index 0000000..d54bb32 --- /dev/null +++ b/service/integration/signal/integration.go @@ -0,0 +1,58 @@ +package signal + +import ( + "context" + "log/slog" + "net/http" + + "prism/service/config" + "prism/service/notification" + + "github.com/go-chi/chi/v5" +) + +type Integration struct { + cfg *config.Config + handlers *Handlers + sender *Sender +} + +func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger) *Integration { + var sender *Sender + if cfg.IsSignalEnabled() { + client := NewClient(cfg.SignalSocket) + sender = NewSender(client, store, logger) + } + return &Integration{ + cfg: cfg, + sender: sender, + } +} + +func (s *Integration) GetSender() *Sender { + return s.sender +} + +func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) { + s.handlers = RegisterRoutes(router, s.cfg, auth) +} + +func (s *Integration) Start(ctx context.Context, logger *slog.Logger) { + if s.handlers != nil && s.handlers.IsEnabled() { + client := s.handlers.GetClient() + account, _ := client.GetLinkedAccount() + if account != nil { + logger.Info("Signal enabled", "status", "linked", "number", FormatPhoneNumber(account.Number)) + } else { + logger.Info("Signal enabled", "status", "unlinked", "action", "visit admin UI to link") + } + } +} + +func (s *Integration) IsEnabled() bool { + return s.cfg.IsSignalEnabled() +} + +func (s *Integration) GetHandlers() *Handlers { + return s.handlers +} diff --git a/service/signal/link.go b/service/integration/signal/link.go similarity index 66% rename from service/signal/link.go rename to service/integration/signal/link.go index 9d59435..80228a8 100644 --- a/service/signal/link.go +++ b/service/integration/signal/link.go @@ -1,21 +1,19 @@ package signal import ( - "encoding/base64" "encoding/json" "fmt" - "io" - "net/http" "net/url" "time" ) type LinkDevice struct { - client *Client - deviceName string - qrCode string - generatedAt time.Time - ttl time.Duration + client *Client + deviceName string + qrCode string + deviceLinkUri string + generatedAt time.Time + ttl time.Duration } func NewLinkDevice(client *Client, deviceName string) *LinkDevice { @@ -31,10 +29,15 @@ type StartLinkResponse struct { } func (l *LinkDevice) GenerateQR() (string, error) { + if l.client == nil { + return "", fmt.Errorf("signal client not initialized") + } + if l.qrCode != "" && time.Since(l.generatedAt) < l.ttl { return l.qrCode, nil } + // Call signal-cli's startLink JSON-RPC method directly result, err := l.client.Call("startLink", nil) if err != nil { return "", fmt.Errorf("failed to start link: %w", err) @@ -50,31 +53,18 @@ func (l *LinkDevice) GenerateQR() (string, error) { return "", fmt.Errorf("empty device link URI") } + l.deviceLinkUri = uri qrURL := fmt.Sprintf("https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=%s", url.QueryEscape(uri)) - //nolint:gosec - resp, err := http.Get(qrURL) - if err != nil { - return "", fmt.Errorf("failed to fetch QR code: %w", err) - } - defer resp.Body.Close() - - qrData, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read QR code: %w", err) - } - - base64Data := base64.StdEncoding.EncodeToString(qrData) - l.qrCode = fmt.Sprintf("data:image/png;base64,%s", base64Data) + l.qrCode = qrURL l.generatedAt = time.Now() - - go l.finishLink(uri) + go l.finishLink() return l.qrCode, nil } -func (l *LinkDevice) finishLink(uri string) { +func (l *LinkDevice) finishLink() { params := map[string]interface{}{ - "deviceLinkUri": uri, + "deviceLinkUri": l.deviceLinkUri, "deviceName": l.deviceName, } diff --git a/service/integration/signal/routes.go b/service/integration/signal/routes.go new file mode 100644 index 0000000..65e6ecf --- /dev/null +++ b/service/integration/signal/routes.go @@ -0,0 +1,23 @@ +package signal + +import ( + "net/http" + + "prism/service/config" + + "github.com/go-chi/chi/v5" +) + +func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler) *Handlers { + if !cfg.IsSignalEnabled() { + return nil + } + + client := NewClient(cfg.SignalSocket) + linkDevice := NewLinkDevice(client, cfg.DeviceName) + handlers := NewHandlers(client, linkDevice) + + router.With(authMiddleware).Get("/fragment/signal", handlers.HandleFragment) + + return handlers +} diff --git a/service/integration/signal/sender.go b/service/integration/signal/sender.go new file mode 100644 index 0000000..88574b9 --- /dev/null +++ b/service/integration/signal/sender.go @@ -0,0 +1,83 @@ +package signal + +import ( + "fmt" + "log/slog" + + "prism/service/notification" +) + +type Sender struct { + client *Client + store *notification.Store + logger *slog.Logger +} + +func NewSender(client *Client, store *notification.Store, logger *slog.Logger) *Sender { + return &Sender{ + client: client, + store: store, + logger: logger, + } +} + +func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error { + if s.client == nil { + return fmt.Errorf("signal integration not enabled") + } + + account, err := s.client.GetLinkedAccount() + if err != nil { + s.logger.Error("Failed to get linked account", "error", err) + return fmt.Errorf("failed to get linked account: %w", err) + } + if account == nil { + s.logger.Error("No linked Signal account found") + return fmt.Errorf("no linked Signal account") + } + + var signalGroupID string + needsNewGroup := mapping.Signal == nil || mapping.Signal.GroupID == "" + + if !needsNewGroup && mapping.Signal.Account != account.Number { + s.logger.Info("Signal account changed, recreating group", + "app", mapping.AppName, + "oldAccount", mapping.Signal.Account, + "newAccount", account.Number) + needsNewGroup = true + } + + if needsNewGroup { + s.logger.Info("Creating new group", "app", mapping.AppName, "account", account.Number) + + newGroupID, accountNumber, err := s.client.CreateGroup(mapping.AppName) + if err != nil { + s.logger.Error("Failed to create group", "app", mapping.AppName, "error", err) + return fmt.Errorf("failed to create group: %w", err) + } + signalGroupID = newGroupID + + s.logger.Info("Created new group", "app", mapping.AppName, "groupID", signalGroupID, "account", accountNumber) + + if err := s.store.UpdateSignal(mapping.AppName, ¬ification.SignalSubscription{ + GroupID: signalGroupID, + Account: accountNumber, + }); err != nil { + s.logger.Warn("Failed to update signal subscription", "error", err) + } + } else { + signalGroupID = mapping.Signal.GroupID + } + + message := notif.Message + if notif.Title != "" { + message = fmt.Sprintf("%s\n\n%s", notif.Title, notif.Message) + } + + if err := s.client.SendGroupMessage(signalGroupID, message); err != nil { + s.logger.Error("Failed to send group message", "groupID", signalGroupID, "error", err) + return err + } + + return nil +} diff --git a/service/integration/telegram/client.go b/service/integration/telegram/client.go new file mode 100644 index 0000000..58a2d30 --- /dev/null +++ b/service/integration/telegram/client.go @@ -0,0 +1,55 @@ +package telegram + +import ( + "context" + "fmt" + + "github.com/mymmrac/telego" + "github.com/mymmrac/telego/telegoutil" +) + +type Client struct { + bot *telego.Bot +} + +func NewClient(token string) (*Client, error) { + if token == "" { + return nil, nil + } + + bot, err := telego.NewBot(token) + if err != nil { + return nil, fmt.Errorf("failed to create telegram bot: %w", err) + } + + return &Client{bot: bot}, nil +} + +func (c *Client) GetMe() (*telego.User, error) { + if c == nil || c.bot == nil { + return nil, fmt.Errorf("telegram client not initialized") + } + return c.bot.GetMe(context.Background()) +} + +func (c *Client) SendMessage(chatID int64, text string) error { + if c == nil || c.bot == nil { + return fmt.Errorf("telegram client not initialized") + } + + msg := telegoutil.Message( + telegoutil.ID(chatID), + text, + ).WithParseMode(telego.ModeHTML) + + _, err := c.bot.SendMessage(context.Background(), msg) + return err +} + +func (c *Client) IsAvailable() bool { + if c == nil || c.bot == nil { + return false + } + _, err := c.bot.GetMe(context.Background()) + return err == nil +} diff --git a/service/integration/telegram/handlers.go b/service/integration/telegram/handlers.go new file mode 100644 index 0000000..560f826 --- /dev/null +++ b/service/integration/telegram/handlers.go @@ -0,0 +1,89 @@ +package telegram + +import ( + "fmt" + "net/http" +) + +type Handlers struct { + client *Client + chatID int64 +} + +func NewHandlers(client *Client, chatID int64) *Handlers { + return &Handlers{ + client: client, + chatID: chatID, + } +} + +func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + + var content string + var statusBadge string + var openAttr string + + if h.client == nil { + statusBadge = `Not Configured` + content = ` +

Setup Instructions:

+ +

See full setup guide

+ ` + openAttr = " open" + } else { + bot, err := h.client.GetMe() + if err != nil { + statusBadge = `Error` + content = fmt.Sprintf(`

Error: %s

`, err) + openAttr = " open" + } else if h.chatID == 0 { + statusBadge = fmt.Sprintf(`Needs Chat ID@%s`, bot.Username) + content = ` +

Complete Setup:

+ + ` + openAttr = " open" + } else { + statusBadge = fmt.Sprintf(`Linked@%s`, bot.Username) + content = ` +

Unlink Instructions:

+ + ` + openAttr = "" + } + } + + html := fmt.Sprintf(`
+ + Telegram + %s + +
%s
+
`, openAttr, statusBadge, content) + + _, _ = fmt.Fprint(w, html) +} + +func (h *Handlers) IsEnabled() bool { + return true +} + +func (h *Handlers) GetClient() *Client { + return h.client +} diff --git a/service/integration/telegram/integration.go b/service/integration/telegram/integration.go new file mode 100644 index 0000000..d801403 --- /dev/null +++ b/service/integration/telegram/integration.go @@ -0,0 +1,62 @@ +package telegram + +import ( + "context" + "log/slog" + "net/http" + + "prism/service/config" + "prism/service/notification" + + "github.com/go-chi/chi/v5" +) + +type Integration struct { + cfg *config.Config + handlers *Handlers + sender *Sender +} + +func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger) *Integration { + client, err := NewClient(cfg.TelegramBotToken) + if err != nil { + logger.Error("Failed to create telegram client", "error", err) + } + + var sender *Sender + if client != nil { + sender = NewSender(client, store, logger, cfg.TelegramChatID) + } + + handlers := NewHandlers(client, cfg.TelegramChatID) + + return &Integration{ + cfg: cfg, + handlers: handlers, + sender: sender, + } +} + +func (t *Integration) GetSender() *Sender { + return t.sender +} + +func (t *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) { + RegisterRoutes(router, t.handlers, auth) +} + +func (t *Integration) Start(ctx context.Context, logger *slog.Logger) { + if t.handlers != nil && t.handlers.IsEnabled() { + client := t.handlers.GetClient() + bot, err := client.GetMe() + if err != nil { + logger.Error("Telegram bot error", "error", err) + } else { + logger.Info("Telegram enabled", "bot", bot.Username, "id", bot.ID) + } + } +} + +func (t *Integration) IsEnabled() bool { + return t.cfg.IsTelegramEnabled() +} diff --git a/service/integration/telegram/routes.go b/service/integration/telegram/routes.go new file mode 100644 index 0000000..b3327dc --- /dev/null +++ b/service/integration/telegram/routes.go @@ -0,0 +1,15 @@ +package telegram + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler) { + if handlers == nil { + return + } + + router.With(auth).Get("/fragment/telegram", handlers.HandleFragment) +} diff --git a/service/integration/telegram/sender.go b/service/integration/telegram/sender.go new file mode 100644 index 0000000..c3ee020 --- /dev/null +++ b/service/integration/telegram/sender.go @@ -0,0 +1,46 @@ +package telegram + +import ( + "fmt" + "log/slog" + + "prism/service/notification" +) + +type Sender struct { + client *Client + store *notification.Store + logger *slog.Logger + DefaultChatID int64 +} + +func NewSender(client *Client, store *notification.Store, logger *slog.Logger, defaultChatID int64) *Sender { + return &Sender{ + client: client, + store: store, + logger: logger, + DefaultChatID: defaultChatID, + } +} + +func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error { + if s.client == nil { + return fmt.Errorf("telegram integration not enabled") + } + + if s.DefaultChatID == 0 { + return fmt.Errorf("no telegram chat configured (set TELEGRAM_CHAT_ID in .env)") + } + + message := notif.Message + if notif.Title != "" { + message = fmt.Sprintf("%s\n%s", notif.Title, notif.Message) + } + + if err := s.client.SendMessage(s.DefaultChatID, message); err != nil { + s.logger.Error("Failed to send telegram message", "chatID", s.DefaultChatID, "error", err) + return err + } + + return nil +} diff --git a/service/server/handlers_webpush.go b/service/integration/webpush/handlers.go similarity index 64% rename from service/server/handlers_webpush.go rename to service/integration/webpush/handlers.go index 963bba0..a17fc85 100644 --- a/service/server/handlers_webpush.go +++ b/service/integration/webpush/handlers.go @@ -1,7 +1,8 @@ -package server +package webpush import ( "encoding/json" + "log/slog" "net/http" "net/url" @@ -10,7 +11,19 @@ import ( "github.com/go-chi/chi/v5" ) -type registerWebPushRequest struct { +type Handlers struct { + store *notification.Store + logger *slog.Logger +} + +func NewHandlers(store *notification.Store, logger *slog.Logger) *Handlers { + return &Handlers{ + store: store, + logger: logger, + } +} + +type registerRequest struct { AppName string `json:"appName"` PushEndpoint string `json:"pushEndpoint"` P256dh *string `json:"p256dh,omitempty"` @@ -18,8 +31,8 @@ type registerWebPushRequest struct { VapidPrivateKey *string `json:"vapidPrivateKey,omitempty"` } -func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) { - var req registerWebPushRequest +func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) { + var req registerRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return @@ -28,20 +41,20 @@ func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) { if req.AppName == "" || req.PushEndpoint == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "appName and pushEndpoint are required"}) //nolint:errcheck + _ = json.NewEncoder(w).Encode(map[string]string{"error": "appName and pushEndpoint are required"}) return } if _, err := url.Parse(req.PushEndpoint); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "Invalid pushEndpoint URL"}) //nolint:errcheck + _ = json.NewEncoder(w).Encode(map[string]string{"error": "Invalid pushEndpoint URL"}) return } - existing, err := s.store.GetApp(req.AppName) + existing, err := h.store.GetApp(req.AppName) if err != nil { - s.logger.Error("Failed to check existing app", "error", err) + h.logger.Error("Failed to check existing app", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } @@ -61,20 +74,20 @@ func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) { } if existing != nil { - if err := s.store.UpdateWebPush(req.AppName, webPush); err != nil { - s.logger.Error("Failed to update webpush endpoint", "error", err) + if err := h.store.UpdateWebPush(req.AppName, webPush); err != nil { + h.logger.Error("Failed to update webpush endpoint", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - s.logger.Info("Updated webpush for existing endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint) + h.logger.Info("Updated webpush for existing endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint) } else { channel := notification.ChannelWebPush - if err := s.store.Register(req.AppName, &channel, nil, webPush); err != nil { - s.logger.Error("Failed to register webpush endpoint", "error", err) + if err := h.store.Register(req.AppName, &channel, nil, webPush); err != nil { + h.logger.Error("Failed to register webpush endpoint", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - s.logger.Info("Registered new webpush endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint) + h.logger.Info("Registered new webpush endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint) } w.Header().Set("Content-Type", "application/json") @@ -82,32 +95,28 @@ func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) { "appName": req.AppName, "channel": "webpush", } - if err := json.NewEncoder(w).Encode(response); err != nil { - s.logger.Error("Failed to encode response", "error", err) - } + _ = json.NewEncoder(w).Encode(response) } -func (s *Server) handleWebPushUnregister(w http.ResponseWriter, r *http.Request) { +func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) { appName := chi.URLParam(r, "appName") if appName == "" { http.Error(w, "appName is required", http.StatusBadRequest) return } - if err := s.store.ClearWebPush(appName); err != nil { - s.logger.Error("Failed to clear webpush endpoint", "error", err) + if err := h.store.ClearWebPush(appName); err != nil { + h.logger.Error("Failed to clear webpush endpoint", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - s.logger.Info("Cleared webpush subscription", "app", appName) + h.logger.Info("Cleared webpush subscription", "app", appName) w.Header().Set("Content-Type", "application/json") response := map[string]string{ "status": "unregistered", "appName": appName, } - if err := json.NewEncoder(w).Encode(response); err != nil { - s.logger.Error("Failed to encode response", "error", err) - } + _ = json.NewEncoder(w).Encode(response) } diff --git a/service/integration/webpush/integration.go b/service/integration/webpush/integration.go new file mode 100644 index 0000000..75595d9 --- /dev/null +++ b/service/integration/webpush/integration.go @@ -0,0 +1,33 @@ +package webpush + +import ( + "context" + "log/slog" + "net/http" + + "prism/service/notification" + + "github.com/go-chi/chi/v5" +) + +type Integration struct { + store *notification.Store + logger *slog.Logger +} + +func NewIntegration(store *notification.Store, logger *slog.Logger) *Integration { + return &Integration{ + store: store, + logger: logger, + } +} + +func (w *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) { + RegisterRoutes(router, w.store, w.logger, auth) +} + +func (w *Integration) Start(ctx context.Context, logger *slog.Logger) {} + +func (w *Integration) IsEnabled() bool { + return true +} diff --git a/service/integration/webpush/routes.go b/service/integration/webpush/routes.go new file mode 100644 index 0000000..72b268d --- /dev/null +++ b/service/integration/webpush/routes.go @@ -0,0 +1,20 @@ +package webpush + +import ( + "log/slog" + "net/http" + + "prism/service/notification" + + "github.com/go-chi/chi/v5" +) + +func RegisterRoutes(router *chi.Mux, store *notification.Store, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) { + handlers := NewHandlers(store, logger) + + router.Route("/webpush/app", func(r chi.Router) { + r.Use(authMiddleware) + r.Post("/", handlers.HandleRegister) + r.Delete("/{appName}", handlers.HandleUnregister) + }) +} diff --git a/service/integration/webpush/sender.go b/service/integration/webpush/sender.go new file mode 100644 index 0000000..19d30e8 --- /dev/null +++ b/service/integration/webpush/sender.go @@ -0,0 +1,73 @@ +package webpush + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "net/http" + + "prism/service/notification" + + webpush "github.com/SherClockHolmes/webpush-go" +) + +type Sender struct { + logger *slog.Logger +} + +func NewSender(logger *slog.Logger) *Sender { + return &Sender{ + logger: logger, + } +} + +func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error { + if mapping.WebPush == nil { + return fmt.Errorf("no push endpoint configured for %s", mapping.AppName) + } + + payload, err := json.Marshal(notif) + if err != nil { + return fmt.Errorf("failed to marshal notification: %w", err) + } + + if mapping.WebPush.HasEncryption() { + subscription := &webpush.Subscription{ + Endpoint: mapping.WebPush.Endpoint, + Keys: webpush.Keys{ + P256dh: mapping.WebPush.P256dh, + Auth: mapping.WebPush.Auth, + }, + } + + resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{ + VAPIDPrivateKey: mapping.WebPush.VapidPrivateKey, + TTL: 86400, + }) + if err != nil { + return fmt.Errorf("failed to send webpush: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webpush returned status %d", resp.StatusCode) + } + + s.logger.Debug("Sent encrypted webpush notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint) + } else { + resp, err := http.Post(mapping.WebPush.Endpoint, "application/json", bytes.NewBuffer(payload)) + if err != nil { + return fmt.Errorf("failed to send webhook: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webhook returned status %d", resp.StatusCode) + } + + s.logger.Debug("Sent plain webhook notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint) + } + + return nil +} diff --git a/service/notification/dispatcher.go b/service/notification/dispatcher.go index 7c76ee6..fbd2dd0 100644 --- a/service/notification/dispatcher.go +++ b/service/notification/dispatcher.go @@ -1,31 +1,58 @@ package notification import ( - "bytes" - "encoding/json" "fmt" "log/slog" - "net/http" - - "prism/service/signal" - - webpush "github.com/SherClockHolmes/webpush-go" ) -type Dispatcher struct { - store *Store - signalClient *signal.Client - logger *slog.Logger +type NotificationSender interface { + Send(mapping *Mapping, notif Notification) error } -func NewDispatcher(store *Store, signalClient *signal.Client, logger *slog.Logger) *Dispatcher { +type Dispatcher struct { + store *Store + senders map[Channel]NotificationSender + logger *slog.Logger +} + +func NewDispatcher(store *Store, logger *slog.Logger) *Dispatcher { return &Dispatcher{ - store: store, - signalClient: signalClient, - logger: logger, + store: store, + senders: make(map[Channel]NotificationSender), + logger: logger, } } +func (d *Dispatcher) RegisterSender(channel Channel, sender NotificationSender) { + d.senders[channel] = sender +} + +func (d *Dispatcher) HasSignal() bool { + _, ok := d.senders[ChannelSignal] + return ok +} + +func (d *Dispatcher) HasTelegram() bool { + _, ok := d.senders[ChannelTelegram] + return ok +} + +func (d *Dispatcher) GetAvailableChannels() []Channel { + var channels []Channel + if d.HasSignal() { + channels = append(channels, ChannelSignal) + } + if d.HasTelegram() { + channels = append(channels, ChannelTelegram) + } + channels = append(channels, ChannelWebPush) + return channels +} + +func (d *Dispatcher) IsValidChannel(channel Channel) bool { + return channel.IsAvailable(d.HasSignal(), d.HasTelegram()) +} + func (d *Dispatcher) Send(appName string, notif Notification) error { mapping, err := d.store.GetApp(appName) if err != nil { @@ -36,7 +63,8 @@ func (d *Dispatcher) Send(appName string, notif Notification) error { if mapping == nil { d.logger.Info("Registering new app", "app", appName) - if err := d.store.RegisterDefault(appName); err != nil { + availableChannels := d.GetAvailableChannels() + if err := d.store.RegisterDefault(appName, availableChannels); err != nil { d.logger.Error("Failed to register app", "app", appName, "error", err) return fmt.Errorf("failed to register app: %w", err) } @@ -48,176 +76,11 @@ func (d *Dispatcher) Send(appName string, notif Notification) error { } } - switch mapping.Channel { - case ChannelWebPush: - return d.sendWebPush(mapping, notif) - case ChannelSignal: - return d.sendSignal(mapping, notif) - default: - d.logger.Error("Unknown channel type", "channel", mapping.Channel) - return fmt.Errorf("unknown channel: %s", mapping.Channel) + sender, ok := d.senders[mapping.Channel] + if !ok { + d.logger.Error("No sender registered for channel", "channel", mapping.Channel) + return fmt.Errorf("no sender for channel: %s", mapping.Channel) } -} - -func (d *Dispatcher) sendSignal(mapping *Mapping, notif Notification) error { - account, err := d.signalClient.GetLinkedAccount() - if err != nil { - d.logger.Error("Failed to get linked account", "error", err) - return fmt.Errorf("failed to get linked account: %w", err) - } - if account == nil { - d.logger.Error("No linked Signal account found") - return fmt.Errorf("no linked Signal account") - } - - var signalGroupID string - needsNewGroup := mapping.Signal == nil || mapping.Signal.GroupID == "" - - if !needsNewGroup && mapping.Signal.Account != account.Number { - d.logger.Info("Signal account changed, recreating group", - "app", mapping.AppName, - "oldAccount", mapping.Signal.Account, - "newAccount", account.Number) - needsNewGroup = true - } - - if needsNewGroup { - d.logger.Info("Creating new group", "app", mapping.AppName, "account", account.Number) - - newGroupID, accountNumber, err := d.createGroup(mapping.AppName) - if err != nil { - d.logger.Error("Failed to create group", "app", mapping.AppName, "error", err) - return fmt.Errorf("failed to create group: %w", err) - } - signalGroupID = newGroupID - - d.logger.Info("Created new group", "app", mapping.AppName, "groupID", signalGroupID, "account", accountNumber) - - if err := d.store.UpdateSignal(mapping.AppName, &SignalSubscription{ - GroupID: signalGroupID, - Account: accountNumber, - }); err != nil { - d.logger.Warn("Failed to update signal subscription", "error", err) - } - } else { - signalGroupID = mapping.Signal.GroupID - } - - if err := d.sendGroupMessage(signalGroupID, notif); err != nil { - d.logger.Error("Failed to send group message", "groupID", signalGroupID, "error", err) - } - - return nil -} - -func (d *Dispatcher) createGroup(appName string) (string, string, error) { - account, err := d.signalClient.GetLinkedAccount() - if err != nil { - return "", "", fmt.Errorf("failed to get linked account: %w", err) - } - if account == nil { - return "", "", fmt.Errorf("no linked Signal account") - } - - params := map[string]interface{}{ - "name": appName, - "member": []string{}, - } - - result, err := d.signalClient.CallWithAccount("updateGroup", params, account.Number) - if err != nil { - return "", "", err - } - - var response struct { - GroupID string `json:"groupId"` - } - if err := json.Unmarshal(result, &response); err != nil { - return "", "", fmt.Errorf("failed to parse updateGroup response: %w", err) - } - - if response.GroupID == "" { - return "", "", fmt.Errorf("empty groupId in response") - } - - return response.GroupID, account.Number, nil -} - -func (d *Dispatcher) sendGroupMessage(groupID string, notif Notification) error { - account, err := d.signalClient.GetLinkedAccount() - if err != nil { - d.logger.Error("Failed to get linked account", "error", err) - return fmt.Errorf("failed to get linked account: %w", err) - } - if account == nil { - d.logger.Error("No linked Signal account found") - return fmt.Errorf("no linked Signal account") - } - - message := notif.Message - if notif.Title != "" { - message = fmt.Sprintf("%s\n%s", notif.Title, notif.Message) - } - - params := map[string]interface{}{ - "groupId": groupID, - "message": message, - "notify-self": true, - } - - _, err = d.signalClient.CallWithAccount("send", params, account.Number) - if err != nil { - d.logger.Error("Signal send API call failed", "error", err) - } - return err -} - -func (d *Dispatcher) sendWebPush(mapping *Mapping, notif Notification) error { - if mapping.WebPush == nil { - return fmt.Errorf("no push endpoint configured for %s", mapping.AppName) - } - - payload, err := json.Marshal(notif) - if err != nil { - return fmt.Errorf("failed to marshal notification: %w", err) - } - - if mapping.WebPush.HasEncryption() { - subscription := &webpush.Subscription{ - Endpoint: mapping.WebPush.Endpoint, - Keys: webpush.Keys{ - P256dh: mapping.WebPush.P256dh, - Auth: mapping.WebPush.Auth, - }, - } - - resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{ - VAPIDPrivateKey: mapping.WebPush.VapidPrivateKey, - TTL: 86400, - }) - if err != nil { - return fmt.Errorf("failed to send webpush: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("webpush returned status %d", resp.StatusCode) - } - - d.logger.Debug("Sent encrypted webpush notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint) - } else { - resp, err := http.Post(mapping.WebPush.Endpoint, "application/json", bytes.NewBuffer(payload)) - if err != nil { - return fmt.Errorf("failed to send webhook: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("webhook returned status %d", resp.StatusCode) - } - - d.logger.Debug("Sent plain webhook notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint) - } - - return nil + + return sender.Send(mapping, notif) } diff --git a/service/notification/notification.go b/service/notification/notification.go index 8e87831..212f38c 100644 --- a/service/notification/notification.go +++ b/service/notification/notification.go @@ -16,10 +16,24 @@ type Notification struct { type Channel string const ( - ChannelSignal Channel = "signal" - ChannelWebPush Channel = "webpush" + ChannelSignal Channel = "signal" + ChannelWebPush Channel = "webpush" + ChannelTelegram Channel = "telegram" ) +func (c Channel) IsAvailable(signalEnabled bool, telegramEnabled bool) bool { + switch c { + case ChannelWebPush: + return true + case ChannelSignal: + return signalEnabled + case ChannelTelegram: + return telegramEnabled + default: + return false + } +} + type WebPushSubscription struct { Endpoint string P256dh string diff --git a/service/notification/store.go b/service/notification/store.go index 76da5a4..a14a6b3 100644 --- a/service/notification/store.go +++ b/service/notification/store.go @@ -42,7 +42,7 @@ func (s *Store) createTables() error { appName TEXT PRIMARY KEY, signalGroupId TEXT, signalAccount TEXT, - channel TEXT NOT NULL DEFAULT 'signal', + channel TEXT NOT NULL DEFAULT 'webpush', pushEndpoint TEXT, p256dh TEXT, auth TEXT, @@ -75,7 +75,7 @@ func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscri vapidPrivateKey = &webPush.VapidPrivateKey } - ch := ChannelSignal + ch := ChannelWebPush if channel != nil { ch = *channel } @@ -96,8 +96,15 @@ func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscri return err } -func (s *Store) RegisterDefault(appName string) error { - return s.Register(appName, nil, nil, nil) +func (s *Store) RegisterDefault(appName string, availableChannels []Channel) error { + var channel Channel + if len(availableChannels) > 0 { + channel = availableChannels[0] + } else { + channel = ChannelWebPush + } + + return s.Register(appName, &channel, nil, nil) } func (s *Store) GetApp(appName string) (*Mapping, error) { diff --git a/service/server/handler_index.go b/service/server/handler_index.go new file mode 100644 index 0000000..e389f2c --- /dev/null +++ b/service/server/handler_index.go @@ -0,0 +1,18 @@ +package server + +import ( + "net/http" +) + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + data := struct { + Version string + }{ + Version: s.version, + } + if err := s.indexTmpl.Execute(w, data); err != nil { + s.logger.Error("failed to execute index template", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} diff --git a/service/server/handlers_action.go b/service/server/handlers_action.go index 8dd2635..145f335 100644 --- a/service/server/handlers_action.go +++ b/service/server/handlers_action.go @@ -39,7 +39,7 @@ func (s *Server) handleToggleChannelAction(w http.ResponseWriter, r *http.Reques return } - if channel != string(notification.ChannelSignal) && channel != string(notification.ChannelWebPush) { + if !s.dispatcher.IsValidChannel(notification.Channel(channel)) { http.Error(w, "Invalid channel", http.StatusBadRequest) return } diff --git a/service/server/handlers_admin.go b/service/server/handlers_admin.go index 6de1f5b..faf10a5 100644 --- a/service/server/handlers_admin.go +++ b/service/server/handlers_admin.go @@ -45,7 +45,7 @@ func (s *Server) handleCreateMapping(w http.ResponseWriter, r *http.Request) { } if req.Channel == "" { - req.Channel = notification.ChannelSignal + req.Channel = notification.ChannelWebPush } if req.Channel == notification.ChannelWebPush && req.PushEndpoint == nil { diff --git a/service/server/handlers_fragment.go b/service/server/handlers_fragment.go index 62f9283..a8ea426 100644 --- a/service/server/handlers_fragment.go +++ b/service/server/handlers_fragment.go @@ -6,117 +6,8 @@ import ( "net/url" "prism/service/notification" - "prism/service/signal" - "prism/service/util" ) -func (s *Server) handleFragmentHealth(w http.ResponseWriter, r *http.Request) { - linked := s.getLinkedAccount() - signalOk := s.signalDaemon.IsRunning() - hasProton := s.cfg.IsProtonEnabled() - - w.Header().Set("Content-Type", "text/html") - - statusClass := "status-error" - statusText := "Disconnected and Unlinked" - var tooltip string - if signalOk && linked != nil { - statusClass = "status-ok" - statusText = "Connected and Linked" - tooltip = fmt.Sprintf(`%s`, util.FormatPhoneNumber(linked.Number)) - } else if signalOk { - statusText = "Connected and Unlinked" - } - - html := fmt.Sprintf(`
-
Signal: %s%s
`, statusClass, statusText, tooltip) - - if hasProton { - protonStatus := "Disconnected" - protonClass := "status-error" - protonTooltip := "" - if s.protonMonitor.IsConnected() { - protonStatus = "Connected" - protonClass = "status-ok" - protonTooltip = fmt.Sprintf(`%s`, s.cfg.ProtonIMAPUsername) - } - html += fmt.Sprintf(` -
Proton Mail: %s%s
`, protonClass, protonStatus, protonTooltip) - } - - html += `
` - - currentLinked := linked != nil - if s.lastSignalLinked == nil || *s.lastSignalLinked != currentLinked { - html += ` -
` - html += s.getSignalInfoHTML() - html += `
` - s.lastSignalLinked = ¤tLinked - } - - // Check if apps changed - mappings, err := s.store.GetAllMappings() - if err == nil { - currentAppsCount := len(mappings) - if s.lastAppsCount == nil || *s.lastAppsCount != currentAppsCount { - html += ` -
` - html += s.getAppsListHTML(mappings) - html += `
` - s.lastAppsCount = ¤tAppsCount - } - } - - _, _ = fmt.Fprint(w, html) //nolint:errcheck // Error writing to ResponseWriter is handled by HTTP server -} - -func (s *Server) handleFragmentSignalInfo(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") - _, _ = fmt.Fprint(w, s.getSignalInfoHTML()) //nolint:errcheck -} - -func (s *Server) getSignalInfoHTML() string { - if s.getLinkedAccount() != nil { - return fmt.Sprintf(`
`, s.cfg.DeviceName) - } - - return fmt.Sprintf(`
%s
`, s.getQRCodeHTML()) -} - -func (s *Server) getLinkedAccount() *signal.AccountInfo { - client := signal.NewClient(s.cfg.SignalCLISocketPath) - account, _ := client.GetLinkedAccount() //nolint:errcheck // Nil account is valid, indicates not linked - return account -} - -func (s *Server) getQRCodeHTML() string { - if s.getLinkedAccount() != nil { - return `

Account already linked

` - } - - qrCode, err := s.linkDevice.GenerateQR() - if err != nil { - s.logger.Error("Failed to generate QR code", "error", err) - return `

Signal daemon is starting up, please refresh in a few seconds...

` - } - - return fmt.Sprintf(`

Scan this QR code with your Signal app:

-

Settings → Linked Devices → Link New Device

-
- QR Code -
`, qrCode) -} - func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) { mappings, err := s.store.GetAllMappings() if err != nil { @@ -131,35 +22,57 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) { func (s *Server) getAppsListHTML(mappings []notification.Mapping) string { if len(mappings) == 0 { - return `

No apps registered

` + return `

No apps registered yet. See real-world examples to get started.

` } + signalLinked := false + if s.integrations.Signal != nil { + handlers := s.integrations.Signal.GetHandlers() + if handlers != nil { + account, _ := handlers.GetClient().GetLinkedAccount() + signalLinked = account != nil + } + } + telegramConfigured := s.cfg.IsTelegramEnabled() && s.cfg.TelegramChatID != 0 + var html string html += `
    ` for _, m := range mappings { isSignal := m.Channel == notification.ChannelSignal isWebPush := m.Channel == notification.ChannelWebPush - channelBadge := "Signal" + isTelegram := m.Channel == notification.ChannelTelegram + channelBadge := "" channelTooltip := "" - if isWebPush && m.WebPush != nil { - if m.WebPush.HasEncryption() { - channelBadge = "WebPush" - } else { - channelBadge = "Webhook" - } - channelTooltip = fmt.Sprintf(`%s`, m.WebPush.Endpoint) - } - if isSignal && m.Signal != nil && m.Signal.GroupID != "" { + channelBadge = "Signal" channelTooltip = fmt.Sprintf(`Group ID: %s`, m.Signal.GroupID) + } else if isWebPush && m.WebPush != nil && m.WebPush.Endpoint != "" { + channelBadge = "WebPush" + channelTooltip = fmt.Sprintf(`%s`, m.WebPush.Endpoint) + } else if isTelegram && telegramConfigured { + channelBadge = "Telegram" + } else { + channelBadge = "Not Configured" + if isSignal { + channelTooltip = `No Signal group created yet. Will auto-create on first notification.` + } else if isTelegram { + channelTooltip = `Set TELEGRAM_CHAT_ID in .env` + } else { + channelTooltip = `No WebPush endpoint registered.` + } } itemHTML := fmt.Sprintf(`
  • %s
    -
    - %s%s`, m.AppName, m.Channel, channelBadge, channelTooltip) +
    `, m.AppName) + + if channelBadge != "Not Configured" { + itemHTML += fmt.Sprintf(`%s%s`, m.Channel, channelBadge, channelTooltip) + } else { + itemHTML += fmt.Sprintf(`%s%s`, channelBadge, channelTooltip) + } if m.WebPush != nil && isWebPush { if u, err := url.Parse(m.WebPush.Endpoint); err == nil { @@ -169,18 +82,29 @@ func (s *Server) getAppsListHTML(mappings []notification.Mapping) string { itemHTML += `
    ` - if m.WebPush != nil { - webpushLabel := "WebPush" - if !m.WebPush.HasEncryption() { - webpushLabel = "Webhook" - } + var channelOptions string + optionCount := 0 + + if signalLinked { + channelOptions += fmt.Sprintf(``, map[bool]string{true: " selected", false: ""}[isSignal]) + optionCount++ + } + if telegramConfigured { + channelOptions += fmt.Sprintf(``, map[bool]string{true: " selected", false: ""}[isTelegram]) + optionCount++ + } + if m.WebPush != nil && m.WebPush.Endpoint != "" { + channelOptions += fmt.Sprintf(``, map[bool]string{true: " selected", false: ""}[isWebPush]) + optionCount++ + } + + if optionCount > 1 { itemHTML += fmt.Sprintf(`
    -
    `, m.AppName, map[bool]string{true: " selected", false: ""}[isSignal], map[bool]string{true: " selected", false: ""}[isWebPush], webpushLabel) + `, m.AppName, channelOptions) } itemHTML += fmt.Sprintf(`
  • `, m.AppName) diff --git a/service/server/handlers_integrations.go b/service/server/handlers_integrations.go new file mode 100644 index 0000000..8181e2f --- /dev/null +++ b/service/server/handlers_integrations.go @@ -0,0 +1,26 @@ +package server + +import ( + "fmt" + "net/http" +) + +func (s *Server) handleFragmentIntegrations(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + + html := "" + + if s.cfg.IsSignalEnabled() { + html += `
    ` + } + + if s.cfg.IsProtonEnabled() { + html += `
    ` + } + + if s.cfg.IsTelegramEnabled() { + html += `
    ` + } + + _, _ = fmt.Fprint(w, html) +} diff --git a/service/server/handlers_proton.go b/service/server/handlers_proton.go deleted file mode 100644 index 20b90fa..0000000 --- a/service/server/handlers_proton.go +++ /dev/null @@ -1,42 +0,0 @@ -package server - -import ( - "encoding/json" - "net/http" -) - -type markReadRequest struct { - UID uint32 `json:"uid"` -} - -func (s *Server) handleProtonMarkRead(w http.ResponseWriter, r *http.Request) { - var req markReadRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"}) //nolint:errcheck - return - } - - if req.UID == 0 { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "uid (number) is required"}) //nolint:errcheck - return - } - - if err := s.protonMonitor.MarkAsRead(req.UID); err != nil { - s.logger.Error("failed to mark email as read", "uid", req.UID, "error", err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "failed to mark as read"}) //nolint:errcheck - return - } - - s.logger.Info("marked email as read", "uid", req.UID) - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil { - s.logger.Error("failed to encode response", "error", err) - } -} diff --git a/service/server/middleware.go b/service/server/middleware.go index a163c74..16a9c66 100644 --- a/service/server/middleware.go +++ b/service/server/middleware.go @@ -11,6 +11,10 @@ import ( "golang.org/x/time/rate" ) +var noisyPaths = map[string]bool{ + "/.well-known/appspecific/com.chrome.devtools.json": true, +} + func authMiddleware(apiKey string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -32,13 +36,15 @@ func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler { wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} next.ServeHTTP(wrapped, r) - logger.Debug("HTTP request", - "method", r.Method, - "path", r.URL.Path, - "status", wrapped.statusCode, - "duration", time.Since(start), - "ip", util.GetClientIP(r), - ) + if !noisyPaths[r.URL.Path] { + logger.Debug("HTTP request", + "method", r.Method, + "path", r.URL.Path, + "status", wrapped.statusCode, + "duration", time.Since(start), + "ip", util.GetClientIP(r), + ) + } }) } } @@ -49,7 +55,9 @@ type responseWriter struct { } func (rw *responseWriter) WriteHeader(code int) { - rw.statusCode = code + if rw.statusCode == http.StatusOK { + rw.statusCode = code + } rw.ResponseWriter.WriteHeader(code) } @@ -58,7 +66,7 @@ func securityHeadersMiddleware() func(http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") - w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; object-src 'none'") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://api.qrserver.com; form-action 'self'; frame-ancestors 'none'; object-src 'none'") next.ServeHTTP(w, r) }) diff --git a/service/server/server.go b/service/server/server.go index b2435a8..46832d8 100644 --- a/service/server/server.go +++ b/service/server/server.go @@ -3,14 +3,16 @@ package server import ( "context" "fmt" + "html/template" "log/slog" "net/http" + "os" + "strings" "time" "prism/service/config" + "prism/service/integration" "prism/service/notification" - "prism/service/proton" - "prism/service/signal" "prism/service/util" "github.com/go-chi/chi/v5" @@ -18,18 +20,16 @@ import ( ) type Server struct { - cfg *config.Config - store *notification.Store - dispatcher *notification.Dispatcher - protonMonitor *proton.Monitor - signalDaemon *signal.Daemon - linkDevice *signal.LinkDevice - logger *slog.Logger - router *chi.Mux - httpServer *http.Server - startTime time.Time - lastSignalLinked *bool // Track signal linked status for change detection - lastAppsCount *int // Track apps count for change detection + cfg *config.Config + store *notification.Store + dispatcher *notification.Dispatcher + integrations *integration.Integrations + logger *slog.Logger + router *chi.Mux + httpServer *http.Server + startTime time.Time + version string + indexTmpl *template.Template } func New(cfg *config.Config) (*Server, error) { @@ -40,23 +40,27 @@ func New(cfg *config.Config) (*Server, error) { return nil, fmt.Errorf("failed to create store: %w", err) } - signalClient := signal.NewClient(cfg.SignalCLISocketPath) - dispatcher := notification.NewDispatcher(store, signalClient, logger) + integrations := integration.Initialize(cfg, store, logger) - protonMonitor := proton.NewMonitor(cfg, dispatcher, logger) + version := "dev" + if versionBytes, err := os.ReadFile("VERSION"); err == nil { + version = strings.TrimSpace(string(versionBytes)) + } - signalDaemon := signal.NewDaemon(cfg.SignalCLIBinaryPath, cfg.SignalCLIDataPath, cfg.SignalCLISocketPath) - linkDevice := signal.NewLinkDevice(signalClient, cfg.DeviceName) + tmpl, err := template.ParseFiles("public/index.html") + if err != nil { + return nil, fmt.Errorf("failed to parse index template: %w", err) + } s := &Server{ - cfg: cfg, - store: store, - dispatcher: dispatcher, - protonMonitor: protonMonitor, - signalDaemon: signalDaemon, - linkDevice: linkDevice, - logger: logger, - startTime: time.Now(), + cfg: cfg, + store: store, + dispatcher: integrations.Dispatcher, + integrations: integrations, + logger: logger, + startTime: time.Now(), + version: version, + indexTmpl: tmpl, } s.setupRoutes() @@ -76,14 +80,13 @@ func (s *Server) setupRoutes() { r.Use(middleware.StripSlashes) r.Use(rateLimitMiddleware(s.cfg.RateLimit)) + r.Get("/", s.handleIndex) r.Handle("/*", http.StripPrefix("/", http.FileServer(http.Dir("./public")))) - r.Route("/fragment", func(r chi.Router) { - r.Use(authMiddleware(s.cfg.APIKey)) - r.Get("/health", s.handleFragmentHealth) - r.Get("/signal-info", s.handleFragmentSignalInfo) - r.Get("/apps", s.handleFragmentApps) - }) + integration.RegisterAll(s.integrations, r, s.cfg, s.store, s.logger, authMiddleware) + + r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/apps", s.handleFragmentApps) + r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/integrations", s.handleFragmentIntegrations) r.Route("/action", func(r chi.Router) { r.Use(authMiddleware(s.cfg.APIKey)) @@ -100,33 +103,13 @@ func (s *Server) setupRoutes() { r.Get("/stats", s.handleGetStats) }) - r.Route("/webpush/app", func(r chi.Router) { - r.Use(authMiddleware(s.cfg.APIKey)) - r.Post("/", s.handleWebPushRegister) - r.Delete("/{appName}", s.handleWebPushUnregister) - }) - - r.Route("/api/proton-mail", func(r chi.Router) { - r.Use(authMiddleware(s.cfg.APIKey)) - r.Post("/mark-read", s.handleProtonMarkRead) - }) - r.With(authMiddleware(s.cfg.APIKey)).Post("/{topic}", s.handleNtfyPublish) s.router = r } func (s *Server) Start(ctx context.Context) error { - s.logger.Info("Starting Signal daemon") - if err := s.signalDaemon.Start(); err != nil { - return fmt.Errorf("failed to start signal daemon: %w", err) - } - - go func() { - if err := s.protonMonitor.Start(ctx); err != nil && err != context.Canceled { - s.logger.Error("Proton monitor error", "error", err) - } - }() + s.integrations.Start(ctx, s.cfg, s.logger) addr := fmt.Sprintf(":%d", s.cfg.Port) s.httpServer = &http.Server{ diff --git a/service/signal/daemon.go b/service/signal/daemon.go deleted file mode 100644 index 3c87697..0000000 --- a/service/signal/daemon.go +++ /dev/null @@ -1,96 +0,0 @@ -package signal - -import ( - "fmt" - "os" - "os/exec" - "syscall" - "time" -) - -type Daemon struct { - binaryPath string - dataPath string - socketPath string - cmd *exec.Cmd -} - -func NewDaemon(binaryPath, dataPath, socketPath string) *Daemon { - return &Daemon{ - binaryPath: binaryPath, - dataPath: dataPath, - socketPath: socketPath, - } -} - -func (d *Daemon) Start() error { - if d.IsRunning() { - return nil - } - - _ = os.Remove(d.socketPath) - - d.cmd = exec.Command( - d.binaryPath, - "--config", d.dataPath, - "daemon", - "--socket", d.socketPath, - "--send-read-receipts", - ) - - d.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - - if err := d.cmd.Start(); err != nil { - return fmt.Errorf("failed to start daemon: %w", err) - } - - timeout := time.After(10 * time.Second) - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-timeout: - _ = d.Stop() //nolint:errcheck - return fmt.Errorf("daemon failed to start within timeout") - case <-ticker.C: - if _, err := os.Stat(d.socketPath); err == nil { - return nil - } - } - } -} - -func (d *Daemon) Stop() error { - if d.cmd == nil || d.cmd.Process == nil { - return nil - } - - if err := d.cmd.Process.Signal(syscall.SIGTERM); err != nil { - return fmt.Errorf("failed to send SIGTERM: %w", err) - } - - done := make(chan error, 1) - go func() { - done <- d.cmd.Wait() - }() - - select { - case <-time.After(5 * time.Second): - _ = d.cmd.Process.Kill() - case <-done: - } - - _ = os.Remove(d.socketPath) - return nil -} - -func (d *Daemon) IsRunning() bool { - if _, err := os.Stat(d.socketPath); os.IsNotExist(err) { - return false - } - - client := NewClient(d.socketPath) - _, err := client.Call("listAccounts", nil) - return err == nil -} diff --git a/service/util/format.go b/service/util/format.go index 8c9ac2d..85f697f 100644 --- a/service/util/format.go +++ b/service/util/format.go @@ -6,49 +6,6 @@ import ( "time" ) -func FormatPhoneNumber(number string) string { - if number == "" { - return number - } - - // US/Canada: +1 (234) 567-8901 - if strings.HasPrefix(number, "+1") && len(number) == 12 { - return fmt.Sprintf("+1 (%s) %s-%s", number[2:5], number[5:8], number[8:]) - } - - // International: +XX XXXX XXXX - if strings.HasPrefix(number, "+") && len(number) > 4 { - digits := number[1:] - var countryCode, rest string - - for i := 1; i <= 3 && i < len(digits); i++ { - if digits[i] < '0' || digits[i] > '9' { - break - } - countryCode = digits[:i+1] - rest = digits[i+1:] - } - - if len(rest) >= 3 { - var parts []string - for len(rest) > 0 { - size := 3 - if len(rest) == 4 || len(rest) == 8 { - size = 4 - } - if size > len(rest) { - size = len(rest) - } - parts = append(parts, rest[:size]) - rest = rest[size:] - } - return "+" + countryCode + " " + strings.Join(parts, " ") - } - } - - return number -} - func FormatUptime(d time.Duration) string { days := int(d.Hours()) / 24 hours := int(d.Hours()) % 24 diff --git a/service/util/log.go b/service/util/log.go index f399d27..d1d8120 100644 --- a/service/util/log.go +++ b/service/util/log.go @@ -18,33 +18,31 @@ const ( ) type ColorHandler struct { - handler slog.Handler - w io.Writer + w io.Writer + level slog.Level } func NewColorHandler(w io.Writer, opts *slog.HandlerOptions) *ColorHandler { + level := slog.LevelInfo + if opts != nil && opts.Level != nil { + level = opts.Level.Level() + } return &ColorHandler{ - handler: slog.NewTextHandler(w, opts), - w: w, + w: w, + level: level, } } func (h *ColorHandler) Enabled(ctx context.Context, level slog.Level) bool { - return h.handler.Enabled(ctx, level) + return level >= h.level } func (h *ColorHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return &ColorHandler{ - handler: h.handler.WithAttrs(attrs), - w: h.w, - } + return h } func (h *ColorHandler) WithGroup(name string) slog.Handler { - return &ColorHandler{ - handler: h.handler.WithGroup(name), - w: h.w, - } + return h } func (h *ColorHandler) Handle(ctx context.Context, r slog.Record) error {