mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 19:54:44 -07:00
using biome for html/cs/js formatting and linting, simplify app to run with no services, re-implement proton and signal implementations to be much better, configure all integration in web UI instead of .env
This commit is contained in:
parent
5d99180862
commit
897df27e00
54 changed files with 2221 additions and 1131 deletions
|
|
@ -5,7 +5,7 @@ tmp_dir = "tmp"
|
||||||
bin = "./tmp/main"
|
bin = "./tmp/main"
|
||||||
cmd = "go build -o ./tmp/main ."
|
cmd = "go build -o ./tmp/main ."
|
||||||
include_ext = ["go", "html", "css", "js"]
|
include_ext = ["go", "html", "css", "js"]
|
||||||
exclude_dir = ["tmp", "data", "signal-cli"]
|
exclude_dir = ["tmp", "data"]
|
||||||
delay = 1000
|
delay = 1000
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
signal-cli/
|
|
||||||
data/
|
data/
|
||||||
*.db
|
*.db
|
||||||
prism
|
prism
|
||||||
|
|
|
||||||
48
.env.example
48
.env.example
|
|
@ -1,47 +1,21 @@
|
||||||
# Required: API key for authentication
|
# Required: API key for authentication
|
||||||
# API_KEY=your-secret-key-here
|
# API_KEY=your-secret-key-here
|
||||||
|
|
||||||
# Optional: Feature flags to enable/disable integrations
|
# Optional Configuration
|
||||||
# FEATURE_ENABLE_SIGNAL=false
|
|
||||||
# FEATURE_ENABLE_PROTON=false
|
|
||||||
# FEATURE_ENABLE_TELEGRAM=false
|
|
||||||
|
|
||||||
# Advanced Configuration
|
# Optional: Enable integrations (default: false - only enable what you need)
|
||||||
|
# ENABLE_SIGNAL=true
|
||||||
|
# ENABLE_TELEGRAM=true
|
||||||
|
# ENABLE_PROTON=true
|
||||||
|
|
||||||
|
# Optional: Server port (default: 8080)
|
||||||
# Optional: Server port
|
|
||||||
# PORT=8080
|
# PORT=8080
|
||||||
|
|
||||||
# Optional: Rate limit for requests (per 15 minute window)
|
# Optional: Rate limit for requests (default: 100 per 15 minute window)
|
||||||
# RATE_LIMIT=100
|
# RATE_LIMIT=100
|
||||||
|
|
||||||
# Optional: Device name shown in Signal app's linked devices list
|
# Optional: Enable verbose logging (default: false)
|
||||||
# DEVICE_NAME=Prism
|
# VERBOSE_LOGGING=false
|
||||||
|
|
||||||
# Optional: Enable verbose logging
|
# Optional: Database storage path (default: ./data/prism.db)
|
||||||
# VERBOSE_LOGGING=true
|
# STORAGE_PATH=./data/prism.db
|
||||||
|
|
||||||
|
|
||||||
# Signal Integration (requires FEATURE_ENABLE_SIGNAL=true)
|
|
||||||
|
|
||||||
# Optional: Path to the signal-cli daemon Unix socket
|
|
||||||
# SIGNAL_SOCKET=/run/signal-cli/socket
|
|
||||||
|
|
||||||
|
|
||||||
# Proton Mail Integration (requires FEATURE_ENABLE_PROTON=true)
|
|
||||||
|
|
||||||
# Required: IMAP credentials for Proton Mail Bridge
|
|
||||||
# PROTON_IMAP_USERNAME=your-email@proton.me
|
|
||||||
# PROTON_IMAP_PASSWORD=bridge-generated-password
|
|
||||||
|
|
||||||
# Optional: Address of the Proton Mail Bridge service
|
|
||||||
# PROTON_BRIDGE_ADDR=protonmail-bridge:143
|
|
||||||
|
|
||||||
|
|
||||||
# Telegram Integration (requires FEATURE_ENABLE_TELEGRAM=true)
|
|
||||||
|
|
||||||
# Required: Bot token from @BotFather
|
|
||||||
# TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
|
|
||||||
|
|
||||||
# Required: Your personal chat ID (message @userinfobot on Telegram to get it)
|
|
||||||
# TELEGRAM_CHAT_ID=123456789
|
|
||||||
|
|
|
||||||
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
|
- **NO DOCUMENTATION FILES**: Never create README, GUIDE, HOWTO, or any other documentation markdown files unless explicitly requested. Code changes only.
|
||||||
- **NO USELESS COMMENTS**: Don't add comments that just restate what the code does. If the code needs a comment to be understood, refactor it to be clearer instead.
|
- **NO USELESS COMMENTS**: Don't add comments that just restate what the code does. If the code needs a comment to be understood, refactor it to be clearer instead.
|
||||||
- **Self-documenting code**: Use clear variable and function names. The code should explain itself.
|
- **Self-documenting code**: Use clear variable and function names. The code should explain itself.
|
||||||
- **Only comment WHY, not WHAT**: If you must add a comment, explain WHY something is done, not WHAT is being done.
|
- **Only comment WHY, not WHAT**: If you must add a comment, explain WHY something is done, not WHAT is being done.
|
||||||
|
|
|
||||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
|
@ -45,5 +45,8 @@ jobs:
|
||||||
version: v2.8.0
|
version: v2.8.0
|
||||||
args: --timeout=5m
|
args: --timeout=5m
|
||||||
|
|
||||||
|
- name: Check frontend
|
||||||
|
run: npx @biomejs/biome@latest check .
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: go build -v .
|
run: go build -v .
|
||||||
|
|
|
||||||
34
.github/workflows/release-signal-cli.yml
vendored
34
.github/workflows/release-signal-cli.yml
vendored
|
|
@ -1,34 +0,0 @@
|
||||||
name: Release Signal CLI
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push signal-cli Docker image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile.signal-cli
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
ghcr.io/lone-cloud/prism-signal-cli:latest
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,4 +1,3 @@
|
||||||
signal-cli/
|
|
||||||
data/
|
data/
|
||||||
*.db
|
*.db
|
||||||
.env
|
.env
|
||||||
|
|
|
||||||
17
Dockerfile
17
Dockerfile
|
|
@ -14,15 +14,26 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||||
-ldflags="-w -s -X main.version=$(cat VERSION 2>/dev/null || echo dev) -X main.commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \
|
-ldflags="-w -s -X main.version=$(cat VERSION 2>/dev/null || echo dev) -X main.commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \
|
||||||
-o prism .
|
-o prism .
|
||||||
|
|
||||||
FROM alpine:3.23
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates wget curl && \
|
||||||
|
curl -L -o signal-cli.gz \
|
||||||
|
https://media.projektzentrisch.de/temp/signal-cli/signal-cli_ubuntu2004_${TARGETARCH}.gz && \
|
||||||
|
gunzip signal-cli.gz && \
|
||||||
|
mv signal-cli /usr/local/bin/signal-cli && \
|
||||||
|
chmod +x /usr/local/bin/signal-cli && \
|
||||||
|
apt-get remove -y curl && \
|
||||||
|
apt-get autoremove -y && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /build/prism .
|
COPY --from=builder /build/prism .
|
||||||
|
|
||||||
RUN adduser -D -u 1000 prism && \
|
RUN useradd -m -u 1000 prism && \
|
||||||
mkdir -p /app/data && \
|
mkdir -p /app/data && \
|
||||||
chown -R prism:prism /app
|
chown -R prism:prism /app
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
FROM alpine:3.23
|
|
||||||
|
|
||||||
RUN apk add --no-cache signal-cli su-exec && \
|
|
||||||
adduser -D -u 1000 signal && \
|
|
||||||
mkdir -p /home/signal/.signal-cli /home/.local/share/signal-cli && \
|
|
||||||
chown -R signal:signal /home/signal /home/.local/share/signal-cli && \
|
|
||||||
echo -e '#!/bin/sh\nrm -f /home/signal/.signal-cli/socket\nchown -R signal:signal /home/signal/.signal-cli\nexec su-exec signal signal-cli --config /home/.local/share/signal-cli daemon --socket /home/signal/.signal-cli/socket' > /entrypoint.sh && \
|
|
||||||
chmod +x /entrypoint.sh
|
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
|
||||||
46
Makefile
46
Makefile
|
|
@ -1,4 +1,4 @@
|
||||||
.PHONY: all build build-linux run dev fmt lint vet clean install-tools deps check-updates update update-all docker-build docker-run docker-down release
|
.PHONY: all build build-linux run dev lint fix vet clean install-tools deps check-updates update update-all docker-build docker-run docker-down release
|
||||||
|
|
||||||
BINARY_NAME=prism
|
BINARY_NAME=prism
|
||||||
VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev")
|
VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev")
|
||||||
|
|
@ -6,7 +6,7 @@ COMMIT?=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
GOBIN?=$(shell command -v go >/dev/null 2>&1 && go env GOPATH || echo "${HOME}/go")/bin
|
GOBIN?=$(shell command -v go >/dev/null 2>&1 && go env GOPATH || echo "${HOME}/go")/bin
|
||||||
export PATH := $(GOBIN):$(PATH)
|
export PATH := $(GOBIN):$(PATH)
|
||||||
|
|
||||||
all: fmt lint build
|
all: fix build
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -ldflags="-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o $(BINARY_NAME) .
|
go build -ldflags="-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o $(BINARY_NAME) .
|
||||||
|
|
@ -18,15 +18,11 @@ dev:
|
||||||
@which air > /dev/null || (echo "Installing air..." && go install github.com/air-verse/air@latest)
|
@which air > /dev/null || (echo "Installing air..." && go install github.com/air-verse/air@latest)
|
||||||
air
|
air
|
||||||
|
|
||||||
fmt:
|
fix:
|
||||||
gofmt -s -w .
|
gofmt -s -w .
|
||||||
goimports -w .
|
goimports -w .
|
||||||
|
|
||||||
lint:
|
|
||||||
golangci-lint run
|
|
||||||
|
|
||||||
fix:
|
|
||||||
golangci-lint run --fix
|
golangci-lint run --fix
|
||||||
|
npx @biomejs/biome@latest check --write --unsafe .
|
||||||
|
|
||||||
vet:
|
vet:
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
@ -39,6 +35,24 @@ install-tools:
|
||||||
@echo "Installing Go tools to $(GOBIN)..."
|
@echo "Installing Go tools to $(GOBIN)..."
|
||||||
go install golang.org/x/tools/cmd/goimports@latest
|
go install golang.org/x/tools/cmd/goimports@latest
|
||||||
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v2.8.0
|
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v2.8.0
|
||||||
|
@echo "Installing signal-cli native binary..."
|
||||||
|
@ARCH=$$(uname -m); \
|
||||||
|
case $$ARCH in \
|
||||||
|
x86_64) SIGNAL_ARCH=amd64 ;; \
|
||||||
|
aarch64) SIGNAL_ARCH=arm64 ;; \
|
||||||
|
*) echo "Unsupported architecture: $$ARCH"; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
|
TMP_DIR=$$(mktemp -d); \
|
||||||
|
cd $$TMP_DIR && \
|
||||||
|
curl -L -o signal-cli.gz \
|
||||||
|
https://media.projektzentrisch.de/temp/signal-cli/signal-cli_ubuntu2004_$${SIGNAL_ARCH}.gz && \
|
||||||
|
gunzip signal-cli.gz && \
|
||||||
|
sudo mv signal-cli /usr/local/bin/signal-cli && \
|
||||||
|
sudo chmod +x /usr/local/bin/signal-cli && \
|
||||||
|
cd - && \
|
||||||
|
rm -rf $$TMP_DIR && \
|
||||||
|
signal-cli --version && \
|
||||||
|
echo "signal-cli installed successfully to /usr/local/bin/signal-cli"
|
||||||
|
|
||||||
deps:
|
deps:
|
||||||
go mod download
|
go mod download
|
||||||
|
|
@ -55,21 +69,9 @@ update-all:
|
||||||
go get -u all
|
go get -u all
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
docker-build:
|
|
||||||
docker build -t prism:$(VERSION) .
|
|
||||||
|
|
||||||
docker-up:
|
docker-up:
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
|
|
||||||
docker-down:
|
|
||||||
docker compose -f docker-compose.dev.yml down
|
|
||||||
|
|
||||||
docker-up-proton:
|
|
||||||
docker compose -f docker-compose.dev.yml up -d protonmail-bridge
|
|
||||||
|
|
||||||
docker-up-signal:
|
|
||||||
docker compose -f docker-compose.dev.yml up -d signal-cli
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
@if [ ! -f VERSION ]; then \
|
@if [ ! -f VERSION ]; then \
|
||||||
echo "Error: VERSION file not found"; \
|
echo "Error: VERSION file not found"; \
|
||||||
|
|
@ -84,7 +86,3 @@ release:
|
||||||
release-dev:
|
release-dev:
|
||||||
@echo "Triggering dev Docker image build..."
|
@echo "Triggering dev Docker image build..."
|
||||||
gh workflow run release-dev.yml
|
gh workflow run release-dev.yml
|
||||||
|
|
||||||
release-signal:
|
|
||||||
@echo "Triggering signal-cli image release via GitHub Actions..."
|
|
||||||
gh workflow run release-signal-cli.yml
|
|
||||||
206
README.md
206
README.md
|
|
@ -4,155 +4,149 @@
|
||||||
|
|
||||||
# Prism
|
# Prism
|
||||||
|
|
||||||
**Self-hosted notification gateway**
|
**Self-hosted notification gateway with email monitoring**
|
||||||
|
|
||||||
[Setup](#setup) • [Real-World Examples](#real-world-examples)
|
[Setup](#setup) • [Integrations](#integrations) • [Examples](#real-world-examples)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- markdownlint-enable MD033 -->
|
<!-- markdownlint-enable MD033 -->
|
||||||
|
|
||||||
Prism is a self-hosted notification gateway. Prism can receive messages and route them to Signal, Telegram or WebPush URLs. Messages can be sent via Webhooks or from an optional Proton Mail integration.
|
Prism is a self-hosted notification gateway. Prism can receive messages and route them to Signal, Telegram or WebPush URLs. Messages can be sent via webhooks or monitored from a Proton Mail account integration.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
### Docker (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download docker-compose.yml
|
# Create .env file
|
||||||
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/docker-compose.yml
|
|
||||||
|
|
||||||
# Download .env.example
|
|
||||||
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example
|
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example
|
||||||
|
mv .env.example .env
|
||||||
# Configure your API key (eg. admin password)
|
|
||||||
cp .env.example .env
|
|
||||||
nano .env # Set API_KEY=your-secret-key-here
|
nano .env # Set API_KEY=your-secret-key-here
|
||||||
|
|
||||||
# Start Prism
|
# Run Prism
|
||||||
docker compose up -d
|
docker run -d \
|
||||||
|
--name prism \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-v prism-data:/app/data \
|
||||||
|
--env-file .env \
|
||||||
|
ghcr.io/lone-cloud/prism:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Prism is now running at <http://localhost:8080>. Enable optional integrations below for custom functionality.
|
### Binary (Alternative)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download latest release
|
||||||
|
curl -L -O https://github.com/lone-cloud/prism/releases/latest/download/prism-linux-amd64
|
||||||
|
chmod +x prism-linux-amd64
|
||||||
|
mv prism-linux-amd64 prism
|
||||||
|
|
||||||
|
# Create .env file
|
||||||
|
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example
|
||||||
|
mv .env.example .env
|
||||||
|
nano .env # Set API_KEY=your-secret-key-here
|
||||||
|
|
||||||
|
# Run Prism
|
||||||
|
./prism
|
||||||
|
```
|
||||||
|
|
||||||
|
Prism is now running at <http://localhost:8080>.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
All integrations are optional. Enable only what you need.
|
All integrations are configured through the web UI - no environment variables or command-line setup needed!
|
||||||
|
|
||||||
|
Authenticate using your `API_KEY` as the password (username can be anything).
|
||||||
|
|
||||||
### Signal
|
### Signal
|
||||||
|
|
||||||
Send notifications through Signal groups.
|
Send notifications through Signal Messenger.
|
||||||
Each registered app will route messages to private Signal groups matching the app name.
|
|
||||||
|
|
||||||
**1. Enable Signal in `.env`:**
|
**Setup:**
|
||||||
|
|
||||||
```bash
|
1. Visit <http://localhost:8080> and authenticate with your API_KEY
|
||||||
FEATURE_ENABLE_SIGNAL=true
|
2. Expand the Signal integration card
|
||||||
```
|
3. Click "Link Device"
|
||||||
|
4. Scan the QR code with Signal on your phone:
|
||||||
|
- Open Signal → Settings → Linked Devices → Link New Device
|
||||||
|
- Scan the displayed QR code
|
||||||
|
5. Your device will link automatically
|
||||||
|
|
||||||
**2. Start Prism with the Signal service:**
|
All notifications will be sent via Signal.
|
||||||
|
|
||||||
```bash
|
**Note:** If running the binary directly (not Docker), you'll need [signal-cli](https://github.com/AsamK/signal-cli/releases) installed and in your PATH. Docker images include signal-cli automatically.
|
||||||
docker compose --profile signal up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Link your Signal account:**
|
|
||||||
|
|
||||||
Visit <http://localhost:8080>, authenticate with your API_KEY, and scan the QR code from your Signal app:
|
|
||||||
|
|
||||||
**Settings → Linked Devices → Link New Device**
|
|
||||||
|
|
||||||
Once linked, new apps will default to Signal delivery.
|
|
||||||
|
|
||||||
### Telegram
|
### Telegram
|
||||||
|
|
||||||
Send notifications through Telegram instead.
|
Send notifications through a Telegram bot.
|
||||||
Unlike the Signal integration, Telegram relies on creating a Telegram bot as it will serve as the notifications messenger.
|
|
||||||
|
|
||||||
**1. Create a Telegram bot:**
|
**Setup:**
|
||||||
|
|
||||||
|
1. Create a bot:
|
||||||
- Message [@BotFather](https://t.me/BotFather) on Telegram
|
- Message [@BotFather](https://t.me/BotFather) on Telegram
|
||||||
- Send `/newbot` and follow the prompts
|
- Send `/newbot` and follow the prompts
|
||||||
- Copy the bot token (looks like `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
|
- Copy the bot token
|
||||||
|
|
||||||
**2. Enable Telegram in `.env`:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
FEATURE_ENABLE_TELEGRAM=true
|
|
||||||
TELEGRAM_BOT_TOKEN=your-bot-token-here
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Get your chat ID:**
|
|
||||||
|
|
||||||
|
2. Get your Chat ID:
|
||||||
- Message [@userinfobot](https://t.me/userinfobot) on Telegram
|
- Message [@userinfobot](https://t.me/userinfobot) on Telegram
|
||||||
- Copy your Chat ID from the bot's response
|
- Copy your Chat ID from the response
|
||||||
|
|
||||||
**4. Add chat ID to `.env`:**
|
3. Configure in Prism:
|
||||||
|
- Visit <http://localhost:8080> and authenticate with your API_KEY
|
||||||
|
- Expand the Telegram integration card
|
||||||
|
- Enter your bot token and chat ID
|
||||||
|
- Click "Configure"
|
||||||
|
|
||||||
```bash
|
All notifications will be sent to your Telegram chat.
|
||||||
TELEGRAM_CHAT_ID=123456789
|
|
||||||
```
|
|
||||||
|
|
||||||
**5. Start Prism:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Telegram runs in the main Prism service (no profile needed)
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
All notifications will now be sent to your Telegram chat. Unlike Signal, Telegram doesn't create separate groups per app - all notifications go to the configured chat ID.
|
|
||||||
|
|
||||||
### Proton Mail
|
### Proton Mail
|
||||||
|
|
||||||
Receive notifications when new Proton Mail emails arrive.
|
Monitor a Proton Mail account and forward new emails as notifications through Signal or Telegram.
|
||||||
Unlike other integrations, this one will generate new messages to be delivered by one of the configured transports.
|
|
||||||
Note that using this integration requires a paid Proton Mail account to be able to use the Proton Mail Bridge that this integration relies on.
|
|
||||||
Also note that the Proton Mail Bridge is RAM hungry.
|
|
||||||
|
|
||||||
> **Note:** The default image (`shenxn/protonmail-bridge:build`) used by Prism compiles from source and supports all architectures. For x86_64 only, you can use `shenxn/protonmail-bridge:latest` (smaller, faster).
|
**Features:**
|
||||||
|
|
||||||
**1. Initialize the Proton Mail Bridge:**
|
- Monitors inbox for new emails in real-time
|
||||||
|
- Supports 2FA-enabled accounts
|
||||||
|
- Auto-creates "Proton Mail" app for received emails
|
||||||
|
- Secure credential storage with AES-256-GCM encryption
|
||||||
|
- Automatic token refresh - no re-authentication needed
|
||||||
|
|
||||||
```bash
|
**Setup:**
|
||||||
docker compose run --rm protonmail-bridge init
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Login at the `>>>` prompt:**
|
1. Visit <http://localhost:8080> and authenticate with your API_KEY
|
||||||
|
2. Configure Signal or Telegram first (required for routing)
|
||||||
|
3. Expand the Proton Mail integration card
|
||||||
|
4. Enter your Proton Mail credentials:
|
||||||
|
- Email address
|
||||||
|
- Password
|
||||||
|
- 2FA code (if enabled)
|
||||||
|
5. Click "Link"
|
||||||
|
|
||||||
```bash
|
Proton Mail will connect and begin monitoring. New emails will appear as notifications from the "Proton Mail" app.
|
||||||
>>> login
|
|
||||||
# Enter your Proton Mail email
|
|
||||||
# Enter your password
|
|
||||||
# Enter your 2FA code
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Get IMAP credentials:**
|
**Note:** Prism uses the official Proton Mail API (same as the Proton Bridge). Credentials are encrypted and stored locally. Tokens refresh automatically in the background.
|
||||||
|
|
||||||
```bash
|
### WebPush
|
||||||
>>> info
|
|
||||||
# Copy the Username and Password
|
|
||||||
>>> exit
|
|
||||||
```
|
|
||||||
|
|
||||||
**4. Add credentials to `.env`:**
|
Send notifications directly to your browser.
|
||||||
|
|
||||||
```bash
|
**Setup:**
|
||||||
FEATURE_ENABLE_PROTON=true
|
|
||||||
PROTON_IMAP_USERNAME=username-from-info-command
|
|
||||||
PROTON_IMAP_PASSWORD=password-from-info-command
|
|
||||||
```
|
|
||||||
|
|
||||||
**5. Start Prism with Proton Mail:**
|
1. Visit <http://localhost:8080> and authenticate with your API_KEY
|
||||||
|
2. Allow browser notifications when prompted
|
||||||
|
3. Apps without Signal or Telegram configured will automatically use WebPush
|
||||||
|
|
||||||
```bash
|
You'll receive browser notifications when messages arrive.
|
||||||
docker compose --profile proton up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Real-World Examples
|
## Real-World Examples
|
||||||
|
|
||||||
### Proton Mail Notifications
|
### Email Monitoring
|
||||||
|
|
||||||
Receive Signal notifications when new emails arrive in your Proton Mail inbox.
|
Receive instant Signal or Telegram notifications when new emails arrive in your Proton Mail inbox.
|
||||||
|
|
||||||
Prism monitors a Proton Mail account via the local bridge and forwards email alerts through Signal. This relies on the same technology that a third-party email client like Thunderbird would be using to integrate with Proton Mail.
|
Prism monitors your Proton Mail account using the official Proton API and forwards new emails as notifications. Perfect for monitoring important accounts without constantly checking email.
|
||||||
|
|
||||||
### Home Assistant Alerts
|
### Home Assistant Alerts
|
||||||
|
|
||||||
|
|
@ -184,7 +178,9 @@ Reboot your Home Assistant system and you'll then be able to send Signal notific
|
||||||
|
|
||||||
#### POST /{appName}
|
#### POST /{appName}
|
||||||
|
|
||||||
Send a notification. Compatible with ntfy format.
|
Send a notification to a specific app. Messages are routed based on your app configuration in the web UI.
|
||||||
|
|
||||||
|
JSON format:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8080/my-app \
|
curl -X POST http://localhost:8080/my-app \
|
||||||
|
|
@ -193,7 +189,7 @@ curl -X POST http://localhost:8080/my-app \
|
||||||
-d '{"title": "Alert", "message": "Something happened"}'
|
-d '{"title": "Alert", "message": "Something happened"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Or ntfy-style:
|
Plain text (ntfy-compatible):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8080/my-app \
|
curl -X POST http://localhost:8080/my-app \
|
||||||
|
|
@ -201,6 +197,11 @@ curl -X POST http://localhost:8080/my-app \
|
||||||
-d "Simple message text"
|
-d "Simple message text"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**App Routing:**
|
||||||
|
- Configure which integration(s) receive messages from each app via the web UI
|
||||||
|
- Apps can route to Signal, Telegram, WebPush, or multiple destinations
|
||||||
|
- Special apps like "Proton Mail" are created automatically
|
||||||
|
|
||||||
### WebPush/Webhook Management
|
### WebPush/Webhook Management
|
||||||
|
|
||||||
#### POST /webpush/app
|
#### POST /webpush/app
|
||||||
|
|
@ -245,8 +246,6 @@ curl -X DELETE http://localhost:8080/webpush/app/my-app \
|
||||||
|
|
||||||
## Monitoring
|
## Monitoring
|
||||||
|
|
||||||
The health of the system can be viewed in the same admin UI used for linking Signal. Prism uses [basic access authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) - provide your `API_KEY` as the password (username can be anything).
|
|
||||||
|
|
||||||
### Health Endpoints
|
### Health Endpoints
|
||||||
|
|
||||||
#### GET /health
|
#### GET /health
|
||||||
|
|
@ -267,5 +266,14 @@ curl http://localhost:8080/api/health \
|
||||||
```
|
```
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"version":"1.0.0","uptime":"3s","signal":{"linked":true},"proton":{"linked":true},"telegram":{"linked":true}}
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"uptime": "2h15m",
|
||||||
|
"signal": {"linked": true, "account": "+1234567890"},
|
||||||
|
"telegram": {"linked": true, "account": "123456789"},
|
||||||
|
"proton": {"linked": true, "account": "user@proton.me"}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
BIN
assets/screenshots/dashboard.webp
Normal file
BIN
assets/screenshots/dashboard.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
40
biome.json
Normal file
40
biome.json
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"includes": [
|
||||||
|
"**/public/**/*.{js,css,html}",
|
||||||
|
"!**/public/htmx.min.js",
|
||||||
|
"!**/service/**/templates/**/*.html"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "tab",
|
||||||
|
"lineWidth": 100
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"semicolons": "always"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
services:
|
|
||||||
prism:
|
|
||||||
container_name: prism
|
|
||||||
image: ghcr.io/lone-cloud/prism:dev
|
|
||||||
ports:
|
|
||||||
- '8080:8080'
|
|
||||||
environment:
|
|
||||||
- PORT=8080
|
|
||||||
- API_KEY=${API_KEY:-}
|
|
||||||
- VERBOSE_LOGGING=${VERBOSE_LOGGING:-false}
|
|
||||||
- RATE_LIMIT=${RATE_LIMIT:-100}
|
|
||||||
- DEVICE_NAME=${DEVICE_NAME:-Prism}
|
|
||||||
- STORAGE_PATH=${STORAGE_PATH:-./data/prism.db}
|
|
||||||
- FEATURE_ENABLE_SIGNAL=${FEATURE_ENABLE_SIGNAL:-false}
|
|
||||||
- SIGNAL_SOCKET=/home/signal/.signal-cli/socket
|
|
||||||
- FEATURE_ENABLE_PROTON=${FEATURE_ENABLE_PROTON:-false}
|
|
||||||
- PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-}
|
|
||||||
- PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-}
|
|
||||||
- PROTON_BRIDGE_ADDR=${PROTON_BRIDGE_ADDR:-protonmail-bridge:143}
|
|
||||||
- FEATURE_ENABLE_TELEGRAM=${FEATURE_ENABLE_TELEGRAM:-false}
|
|
||||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
|
||||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
|
||||||
volumes:
|
|
||||||
- prism-data:/app/data
|
|
||||||
- signal-socket:/home/signal/.signal-cli:ro
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
signal-cli:
|
|
||||||
container_name: signal-cli
|
|
||||||
image: ghcr.io/lone-cloud/prism-signal-cli:latest
|
|
||||||
profiles: ['signal']
|
|
||||||
volumes:
|
|
||||||
- signal-data:/home/.local/share/signal-cli
|
|
||||||
- signal-socket:/home/signal/.signal-cli
|
|
||||||
- /tmp/signal-cli:/home/signal/.signal-cli
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
protonmail-bridge:
|
|
||||||
container_name: protonmail-bridge
|
|
||||||
image: shenxn/protonmail-bridge:build
|
|
||||||
profiles: ['proton']
|
|
||||||
ports:
|
|
||||||
- '127.0.0.1:143:143'
|
|
||||||
volumes:
|
|
||||||
- proton-bridge-data:/root
|
|
||||||
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
signal-data:
|
|
||||||
signal-socket:
|
|
||||||
prism-data:
|
|
||||||
proton-bridge-data:
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
services:
|
|
||||||
prism:
|
|
||||||
container_name: prism
|
|
||||||
image: ghcr.io/lone-cloud/prism:latest
|
|
||||||
ports:
|
|
||||||
- '8080:8080'
|
|
||||||
environment:
|
|
||||||
- PORT=8080
|
|
||||||
- API_KEY=${API_KEY:-}
|
|
||||||
- VERBOSE_LOGGING=${VERBOSE_LOGGING:-false}
|
|
||||||
- RATE_LIMIT=${RATE_LIMIT:-100}
|
|
||||||
- DEVICE_NAME=${DEVICE_NAME:-Prism}
|
|
||||||
- STORAGE_PATH=${STORAGE_PATH:-./data/prism.db}
|
|
||||||
- FEATURE_ENABLE_SIGNAL=${FEATURE_ENABLE_SIGNAL:-false}
|
|
||||||
- SIGNAL_SOCKET=/home/signal/.signal-cli/socket
|
|
||||||
- FEATURE_ENABLE_PROTON=${FEATURE_ENABLE_PROTON:-false}
|
|
||||||
- PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-}
|
|
||||||
- PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-}
|
|
||||||
- PROTON_BRIDGE_ADDR=${PROTON_BRIDGE_ADDR:-protonmail-bridge:143}
|
|
||||||
- FEATURE_ENABLE_TELEGRAM=${FEATURE_ENABLE_TELEGRAM:-false}
|
|
||||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
|
||||||
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
|
||||||
volumes:
|
|
||||||
- prism-data:/app/data
|
|
||||||
- signal-socket:/home/signal/.signal-cli:ro
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
signal-cli:
|
|
||||||
container_name: signal-cli
|
|
||||||
image: ghcr.io/lone-cloud/prism-signal-cli:latest
|
|
||||||
profiles: ['signal']
|
|
||||||
volumes:
|
|
||||||
- signal-data:/home/.local/share/signal-cli
|
|
||||||
- signal-socket:/home/signal/.signal-cli
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
protonmail-bridge:
|
|
||||||
container_name: protonmail-bridge
|
|
||||||
image: shenxn/protonmail-bridge:build
|
|
||||||
profiles: ['proton']
|
|
||||||
volumes:
|
|
||||||
- proton-bridge-data:/root
|
|
||||||
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
signal-data:
|
|
||||||
signal-socket:
|
|
||||||
prism-data:
|
|
||||||
proton-bridge-data:
|
|
||||||
13
go.mod
13
go.mod
|
|
@ -4,23 +4,25 @@ go 1.25.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
github.com/emersion/hydroxide v0.2.31
|
||||||
github.com/go-chi/chi/v5 v5.2.5
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mymmrac/telego v1.5.1
|
github.com/mymmrac/telego v1.5.1
|
||||||
|
golang.org/x/crypto v0.47.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
modernc.org/sqlite v1.44.3
|
modernc.org/sqlite v1.44.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/emersion/go-message v0.18.2 // indirect
|
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 // indirect
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/grbit/go-json v0.11.0 // indirect
|
github.com/grbit/go-json v0.11.0 // indirect
|
||||||
|
|
@ -33,10 +35,9 @@ require (
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||||
github.com/valyala/fastjson v1.6.7 // indirect
|
github.com/valyala/fastjson v1.6.7 // indirect
|
||||||
golang.org/x/arch v0.23.0 // indirect
|
golang.org/x/arch v0.24.0 // indirect
|
||||||
golang.org/x/crypto v0.47.0 // indirect
|
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
modernc.org/gc/v3 v3.1.2 // indirect
|
modernc.org/gc/v3 v3.1.2 // indirect
|
||||||
modernc.org/libc v1.67.7 // indirect
|
modernc.org/libc v1.67.7 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|
|
||||||
22
go.sum
22
go.sum
|
|
@ -1,3 +1,5 @@
|
||||||
|
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||||
|
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
|
|
@ -8,6 +10,8 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
|
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|
@ -15,12 +19,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 h1:7aCSuwTBzg7BCPRRaBJD0weKZYdeAykOrY6ktpx8Vvc=
|
||||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63/go.mod h1:eRwwJnuLVFtYTC+AI2JDJTMcuQUTYhBIK4I6bC5tpqw=
|
||||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
github.com/emersion/hydroxide v0.2.31 h1:ofPKtEpD+AtE2oKJhcQKhUjubQS8+AHIjCuO3aeLBsM=
|
||||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
github.com/emersion/hydroxide v0.2.31/go.mod h1:jhoMVyP0z2GACrmFkL0ppcWKt2LbC02auADSSsB4vH4=
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
|
||||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
|
@ -74,8 +76,8 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
|
|
@ -122,8 +124,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
|
|
||||||
208
public/index.css
208
public/index.css
|
|
@ -3,42 +3,27 @@
|
||||||
--bg-secondary: #fdfdfd;
|
--bg-secondary: #fdfdfd;
|
||||||
--text-primary: #000;
|
--text-primary: #000;
|
||||||
--text-secondary: #666;
|
--text-secondary: #666;
|
||||||
--text-on-color: #fafafa;
|
--text-on-color: #fff;
|
||||||
--border-color: rgba(0, 0, 0, 0.1);
|
--border-color: rgba(0, 0, 0, 0.1);
|
||||||
--accent: #8159b8;
|
--accent: #00b4d8;
|
||||||
--success: #28a745;
|
--success: #06d6a0;
|
||||||
--error: #dc3545;
|
--error: #ef476f;
|
||||||
--spinner-track: #e0e0e0;
|
--spinner-track: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not([data-theme="light"]):not([data-theme="dark"]) {
|
:root:not([data-theme="light"]) {
|
||||||
--bg-primary: #1a1a1a;
|
--bg-primary: #1a1a1a;
|
||||||
--bg-secondary: #2d2d2d;
|
--bg-secondary: #2d2d2d;
|
||||||
--text-primary: #e0e0e0;
|
--text-primary: #e0e0e0;
|
||||||
--text-secondary: #a0a0a0;
|
--text-secondary: #a0a0a0;
|
||||||
--text-on-color: #fff;
|
--text-on-color: #fff;
|
||||||
--border-color: rgba(255, 255, 255, 0.1);
|
--border-color: rgba(255, 255, 255, 0.1);
|
||||||
--accent: #a78bfa;
|
--accent: #60c5ff;
|
||||||
--success: #28a745;
|
|
||||||
--error: #ef4444;
|
|
||||||
--spinner-track: #4a4a4a;
|
--spinner-track: #4a4a4a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] {
|
|
||||||
--bg-primary: #f5f5f5;
|
|
||||||
--bg-secondary: #fdfdfd;
|
|
||||||
--text-primary: #000;
|
|
||||||
--text-secondary: #666;
|
|
||||||
--text-on-color: #fafafa;
|
|
||||||
--border-color: rgba(0, 0, 0, 0.1);
|
|
||||||
--accent: #8159b8;
|
|
||||||
--success: #28a745;
|
|
||||||
--error: #dc3545;
|
|
||||||
--spinner-track: #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
--bg-primary: #1a1a1a;
|
--bg-primary: #1a1a1a;
|
||||||
--bg-secondary: #2d2d2d;
|
--bg-secondary: #2d2d2d;
|
||||||
|
|
@ -46,9 +31,7 @@
|
||||||
--text-secondary: #a0a0a0;
|
--text-secondary: #a0a0a0;
|
||||||
--text-on-color: #fff;
|
--text-on-color: #fff;
|
||||||
--border-color: rgba(255, 255, 255, 0.1);
|
--border-color: rgba(255, 255, 255, 0.1);
|
||||||
--accent: #a78bfa;
|
--accent: #60c5ff;
|
||||||
--success: #28a745;
|
|
||||||
--error: #ef4444;
|
|
||||||
--spinner-track: #4a4a4a;
|
--spinner-track: #4a4a4a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,7 +124,7 @@ details[open] > .card-header::before {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card #apps-list {
|
.card-content {
|
||||||
padding: 0 1.25rem 1.25rem 1.25rem;
|
padding: 0 1.25rem 1.25rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,8 +146,7 @@ a:hover {
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-badge .tooltip,
|
.tooltip {
|
||||||
.integration-status .tooltip {
|
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -186,8 +168,7 @@ a:hover {
|
||||||
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2);
|
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-badge .tooltip::after,
|
.tooltip::after {
|
||||||
.integration-status .tooltip::after {
|
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
|
|
@ -197,8 +178,7 @@ a:hover {
|
||||||
border-top-color: var(--bg-secondary);
|
border-top-color: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-badge:hover .tooltip,
|
:hover > .tooltip {
|
||||||
.integration-status:hover .tooltip {
|
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
@ -312,44 +292,12 @@ a:hover {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: filter 0.2s;
|
transition: filter 0.2s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete:hover {
|
.btn-delete:hover {
|
||||||
filter: brightness(0.9);
|
filter: brightness(0.85);
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel {
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--text-secondary);
|
|
||||||
color: var(--text-on-color);
|
|
||||||
border: none;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: filter 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel:hover {
|
|
||||||
filter: brightness(0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-button {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.625rem 1.25rem;
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--text-on-color);
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
margin-top: 0.625rem;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: filter 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-button:hover {
|
|
||||||
filter: brightness(0.9);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
|
|
@ -452,26 +400,122 @@ details[open] > .integration-header::before {
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-code-container {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-code {
|
|
||||||
height: 22rem;
|
|
||||||
width: 22rem;
|
|
||||||
border: 0.125rem solid var(--border-color);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 0.625rem;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-not-configured {
|
.channel-not-configured {
|
||||||
background: var(--error);
|
background: var(--error);
|
||||||
color: var(--text-on-color);
|
color: var(--text-on-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-muted {
|
.auth-form {
|
||||||
color: var(--text-secondary);
|
margin-top: 1rem;
|
||||||
font-size: 0.875rem;
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="email"],
|
||||||
|
.form-group input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary,
|
||||||
|
.btn-danger {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: filter 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover,
|
||||||
|
.btn-secondary:hover,
|
||||||
|
.btn-danger:hover {
|
||||||
|
filter: brightness(0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled,
|
||||||
|
.btn-secondary:disabled,
|
||||||
|
.btn-danger:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-on-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
color: var(--text-on-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--error);
|
||||||
|
color: var(--text-on-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status.success {
|
||||||
|
display: block;
|
||||||
|
background: var(--success);
|
||||||
|
color: var(--text-on-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status.error {
|
||||||
|
display: block;
|
||||||
|
background: var(--error);
|
||||||
|
color: var(--text-on-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-status.info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-on-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-container {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-wrapper {
|
||||||
|
background: var(--text-on-color);
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code-wrapper img {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 25rem;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,14 @@
|
||||||
<link rel="stylesheet" href="/index.css?v={{.Version}}">
|
<link rel="stylesheet" href="/index.css?v={{.Version}}">
|
||||||
<script src="/htmx.min.js"></script>
|
<script src="/htmx.min.js"></script>
|
||||||
<script src="/theme.js?v={{.Version}}"></script>
|
<script src="/theme.js?v={{.Version}}"></script>
|
||||||
|
<script src="/integration.js?v={{.Version}}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<details class="card" open>
|
<details class="card" open>
|
||||||
<summary class="card-header">
|
<summary class="card-header">
|
||||||
<span class="integration-name">Registered Applications</span>
|
<span class="integration-name">Registered Applications</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div id="apps-list"
|
<div id="apps-list" class="card-content"
|
||||||
hx-get="/fragment/apps"
|
hx-get="/fragment/apps"
|
||||||
hx-trigger="load">
|
hx-trigger="load">
|
||||||
<div class="loading">
|
<div class="loading">
|
||||||
|
|
|
||||||
219
public/integration.js
Normal file
219
public/integration.js
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
document.addEventListener('submit', async (e) => {
|
||||||
|
if (e.target.id === 'telegram-auth-form') {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
const status = document.getElementById('telegram-auth-status');
|
||||||
|
const btn = form.querySelector('button[type="submit"]');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const response = await fetch('/api/telegram/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
bot_token: formData.get('bot_token'),
|
||||||
|
chat_id: formData.get('chat_id'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
status.textContent = `Error: ${error.error || 'Failed to save'}`;
|
||||||
|
status.className = 'auth-status error';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = `Error: ${err.message}`;
|
||||||
|
status.className = 'auth-status error';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.id === 'telegram-chatid-form') {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
const status = document.getElementById('telegram-chatid-status');
|
||||||
|
const btn = form.querySelector('button[type="submit"]');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const botToken = form.dataset.botToken;
|
||||||
|
const response = await fetch('/api/telegram/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
bot_token: botToken,
|
||||||
|
chat_id: formData.get('chat_id'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
status.textContent = `Error: ${error.error || 'Failed to save'}`;
|
||||||
|
status.className = 'auth-status error';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = `Error: ${err.message}`;
|
||||||
|
status.className = 'auth-status error';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.id === 'proton-auth-form') {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
const status = document.getElementById('proton-auth-status');
|
||||||
|
const btn = form.querySelector('button[type="submit"]');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const response = await fetch('/api/proton/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: formData.get('email'),
|
||||||
|
password: formData.get('password'),
|
||||||
|
totp: formData.get('totp'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
status.textContent = `Error: ${error.error || 'Authentication failed'}`;
|
||||||
|
status.className = 'auth-status error';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = `Error: ${err.message}`;
|
||||||
|
status.className = 'auth-status error';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let signalLinkingPoll = null;
|
||||||
|
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
if (e.target.id === 'signal-link-btn') {
|
||||||
|
const btn = e.target;
|
||||||
|
const qrContainer = document.getElementById('signal-qr-container');
|
||||||
|
const qrCode = document.getElementById('signal-qr-code');
|
||||||
|
|
||||||
|
btn.style.display = 'none';
|
||||||
|
qrContainer.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/signal/link', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ device_name: 'Prism' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
qrContainer.innerHTML = `<p class="channel-not-configured">Error: ${error.error || 'Failed to generate link'}</p>`;
|
||||||
|
qrContainer.style.display = 'block';
|
||||||
|
btn.style.display = 'inline-block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(data.qr_code)}`;
|
||||||
|
qrCode.src = qrUrl;
|
||||||
|
qrContainer.style.display = 'block';
|
||||||
|
|
||||||
|
signalLinkingPoll = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const statusResp = await fetch('/api/signal/status');
|
||||||
|
const statusData = await statusResp.json();
|
||||||
|
|
||||||
|
if (statusData.linked) {
|
||||||
|
clearInterval(signalLinkingPoll);
|
||||||
|
qrContainer.innerHTML = '<p class="auth-status success">Linked! Refreshing...</p>';
|
||||||
|
setTimeout(() => location.reload(), 1000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Status check failed:', err);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
} catch (err) {
|
||||||
|
qrContainer.innerHTML = `<p class="channel-not-configured">Error: ${err.message}</p>`;
|
||||||
|
qrContainer.style.display = 'block';
|
||||||
|
btn.style.display = 'inline-block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.classList.contains('reload-btn')) {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.classList.contains('delete-telegram-btn')) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!confirm('Unlink Telegram integration?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = e.target;
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/telegram/auth', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(`Error: ${error.error || 'Failed to unlink integration'}`);
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.classList.contains('delete-proton-btn')) {
|
||||||
|
if (!confirm('Unlink Proton Mail integration?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/proton/auth', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(`Error: ${error.error || 'Failed to unlink integration'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -7,22 +7,14 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
TelegramChatID int64
|
|
||||||
Port int
|
Port int
|
||||||
RateLimit int
|
RateLimit int
|
||||||
APIKey string
|
APIKey string
|
||||||
DeviceName string
|
|
||||||
PrismEndpointPrefix string
|
|
||||||
SignalSocket string
|
|
||||||
ProtonIMAPUsername string
|
|
||||||
ProtonIMAPPassword string
|
|
||||||
ProtonBridgeAddr string
|
|
||||||
TelegramBotToken string
|
|
||||||
StoragePath string
|
StoragePath string
|
||||||
VerboseLogging bool
|
VerboseLogging bool
|
||||||
EnableSignal bool
|
EnableSignal bool
|
||||||
EnableProton bool
|
|
||||||
EnableTelegram bool
|
EnableTelegram bool
|
||||||
|
EnableProton bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
|
|
@ -31,25 +23,12 @@ func Load() (*Config, error) {
|
||||||
Port: getEnvInt("PORT", 8080),
|
Port: getEnvInt("PORT", 8080),
|
||||||
VerboseLogging: getEnvBool("VERBOSE_LOGGING", false),
|
VerboseLogging: getEnvBool("VERBOSE_LOGGING", false),
|
||||||
RateLimit: getEnvInt("RATE_LIMIT", 100),
|
RateLimit: getEnvInt("RATE_LIMIT", 100),
|
||||||
DeviceName: getEnvString("DEVICE_NAME", "Prism"),
|
|
||||||
|
|
||||||
EnableSignal: getEnvBool("FEATURE_ENABLE_SIGNAL", false),
|
|
||||||
SignalSocket: getEnvString("SIGNAL_SOCKET", "/run/signal-cli/socket"),
|
|
||||||
|
|
||||||
EnableProton: getEnvBool("FEATURE_ENABLE_PROTON", false),
|
|
||||||
ProtonIMAPUsername: os.Getenv("PROTON_IMAP_USERNAME"),
|
|
||||||
ProtonIMAPPassword: os.Getenv("PROTON_IMAP_PASSWORD"),
|
|
||||||
ProtonBridgeAddr: getEnvString("PROTON_BRIDGE_ADDR", "protonmail-bridge:143"),
|
|
||||||
|
|
||||||
EnableTelegram: getEnvBool("FEATURE_ENABLE_TELEGRAM", false),
|
|
||||||
TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
|
|
||||||
TelegramChatID: getEnvInt64("TELEGRAM_CHAT_ID", 0),
|
|
||||||
|
|
||||||
StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"),
|
StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"),
|
||||||
|
EnableSignal: getEnvBool("ENABLE_SIGNAL", false),
|
||||||
|
EnableTelegram: getEnvBool("ENABLE_TELEGRAM", false),
|
||||||
|
EnableProton: getEnvBool("ENABLE_PROTON", false),
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.PrismEndpointPrefix = fmt.Sprintf("[%s:", cfg.DeviceName)
|
|
||||||
|
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -64,18 +43,6 @@ func (c *Config) Validate() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) IsProtonEnabled() bool {
|
|
||||||
return c.EnableProton && c.ProtonIMAPUsername != "" && c.ProtonIMAPPassword != "" && c.ProtonBridgeAddr != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Config) IsSignalEnabled() bool {
|
|
||||||
return c.EnableSignal
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Config) IsTelegramEnabled() bool {
|
|
||||||
return c.EnableTelegram
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnvString(key, defaultValue string) string {
|
func getEnvString(key, defaultValue string) string {
|
||||||
if value := os.Getenv(key); value != "" {
|
if value := os.Getenv(key); value != "" {
|
||||||
return value
|
return value
|
||||||
|
|
@ -92,15 +59,6 @@ func getEnvInt(key string, defaultValue int) int {
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnvInt64(key string, defaultValue int64) int64 {
|
|
||||||
if value := os.Getenv(key); value != "" {
|
|
||||||
if i, err := strconv.ParseInt(value, 10, 64); err == nil {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnvBool(key string, defaultValue bool) bool {
|
func getEnvBool(key string, defaultValue bool) bool {
|
||||||
if value := os.Getenv(key); value != "" {
|
if value := os.Getenv(key); value != "" {
|
||||||
return value == "true" || value == "1"
|
return value == "true" || value == "1"
|
||||||
|
|
|
||||||
293
service/credentials/credentials.go
Normal file
293
service/credentials/credentials.go
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
package credentials
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/scrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IntegrationType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
IntegrationSignal IntegrationType = "signal"
|
||||||
|
IntegrationProton IntegrationType = "proton"
|
||||||
|
IntegrationTelegram IntegrationType = "telegram"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProtonCredentials struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
UID string `json:"uid,omitempty"`
|
||||||
|
AccessToken string `json:"access_token,omitempty"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
KeySalts map[string][]byte `json:"key_salts,omitempty"`
|
||||||
|
State *ProtonState `json:"state,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProtonState struct {
|
||||||
|
LastEventID string `json:"last_event_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelegramCredentials struct {
|
||||||
|
BotToken string `json:"bot_token"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignalCredentials struct {
|
||||||
|
Linked bool `json:"linked"`
|
||||||
|
PhoneNumber string `json:"phone_number"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
encryptionKey []byte
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(db *sql.DB, masterPassword string) (*Store, error) {
|
||||||
|
return NewStoreWithLogger(db, masterPassword, slog.Default())
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStoreWithLogger(db *sql.DB, masterPassword string, logger *slog.Logger) (*Store, error) {
|
||||||
|
key, err := deriveKey(masterPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to derive encryption key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &Store{
|
||||||
|
db: db,
|
||||||
|
encryptionKey: key,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.createTable(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.CheckIntegrity(); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "corrupted") {
|
||||||
|
logger.Warn("Credentials corrupted (API_KEY likely changed), clearing all integration credentials", "error", err)
|
||||||
|
if clearErr := store.ClearAll(); clearErr != nil {
|
||||||
|
logger.Error("Failed to clear corrupted credentials", "error", clearErr)
|
||||||
|
} else {
|
||||||
|
logger.Info("Cleared all integration credentials - please reconfigure integrations")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) createTable() error {
|
||||||
|
query := `
|
||||||
|
CREATE TABLE IF NOT EXISTS integration_credentials (
|
||||||
|
integration_type TEXT PRIMARY KEY,
|
||||||
|
credentials_encrypted BLOB NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`
|
||||||
|
_, err := s.db.Exec(query)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveKey(password string) ([]byte, error) {
|
||||||
|
salt := []byte("prism-integration-salt-v1")
|
||||||
|
return scrypt.Key([]byte(password), salt, 32768, 8, 1, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) encrypt(plaintext []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||||
|
return ciphertext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) decrypt(ciphertext []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(s.encryptionKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ciphertext) < gcm.NonceSize() {
|
||||||
|
return nil, fmt.Errorf("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertext := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveProton(creds *ProtonCredentials) error {
|
||||||
|
return s.saveCredentials(IntegrationProton, creds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetProton() (*ProtonCredentials, error) {
|
||||||
|
var creds ProtonCredentials
|
||||||
|
err := s.getCredentials(IntegrationProton, &creds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &creds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveTelegram(creds *TelegramCredentials) error {
|
||||||
|
return s.saveCredentials(IntegrationTelegram, creds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetTelegram() (*TelegramCredentials, error) {
|
||||||
|
var creds TelegramCredentials
|
||||||
|
err := s.getCredentials(IntegrationTelegram, &creds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &creds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveSignal(creds *SignalCredentials) error {
|
||||||
|
return s.saveCredentials(IntegrationSignal, creds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetSignal() (*SignalCredentials, error) {
|
||||||
|
var creds SignalCredentials
|
||||||
|
err := s.getCredentials(IntegrationSignal, &creds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &creds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) saveCredentials(integrationType IntegrationType, credentials interface{}) error {
|
||||||
|
jsonData, err := json.Marshal(credentials)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, err := s.encrypt(jsonData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO integration_credentials (integration_type, credentials_encrypted, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(integration_type) DO UPDATE SET
|
||||||
|
credentials_encrypted = excluded.credentials_encrypted,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`
|
||||||
|
_, err = s.db.Exec(query, string(integrationType), encrypted)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) getCredentials(integrationType IntegrationType, dest interface{}) error {
|
||||||
|
query := `
|
||||||
|
SELECT credentials_encrypted
|
||||||
|
FROM integration_credentials
|
||||||
|
WHERE integration_type = ? AND enabled = 1
|
||||||
|
`
|
||||||
|
var encrypted []byte
|
||||||
|
err := s.db.QueryRow(query, string(integrationType)).Scan(&encrypted)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return fmt.Errorf("integration %s not configured", integrationType)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := s.decrypt(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decrypt credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(decrypted, dest); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteIntegration(integrationType IntegrationType) error {
|
||||||
|
query := `DELETE FROM integration_credentials WHERE integration_type = ?`
|
||||||
|
_, err := s.db.Exec(query, string(integrationType))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetEnabled(integrationType IntegrationType, enabled bool) error {
|
||||||
|
query := `UPDATE integration_credentials SET enabled = ? WHERE integration_type = ?`
|
||||||
|
_, err := s.db.Exec(query, enabled, string(integrationType))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) IsEnabled(integrationType IntegrationType) (bool, error) {
|
||||||
|
query := `SELECT enabled FROM integration_credentials WHERE integration_type = ?`
|
||||||
|
var enabled bool
|
||||||
|
err := s.db.QueryRow(query, string(integrationType)).Scan(&enabled)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return enabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ClearAll() error {
|
||||||
|
query := `DELETE FROM integration_credentials`
|
||||||
|
_, err := s.db.Exec(query)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CheckIntegrity() error {
|
||||||
|
query := `SELECT integration_type, credentials_encrypted FROM integration_credentials`
|
||||||
|
rows, err := s.db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var integrationType string
|
||||||
|
var encrypted []byte
|
||||||
|
if err := rows.Scan(&integrationType, &encrypted); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.decrypt(encrypted); err != nil {
|
||||||
|
return fmt.Errorf("credentials corrupted for %s (likely API_KEY changed): %w", integrationType, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
10
service/integration/embed.go
Normal file
10
service/integration/embed.go
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed templates/*.html
|
||||||
|
var templates embed.FS
|
||||||
|
|
||||||
|
func GetTemplates() embed.FS {
|
||||||
|
return templates
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,9 @@ package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
|
@ -17,7 +20,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Integration interface {
|
type Integration interface {
|
||||||
RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler)
|
RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger)
|
||||||
Start(ctx context.Context, logger *slog.Logger)
|
Start(ctx context.Context, logger *slog.Logger)
|
||||||
IsEnabled() bool
|
IsEnabled() bool
|
||||||
}
|
}
|
||||||
|
|
@ -26,35 +29,72 @@ type Integrations struct {
|
||||||
Dispatcher *notification.Dispatcher
|
Dispatcher *notification.Dispatcher
|
||||||
Signal *signal.Integration
|
Signal *signal.Integration
|
||||||
Telegram *telegram.Integration
|
Telegram *telegram.Integration
|
||||||
|
Proton *proton.Integration
|
||||||
integrations []Integration
|
integrations []Integration
|
||||||
}
|
}
|
||||||
|
|
||||||
func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmplRenderer *util.TemplateRenderer) *Integrations {
|
func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logger, baseTmpl *template.Template) (*Integrations, *template.Template, error) {
|
||||||
signalIntegration := signal.NewIntegration(cfg, store, logger, tmplRenderer)
|
fragmentTmpl := baseTmpl
|
||||||
telegramIntegration := telegram.NewIntegration(cfg, store, logger, tmplRenderer)
|
var err error
|
||||||
|
|
||||||
|
fragmentTmpl, err = fragmentTmpl.ParseFS(GetTemplates(), "templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to parse integration templates: %w", err)
|
||||||
|
}
|
||||||
|
fragmentTmpl, err = fragmentTmpl.ParseFS(signal.GetTemplates(), "templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to parse signal templates: %w", err)
|
||||||
|
}
|
||||||
|
fragmentTmpl, err = fragmentTmpl.ParseFS(telegram.GetTemplates(), "templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to parse telegram templates: %w", err)
|
||||||
|
}
|
||||||
|
fragmentTmpl, err = fragmentTmpl.ParseFS(proton.GetTemplates(), "templates/*.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to parse proton templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmplRenderer := util.NewTemplateRenderer(fragmentTmpl)
|
||||||
|
|
||||||
|
var signalIntegration *signal.Integration
|
||||||
|
var telegramIntegration *telegram.Integration
|
||||||
dispatcher := notification.NewDispatcher(store, logger)
|
dispatcher := notification.NewDispatcher(store, logger)
|
||||||
|
|
||||||
|
var integrations []Integration
|
||||||
|
|
||||||
|
var protonIntegration *proton.Integration
|
||||||
|
|
||||||
|
if cfg.EnableSignal {
|
||||||
|
signalIntegration = signal.NewIntegration(cfg, store, logger, tmplRenderer)
|
||||||
if signalSender := signalIntegration.GetSender(); signalSender != nil {
|
if signalSender := signalIntegration.GetSender(); signalSender != nil {
|
||||||
dispatcher.RegisterSender(notification.ChannelSignal, signalSender)
|
dispatcher.RegisterSender(notification.ChannelSignal, signalSender)
|
||||||
}
|
}
|
||||||
|
integrations = append(integrations, signalIntegration)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.EnableTelegram {
|
||||||
|
telegramIntegration = telegram.NewIntegration(cfg, store, logger, tmplRenderer)
|
||||||
if telegramSender := telegramIntegration.GetSender(); telegramSender != nil {
|
if telegramSender := telegramIntegration.GetSender(); telegramSender != nil {
|
||||||
dispatcher.RegisterSender(notification.ChannelTelegram, telegramSender)
|
dispatcher.RegisterSender(notification.ChannelTelegram, telegramSender)
|
||||||
}
|
}
|
||||||
dispatcher.RegisterSender(notification.ChannelWebPush, webpush.NewSender(logger))
|
integrations = append(integrations, telegramIntegration)
|
||||||
|
|
||||||
integrations := []Integration{
|
|
||||||
signalIntegration,
|
|
||||||
telegramIntegration,
|
|
||||||
proton.NewIntegration(cfg, dispatcher, logger, tmplRenderer),
|
|
||||||
webpush.NewIntegration(store, logger),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.EnableProton {
|
||||||
|
protonIntegration = proton.NewIntegration(cfg, dispatcher, logger, tmplRenderer, store.GetDB(), cfg.APIKey)
|
||||||
|
integrations = append(integrations, protonIntegration)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatcher.RegisterSender(notification.ChannelWebPush, webpush.NewSender(logger))
|
||||||
|
integrations = append(integrations, webpush.NewIntegration(store, logger))
|
||||||
|
|
||||||
return &Integrations{
|
return &Integrations{
|
||||||
Dispatcher: dispatcher,
|
Dispatcher: dispatcher,
|
||||||
Signal: signalIntegration,
|
Signal: signalIntegration,
|
||||||
Telegram: telegramIntegration,
|
Telegram: telegramIntegration,
|
||||||
|
Proton: protonIntegration,
|
||||||
integrations: integrations,
|
integrations: integrations,
|
||||||
}
|
}, fragmentTmpl, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Integrations) Start(ctx context.Context, cfg *config.Config, logger *slog.Logger) {
|
func (i *Integrations) Start(ctx context.Context, cfg *config.Config, logger *slog.Logger) {
|
||||||
|
|
@ -67,7 +107,8 @@ func (i *Integrations) Start(ctx context.Context, cfg *config.Config, logger *sl
|
||||||
|
|
||||||
func RegisterAll(integrations *Integrations, router *chi.Mux, cfg *config.Config, store *notification.Store, logger *slog.Logger, authMiddleware func(string) func(http.Handler) http.Handler) {
|
func RegisterAll(integrations *Integrations, router *chi.Mux, cfg *config.Config, store *notification.Store, logger *slog.Logger, authMiddleware func(string) func(http.Handler) http.Handler) {
|
||||||
auth := authMiddleware(cfg.APIKey)
|
auth := authMiddleware(cfg.APIKey)
|
||||||
|
db := store.GetDB()
|
||||||
for _, integration := range integrations.integrations {
|
for _, integration := range integrations.integrations {
|
||||||
integration.RegisterRoutes(router, auth)
|
integration.RegisterRoutes(router, auth, db, cfg.APIKey, logger)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
package proton
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/emersion/go-imap/v2/imapclient"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m *Monitor) connect() error {
|
|
||||||
addr := m.cfg.ProtonBridgeAddr
|
|
||||||
|
|
||||||
options := &imapclient.Options{
|
|
||||||
UnilateralDataHandler: &imapclient.UnilateralDataHandler{
|
|
||||||
Mailbox: func(data *imapclient.UnilateralDataMailbox) {
|
|
||||||
if data.NumMessages != nil {
|
|
||||||
select {
|
|
||||||
case m.newMessagesChan <- struct{}{}:
|
|
||||||
default:
|
|
||||||
m.logger.Warn("New messages channel full, skipping signal")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := imapclient.DialInsecure(addr, options)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to dial: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.Login(m.cfg.ProtonIMAPUsername, m.cfg.ProtonIMAPPassword).Wait(); err != nil {
|
|
||||||
c.Close()
|
|
||||||
return fmt.Errorf("failed to login: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.client = c
|
|
||||||
m.logger.Info("Connected to Proton Bridge")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Monitor) monitor(ctx context.Context) error {
|
|
||||||
selectCmd := m.client.Select(imapInbox, nil)
|
|
||||||
_, err := selectCmd.Wait()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to select inbox: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.monitorStartTime.IsZero() {
|
|
||||||
m.monitorStartTime = time.Now()
|
|
||||||
m.logger.Info("Proton Mail monitor start time set", "time", m.monitorStartTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
idleCmd, err := m.client.Idle()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to start idle: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
idleCmd.Close()
|
|
||||||
return ctx.Err()
|
|
||||||
|
|
||||||
case <-m.newMessagesChan:
|
|
||||||
idleCmd.Close()
|
|
||||||
|
|
||||||
m.sendNotification()
|
|
||||||
|
|
||||||
idleCmd, err = m.client.Idle()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to restart idle: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
package proton
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"prism/service/notification"
|
|
||||||
|
|
||||||
"github.com/emersion/go-imap/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m *Monitor) sendNotification() error {
|
|
||||||
selectData, err := m.client.Select(imapInbox, nil).Wait()
|
|
||||||
if err != nil {
|
|
||||||
m.logger.Error("Failed to select inbox", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if selectData.NumMessages == 0 {
|
|
||||||
m.logger.Warn("No messages in mailbox")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
latestSeq := selectData.NumMessages
|
|
||||||
|
|
||||||
seqSet := imap.SeqSetNum(latestSeq)
|
|
||||||
|
|
||||||
fetchOptions := &imap.FetchOptions{
|
|
||||||
Envelope: true,
|
|
||||||
UID: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchCmd := m.client.Fetch(seqSet, fetchOptions)
|
|
||||||
defer fetchCmd.Close()
|
|
||||||
|
|
||||||
msg := fetchCmd.Next()
|
|
||||||
if msg == nil {
|
|
||||||
m.logger.Warn("No message found to send notification")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
msgData, err := msg.Collect()
|
|
||||||
if err != nil {
|
|
||||||
m.logger.Error("Failed to collect message", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if msgData.Envelope == nil {
|
|
||||||
m.logger.Warn("Message has no envelope")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if msgData.Envelope.Date.Before(m.monitorStartTime) {
|
|
||||||
m.logger.Debug("Skipping notification for old email",
|
|
||||||
"subject", msgData.Envelope.Subject,
|
|
||||||
"date", msgData.Envelope.Date,
|
|
||||||
"monitorStart", m.monitorStartTime)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var from string
|
|
||||||
if len(msgData.Envelope.From) > 0 {
|
|
||||||
addr := msgData.Envelope.From[0]
|
|
||||||
if addr.Name != "" {
|
|
||||||
from = addr.Name
|
|
||||||
} else {
|
|
||||||
from = addr.Addr()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
from = "Unknown sender"
|
|
||||||
}
|
|
||||||
|
|
||||||
subject := msgData.Envelope.Subject
|
|
||||||
if subject == "" {
|
|
||||||
subject = "No subject"
|
|
||||||
}
|
|
||||||
|
|
||||||
notif := notification.Notification{
|
|
||||||
Title: from,
|
|
||||||
Message: subject,
|
|
||||||
Actions: []notification.Action{
|
|
||||||
{
|
|
||||||
ID: "mark-read",
|
|
||||||
Endpoint: "/api/proton-mail/mark-read",
|
|
||||||
Method: "POST",
|
|
||||||
Data: map[string]any{
|
|
||||||
"uid": msgData.UID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.dispatcher.Send(prismTopic, notif); err != nil {
|
|
||||||
m.logger.Error("Failed to send notification", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Info("Sent ProtonMail notification", "from", from, "subject", subject, "uid", msgData.UID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Monitor) MarkAsRead(uid uint32) error {
|
|
||||||
if m.client == nil {
|
|
||||||
return fmt.Errorf("not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
uidSet := imap.UIDSet{}
|
|
||||||
uidSet.AddNum(imap.UID(uid))
|
|
||||||
|
|
||||||
storeFlags := &imap.StoreFlags{
|
|
||||||
Op: imap.StoreFlagsAdd,
|
|
||||||
Flags: []imap.Flag{imap.FlagSeen},
|
|
||||||
Silent: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
storeCmd := m.client.Store(uidSet, storeFlags, nil)
|
|
||||||
|
|
||||||
for {
|
|
||||||
msg := storeCmd.Next()
|
|
||||||
if msg == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := storeCmd.Close(); err != nil {
|
|
||||||
return fmt.Errorf("failed to mark message as read: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Info("Marked message as read", "uid", uid)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
package proton
|
package proton
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"prism/service/credentials"
|
||||||
"prism/service/util"
|
"prism/service/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -14,6 +16,8 @@ type Handlers struct {
|
||||||
username string
|
username string
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
tmpl *util.TemplateRenderer
|
tmpl *util.TemplateRenderer
|
||||||
|
db *sql.DB
|
||||||
|
apiKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProtonContentData struct {
|
type ProtonContentData struct {
|
||||||
|
|
@ -36,31 +40,61 @@ func NewHandlers(monitor *Monitor, username string, logger *slog.Logger, tmpl *u
|
||||||
username: username,
|
username: username,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
|
db: nil,
|
||||||
|
apiKey: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) SetDB(db *sql.DB, apiKey string) {
|
||||||
|
h.db = db
|
||||||
|
h.apiKey = apiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) LoadFreshCredentials() (string, bool) {
|
||||||
|
if h.db == nil || h.apiKey == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
credStore, err := credentials.NewStore(h.db, h.apiKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := credStore.GetProton()
|
||||||
|
if err != nil || creds == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return creds.Email, true
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.monitor == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
|
||||||
|
email, hasCredentials := h.LoadFreshCredentials()
|
||||||
|
|
||||||
var contentData ProtonContentData
|
var contentData ProtonContentData
|
||||||
var integData IntegrationData
|
var integData IntegrationData
|
||||||
integData.Name = "Proton Mail"
|
integData.Name = "Proton Mail"
|
||||||
|
|
||||||
if h.monitor.IsConnected() {
|
if !hasCredentials {
|
||||||
integData.StatusClass = "connected"
|
|
||||||
integData.StatusText = "Linked"
|
|
||||||
integData.StatusTooltip = h.username
|
|
||||||
integData.Open = false
|
|
||||||
contentData.Connected = true
|
|
||||||
} else {
|
|
||||||
integData.StatusClass = "disconnected"
|
integData.StatusClass = "disconnected"
|
||||||
integData.StatusText = "Unlinked"
|
integData.StatusText = "Unlinked"
|
||||||
|
integData.StatusTooltip = "Enter credentials to link"
|
||||||
integData.Open = true
|
integData.Open = true
|
||||||
contentData.Connected = false
|
} else if h.monitor == nil || !h.monitor.IsConnected() {
|
||||||
|
integData.StatusClass = "disconnected"
|
||||||
|
integData.StatusText = "Connecting…"
|
||||||
|
integData.StatusTooltip = email
|
||||||
|
integData.Open = false
|
||||||
|
integData.PollAttrs = `hx-get="/fragment/integrations/proton" hx-trigger="every 2s"`
|
||||||
|
contentData.Connected = true
|
||||||
|
} else {
|
||||||
|
integData.StatusClass = "connected"
|
||||||
|
integData.StatusText = "Linked"
|
||||||
|
integData.StatusTooltip = email
|
||||||
|
integData.Open = false
|
||||||
|
contentData.Connected = true
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := h.tmpl.RenderHTML("proton-content.html", contentData)
|
content, err := h.tmpl.RenderHTML("proton-content.html", contentData)
|
||||||
|
|
@ -88,7 +122,7 @@ func (h *Handlers) GetMonitor() *Monitor {
|
||||||
}
|
}
|
||||||
|
|
||||||
type markReadRequest struct {
|
type markReadRequest struct {
|
||||||
UID uint32 `json:"uid"`
|
UID string `json:"uid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -98,7 +132,7 @@ func (h *Handlers) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.UID == 0 {
|
if req.UID == "" {
|
||||||
util.JSONError(w, "uid (number) is required", http.StatusBadRequest)
|
util.JSONError(w, "uid (number) is required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -121,3 +155,38 @@ func (h *Handlers) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
|
||||||
h.logger.Error("failed to encode response", "error", err)
|
h.logger.Error("failed to encode response", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type archiveRequest struct {
|
||||||
|
UID string `json:"uid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) HandleArchive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req archiveRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
util.JSONError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.UID == "" {
|
||||||
|
util.JSONError(w, "uid is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.monitor == nil {
|
||||||
|
util.JSONError(w, "Proton integration not enabled", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.monitor.Archive(req.UID); err != nil {
|
||||||
|
h.logger.Error("failed to archive email", "uid", req.UID, "error", err)
|
||||||
|
util.JSONError(w, "failed to archive", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("archived email", "uid", req.UID)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil {
|
||||||
|
h.logger.Error("failed to encode response", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ package proton
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"prism/service/config"
|
"prism/service/config"
|
||||||
|
"prism/service/credentials"
|
||||||
"prism/service/notification"
|
"prism/service/notification"
|
||||||
"prism/service/util"
|
"prism/service/util"
|
||||||
|
|
||||||
|
|
@ -18,35 +20,64 @@ type Integration struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
handlers *Handlers
|
handlers *Handlers
|
||||||
tmpl *util.TemplateRenderer
|
tmpl *util.TemplateRenderer
|
||||||
|
monitor *Monitor
|
||||||
|
db *sql.DB
|
||||||
|
apiKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIntegration(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
|
func NewIntegration(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger, tmpl *util.TemplateRenderer, db *sql.DB, apiKey string) *Integration {
|
||||||
|
monitor := NewMonitor(cfg, dispatcher, logger)
|
||||||
|
handlers := NewHandlers(monitor, "", logger, tmpl)
|
||||||
return &Integration{
|
return &Integration{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
dispatcher: dispatcher,
|
dispatcher: dispatcher,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
handlers: handlers,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
|
monitor: monitor,
|
||||||
|
db: db,
|
||||||
|
apiKey: apiKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) {
|
func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
|
||||||
p.handlers = RegisterRoutes(router, p.cfg, p.dispatcher, p.logger, auth, p.tmpl)
|
credStore, err := credentials.NewStore(db, apiKey)
|
||||||
|
if err == nil {
|
||||||
|
if creds, err := credStore.GetProton(); err == nil {
|
||||||
|
p.handlers.username = creds.Email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterRoutes(router, p.handlers, auth, db, apiKey, logger, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
func (p *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
||||||
if p.handlers != nil && p.handlers.IsEnabled() {
|
credStore, err := credentials.NewStore(p.db, p.apiKey)
|
||||||
logger.Info("Proton Mail configured", "status", "connecting in background", "bridge", p.cfg.ProtonBridgeAddr)
|
if err != nil {
|
||||||
go func() {
|
logger.Error("Failed to initialize credentials store for Proton", "error", err)
|
||||||
monitor := p.handlers.GetMonitor()
|
return
|
||||||
if err := monitor.Start(ctx); err != nil && ctx.Err() == nil {
|
|
||||||
logger.Error("Proton monitor error", "error", err)
|
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
|
creds, err := credStore.GetProton()
|
||||||
|
if err != nil {
|
||||||
|
logger.Debug("Proton credentials not configured", "error", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := p.monitor.Start(ctx, credStore); err != nil {
|
||||||
|
logger.Error("Failed to start Proton monitor", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.handlers != nil {
|
||||||
|
p.handlers.username = creds.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Proton Mail enabled", "email", creds.Email)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Integration) IsEnabled() bool {
|
func (p *Integration) IsEnabled() bool {
|
||||||
return p.cfg.IsProtonEnabled()
|
return p.cfg.EnableProton
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Integration) GetHandlers() *Handlers {
|
func (p *Integration) GetHandlers() *Handlers {
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,19 @@ package proton
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"prism/service/config"
|
"prism/service/config"
|
||||||
|
"prism/service/credentials"
|
||||||
"prism/service/notification"
|
"prism/service/notification"
|
||||||
|
|
||||||
"github.com/emersion/go-imap/v2/imapclient"
|
"github.com/emersion/hydroxide/protonmail"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
imapInbox = "INBOX"
|
pollInterval = 30 * time.Second
|
||||||
imapReconnectBaseDelay = 10 * time.Second
|
|
||||||
imapMaxReconnectDelay = 5 * time.Minute
|
|
||||||
imapMaxReconnectAttempts = 50
|
|
||||||
prismTopic = "Proton Mail"
|
prismTopic = "Proton Mail"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -23,9 +22,11 @@ type Monitor struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
dispatcher *notification.Dispatcher
|
dispatcher *notification.Dispatcher
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
client *imapclient.Client
|
credStore *credentials.Store
|
||||||
monitorStartTime time.Time
|
client *protonmail.Client
|
||||||
newMessagesChan chan struct{}
|
eventID string
|
||||||
|
unseenMessageIDs map[string]time.Time
|
||||||
|
startTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Monitor {
|
func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Monitor {
|
||||||
|
|
@ -33,44 +34,315 @@ func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
dispatcher: dispatcher,
|
dispatcher: dispatcher,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
newMessagesChan: make(chan struct{}, 10),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) Start(ctx context.Context) error {
|
func (m *Monitor) Start(ctx context.Context, credStore *credentials.Store) error {
|
||||||
if !m.cfg.IsProtonEnabled() {
|
creds, err := credStore.GetProton()
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Debug("Proton credentials not configured", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
m.logger.Info("Starting Proton Mail monitor")
|
m.credStore = credStore
|
||||||
|
m.logger.Info("Starting Proton Mail monitor", "email", creds.Email)
|
||||||
|
|
||||||
|
c := &protonmail.Client{
|
||||||
|
RootURL: "https://mail.proton.me/api",
|
||||||
|
AppVersion: "Other",
|
||||||
|
}
|
||||||
|
|
||||||
|
var auth *protonmail.Auth
|
||||||
|
|
||||||
|
if creds.UID != "" && creds.AccessToken != "" && creds.RefreshToken != "" {
|
||||||
|
auth = &protonmail.Auth{
|
||||||
|
UID: creds.UID,
|
||||||
|
AccessToken: creds.AccessToken,
|
||||||
|
RefreshToken: creds.RefreshToken,
|
||||||
|
Scope: creds.Scope,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.Unlock(auth, creds.KeySalts, creds.Password)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error("Failed to unlock keys - password may have changed", "error", err)
|
||||||
|
if deleteErr := credStore.DeleteIntegration(credentials.IntegrationProton); deleteErr != nil {
|
||||||
|
m.logger.Error("Failed to clear invalid credentials", "error", deleteErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to unlock keys (password changed?): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Info("Restored Proton session from stored tokens")
|
||||||
|
} else if creds.Password != "" {
|
||||||
|
authInfo, err := c.AuthInfo(creds.Email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
authResult, err := c.Auth(creds.Email, creds.Password, authInfo)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
auth = authResult
|
||||||
|
|
||||||
|
keySalts, err := c.ListKeySalts()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get key salts: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.Unlock(auth, keySalts, creds.Password)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error("Failed to unlock keys", "error", err)
|
||||||
|
if deleteErr := credStore.DeleteIntegration(credentials.IntegrationProton); deleteErr != nil {
|
||||||
|
m.logger.Error("Failed to clear invalid credentials", "error", deleteErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to unlock keys: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds.KeySalts = keySalts
|
||||||
|
if err := credStore.SaveProton(creds); err != nil {
|
||||||
|
m.logger.Warn("Failed to cache key salts", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Info("Authenticated and unlocked Proton session")
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("no valid credentials found - need password or tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.ReAuth = func() error {
|
||||||
|
newAuth, err := c.AuthRefresh(auth)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error("Token refresh failed", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.Unlock(newAuth, creds.KeySalts, creds.Password)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error("Token refresh failed - cannot unlock keys", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
auth = newAuth
|
||||||
|
|
||||||
|
updatedCreds, err := m.credStore.GetProton()
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warn("Failed to get credentials for token update", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedCreds.UID = newAuth.UID
|
||||||
|
updatedCreds.AccessToken = newAuth.AccessToken
|
||||||
|
updatedCreds.RefreshToken = newAuth.RefreshToken
|
||||||
|
updatedCreds.Scope = newAuth.Scope
|
||||||
|
|
||||||
|
if err := m.credStore.SaveProton(updatedCreds); err != nil {
|
||||||
|
m.logger.Warn("Failed to save refreshed tokens", "error", err)
|
||||||
|
} else {
|
||||||
|
m.logger.Info("Proton tokens refreshed and saved")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m.client = c
|
||||||
|
m.startTime = time.Now()
|
||||||
|
|
||||||
|
if creds.State != nil {
|
||||||
|
m.eventID = creds.State.LastEventID
|
||||||
|
m.logger.Info("Restored Proton state", "eventID", m.eventID)
|
||||||
|
} else {
|
||||||
|
m.eventID = auth.EventID
|
||||||
|
if err := m.saveState(creds); err != nil {
|
||||||
|
m.logger.Warn("Failed to save initial state", "error", err)
|
||||||
|
}
|
||||||
|
m.logger.Info("Initialized Proton state", "eventID", m.eventID)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.unseenMessageIDs = make(map[string]time.Time)
|
||||||
|
|
||||||
|
go m.pollEvents(ctx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) pollEvents(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(pollInterval)
|
||||||
|
cleanupTicker := time.NewTicker(1 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
defer cleanupTicker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return
|
||||||
default:
|
case <-ticker.C:
|
||||||
if err := m.connect(); err != nil {
|
if err := m.checkEvents(); err != nil {
|
||||||
m.logger.Error("Failed to connect to IMAP", "error", err)
|
m.logger.Error("Failed to check events", "error", err)
|
||||||
time.Sleep(imapReconnectBaseDelay)
|
}
|
||||||
continue
|
case <-cleanupTicker.C:
|
||||||
|
m.cleanupOldMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.monitor(ctx); err != nil {
|
func (m *Monitor) checkEvents() error {
|
||||||
m.logger.Error("Monitor error", "error", err)
|
if m.client == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.client != nil {
|
event, err := m.client.GetEvent(m.eventID)
|
||||||
if err := m.client.Logout(); err != nil {
|
if err != nil {
|
||||||
m.logger.Error("Logout failed", "error", err)
|
return err
|
||||||
}
|
|
||||||
m.client = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(imapReconnectBaseDelay)
|
if event.ID != m.eventID {
|
||||||
|
m.processMessageEvents(event.Messages)
|
||||||
|
m.eventID = event.ID
|
||||||
|
|
||||||
|
creds, err := m.credStore.GetProton()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
if err := m.saveState(creds); err != nil {
|
||||||
|
m.logger.Warn("Failed to save state", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) processMessageEvents(events []*protonmail.EventMessage) {
|
||||||
|
for _, evt := range events {
|
||||||
|
switch evt.Action {
|
||||||
|
case protonmail.EventCreate:
|
||||||
|
if evt.Created != nil {
|
||||||
|
msg := evt.Created
|
||||||
|
if msg.Unread == 1 && hasLabel(msg, protonmail.LabelInbox) && msg.Time.Time().After(m.startTime) {
|
||||||
|
if _, seen := m.unseenMessageIDs[msg.ID]; !seen {
|
||||||
|
m.unseenMessageIDs[msg.ID] = time.Now()
|
||||||
|
m.sendNotification(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case protonmail.EventUpdate, protonmail.EventUpdateFlags:
|
||||||
|
if evt.Updated != nil && evt.Updated.Unread != nil && *evt.Updated.Unread == 0 {
|
||||||
|
if _, wasSent := m.unseenMessageIDs[evt.ID]; wasSent {
|
||||||
|
m.clearNotification(evt.ID)
|
||||||
|
}
|
||||||
|
delete(m.unseenMessageIDs, evt.ID)
|
||||||
|
}
|
||||||
|
case protonmail.EventDelete:
|
||||||
|
if _, wasSent := m.unseenMessageIDs[evt.ID]; wasSent {
|
||||||
|
m.clearNotification(evt.ID)
|
||||||
|
}
|
||||||
|
delete(m.unseenMessageIDs, evt.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) cleanupOldMessages() {
|
||||||
|
cutoff := time.Now().Add(-24 * time.Hour)
|
||||||
|
for msgID, notifiedAt := range m.unseenMessageIDs {
|
||||||
|
if notifiedAt.Before(cutoff) {
|
||||||
|
delete(m.unseenMessageIDs, msgID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m.unseenMessageIDs) > 0 {
|
||||||
|
m.logger.Debug("Cleaned up old message IDs", "remaining", len(m.unseenMessageIDs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) saveState(creds *credentials.ProtonCredentials) error {
|
||||||
|
creds.State = &credentials.ProtonState{
|
||||||
|
LastEventID: m.eventID,
|
||||||
|
}
|
||||||
|
return m.credStore.SaveProton(creds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasLabel(msg *protonmail.Message, labelID string) bool {
|
||||||
|
for _, id := range msg.LabelIDs {
|
||||||
|
if id == labelID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) sendNotification(msg *protonmail.Message) {
|
||||||
|
from := "Unknown"
|
||||||
|
if msg.Sender != nil {
|
||||||
|
if msg.Sender.Name != "" {
|
||||||
|
from = msg.Sender.Name
|
||||||
|
} else {
|
||||||
|
from = msg.Sender.Address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := msg.Subject
|
||||||
|
if subject == "" {
|
||||||
|
subject = "(No subject)"
|
||||||
|
}
|
||||||
|
|
||||||
|
notif := notification.Notification{
|
||||||
|
Title: from,
|
||||||
|
Message: subject,
|
||||||
|
Tag: "proton-" + msg.ID,
|
||||||
|
Actions: []notification.Action{
|
||||||
|
{
|
||||||
|
ID: "archive",
|
||||||
|
Label: "Archive",
|
||||||
|
Endpoint: "/api/proton/archive",
|
||||||
|
Method: "POST",
|
||||||
|
Data: map[string]any{
|
||||||
|
"uid": msg.ID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "mark-read",
|
||||||
|
Label: "Mark as Read",
|
||||||
|
Endpoint: "/api/proton/mark-read",
|
||||||
|
Method: "POST",
|
||||||
|
Data: map[string]any{
|
||||||
|
"uid": msg.ID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.dispatcher.Send(prismTopic, notif); err != nil {
|
||||||
|
m.logger.Error("Failed to send notification", "error", err)
|
||||||
|
} else {
|
||||||
|
m.logger.Info("Sent notification", "from", from, "subject", subject, "msgID", msg.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) clearNotification(msgID string) {
|
||||||
|
notif := notification.Notification{
|
||||||
|
Tag: "proton-" + msgID,
|
||||||
|
Title: "",
|
||||||
|
Message: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.dispatcher.Send(prismTopic, notif); err != nil {
|
||||||
|
m.logger.Error("Failed to clear notification", "error", err, "msgID", msgID)
|
||||||
|
} else {
|
||||||
|
m.logger.Debug("Cleared notification", "msgID", msgID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) IsConnected() bool {
|
func (m *Monitor) IsConnected() bool {
|
||||||
return m.client != nil
|
return m.client != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) MarkAsRead(msgID string) error {
|
||||||
|
if m.client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.client.MarkMessagesRead([]string{msgID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) Archive(msgID string) error {
|
||||||
|
if m.client == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.client.UnlabelMessages(protonmail.LabelInbox, []string{msgID})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
package proton
|
package proton
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"prism/service/config"
|
"prism/service/credentials"
|
||||||
"prism/service/notification"
|
"prism/service/notification"
|
||||||
"prism/service/util"
|
"prism/service/util"
|
||||||
|
|
||||||
|
"github.com/emersion/hydroxide/protonmail"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -19,20 +23,164 @@ func GetTemplates() embed.FS {
|
||||||
return templates
|
return templates
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterRoutes(router *chi.Mux, cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler, tmpl *util.TemplateRenderer) *Handlers {
|
type authHandler struct {
|
||||||
if !cfg.IsProtonEnabled() {
|
db *sql.DB
|
||||||
return nil
|
apiKey string
|
||||||
|
logger *slog.Logger
|
||||||
|
integration *Integration
|
||||||
|
dispatcher *notification.Dispatcher
|
||||||
}
|
}
|
||||||
|
|
||||||
monitor := NewMonitor(cfg, dispatcher, logger)
|
type protonAuthRequest struct {
|
||||||
handlers := NewHandlers(monitor, cfg.ProtonIMAPUsername, logger, tmpl)
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
TOTP string `json:"totp"`
|
||||||
|
}
|
||||||
|
|
||||||
router.With(authMiddleware).Get("/fragment/proton", handlers.HandleFragment)
|
func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req protonAuthRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
util.JSONError(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
router.Route("/api/proton-mail", func(r chi.Router) {
|
if req.Email == "" || req.Password == "" {
|
||||||
r.Use(authMiddleware)
|
util.JSONError(w, "email and password are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &protonmail.Client{
|
||||||
|
RootURL: "https://mail.proton.me/api",
|
||||||
|
AppVersion: "Other",
|
||||||
|
}
|
||||||
|
authInfo, err := c.AuthInfo(req.Email)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to get auth info", "error", err)
|
||||||
|
util.JSONError(w, "Failed to get auth info", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, err := c.Auth(req.Email, req.Password, authInfo)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Authentication failed", "error", err)
|
||||||
|
util.JSONError(w, "Incorrect login credentials. Please try again", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth.TwoFactor.Enabled != 0 {
|
||||||
|
if req.TOTP == "" {
|
||||||
|
util.JSONError(w, "2FA enabled: TOTP code required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if auth.TwoFactor.TOTP != 1 {
|
||||||
|
util.JSONError(w, "Only TOTP is supported as a 2FA method", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scope, err := c.AuthTOTP(req.TOTP)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("TOTP verification failed", "error", err)
|
||||||
|
util.JSONError(w, "Invalid 2FA code. Please try again", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth.Scope = scope
|
||||||
|
}
|
||||||
|
|
||||||
|
credStore, err := credentials.NewStore(h.db, h.apiKey)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to initialize credentials store", "error", err)
|
||||||
|
util.JSONError(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keySalts, err := c.ListKeySalts()
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to get key salts", "error", err)
|
||||||
|
util.JSONError(w, "Failed to complete authentication", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := &credentials.ProtonCredentials{
|
||||||
|
Email: req.Email,
|
||||||
|
Password: req.Password,
|
||||||
|
UID: auth.UID,
|
||||||
|
AccessToken: auth.AccessToken,
|
||||||
|
RefreshToken: auth.RefreshToken,
|
||||||
|
Scope: auth.Scope,
|
||||||
|
KeySalts: keySalts,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := credStore.SaveProton(creds); err != nil {
|
||||||
|
h.logger.Error("Failed to save Proton credentials", "error", err)
|
||||||
|
util.JSONError(w, "Failed to save credentials", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.dispatcher != nil {
|
||||||
|
if err := h.dispatcher.RegisterApp(prismTopic); err != nil {
|
||||||
|
h.logger.Warn("Failed to auto-create Proton Mail app mapping", "error", err)
|
||||||
|
} else {
|
||||||
|
h.logger.Info("Created Proton Mail app mapping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.integration != nil {
|
||||||
|
go func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := h.integration.monitor.Start(ctx, credStore); err != nil {
|
||||||
|
h.logger.Error("Failed to start Proton monitor after auth", "error", err)
|
||||||
|
} else {
|
||||||
|
h.logger.Info("Proton monitor started after authentication")
|
||||||
|
if h.integration.handlers != nil {
|
||||||
|
h.integration.handlers.username = req.Email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
credStore, err := credentials.NewStore(h.db, h.apiKey)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to initialize credentials store", "error", err)
|
||||||
|
util.JSONError(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := credStore.DeleteIntegration(credentials.IntegrationProton); err != nil {
|
||||||
|
h.logger.Error("Failed to delete integration", "error", err)
|
||||||
|
util.JSONError(w, "Failed to delete integration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger, integration *Integration) {
|
||||||
|
if handlers == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.SetDB(db, apiKey)
|
||||||
|
|
||||||
|
authH := &authHandler{
|
||||||
|
db: db,
|
||||||
|
apiKey: apiKey,
|
||||||
|
logger: logger,
|
||||||
|
integration: integration,
|
||||||
|
dispatcher: integration.dispatcher,
|
||||||
|
}
|
||||||
|
|
||||||
|
router.With(auth).Get("/fragment/proton", handlers.HandleFragment)
|
||||||
|
|
||||||
|
router.Route("/api/proton", func(r chi.Router) {
|
||||||
|
r.Use(auth)
|
||||||
r.Post("/mark-read", handlers.HandleMarkRead)
|
r.Post("/mark-read", handlers.HandleMarkRead)
|
||||||
|
r.Post("/archive", handlers.HandleArchive)
|
||||||
|
r.Post("/auth", authH.handleAuth)
|
||||||
|
r.Delete("/auth", authH.handleDelete)
|
||||||
})
|
})
|
||||||
|
|
||||||
return handlers
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
{{if .Connected}}
|
{{if .Connected}}
|
||||||
<p><strong>Unlink Instructions:</strong></p>
|
<button class="btn-danger delete-proton-btn">Unlink</button>
|
||||||
<ol class="link-instructions">
|
|
||||||
<li>Run: <code>docker compose run protonmail-bridge init</code></li>
|
|
||||||
<li>In the bridge CLI, use: <code>logout</code></li>
|
|
||||||
<li>Then: <code>exit</code> to close the bridge</li>
|
|
||||||
</ol>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<p><strong>Setup Instructions:</strong></p>
|
<p><strong>Link Proton Account:</strong></p>
|
||||||
<ol class="link-instructions">
|
<form id="proton-auth-form" class="auth-form">
|
||||||
<li>Run: <code>docker compose run --rm protonmail-bridge init</code></li>
|
<div class="form-group">
|
||||||
<li>At the prompt, use: <code>login</code> and enter your Proton Mail credentials</li>
|
<label for="proton-email">Email:</label>
|
||||||
<li>Run: <code>info</code> to get your IMAP username and password</li>
|
<input type="email" id="proton-email" name="email" placeholder="your-email@proton.me" required>
|
||||||
<li>Add credentials to <code>.env</code> and restart</li>
|
</div>
|
||||||
</ol>
|
<div class="form-group">
|
||||||
<p class="text-muted">See <a href="https://github.com/lone-cloud/prism#proton-mail" target="_blank">full setup guide</a></p>
|
<label for="proton-password">Password:</label>
|
||||||
|
<input type="password" id="proton-password" name="password" placeholder="Your Proton password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="proton-totp">2FA Code (optional):</label>
|
||||||
|
<input type="text" id="proton-totp" name="totp" placeholder="123456" pattern="[0-9]{6}">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Link</button>
|
||||||
|
<div id="proton-auth-status" class="auth-status"></div>
|
||||||
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,114 +1,148 @@
|
||||||
package signal
|
package signal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"io"
|
||||||
"sync/atomic"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var rpcID int32
|
const (
|
||||||
|
DefaultDeviceName = "Prism"
|
||||||
|
DefaultConfigPath = ".local/share/signal-cli"
|
||||||
|
)
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
SocketPath string
|
ConfigPath string
|
||||||
|
enabled bool
|
||||||
|
accountCache *Account
|
||||||
|
accountCacheTime time.Time
|
||||||
|
accountCacheMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient() *Client {
|
||||||
|
configPath := filepath.Join(os.Getenv("HOME"), DefaultConfigPath)
|
||||||
|
|
||||||
|
enabled := false
|
||||||
|
if _, err := exec.LookPath("signal-cli"); err == nil {
|
||||||
|
enabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClient(socketPath string) *Client {
|
|
||||||
return &Client{
|
return &Client{
|
||||||
SocketPath: socketPath,
|
ConfigPath: configPath,
|
||||||
|
enabled: enabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type RPCRequest struct {
|
func (c *Client) IsEnabled() bool {
|
||||||
JSONRPC string `json:"jsonrpc"`
|
return c.enabled
|
||||||
ID int32 `json:"id"`
|
|
||||||
Method string `json:"method"`
|
|
||||||
Params map[string]any `json:"params"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RPCResponse struct {
|
|
||||||
JSONRPC string `json:"jsonrpc"`
|
|
||||||
ID int32 `json:"id"`
|
|
||||||
Result json.RawMessage `json:"result,omitempty"`
|
|
||||||
Error *RPCError `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RPCError struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) Call(method string, params map[string]any) (json.RawMessage, error) {
|
|
||||||
conn, err := net.Dial("unix", c.SocketPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to connect: %w", err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
request := RPCRequest{
|
|
||||||
JSONRPC: "2.0",
|
|
||||||
ID: atomic.AddInt32(&rpcID, 1),
|
|
||||||
Method: method,
|
|
||||||
Params: params,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.NewEncoder(conn).Encode(&request); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to encode request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var response RPCResponse
|
|
||||||
if err := json.NewDecoder(conn).Decode(&response); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if response.Error != nil {
|
|
||||||
return nil, fmt.Errorf("signal-cli error: %s", response.Error.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) CallWithAccount(method string, params map[string]any, account string) (json.RawMessage, error) {
|
|
||||||
if params == nil {
|
|
||||||
params = make(map[string]any)
|
|
||||||
}
|
|
||||||
params["account"] = account
|
|
||||||
return c.Call(method, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
type AccountInfo struct {
|
|
||||||
Number string `json:"number"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Account struct {
|
type Account struct {
|
||||||
Number string
|
Number string
|
||||||
|
UUID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) exec(args ...string) ([]byte, error) {
|
||||||
|
if !c.enabled {
|
||||||
|
return nil, fmt.Errorf("signal-cli not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseArgs := []string{"--config", c.ConfigPath, "--output=json"}
|
||||||
|
cmd := exec.Command("signal-cli", append(baseArgs, args...)...)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
if stderr.Len() > 0 {
|
||||||
|
return nil, fmt.Errorf("signal-cli: %s", strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdout.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetLinkedAccount() (*Account, error) {
|
func (c *Client) GetLinkedAccount() (*Account, error) {
|
||||||
if c == nil {
|
if c == nil || !c.enabled {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := c.Call("listAccounts", nil)
|
c.accountCacheMu.RLock()
|
||||||
|
if time.Since(c.accountCacheTime) < 30*time.Second {
|
||||||
|
cached := c.accountCache
|
||||||
|
c.accountCacheMu.RUnlock()
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
c.accountCacheMu.RUnlock()
|
||||||
|
|
||||||
|
c.accountCacheMu.Lock()
|
||||||
|
defer c.accountCacheMu.Unlock()
|
||||||
|
|
||||||
|
if time.Since(c.accountCacheTime) < 30*time.Second {
|
||||||
|
return c.accountCache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
accountsFile := filepath.Join(c.ConfigPath, "data", "accounts.json")
|
||||||
|
if _, err := os.Stat(accountsFile); os.IsNotExist(err) {
|
||||||
|
c.accountCache = nil
|
||||||
|
c.accountCacheTime = time.Now()
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(accountsFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var accounts []AccountInfo
|
var accountsData struct {
|
||||||
if err := json.Unmarshal(result, &accounts); err != nil {
|
Accounts []struct {
|
||||||
|
Number string `json:"number"`
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
} `json:"accounts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &accountsData); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(accounts) == 0 {
|
if len(accountsData.Accounts) == 0 {
|
||||||
|
c.accountCache = nil
|
||||||
|
c.accountCacheTime = time.Now()
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Account{Number: accounts[0].Number}, nil
|
account := &Account{
|
||||||
|
Number: accountsData.Accounts[0].Number,
|
||||||
|
UUID: accountsData.Accounts[0].UUID,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("signal-cli", "-a", account.Number, "receive", "--timeout", "0")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
errStr := strings.ToLower(string(output))
|
||||||
|
if strings.Contains(errStr, "not registered") || strings.Contains(errStr, "authorization failed") {
|
||||||
|
c.accountCache = nil
|
||||||
|
c.accountCacheTime = time.Now()
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.accountCache = account
|
||||||
|
c.accountCacheTime = time.Now()
|
||||||
|
return account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) CreateGroup(name string) (string, string, error) {
|
func (c *Client) CreateGroup(name string) (string, string, error) {
|
||||||
if c == nil {
|
if c == nil || !c.enabled {
|
||||||
return "", "", fmt.Errorf("signal client not initialized")
|
return "", "", fmt.Errorf("signal client not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,49 +154,157 @@ func (c *Client) CreateGroup(name string) (string, string, error) {
|
||||||
return "", "", fmt.Errorf("no linked Signal account")
|
return "", "", fmt.Errorf("no linked Signal account")
|
||||||
}
|
}
|
||||||
|
|
||||||
params := map[string]any{
|
output, err := c.exec("-o", "json", "-a", account.Number, "updateGroup", "-n", name)
|
||||||
"name": name,
|
|
||||||
"member": []string{},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := c.CallWithAccount("updateGroup", params, account.Number)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", fmt.Errorf("failed to create group: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var response struct {
|
var response struct {
|
||||||
GroupID string `json:"groupId"`
|
GroupID string `json:"groupId"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(result, &response); err != nil {
|
|
||||||
return "", "", fmt.Errorf("failed to parse updateGroup response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if response.GroupID == "" {
|
lines := bytes.Split(output, []byte("\n"))
|
||||||
return "", "", fmt.Errorf("empty groupId in response")
|
for _, line := range lines {
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
if err := json.Unmarshal(line, &response); err == nil && response.GroupID != "" {
|
||||||
return response.GroupID, account.Number, nil
|
return response.GroupID, account.Number, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", fmt.Errorf("failed to parse group creation response")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) SendGroupMessage(groupID, message string) error {
|
func (c *Client) SendGroupMessage(groupID, message string) error {
|
||||||
if c == nil {
|
if c == nil || !c.enabled {
|
||||||
return fmt.Errorf("signal client not initialized")
|
return fmt.Errorf("signal client not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
account, err := c.GetLinkedAccount()
|
account, err := c.GetLinkedAccount()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get account: %w", err)
|
return fmt.Errorf("failed to get linked account: %w", err)
|
||||||
}
|
}
|
||||||
if account == nil {
|
if account == nil {
|
||||||
return fmt.Errorf("no linked account")
|
return fmt.Errorf("no linked Signal account")
|
||||||
}
|
}
|
||||||
|
|
||||||
params := map[string]any{
|
_, err = c.exec("-a", account.Number, "send", "-g", groupID, "--notify-self", "-m", message)
|
||||||
"groupId": groupID,
|
|
||||||
"message": message,
|
|
||||||
"notifySelf": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = c.CallWithAccount("send", params, account.Number)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) LinkDevice(deviceName string) (string, error) {
|
||||||
|
if c == nil || !c.enabled {
|
||||||
|
return "", fmt.Errorf("signal-cli not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
if deviceName == "" {
|
||||||
|
deviceName = DefaultDeviceName
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(c.ConfigPath, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create config directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dataDir := filepath.Join(c.ConfigPath, "data")
|
||||||
|
if entries, err := os.ReadDir(c.ConfigPath); err == nil {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() && strings.HasPrefix(entry.Name(), "+") {
|
||||||
|
accountDir := filepath.Join(c.ConfigPath, entry.Name())
|
||||||
|
os.RemoveAll(accountDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
os.RemoveAll(dataDir)
|
||||||
|
|
||||||
|
cmd := exec.Command("signal-cli", "--config", c.ConfigPath, "link", "-n", deviceName)
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to start link command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var output bytes.Buffer
|
||||||
|
buf := make([]byte, 8192)
|
||||||
|
|
||||||
|
done := make(chan string, 1)
|
||||||
|
errChan := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
n, err := stdout.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
output.Write(buf[:n])
|
||||||
|
text := output.String()
|
||||||
|
|
||||||
|
if idx := strings.Index(text, "sgnl://linkdevice"); idx != -1 {
|
||||||
|
end := strings.IndexAny(text[idx:], " \n\r\t")
|
||||||
|
var url string
|
||||||
|
if end == -1 {
|
||||||
|
url = text[idx:]
|
||||||
|
} else {
|
||||||
|
url = text[idx : idx+end]
|
||||||
|
}
|
||||||
|
done <- strings.TrimSpace(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
errChan <- err
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
n, err := stderr.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
output.Write(buf[:n])
|
||||||
|
text := output.String()
|
||||||
|
|
||||||
|
if idx := strings.Index(text, "sgnl://linkdevice"); idx != -1 {
|
||||||
|
end := strings.IndexAny(text[idx:], " \n\r\t")
|
||||||
|
var url string
|
||||||
|
if end == -1 {
|
||||||
|
url = text[idx:]
|
||||||
|
} else {
|
||||||
|
url = text[idx : idx+end]
|
||||||
|
}
|
||||||
|
done <- strings.TrimSpace(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case url := <-done:
|
||||||
|
go func() {
|
||||||
|
io.Copy(io.Discard, stdout)
|
||||||
|
io.Copy(io.Discard, stderr)
|
||||||
|
cmd.Wait()
|
||||||
|
}()
|
||||||
|
return url, nil
|
||||||
|
case err := <-errChan:
|
||||||
|
cmd.Process.Kill()
|
||||||
|
return "", err
|
||||||
|
case <-time.After(5 * time.Minute):
|
||||||
|
cmd.Process.Kill()
|
||||||
|
return "", fmt.Errorf("timeout waiting for QR code URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package signal
|
package signal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -10,7 +11,6 @@ import (
|
||||||
|
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
client *Client
|
client *Client
|
||||||
linkDevice *LinkDevice
|
|
||||||
tmpl *util.TemplateRenderer
|
tmpl *util.TemplateRenderer
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
@ -32,49 +32,47 @@ type IntegrationData struct {
|
||||||
PollAttrs string
|
PollAttrs string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandlers(client *Client, linkDevice *LinkDevice, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers {
|
func NewHandlers(client *Client, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers {
|
||||||
return &Handlers{
|
return &Handlers{
|
||||||
client: client,
|
client: client,
|
||||||
linkDevice: linkDevice,
|
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.client == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
|
||||||
account, _ := h.client.GetLinkedAccount()
|
|
||||||
|
|
||||||
var contentData SignalContentData
|
var contentData SignalContentData
|
||||||
var integData IntegrationData
|
var integData IntegrationData
|
||||||
integData.Name = "Signal"
|
integData.Name = "Signal"
|
||||||
|
|
||||||
if account != nil {
|
if h.client == nil || !h.client.IsEnabled() {
|
||||||
|
integData.StatusClass = "disconnected"
|
||||||
|
integData.StatusText = "Not Available"
|
||||||
|
integData.StatusTooltip = "signal-cli not found in PATH"
|
||||||
|
integData.Open = true
|
||||||
|
contentData.Error = "signal-cli not found. Download from: https://github.com/AsamK/signal-cli/releases"
|
||||||
|
} else {
|
||||||
|
account, err := h.client.GetLinkedAccount()
|
||||||
|
if err != nil {
|
||||||
|
integData.StatusClass = "disconnected"
|
||||||
|
integData.StatusText = "Error"
|
||||||
|
integData.StatusTooltip = err.Error()
|
||||||
|
integData.Open = true
|
||||||
|
contentData.Error = err.Error()
|
||||||
|
} else if account == nil {
|
||||||
|
integData.StatusClass = "disconnected"
|
||||||
|
integData.StatusText = "Unlinked"
|
||||||
|
integData.StatusTooltip = "Click to link device with Signal"
|
||||||
|
integData.Open = true
|
||||||
|
} else {
|
||||||
integData.StatusClass = "connected"
|
integData.StatusClass = "connected"
|
||||||
integData.StatusText = "Linked"
|
integData.StatusText = "Linked"
|
||||||
integData.StatusTooltip = FormatPhoneNumber(account.Number)
|
integData.StatusTooltip = FormatPhoneNumber(account.Number)
|
||||||
integData.Open = false
|
integData.Open = false
|
||||||
integData.PollAttrs = ""
|
|
||||||
|
|
||||||
contentData.Linked = true
|
contentData.Linked = true
|
||||||
contentData.DeviceName = h.linkDevice.deviceName
|
contentData.DeviceName = FormatPhoneNumber(account.Number)
|
||||||
} else {
|
|
||||||
integData.StatusClass = "unlinked"
|
|
||||||
integData.StatusText = "Unlinked"
|
|
||||||
integData.Open = true
|
|
||||||
integData.PollAttrs = ""
|
|
||||||
|
|
||||||
contentData.Linked = false
|
|
||||||
qrCode, err := h.linkDevice.GenerateQR()
|
|
||||||
if err != nil {
|
|
||||||
contentData.Error = err.Error()
|
|
||||||
} else {
|
|
||||||
contentData.QRCode = qrCode
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,9 +93,64 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) IsEnabled() bool {
|
func (h *Handlers) IsEnabled() bool {
|
||||||
return h.client != nil
|
return h.client != nil && h.client.IsEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) GetClient() *Client {
|
func (h *Handlers) GetClient() *Client {
|
||||||
return h.client
|
return h.client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) HandleLinkDevice(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.client == nil || !h.client.IsEnabled() {
|
||||||
|
util.JSONError(w, "signal-cli not available", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
req.DeviceName = DefaultDeviceName
|
||||||
|
}
|
||||||
|
|
||||||
|
qrCode, err := h.client.LinkDevice(req.DeviceName)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to generate link code", "error", err)
|
||||||
|
util.JSONError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"qr_code": qrCode,
|
||||||
|
"status": "linking",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) HandleLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.client == nil || !h.client.IsEnabled() {
|
||||||
|
util.JSONError(w, "signal-cli not available", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := h.client.GetLinkedAccount()
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to get linked account", "error", err)
|
||||||
|
util.JSONError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Debug("Link status check", "linked", account != nil, "account", account)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if account != nil {
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"linked": true,
|
||||||
|
"phone_number": account.Number,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"linked": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package signal
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@ import (
|
||||||
|
|
||||||
type Integration struct {
|
type Integration struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
client *Client
|
||||||
handlers *Handlers
|
handlers *Handlers
|
||||||
sender *Sender
|
sender *Sender
|
||||||
tmpl *util.TemplateRenderer
|
tmpl *util.TemplateRenderer
|
||||||
|
|
@ -21,13 +23,11 @@ type Integration struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
|
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
|
||||||
var sender *Sender
|
client := NewClient()
|
||||||
if cfg.IsSignalEnabled() {
|
sender := NewSender(client, store, logger)
|
||||||
client := NewClient(cfg.SignalSocket)
|
|
||||||
sender = NewSender(client, store, logger)
|
|
||||||
}
|
|
||||||
return &Integration{
|
return &Integration{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
client: client,
|
||||||
sender: sender,
|
sender: sender,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
|
@ -38,8 +38,8 @@ func (s *Integration) GetSender() *Sender {
|
||||||
return s.sender
|
return s.sender
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) {
|
func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
|
||||||
s.handlers = RegisterRoutes(router, s.cfg, auth, s.tmpl, s.logger)
|
s.handlers = RegisterRoutes(router, s.cfg, auth, s.tmpl, s.logger, s.client)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
func (s *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
||||||
|
|
@ -51,11 +51,16 @@ func (s *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
||||||
} else {
|
} else {
|
||||||
logger.Info("Signal enabled", "status", "unlinked", "action", "visit admin UI to link")
|
logger.Info("Signal enabled", "status", "unlinked", "action", "visit admin UI to link")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
logger.Info("Signal disabled", "reason", "signal-cli not found in PATH")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Integration) IsEnabled() bool {
|
func (s *Integration) IsEnabled() bool {
|
||||||
return s.cfg.IsSignalEnabled()
|
if s.handlers == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.handlers.IsEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Integration) GetHandlers() *Handlers {
|
func (s *Integration) GetHandlers() *Handlers {
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
package signal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LinkDevice struct {
|
|
||||||
client *Client
|
|
||||||
deviceName string
|
|
||||||
qrCode string
|
|
||||||
deviceLinkUri string
|
|
||||||
generatedAt time.Time
|
|
||||||
ttl time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewLinkDevice(client *Client, deviceName string) *LinkDevice {
|
|
||||||
return &LinkDevice{
|
|
||||||
client: client,
|
|
||||||
deviceName: deviceName,
|
|
||||||
ttl: 10 * time.Minute,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type StartLinkResponse struct {
|
|
||||||
DeviceLinkURI string `json:"deviceLinkUri"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *LinkDevice) GenerateQR() (string, error) {
|
|
||||||
if l.client == nil {
|
|
||||||
return "", fmt.Errorf("signal client not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
if l.qrCode != "" && time.Since(l.generatedAt) < l.ttl {
|
|
||||||
return l.qrCode, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := l.client.Call("startLink", nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to start link: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var response StartLinkResponse
|
|
||||||
if err := json.Unmarshal(result, &response); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse link response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
uri := response.DeviceLinkURI
|
|
||||||
if uri == "" {
|
|
||||||
return "", fmt.Errorf("empty device link URI")
|
|
||||||
}
|
|
||||||
|
|
||||||
l.deviceLinkUri = uri
|
|
||||||
qrURL := fmt.Sprintf("https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=%s", url.QueryEscape(uri))
|
|
||||||
l.qrCode = qrURL
|
|
||||||
l.generatedAt = time.Now()
|
|
||||||
go l.finishLink()
|
|
||||||
|
|
||||||
return l.qrCode, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *LinkDevice) finishLink() {
|
|
||||||
params := map[string]any{
|
|
||||||
"deviceLinkUri": l.deviceLinkUri,
|
|
||||||
"deviceName": l.deviceName,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := l.client.Call("finishLink", params)
|
|
||||||
if err == nil {
|
|
||||||
l.qrCode = ""
|
|
||||||
l.generatedAt = time.Time{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,16 +18,12 @@ func GetTemplates() embed.FS {
|
||||||
return templates
|
return templates
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers {
|
func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler, tmpl *util.TemplateRenderer, logger *slog.Logger, client *Client) *Handlers {
|
||||||
if !cfg.IsSignalEnabled() {
|
handlers := NewHandlers(client, tmpl, logger)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
client := NewClient(cfg.SignalSocket)
|
|
||||||
linkDevice := NewLinkDevice(client, cfg.DeviceName)
|
|
||||||
handlers := NewHandlers(client, linkDevice, tmpl, logger)
|
|
||||||
|
|
||||||
router.With(authMiddleware).Get("/fragment/signal", handlers.HandleFragment)
|
router.With(authMiddleware).Get("/fragment/signal", handlers.HandleFragment)
|
||||||
|
router.With(authMiddleware).Post("/api/signal/link", handlers.HandleLinkDevice)
|
||||||
|
router.With(authMiddleware).Get("/api/signal/status", handlers.HandleLinkStatus)
|
||||||
|
|
||||||
return handlers
|
return handlers
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,26 @@
|
||||||
{{if .Linked}}
|
{{if .Linked}}
|
||||||
<p><strong>Unlink Instructions:</strong></p>
|
<p><strong>To unlink:</strong></p>
|
||||||
<ol class="link-instructions">
|
<ol class="link-instructions">
|
||||||
<li>Open Signal on your phone</li>
|
<li>Open Signal on your phone</li>
|
||||||
<li>Go to Settings → Linked Devices</li>
|
<li>Go to Settings → Linked Devices</li>
|
||||||
<li>Find and remove <strong>{{.DeviceName}}</strong></li>
|
<li>Find and remove this device</li>
|
||||||
</ol>
|
</ol>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{if .Error}}
|
{{if .Error}}
|
||||||
<p>Error generating QR code: {{.Error}}</p>
|
<p class="channel-not-configured">{{.Error}}</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p><strong>Link your Signal (or <a href="https://molly.im" target="_blank" rel="noopener">Molly</a>) account:</strong></p>
|
<button id="signal-link-btn" class="btn-primary">Link Device</button>
|
||||||
|
<div id="signal-qr-container" class="qr-container" style="display:none;">
|
||||||
|
<p><strong>Scan this QR code with Signal:</strong></p>
|
||||||
<ol class="link-instructions">
|
<ol class="link-instructions">
|
||||||
<li>Open Signal on your phone</li>
|
<li>Open Signal on your phone</li>
|
||||||
<li>Go to Settings → Linked Devices</li>
|
<li>Go to Settings → Linked Devices</li>
|
||||||
|
<li>Tap "+" or "Link New Device"</li>
|
||||||
<li>Scan the QR code below</li>
|
<li>Scan the QR code below</li>
|
||||||
</ol>
|
</ol>
|
||||||
<div class="qr-code-container"
|
<div class="qr-code-wrapper">
|
||||||
hx-get="/fragment/signal"
|
<img id="signal-qr-code" alt="Signal QR Code">
|
||||||
hx-trigger="every 3s"
|
</div>
|
||||||
hx-swap="outerHTML"
|
|
||||||
hx-target="closest .integration-card">
|
|
||||||
<img src="{{.QRCode}}" alt="Signal QR Code" class="qr-code"/>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package telegram
|
package telegram
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"prism/service/credentials"
|
||||||
"prism/service/util"
|
"prism/service/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -13,6 +16,8 @@ type Handlers struct {
|
||||||
chatID int64
|
chatID int64
|
||||||
tmpl *util.TemplateRenderer
|
tmpl *util.TemplateRenderer
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
db *sql.DB
|
||||||
|
apiKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TelegramContentData struct {
|
type TelegramContentData struct {
|
||||||
|
|
@ -37,29 +42,73 @@ func NewHandlers(client *Client, chatID int64, tmpl *util.TemplateRenderer, logg
|
||||||
chatID: chatID,
|
chatID: chatID,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
db: nil,
|
||||||
|
apiKey: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) SetDB(db *sql.DB, apiKey string) {
|
||||||
|
h.db = db
|
||||||
|
h.apiKey = apiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) loadFreshCredentials() (*Client, int64, bool) {
|
||||||
|
if h.db == nil || h.apiKey == "" {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
credStore, err := credentials.NewStore(h.db, h.apiKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := credStore.GetTelegram()
|
||||||
|
if err != nil || creds == nil {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := NewClient(creds.BotToken)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to create Telegram client", "error", err)
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatID int64
|
||||||
|
if creds.ChatID != "" {
|
||||||
|
chatID, err = strconv.ParseInt(creds.ChatID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to parse chat ID", "error", err)
|
||||||
|
return client, 0, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, chatID, true
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
|
||||||
|
client, chatID, _ := h.loadFreshCredentials()
|
||||||
|
|
||||||
var contentData TelegramContentData
|
var contentData TelegramContentData
|
||||||
var integData IntegrationData
|
var integData IntegrationData
|
||||||
integData.Name = "Telegram"
|
integData.Name = "Telegram"
|
||||||
|
|
||||||
if h.client == nil {
|
if client == nil {
|
||||||
integData.StatusClass = "disconnected"
|
integData.StatusClass = "disconnected"
|
||||||
integData.StatusText = "Not Configured"
|
integData.StatusText = "Unlinked"
|
||||||
|
integData.StatusTooltip = "Enter bot token to link"
|
||||||
integData.Open = true
|
integData.Open = true
|
||||||
contentData.NotConfigured = true
|
contentData.NotConfigured = true
|
||||||
} else {
|
} else {
|
||||||
bot, err := h.client.GetMe()
|
bot, err := client.GetMe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
integData.StatusClass = "disconnected"
|
integData.StatusClass = "disconnected"
|
||||||
integData.StatusText = "Error"
|
integData.StatusText = "Error"
|
||||||
|
integData.StatusTooltip = err.Error()
|
||||||
integData.Open = true
|
integData.Open = true
|
||||||
contentData.Error = err.Error()
|
contentData.Error = err.Error()
|
||||||
} else if h.chatID == 0 {
|
} else if chatID == 0 {
|
||||||
integData.StatusClass = "disconnected"
|
integData.StatusClass = "disconnected"
|
||||||
integData.StatusText = "Needs Chat ID"
|
integData.StatusText = "Needs Chat ID"
|
||||||
integData.StatusTooltip = "@" + bot.Username
|
integData.StatusTooltip = "@" + bot.Username
|
||||||
|
|
@ -94,5 +143,11 @@ func (h *Handlers) IsEnabled() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) GetClient() *Client {
|
func (h *Handlers) GetClient() *Client {
|
||||||
return h.client
|
client, _, _ := h.loadFreshCredentials()
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) GetChatID() int64 {
|
||||||
|
_, chatID, _ := h.loadFreshCredentials()
|
||||||
|
return chatID
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@ package telegram
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"prism/service/config"
|
"prism/service/config"
|
||||||
|
"prism/service/credentials"
|
||||||
"prism/service/notification"
|
"prism/service/notification"
|
||||||
"prism/service/util"
|
"prism/service/util"
|
||||||
|
|
||||||
|
|
@ -20,17 +23,33 @@ type Integration struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
|
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
|
||||||
client, err := NewClient(cfg.TelegramBotToken)
|
var client *Client
|
||||||
if err != nil {
|
var chatID int64
|
||||||
logger.Error("Failed to create telegram client", "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var sender *Sender
|
var sender *Sender
|
||||||
if client != nil {
|
|
||||||
sender = NewSender(client, store, logger, cfg.TelegramChatID)
|
credStore, err := credentials.NewStoreWithLogger(store.GetDB(), cfg.APIKey, logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to initialize credentials store for Telegram", "error", err)
|
||||||
|
} else {
|
||||||
|
creds, err := credStore.GetTelegram()
|
||||||
|
if err == nil && creds != nil {
|
||||||
|
logger.Info("Loading Telegram credentials from database")
|
||||||
|
client, err = NewClient(creds.BotToken)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to create Telegram client", "error", err)
|
||||||
|
client = nil
|
||||||
|
}
|
||||||
|
if creds.ChatID != "" {
|
||||||
|
chatID, _ = strconv.ParseInt(creds.ChatID, 10, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handlers := NewHandlers(client, cfg.TelegramChatID, tmpl, logger)
|
if client != nil {
|
||||||
|
sender = NewSender(client, store, logger, chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers := NewHandlers(client, chatID, tmpl, logger)
|
||||||
|
|
||||||
return &Integration{
|
return &Integration{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
|
@ -48,22 +67,31 @@ func (t *Integration) GetHandlers() *Handlers {
|
||||||
return t.handlers
|
return t.handlers
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) {
|
func (t *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
|
||||||
RegisterRoutes(router, t.handlers, auth)
|
RegisterRoutes(router, t.handlers, auth, db, apiKey, logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
||||||
if t.handlers != nil && t.handlers.IsEnabled() {
|
|
||||||
client := t.handlers.GetClient()
|
client := t.handlers.GetClient()
|
||||||
|
if client == nil {
|
||||||
|
logger.Info("Telegram not configured", "action", "visit admin UI to configure")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
bot, err := client.GetMe()
|
bot, err := client.GetMe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Telegram bot error", "error", err)
|
logger.Error("Telegram bot error", "error", err)
|
||||||
} else {
|
return
|
||||||
logger.Info("Telegram enabled", "bot", bot.Username, "id", bot.ID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chatID := t.handlers.chatID
|
||||||
|
if chatID == 0 {
|
||||||
|
logger.Info("Telegram bot connected", "bot", bot.Username, "status", "needs_chat_id")
|
||||||
|
} else {
|
||||||
|
logger.Info("Telegram enabled", "bot", bot.Username, "chat_id", chatID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Integration) IsEnabled() bool {
|
func (t *Integration) IsEnabled() bool {
|
||||||
return t.cfg.IsTelegramEnabled()
|
return t.handlers != nil && t.handlers.GetClient() != nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
package telegram
|
package telegram
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"prism/service/credentials"
|
||||||
|
"prism/service/util"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -14,10 +20,75 @@ func GetTemplates() embed.FS {
|
||||||
return templates
|
return templates
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler) {
|
type authHandler struct {
|
||||||
|
db *sql.DB
|
||||||
|
apiKey string
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type telegramAuthRequest struct {
|
||||||
|
BotToken string `json:"bot_token"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req telegramAuthRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
util.JSONError(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.BotToken == "" || req.ChatID == "" {
|
||||||
|
util.JSONError(w, "bot_token and chat_id are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credStore, err := credentials.NewStore(h.db, h.apiKey)
|
||||||
|
if err != nil {
|
||||||
|
util.LogAndError(w, h.logger, "Failed to initialize credentials store", http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := &credentials.TelegramCredentials{
|
||||||
|
BotToken: req.BotToken,
|
||||||
|
ChatID: req.ChatID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := credStore.SaveTelegram(creds); err != nil {
|
||||||
|
util.LogAndError(w, h.logger, "Failed to save Telegram credentials", http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
credStore, err := credentials.NewStore(h.db, h.apiKey)
|
||||||
|
if err != nil {
|
||||||
|
util.LogAndError(w, h.logger, "Failed to initialize credentials store", http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := credStore.DeleteIntegration(credentials.IntegrationTelegram); err != nil {
|
||||||
|
util.LogAndError(w, h.logger, "Failed to delete integration", http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
|
||||||
if handlers == nil {
|
if handlers == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handlers.SetDB(db, apiKey)
|
||||||
|
|
||||||
|
authH := &authHandler{db: db, apiKey: apiKey, logger: logger}
|
||||||
|
|
||||||
router.With(auth).Get("/fragment/telegram", handlers.HandleFragment)
|
router.With(auth).Get("/fragment/telegram", handlers.HandleFragment)
|
||||||
|
router.With(auth).Post("/api/telegram/auth", authH.handleAuth)
|
||||||
|
router.With(auth).Delete("/api/telegram/auth", authH.handleDelete)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
|
||||||
message = fmt.Sprintf("<b>%s</b>\n%s", notif.Title, notif.Message)
|
message = fmt.Sprintf("<b>%s</b>\n%s", notif.Title, notif.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
fullMessage := fmt.Sprintf("📱 <b>%s</b>\n\n%s", mapping.AppName, message)
|
fullMessage := fmt.Sprintf("<b>%s</b>\n\n%s", mapping.AppName, message)
|
||||||
|
|
||||||
if err := s.client.SendMessage(s.DefaultChatID, fullMessage); err != nil {
|
if err := s.client.SendMessage(s.DefaultChatID, fullMessage); err != nil {
|
||||||
s.logger.Error("Failed to send telegram message", "chatID", s.DefaultChatID, "error", err)
|
s.logger.Error("Failed to send telegram message", "chatID", s.DefaultChatID, "error", err)
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,39 @@
|
||||||
{{if .NotConfigured}}
|
{{if .NotConfigured}}
|
||||||
<p><strong>Setup Instructions:</strong></p>
|
<p><strong>Setup Telegram Bot:</strong></p>
|
||||||
<ol class="link-instructions">
|
<ol class="link-instructions">
|
||||||
<li>Message <a href="https://t.me/BotFather" target="_blank">@BotFather</a> on Telegram</li>
|
<li>Message <a href="https://t.me/BotFather" target="_blank">@BotFather</a> on Telegram</li>
|
||||||
<li>Send <code>/newbot</code> and follow the prompts</li>
|
<li>Send <code>/newbot</code> and follow the prompts</li>
|
||||||
<li>Copy the bot token and add to <code>.env</code>: <code>TELEGRAM_BOT_TOKEN=your-token</code></li>
|
<li>Copy the bot token from BotFather</li>
|
||||||
<li>Message <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> to get your Chat ID</li>
|
<li>Message <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> to get your Chat ID</li>
|
||||||
<li>Add to <code>.env</code>: <code>TELEGRAM_CHAT_ID=your-chat-id</code></li>
|
<li>Enter both below:</li>
|
||||||
<li>Restart Prism</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
<p class="text-muted">See <a href="https://github.com/lone-cloud/prism#telegram" target="_blank">full setup guide</a></p>
|
<form id="telegram-auth-form" class="auth-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="telegram-bot-token">Bot Token:</label>
|
||||||
|
<input type="text" id="telegram-bot-token" name="bot_token" placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="telegram-chat-id">Chat ID:</label>
|
||||||
|
<input type="text" id="telegram-chat-id" name="chat_id" placeholder="123456789" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Configure</button>
|
||||||
|
<div id="telegram-auth-status" class="auth-status"></div>
|
||||||
|
</form>
|
||||||
{{else if .Error}}
|
{{else if .Error}}
|
||||||
<p>Error: {{.Error}}</p>
|
<p>Error: {{.Error}}</p>
|
||||||
|
<button class="btn-secondary reload-btn">Retry</button>
|
||||||
{{else if .NeedsChatID}}
|
{{else if .NeedsChatID}}
|
||||||
<p><strong>Complete Setup:</strong></p>
|
<p><strong>Complete Setup:</strong></p>
|
||||||
<ol class="link-instructions">
|
<p>Bot is configured, but Chat ID is missing.</p>
|
||||||
<li>Message <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> on Telegram to get your Chat ID</li>
|
<form id="telegram-chatid-form" class="auth-form" data-bot-token="{{.BotToken}}">
|
||||||
<li>Add to <code>.env</code>: <code>TELEGRAM_CHAT_ID=your-chat-id</code></li>
|
<div class="form-group">
|
||||||
<li>Restart Prism</li>
|
<label for="telegram-chat-id-only">Chat ID:</label>
|
||||||
</ol>
|
<input type="text" id="telegram-chat-id-only" name="chat_id" placeholder="Get from @userinfobot" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Save Chat ID</button>
|
||||||
|
<div id="telegram-chatid-status" class="auth-status"></div>
|
||||||
|
</form>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p><strong>Unlink Instructions:</strong></p>
|
<button class="btn-danger delete-telegram-btn">Unlink</button>
|
||||||
<ol class="link-instructions">
|
|
||||||
<li>Remove <code>TELEGRAM_BOT_TOKEN</code> and <code>TELEGRAM_CHAT_ID</code> from <code>.env</code></li>
|
|
||||||
<li>Restart: <code>docker compose restart prism</code></li>
|
|
||||||
</ol>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|
|
||||||
17
service/integration/templates/integrations.html
Normal file
17
service/integration/templates/integrations.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{{if .EnableSignal}}
|
||||||
|
<div id="signal-integration" class="integration-container" hx-get="/fragment/signal" hx-trigger="load">
|
||||||
|
<div class="loading"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .EnableTelegram}}
|
||||||
|
<div id="telegram-integration" class="integration-container" hx-get="/fragment/telegram" hx-trigger="load">
|
||||||
|
<div class="loading"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .EnableProton}}
|
||||||
|
<div id="proton-integration" class="integration-container" hx-get="/fragment/proton" hx-trigger="load">
|
||||||
|
<div class="loading"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
@ -2,6 +2,7 @@ package webpush
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
|
@ -22,7 +23,7 @@ func NewIntegration(store *notification.Store, logger *slog.Logger) *Integration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) {
|
func (w *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
|
||||||
RegisterRoutes(router, w.store, w.logger, auth)
|
RegisterRoutes(router, w.store, w.logger, auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,11 @@ func (d *Dispatcher) IsValidChannel(channel Channel) bool {
|
||||||
return channel.IsAvailable(d.HasSignal(), d.HasTelegram())
|
return channel.IsAvailable(d.HasSignal(), d.HasTelegram())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) RegisterApp(appName string) error {
|
||||||
|
availableChannels := d.GetAvailableChannels()
|
||||||
|
return d.store.RegisterDefault(appName, availableChannels)
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) Send(appName string, notif Notification) error {
|
func (d *Dispatcher) Send(appName string, notif Notification) error {
|
||||||
mapping, err := d.store.GetApp(appName)
|
mapping, err := d.store.GetApp(appName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -90,8 +95,8 @@ func (d *Dispatcher) Send(appName string, notif Notification) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) sendWithRetry(sender NotificationSender, mapping *Mapping, notif Notification, appName string) error {
|
func (d *Dispatcher) sendWithRetry(sender NotificationSender, mapping *Mapping, notif Notification, appName string) error {
|
||||||
maxRetries := 3
|
maxRetries := 10
|
||||||
baseDelay := 100 * time.Millisecond
|
baseDelay := 500 * time.Millisecond
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package notification
|
||||||
|
|
||||||
type Action struct {
|
type Action struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
Endpoint string `json:"endpoint"`
|
Endpoint string `json:"endpoint"`
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
Data map[string]any `json:"data,omitempty"`
|
Data map[string]any `json:"data,omitempty"`
|
||||||
|
|
@ -10,6 +11,7 @@ type Action struct {
|
||||||
type Notification struct {
|
type Notification struct {
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
Actions []Action `json:"actions,omitempty"`
|
Actions []Action `json:"actions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,10 @@ func (s *Store) Close() error {
|
||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetDB() *sql.DB {
|
||||||
|
return s.db
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscription, webPush *WebPushSubscription) error {
|
func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscription, webPush *WebPushSubscription) error {
|
||||||
var signalGroupID, signalAccount *string
|
var signalGroupID, signalAccount *string
|
||||||
if signal != nil {
|
if signal != nil {
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,6 @@ func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
|
||||||
"mappingsCount": len(mappings),
|
"mappingsCount": len(mappings),
|
||||||
"signalCount": countByChannel(mappings, notification.ChannelSignal),
|
"signalCount": countByChannel(mappings, notification.ChannelSignal),
|
||||||
"webpushCount": countByChannel(mappings, notification.ChannelWebPush),
|
"webpushCount": countByChannel(mappings, notification.ChannelWebPush),
|
||||||
"protonEnabled": s.cfg.IsProtonEnabled(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
|
||||||
|
|
@ -41,17 +41,31 @@ func (s *Server) buildAppListData(mappings []notification.Mapping) []AppListItem
|
||||||
signalLinked = account != nil
|
signalLinked = account != nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
telegramConfigured := s.cfg.IsTelegramEnabled() && s.cfg.TelegramChatID != 0
|
|
||||||
|
telegramLinked := false
|
||||||
|
var telegramInfo string
|
||||||
|
if s.integrations.Telegram != nil {
|
||||||
|
handlers := s.integrations.Telegram.GetHandlers()
|
||||||
|
if handlers != nil && handlers.GetClient() != nil {
|
||||||
|
chatID := handlers.GetChatID()
|
||||||
|
telegramLinked = chatID != 0
|
||||||
|
if telegramLinked {
|
||||||
|
if bot, err := handlers.GetClient().GetMe(); err == nil {
|
||||||
|
telegramInfo = "@" + bot.Username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
items := make([]AppListItem, 0, len(mappings))
|
items := make([]AppListItem, 0, len(mappings))
|
||||||
for _, m := range mappings {
|
for _, m := range mappings {
|
||||||
item := s.buildAppListItem(m, signalLinked, telegramConfigured)
|
item := s.buildAppListItem(m, signalLinked, telegramLinked, telegramInfo)
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) buildAppListItem(m notification.Mapping, signalLinked, telegramConfigured bool) AppListItem {
|
func (s *Server) buildAppListItem(m notification.Mapping, signalLinked, telegramLinked bool, telegramInfo string) AppListItem {
|
||||||
isSignal := m.Channel == notification.ChannelSignal
|
isSignal := m.Channel == notification.ChannelSignal
|
||||||
isWebPush := m.Channel == notification.ChannelWebPush
|
isWebPush := m.Channel == notification.ChannelWebPush
|
||||||
isTelegram := m.Channel == notification.ChannelTelegram
|
isTelegram := m.Channel == notification.ChannelTelegram
|
||||||
|
|
@ -72,11 +86,12 @@ func (s *Server) buildAppListItem(m notification.Mapping, signalLinked, telegram
|
||||||
if u, err := url.Parse(m.WebPush.Endpoint); err == nil {
|
if u, err := url.Parse(m.WebPush.Endpoint); err == nil {
|
||||||
item.Hostname = u.Hostname()
|
item.Hostname = u.Hostname()
|
||||||
}
|
}
|
||||||
} else if isTelegram && telegramConfigured {
|
} else if isTelegram && telegramLinked {
|
||||||
item.ChannelBadge = m.Channel.Label()
|
item.ChannelBadge = m.Channel.Label()
|
||||||
|
item.Tooltip = telegramInfo
|
||||||
item.ChannelConfigured = true
|
item.ChannelConfigured = true
|
||||||
} else {
|
} else {
|
||||||
item.ChannelBadge = "Not Configured"
|
item.ChannelBadge = "Unlinked"
|
||||||
item.ChannelConfigured = false
|
item.ChannelConfigured = false
|
||||||
if isSignal {
|
if isSignal {
|
||||||
item.Tooltip = "No Signal group created yet. Will auto-create on first notification."
|
item.Tooltip = "No Signal group created yet. Will auto-create on first notification."
|
||||||
|
|
@ -87,7 +102,7 @@ func (s *Server) buildAppListItem(m notification.Mapping, signalLinked, telegram
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item.ChannelOptions = s.buildChannelOptions(m, signalLinked, telegramConfigured)
|
item.ChannelOptions = s.buildChannelOptions(m, signalLinked, telegramLinked)
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,14 +113,14 @@ func (s *Server) buildChannelOptions(m notification.Mapping, signalLinked, teleg
|
||||||
|
|
||||||
var options []SelectOption
|
var options []SelectOption
|
||||||
|
|
||||||
if signalLinked {
|
if s.integrations.Signal != nil {
|
||||||
options = append(options, SelectOption{
|
options = append(options, SelectOption{
|
||||||
Value: notification.ChannelSignal.String(),
|
Value: notification.ChannelSignal.String(),
|
||||||
Label: notification.ChannelSignal.Label(),
|
Label: notification.ChannelSignal.Label(),
|
||||||
Selected: isSignal,
|
Selected: isSignal,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if telegramConfigured {
|
if s.integrations.Telegram != nil {
|
||||||
options = append(options, SelectOption{
|
options = append(options, SelectOption{
|
||||||
Value: notification.ChannelTelegram.String(),
|
Value: notification.ChannelTelegram.String(),
|
||||||
Label: notification.ChannelTelegram.Label(),
|
Label: notification.ChannelTelegram.Label(),
|
||||||
|
|
|
||||||
|
|
@ -48,13 +48,6 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.cfg.IsProtonEnabled() {
|
|
||||||
resp.Proton = &integrationHealth{
|
|
||||||
Linked: true,
|
|
||||||
Account: s.cfg.ProtonIMAPUsername,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled() {
|
if s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled() {
|
||||||
telegramClient := s.integrations.Telegram.GetHandlers().GetClient()
|
telegramClient := s.integrations.Telegram.GetHandlers().GetClient()
|
||||||
if telegramClient != nil {
|
if telegramClient != nil {
|
||||||
|
|
@ -68,6 +61,19 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.integrations.Proton != nil && s.integrations.Proton.IsEnabled() {
|
||||||
|
protonHandlers := s.integrations.Proton.GetHandlers()
|
||||||
|
if protonHandlers != nil && protonHandlers.IsEnabled() {
|
||||||
|
email, hasCredentials := protonHandlers.LoadFreshCredentials()
|
||||||
|
if hasCredentials {
|
||||||
|
resp.Proton = &integrationHealth{
|
||||||
|
Linked: true,
|
||||||
|
Account: email,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||||
s.logger.Error("Failed to encode health response", "error", err)
|
s.logger.Error("Failed to encode health response", "error", err)
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,20 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"bytes"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"prism/service/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleFragmentIntegrations(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleFragmentIntegrations(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
|
||||||
html := ""
|
var buf bytes.Buffer
|
||||||
|
if err := s.fragmentTmpl.ExecuteTemplate(&buf, "integrations.html", s.cfg); err != nil {
|
||||||
if s.cfg.IsSignalEnabled() {
|
util.LogAndError(w, s.logger, "Internal server error", http.StatusInternalServerError, err)
|
||||||
html += `<div id="signal-integration" class="integration-container" hx-get="/fragment/signal" hx-trigger="load"><div class="loading"><div class="spinner"></div></div></div>`
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.cfg.IsProtonEnabled() {
|
w.Write(buf.Bytes())
|
||||||
html += `<div id="proton-integration" class="integration-container" hx-get="/fragment/proton" hx-trigger="load"><div class="loading"><div class="spinner"></div></div></div>`
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.cfg.IsTelegramEnabled() {
|
|
||||||
html += `<div id="telegram-integration" class="integration-container" hx-get="/fragment/telegram" hx-trigger="load"><div class="loading"><div class="spinner"></div></div></div>`
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = fmt.Fprint(w, html)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,6 @@ import (
|
||||||
|
|
||||||
"prism/service/config"
|
"prism/service/config"
|
||||||
"prism/service/integration"
|
"prism/service/integration"
|
||||||
"prism/service/integration/proton"
|
|
||||||
"prism/service/integration/signal"
|
|
||||||
"prism/service/integration/telegram"
|
|
||||||
"prism/service/notification"
|
"prism/service/notification"
|
||||||
"prism/service/util"
|
"prism/service/util"
|
||||||
|
|
||||||
|
|
@ -55,21 +52,11 @@ func New(cfg *config.Config, publicAssets embed.FS) (*Server, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse fragment templates: %w", err)
|
return nil, fmt.Errorf("failed to parse fragment templates: %w", err)
|
||||||
}
|
}
|
||||||
fragmentTmpl, err = fragmentTmpl.ParseFS(signal.GetTemplates(), "templates/*.html")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse signal templates: %w", err)
|
|
||||||
}
|
|
||||||
fragmentTmpl, err = fragmentTmpl.ParseFS(telegram.GetTemplates(), "templates/*.html")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse telegram templates: %w", err)
|
|
||||||
}
|
|
||||||
fragmentTmpl, err = fragmentTmpl.ParseFS(proton.GetTemplates(), "templates/*.html")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse proton templates: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
templateRenderer := util.NewTemplateRenderer(fragmentTmpl)
|
integrations, fragmentTmpl, err := integration.Initialize(cfg, store, logger, fragmentTmpl)
|
||||||
integrations := integration.Initialize(cfg, store, logger, templateRenderer)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
version := "dev"
|
version := "dev"
|
||||||
if versionBytes, err := os.ReadFile("VERSION"); err == nil {
|
if versionBytes, err := os.ReadFile("VERSION"); err == nil {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
</select>
|
</select>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
<button class="btn-delete" hx-delete="/action/app/{{.AppName}}" hx-target="#apps-list" hx-swap="innerHTML">Delete</button>
|
<button class="btn-delete" hx-delete="/action/app/{{.AppName}}" hx-target="#apps-list" hx-swap="innerHTML" hx-confirm="Delete {{.AppName}}?">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue