Compare commits
No commits in common. "v0.3.0" and "master" have entirely different histories.
|
|
@ -3,7 +3,7 @@ tmp_dir = "tmp"
|
|||
|
||||
[build]
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ."
|
||||
cmd = "go build -ldflags=\"-X main.version=$(cat VERSION 2>/dev/null || echo 'dev')\" -o ./tmp/main ."
|
||||
include_ext = ["go", "html", "css", "js"]
|
||||
exclude_dir = ["tmp", "data"]
|
||||
delay = 1000
|
||||
|
|
|
|||
14
.env.example
|
|
@ -1,18 +1,18 @@
|
|||
# Required: API key for authentication
|
||||
# Required: Password for web UI login and credential encryption (use a strong unique password)
|
||||
# API_KEY=your-secret-key-here
|
||||
|
||||
# Optional Configuration
|
||||
|
||||
# Optional: Enable integrations (default: false - only enable what you need)
|
||||
# ENABLE_SIGNAL=true
|
||||
# ENABLE_TELEGRAM=true
|
||||
# ENABLE_PROTON=true
|
||||
# Optional: Disable integrations (default: all enabled)
|
||||
# ENABLE_SIGNAL=false
|
||||
# ENABLE_TELEGRAM=false
|
||||
# ENABLE_PROTON=false
|
||||
|
||||
# Optional: Server port (default: 8080)
|
||||
# PORT=8080
|
||||
|
||||
# Optional: Rate limit for requests (default: 100 per 15 minute window)
|
||||
# RATE_LIMIT=100
|
||||
# Optional: Rate limit for requests (default: 20 req/s per IP). Set to 0 to disable (e.g. behind a remote reverse proxy with its own rate limiting)
|
||||
# RATE_LIMIT=20
|
||||
|
||||
# Optional: Enable verbose logging (default: false)
|
||||
# VERBOSE_LOGGING=false
|
||||
|
|
|
|||
21
.github/copilot-instructions.md
vendored
|
|
@ -6,6 +6,7 @@
|
|||
- **NO USELESS COMMENTS**: Don't add comments that just restate what the code does. If the code needs a comment to be understood, refactor it to be clearer instead.
|
||||
- **Self-documenting code**: Use clear variable and function names. The code should explain itself.
|
||||
- **Only comment WHY, not WHAT**: If you must add a comment, explain WHY something is done, not WHAT is being done.
|
||||
- all terminal commands must work for zsh
|
||||
|
||||
### Bad Comments (Don't Do This)
|
||||
|
||||
|
|
@ -34,3 +35,23 @@ if time.Since(l.generatedAt) < l.ttl {
|
|||
- Prefer composition over inheritance
|
||||
- Handle errors properly, don't ignore them
|
||||
|
||||
## Design Context
|
||||
|
||||
### Users
|
||||
Self-hosters — technically capable people who run their own infrastructure. They visit this UI occasionally to configure integrations (Signal, Telegram, WebPush, Proton Mail), register apps, and verify things are wired up. Could be on any device, but desktop is most common. Setup is a one-time or rare task; the UI is not used daily.
|
||||
|
||||
### Brand Personality
|
||||
Sharp. Minimal. No-nonsense. This is a tool for people who run servers, not a product trying to sell them something. The interface should feel like it respects their time — no filler, no decorative chrome, no marketing energy.
|
||||
|
||||
### Aesthetic Direction
|
||||
Refined utilitarian. Light theme by default, dark by system preference (keep existing both-modes behavior). The palette should feel clean and precise, not sterile. The cyan accent (`#56c3de`) is a good starting point for the brand hue — it reads as calm and technical without being loud. Neutrals should be very subtly tinted toward it.
|
||||
|
||||
Anti-reference: PHPMyAdmin / boring CRUD admin panel. The goal is something that wouldn't look out of place as a polished open-source project UI — functional, cohesive, with quiet confidence.
|
||||
|
||||
### Design Principles
|
||||
1. **Earn every element** — if it doesn't serve a function, remove it. No decorative chrome.
|
||||
2. **Precision over personality** — tight spacing, clear hierarchy, no ambiguity about what's interactive.
|
||||
3. **Trust through clarity** — statuses should be instantly readable; nothing should make the user wonder "did that work?"
|
||||
4. **Quiet confidence** — not boring, not flashy. Typography and spacing do the work, not color.
|
||||
5. **Polish the structure, don't replace it** — the existing `<details>`-card pattern and HTMX architecture should be respected. Improve the CSS layer, don't overhaul templates.
|
||||
|
||||
|
|
|
|||
13
.github/dependabot.yml
vendored
|
|
@ -1,13 +0,0 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
20
.github/workflows/ci.yml
vendored
|
|
@ -6,6 +6,9 @@ on:
|
|||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
GOLANGCI_LINT_VERSION: v2.8.0
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -15,20 +18,19 @@ jobs:
|
|||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.25'
|
||||
go-version: '1.26'
|
||||
cache: true
|
||||
|
||||
- name: Cache Go modules
|
||||
- name: Cache Go tools
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
path: ~/go/bin
|
||||
key: go-tools-${{ runner.os }}-goimports-latest-golangci-lint-${{ env.GOLANGCI_LINT_VERSION }}
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
go install golang.org/x/tools/cmd/goimports@latest
|
||||
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.8.0
|
||||
which goimports || go install golang.org/x/tools/cmd/goimports@latest
|
||||
which golangci-lint || go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${{ env.GOLANGCI_LINT_VERSION }}
|
||||
|
||||
- name: Build
|
||||
- name: Lint & Build
|
||||
run: make all
|
||||
|
|
|
|||
2
.github/workflows/release-dev.yml
vendored
|
|
@ -30,4 +30,6 @@ jobs:
|
|||
push: true
|
||||
build-args: |
|
||||
VERSION=dev
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ghcr.io/lone-cloud/prism:dev
|
||||
|
|
|
|||
11
.github/workflows/release.yml
vendored
|
|
@ -16,7 +16,8 @@ jobs:
|
|||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.25'
|
||||
go-version: '1.26'
|
||||
cache: true
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
|
|
@ -28,14 +29,16 @@ jobs:
|
|||
VERSION=$(cat VERSION)
|
||||
|
||||
# Linux AMD64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOTOOLCHAIN=local go build \
|
||||
-trimpath \
|
||||
-buildvcs=false \
|
||||
-ldflags="-s -w -X main.version=$VERSION" \
|
||||
-o prism-linux-amd64 .
|
||||
|
||||
# Linux ARM64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build \
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 GOTOOLCHAIN=local go build \
|
||||
-trimpath \
|
||||
-buildvcs=false \
|
||||
-ldflags="-s -w -X main.version=$VERSION" \
|
||||
-o prism-linux-arm64 .
|
||||
|
||||
|
|
@ -57,6 +60,8 @@ jobs:
|
|||
push: true
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.tag }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: |
|
||||
ghcr.io/lone-cloud/prism:${{ steps.version.outputs.tag }}
|
||||
ghcr.io/lone-cloud/prism:latest
|
||||
|
|
|
|||
3
.gitignore
vendored
|
|
@ -5,3 +5,6 @@ data/
|
|||
prism-*
|
||||
tmp
|
||||
.VSCodeCounter/
|
||||
.image-digests
|
||||
.github/skills/
|
||||
.impeccable.md
|
||||
|
|
|
|||
|
|
@ -2,16 +2,18 @@ FROM golang:1.26-alpine3.23 AS builder
|
|||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN VERSION=$(cat VERSION 2>/dev/null || echo "dev") && \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
RUN --mount=type=cache,target=/root/.cache/go \
|
||||
VERSION=$(cat VERSION 2>/dev/null | tr -d '\n' || echo "dev") && \
|
||||
CGO_ENABLED=0 GOOS=linux GOTOOLCHAIN=local go build \
|
||||
-trimpath \
|
||||
-buildvcs=false \
|
||||
-ldflags="-w -s -X main.version=${VERSION}" \
|
||||
-o prism .
|
||||
|
||||
|
|
|
|||
54
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.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
|
||||
.PHONY: all build start dev fix install-tools check-updates release release-dev
|
||||
|
||||
BINARY_NAME=prism
|
||||
VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev")
|
||||
|
|
@ -18,22 +18,17 @@ dev:
|
|||
air
|
||||
|
||||
fix:
|
||||
go mod download
|
||||
go mod tidy
|
||||
gofmt -s -w .
|
||||
goimports -w .
|
||||
golangci-lint run --fix
|
||||
npx @biomejs/biome@latest check --write --unsafe .
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY_NAME) $(BINARY_NAME)-*
|
||||
rm -rf data/
|
||||
|
||||
install-tools:
|
||||
@echo "Installing Go tools to $(GOBIN)..."
|
||||
go install golang.org/x/tools/cmd/goimports@latest
|
||||
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v2.8.0
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0
|
||||
@echo "Installing signal-cli native binary..."
|
||||
@ARCH=$$(uname -m); \
|
||||
case $$ARCH in \
|
||||
|
|
@ -47,23 +42,32 @@ install-tools:
|
|||
signal-cli --version && \
|
||||
echo "signal-cli installed successfully to /usr/local/bin/signal-cli"
|
||||
|
||||
deps:
|
||||
go mod download
|
||||
go mod tidy
|
||||
|
||||
check-updates:
|
||||
@go list -u -m -f '{{if not .Indirect}}{{.Path}} {{.Version}}{{if .Update}} [{{.Update.Version}}]{{end}}{{end}}' all | grep "\[" || echo "All dependencies are up to date"
|
||||
|
||||
update:
|
||||
go get -u ./...
|
||||
go mod tidy
|
||||
|
||||
update-all:
|
||||
go get -u all
|
||||
go mod tidy
|
||||
|
||||
docker-up:
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
@echo "=== Go module updates ==="
|
||||
@go list -u -m -f '{{if not .Indirect}}{{.Path}} {{.Version}}{{if .Update}} -> {{.Update.Version}}{{end}}{{end}}' all | grep " -> " || echo "All Go dependencies are up to date"
|
||||
@echo ""
|
||||
@echo "=== Dockerfile base image updates ==="
|
||||
@for image in $$(grep -E '^FROM ' Dockerfile | awk '{print $$2}' | grep -v 'AS'); do \
|
||||
echo "Checking $$image..."; \
|
||||
name=$$(echo $$image | cut -d: -f1); \
|
||||
tag=$$(echo $$image | cut -d: -f2); \
|
||||
digest=$$(curl -sf "https://hub.docker.com/v2/repositories/library/$$name/tags/$$tag" | jq -r '.digest // empty'); \
|
||||
if [ -z "$$digest" ]; then \
|
||||
echo " $$image: could not fetch digest (offline or image not found)"; \
|
||||
continue; \
|
||||
fi; \
|
||||
cache_key=$$(echo $$image | tr ':/' '--'); \
|
||||
stored=$$(grep "^$$cache_key=" .image-digests 2>/dev/null | cut -d= -f2); \
|
||||
if [ -z "$$stored" ]; then \
|
||||
echo "$$cache_key=$$digest" >> .image-digests; \
|
||||
echo " $$image: digest saved for future comparisons"; \
|
||||
elif [ "$$stored" = "$$digest" ]; then \
|
||||
echo " $$image: up to date"; \
|
||||
else \
|
||||
sed -i "s|^$$cache_key=.*|$$cache_key=$$digest|" .image-digests; \
|
||||
echo " $$image: UPDATE AVAILABLE (image content has changed, consider rebuilding)"; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
release:
|
||||
@if [ ! -f VERSION ]; then \
|
||||
|
|
|
|||
222
README.md
|
|
@ -1,65 +1,64 @@
|
|||
<div align="center">
|
||||
|
||||
<img src="assets/prism.webp" alt="Prism Icon" width="120" height="120" />
|
||||
<img src="assets/prism.webp" alt="Prism Icon" width="80" height="80" />
|
||||
|
||||
# Prism
|
||||
|
||||
**Self-hosted notification gateway with email monitoring**
|
||||
**Private notification gateway**
|
||||
|
||||
[Setup](#setup) • [Integrations](#integrations) • [Examples](#real-world-examples)
|
||||
[Setup](#setup) • [Integrations](#integrations) • [API](#api) • [Examples](#real-world-examples) • [Monitoring](#monitoring)
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 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 monitored from a Proton Mail account integration.
|
||||
Prism sits between your services and your phone. Services send HTTP notifications; Prism delivers them to Signal, Telegram or WebPush. Prism is ntfy-compatible, so existing integrations work without changes. It can optionally monitor a Proton Mail inbox and send a notification for new emails.
|
||||
|
||||
Android companion app: [prism-android](https://github.com/lone-cloud/prism-android)
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/screenshots/light.webp" alt="Prism Dashboard (light)" width="70%" />
|
||||
<img src="assets/screenshots/dark.webp" alt="Prism Dashboard (dark)" width="70%" />
|
||||
</p>
|
||||
|
||||
## Setup
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
```bash
|
||||
# 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
|
||||
docker run -d \
|
||||
--name prism \
|
||||
-p 8080:8080 \
|
||||
-v prism-data:/app/data \
|
||||
-v signal-data:/home/prism/.local/share/signal-cli \
|
||||
--env-file .env \
|
||||
ghcr.io/lone-cloud/prism:latest
|
||||
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/docker-compose.yml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 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>.
|
||||
|
||||

|
||||
## Security
|
||||
|
||||
**Deploy behind HTTPS.** Every API request sends your `API_KEY` in the `Authorization` header. Over plain HTTP that header is transmitted in cleartext — anyone who can observe the traffic between your callers and the server can read the key and make authenticated requests. Use a reverse proxy with TLS termination (Caddy, nginx, Traefik) or a tunnel service like Cloudflare Tunnel in front of Prism.
|
||||
|
||||
Only use http URLs when callers run on the same host and traffic never leaves the machine.
|
||||
|
||||
## Integrations
|
||||
|
||||
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).
|
||||
All integrations are configured through the web UI. Authenticate with your `API_KEY` as the password (username can be anything).
|
||||
|
||||
### Signal
|
||||
|
||||
|
|
@ -75,9 +74,7 @@ Send notifications through Signal Messenger.
|
|||
- Scan the displayed QR code
|
||||
5. Your device will link automatically
|
||||
|
||||
All notifications will be sent via Signal.
|
||||
|
||||
**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.
|
||||
> **Note:** Binary installs require [signal-cli](https://github.com/AsamK/signal-cli/releases) in your PATH. Docker includes it automatically.
|
||||
|
||||
### Telegram
|
||||
|
||||
|
|
@ -100,20 +97,10 @@ Send notifications through a Telegram bot.
|
|||
- Enter your bot token and chat ID
|
||||
- Click "Configure"
|
||||
|
||||
All notifications will be sent to your Telegram chat.
|
||||
|
||||
### Proton Mail
|
||||
|
||||
Monitor a Proton Mail account and forward new emails as notifications through Signal or Telegram.
|
||||
|
||||
**Features:**
|
||||
|
||||
- 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
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Visit <http://localhost:8080> and authenticate with your API_KEY
|
||||
|
|
@ -125,9 +112,7 @@ Monitor a Proton Mail account and forward new emails as notifications through Si
|
|||
- 2FA code (if enabled)
|
||||
5. Click "Link"
|
||||
|
||||
Proton Mail will connect and begin monitoring. New emails will appear as notifications from the "Proton Mail" app.
|
||||
|
||||
**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.
|
||||
New emails appear as notifications from the "Proton Mail" app. Credentials are encrypted (AES-256-GCM) and tokens refresh automatically.
|
||||
|
||||
### WebPush
|
||||
|
||||
|
|
@ -139,49 +124,21 @@ Send notifications directly to your browser.
|
|||
2. Allow browser notifications when prompted
|
||||
3. Apps without Signal or Telegram configured will automatically use WebPush
|
||||
|
||||
You'll receive browser notifications when messages arrive.
|
||||
## API
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Email Monitoring
|
||||
|
||||
Receive instant Signal or Telegram notifications when new emails arrive in your Proton Mail inbox.
|
||||
|
||||
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
|
||||
|
||||
Add a rest notification configuration (eg. add to configuration.yaml) to Home Assistant like:
|
||||
|
||||
```yaml
|
||||
notify:
|
||||
- platform: rest
|
||||
name: Prism
|
||||
resource: "http://<Your Prism server network IP>/Home Assistant"
|
||||
method: POST
|
||||
headers:
|
||||
Authorization: !secret prism_api_key
|
||||
```
|
||||
|
||||
Since Home Assistant and Prism are both on your local network, HTTP is allowed automatically - no additional configuration needed.
|
||||
|
||||
Add your API_KEY to your secrets.yaml:
|
||||
All API endpoints require authentication with your API key:
|
||||
|
||||
```bash
|
||||
prism_api_key: "Bearer YOUR_API_KEY_HERE"
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
```
|
||||
|
||||
Reboot your Home Assistant system and you'll then be able to send Signal notifications to yourself by using this notify prism action.
|
||||
### ntfy-compatible publish API
|
||||
|
||||
## API Reference
|
||||
Publish notifications to `POST /{appName}`.
|
||||
|
||||
### Send Notification
|
||||
`{appName}` is the target app/topic name. Messages are routed to all subscriptions configured for that app (Signal, Telegram, WebPush).
|
||||
|
||||
#### POST /{appName}
|
||||
|
||||
Send a notification to a specific app. Messages are routed based on your app configuration in the web UI.
|
||||
|
||||
JSON format:
|
||||
**JSON payload:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/my-app \
|
||||
|
|
@ -190,7 +147,16 @@ curl -X POST http://localhost:8080/my-app \
|
|||
-d '{"title": "Alert", "message": "Something happened"}'
|
||||
```
|
||||
|
||||
Plain text (ntfy-compatible):
|
||||
**JSON payload with image:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/my-app \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title": "Motion Detected", "message": "Front door", "attach": "https://example.com/snapshot.jpg"}'
|
||||
```
|
||||
|
||||
**Plain text payload:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/my-app \
|
||||
|
|
@ -198,60 +164,125 @@ curl -X POST http://localhost:8080/my-app \
|
|||
-d "Simple message text"
|
||||
```
|
||||
|
||||
**App Routing:**
|
||||
- Configure which integration(s) receive messages from each app via the web UI
|
||||
- Apps can route to Signal, Telegram, WebPush, or multiple destinations
|
||||
- Special apps like "Proton Mail" are created automatically
|
||||
### WebPush subscription API
|
||||
|
||||
### WebPush/Webhook Management
|
||||
Register and remove WebPush subscriptions for an app.
|
||||
|
||||
#### POST /api/v1/webpush/app
|
||||
#### POST /api/v1/webpush/subscriptions
|
||||
|
||||
Register or update a WebPush subscription or plain webhook.
|
||||
Creates a WebPush subscription.
|
||||
|
||||
Encrypted WebPush (all crypto fields required):
|
||||
Required fields:
|
||||
|
||||
- `appName`
|
||||
- `pushEndpoint`
|
||||
|
||||
Optional encrypted payload fields (must be provided together):
|
||||
|
||||
- `p256dh`
|
||||
- `auth`
|
||||
- `vapidPrivateKey`
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/webpush/app \
|
||||
curl -X POST http://localhost:8080/api/v1/webpush/subscriptions \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"appName": "my-app",
|
||||
"pushEndpoint": "https://updates.push.services.mozilla.org/...",
|
||||
"p256dh": "base64-encoded-key",
|
||||
"auth": "base64-encoded-auth",
|
||||
"vapidPrivateKey": "base64-encoded-vapid-key"
|
||||
"pushEndpoint": "https://example.push.service/send/abc123",
|
||||
"p256dh": "BASE64URL_P256DH",
|
||||
"auth": "BASE64URL_AUTH",
|
||||
"vapidPrivateKey": "BASE64URL_VAPID_PRIVATE_KEY"
|
||||
}'
|
||||
```
|
||||
|
||||
Plain HTTP webhook (no encryption):
|
||||
Minimal registration (without encrypted payload fields):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/webpush/app \
|
||||
curl -X POST http://localhost:8080/api/v1/webpush/subscriptions \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"appName": "my-app",
|
||||
"pushEndpoint": "https://your-server.com/webhook"
|
||||
"pushEndpoint": "http://localhost:9001/mock-push"
|
||||
}'
|
||||
```
|
||||
|
||||
#### DELETE /api/v1/webpush/app/{appName}
|
||||
#### DELETE /api/v1/webpush/subscriptions/{subscriptionId}
|
||||
|
||||
Unregister a WebPush subscription (clears WebPush settings, reverts to Signal).
|
||||
Removes a WebPush subscription by ID.
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8080/api/v1/webpush/app/my-app \
|
||||
curl -X DELETE http://localhost:8080/api/v1/webpush/subscriptions/SUBSCRIPTION_ID \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Home Assistant
|
||||
|
||||
Add to `configuration.yaml`:
|
||||
|
||||
```yaml
|
||||
notify:
|
||||
- platform: rest
|
||||
name: Prism
|
||||
resource: "http://<Your Prism server network IP>/Home Assistant"
|
||||
method: POST_JSON
|
||||
headers:
|
||||
Authorization: !secret prism_api_key
|
||||
data_template:
|
||||
title: "{{ title }}"
|
||||
message: "{{ message }}"
|
||||
image: "{{ data.image | default('') }}"
|
||||
```
|
||||
|
||||
Add to `secrets.yaml`:
|
||||
|
||||
```bash
|
||||
prism_api_key: "Bearer YOUR_API_KEY_HERE"
|
||||
```
|
||||
|
||||
Then use the `notify.prism` action in automations.
|
||||
|
||||
**Sending an image from a camera snapshot:**
|
||||
|
||||
Take a snapshot first, save it to HA's local static file server, then send the URL:
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.front_door
|
||||
data:
|
||||
filename: /config/www/snapshot_front.jpg
|
||||
- action: notify.prism
|
||||
data:
|
||||
title: "Motion Detected"
|
||||
message: "Front door camera triggered"
|
||||
data:
|
||||
image: https://<your-ha-domain>/local/snapshot_front.jpg
|
||||
```
|
||||
|
||||
The `/config/www/` directory is served as `/local/` by Home Assistant's built-in HTTP server. Use your HA's external HTTPS URL so Prism can fetch the image when delivering the notification.
|
||||
|
||||
### Beszel
|
||||
|
||||
In [Beszel](https://beszel.dev)'s **Settings → Notifications**, add:
|
||||
|
||||
```
|
||||
ntfy://:YOUR_API_KEY@<prism-host>:<port>/Beszel?disableTLS=yes
|
||||
```
|
||||
|
||||
`disableTLS=yes` is only needed for local HTTP. The app name (`Beszel`) can be anything.
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Endpoints
|
||||
|
||||
#### GET /health
|
||||
|
||||
Public health check endpoint (no authentication required). Returns `200 OK` when the service is running. Used for Docker health checks and load balancer health probes.
|
||||
Public. Returns `200 OK` when running.
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
|
|
@ -259,7 +290,7 @@ curl http://localhost:8080/health
|
|||
|
||||
#### GET /api/v1/health
|
||||
|
||||
Detailed health endpoint (requires authentication). Returns JSON with uptime and integration status:
|
||||
Authenticated. Returns uptime and integration status:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/health \
|
||||
|
|
@ -268,13 +299,10 @@ curl http://localhost:8080/api/v1/health \
|
|||
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"version": "1.2.0",
|
||||
"uptime": "2h15m",
|
||||
"signal": {"linked": true, "account": "+1234567890"},
|
||||
"telegram": {"linked": true, "account": "123456789"},
|
||||
"proton": {"linked": true, "account": "user@proton.me"}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.3.0
|
||||
1.4.2
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 16 KiB |
BIN
assets/screenshots/dark.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 22 KiB |
BIN
assets/screenshots/light.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
48
go.mod
|
|
@ -1,45 +1,51 @@
|
|||
module prism
|
||||
|
||||
go 1.25.6
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/emersion/hydroxide v0.2.31
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/emersion/hydroxide v0.2.32
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mymmrac/telego v1.6.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/time v0.14.0
|
||||
modernc.org/sqlite v1.45.0
|
||||
github.com/mymmrac/telego v1.9.0
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 // indirect
|
||||
github.com/fogleman/gg v1.3.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grbit/go-json v0.11.0 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.7 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
modernc.org/gc/v3 v3.1.2 // indirect
|
||||
modernc.org/libc v1.67.7 // indirect
|
||||
github.com/valyala/fasthttp v1.71.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
golang.org/x/arch v0.26.0 // indirect
|
||||
golang.org/x/image v0.39.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
|
|
|||
113
go.sum
|
|
@ -1,15 +1,15 @@
|
|||
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/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
|
||||
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/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=
|
||||
|
|
@ -21,13 +21,19 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 h1:7aCSuwTBzg7BCPRRaBJD0weKZYdeAykOrY6ktpx8Vvc=
|
||||
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63/go.mod h1:eRwwJnuLVFtYTC+AI2JDJTMcuQUTYhBIK4I6bC5tpqw=
|
||||
github.com/emersion/hydroxide v0.2.31 h1:ofPKtEpD+AtE2oKJhcQKhUjubQS8+AHIjCuO3aeLBsM=
|
||||
github.com/emersion/hydroxide v0.2.31/go.mod h1:jhoMVyP0z2GACrmFkL0ppcWKt2LbC02auADSSsB4vH4=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/emersion/hydroxide v0.2.32 h1:Lg6GtUDgG+pGoKgKwlTxgTr4jL/Of9iqsqFhhN5RLDA=
|
||||
github.com/emersion/hydroxide v0.2.32/go.mod h1:ENJLlgG+CrTEhAuBARo7LXNsNkhsm+8FvKh1EJqosMg=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
|
|
@ -39,16 +45,18 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
|||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
||||
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mymmrac/telego v1.9.0 h1:ZUJxZaPx/1IgRvVb5lXnUB8FgW5rNYfRe6Q2EJ4OJ+Y=
|
||||
github.com/mymmrac/telego v1.9.0/go.mod h1:tVEB7OqiOPx8elRk9+ETkwiDQrUhWSB2XmAKIY9KmWY=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
|
|
@ -67,27 +75,37 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
|
||||
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34=
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ=
|
||||
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
||||
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4=
|
||||
golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260209203927-2842357ff358 h1:kpfSV7uLwKJbFSEgNhWzGSL47NDSF/5pYYQw1V0ub6c=
|
||||
golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
|
|
@ -110,22 +128,21 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
|
|
@ -144,8 +161,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
|
|
@ -159,30 +174,30 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
|
||||
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
|
||||
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
|
|
|||
4
main.go
|
|
@ -29,7 +29,7 @@ func init() {
|
|||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "version" {
|
||||
fmt.Printf("Prism %s\n", version)
|
||||
fmt.Println("Prism v", version)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ func main() {
|
|||
}
|
||||
|
||||
func runServer(cfg *config.Config, logger *slog.Logger) error {
|
||||
srv, err := server.New(cfg, publicAssets)
|
||||
srv, err := server.New(cfg, publicAssets, version, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create server: %w", err)
|
||||
}
|
||||
|
|
|
|||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 664 B After Width: | Height: | Size: 820 B |
BIN
public/icon-192.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
632
public/index.css
|
|
@ -1,39 +1,34 @@
|
|||
/* Variables */
|
||||
:root {
|
||||
--bg-primary: #f5f5f5;
|
||||
--bg-secondary: #fdfdfd;
|
||||
--text-primary: #000;
|
||||
--text-secondary: #666;
|
||||
--text-on-color: #fff;
|
||||
--border-color: rgba(0, 0, 0, 0.1);
|
||||
--accent: #00b4d8;
|
||||
--success: #06d6a0;
|
||||
--error: #ef476f;
|
||||
--spinner-track: #e0e0e0;
|
||||
color-scheme: light dark;
|
||||
--bg-primary: light-dark(#f5f7fa, #141618);
|
||||
--bg-secondary: light-dark(#fafbfd, #1e2124);
|
||||
--text-primary: light-dark(#0f1115, #dde1e7);
|
||||
--text-secondary: light-dark(#566070, #8ea0b5);
|
||||
--text-on-color: #f8fafc;
|
||||
--border-color: light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1));
|
||||
--accent: light-dark(#0b74b5, #71c9f8);
|
||||
--accent-hover: light-dark(#0a6399, #4db8f5);
|
||||
--text-on-accent: light-dark(#f8fafc, #00243c);
|
||||
--success: light-dark(#077a5d, #34d399);
|
||||
--success-hover: light-dark(#065e48, #2ab886);
|
||||
--success-bg: light-dark(#077a5d, #065e48);
|
||||
--error: light-dark(#c01746, #f87171);
|
||||
--error-hover: light-dark(#9e1239, #7f1d3e);
|
||||
--error-bg: light-dark(#c01746, #9e1239);
|
||||
--channel-signal-bg: light-dark(#e0f0fa, #1a3044);
|
||||
--channel-signal-hover: light-dark(#cce4f3, #243d55);
|
||||
--channel-webpush-bg: light-dark(#e0f5ef, #1a3329);
|
||||
--channel-webpush-hover: light-dark(#cceade, #243d33);
|
||||
--spinner-track: light-dark(#d9dde5, #2e3440);
|
||||
--shadow-tooltip: light-dark(rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-on-color: #fff;
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--accent: #60c5ff;
|
||||
--spinner-track: #4a4a4a;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-on-color: #fff;
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--accent: #60c5ff;
|
||||
--spinner-track: #4a4a4a;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Reset */
|
||||
|
|
@ -81,9 +76,8 @@ h6 {
|
|||
|
||||
/* Base */
|
||||
body {
|
||||
font-family: system-ui;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-family:
|
||||
ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
max-width: 50rem;
|
||||
margin: 1rem auto;
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
|
|
@ -101,21 +95,23 @@ a {
|
|||
}
|
||||
|
||||
a:hover {
|
||||
opacity: 0.8;
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Components */
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
.card,
|
||||
.integration-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
box-shadow: 0 0.125rem 0.25rem var(--border-color);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
.card-header,
|
||||
.integration-header {
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
display: flex;
|
||||
|
|
@ -127,11 +123,13 @@ a:hover {
|
|||
user-select: none;
|
||||
}
|
||||
|
||||
.card-header::-webkit-details-marker {
|
||||
.card-header::-webkit-details-marker,
|
||||
.integration-header::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-header::before {
|
||||
.card-header::before,
|
||||
.integration-header::before {
|
||||
content: "▶";
|
||||
font-size: 0.7em;
|
||||
margin-right: 0.5rem;
|
||||
|
|
@ -139,16 +137,40 @@ a:hover {
|
|||
display: inline-block;
|
||||
}
|
||||
|
||||
details[open] > .card-header::before {
|
||||
details[open] > .card-header::before,
|
||||
details[open] > .integration-header::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
.card-content,
|
||||
.integration-content {
|
||||
padding: 0 1.25rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
/* Page identity */
|
||||
.site-header {
|
||||
padding-bottom: 1.25rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.site-wordmark {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.card-header h2,
|
||||
.integration-header h3 {
|
||||
margin-bottom: 0;
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
.channel-badge:has(.tooltip),
|
||||
.integration-status:has(.tooltip) {
|
||||
cursor: help;
|
||||
}
|
||||
|
|
@ -161,18 +183,18 @@ details[open] > .card-header::before {
|
|||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
background: #1e2124;
|
||||
color: #fafbfd;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 0.0625rem solid var(--border-color);
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 0.25rem 0.5rem var(--shadow-tooltip);
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
|
|
@ -182,10 +204,11 @@ details[open] > .card-header::before {
|
|||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 0.3125rem solid transparent;
|
||||
border-top-color: var(--bg-secondary);
|
||||
border-top-color: #1e2124;
|
||||
}
|
||||
|
||||
:hover > .tooltip {
|
||||
:hover > .tooltip,
|
||||
:focus-within > .tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
@ -203,6 +226,10 @@ details[open] > .card-header::before {
|
|||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
|
|
@ -213,8 +240,6 @@ details[open] > .card-header::before {
|
|||
.app-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
max-height: 18.75rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
|
|
@ -232,46 +257,112 @@ details[open] > .card-header::before {
|
|||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 1.1em;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.app-channel {
|
||||
.app-subscriptions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9em;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.channel-badge {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85em;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.875em;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
border: none;
|
||||
background: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.channel-signal {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
.badge-active {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.channel-webpush {
|
||||
background: var(--success);
|
||||
color: var(--text-on-color);
|
||||
.badge-active:hover {
|
||||
background-color: var(--channel-signal-hover);
|
||||
}
|
||||
|
||||
.channel-telegram {
|
||||
background: #0088cc;
|
||||
color: var(--text-on-color);
|
||||
.channel-webpush.badge-active:hover {
|
||||
background-color: var(--channel-webpush-hover);
|
||||
}
|
||||
|
||||
.app-detail {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85em;
|
||||
.badge-active.htmx-request {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
cursor: pointer;
|
||||
border: 0.0625rem dashed currentColor;
|
||||
transition: filter 0.2s ease;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.badge-inactive:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.badge-inactive.htmx-request {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.badge-subscribed {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.channel-signal.badge-active {
|
||||
background: var(--channel-signal-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-signal.badge-inactive {
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.channel-signal.badge-subscribed {
|
||||
background: var(--channel-signal-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-webpush.badge-active {
|
||||
background: var(--channel-webpush-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.channel-webpush.badge-subscribed {
|
||||
background: var(--channel-signal-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-telegram.badge-active {
|
||||
background: var(--channel-signal-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-telegram.badge-inactive {
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.channel-telegram.badge-subscribed {
|
||||
background: var(--channel-signal-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
|
|
@ -280,33 +371,26 @@ details[open] > .card-header::before {
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.channel-form {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.channel-select {
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 0.0625rem solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--error);
|
||||
color: var(--text-on-color);
|
||||
.btn-delete-sub {
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
color: inherit;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: filter 0.2s ease;
|
||||
white-space: nowrap;
|
||||
padding: 0.125rem 0.25rem;
|
||||
margin-left: 0.125rem;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
line-height: 1;
|
||||
min-width: 1rem;
|
||||
min-height: 1.5rem;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
filter: brightness(0.85);
|
||||
.btn-delete-sub:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
|
|
@ -333,76 +417,51 @@ details[open] > .card-header::before {
|
|||
}
|
||||
|
||||
/* Integrations */
|
||||
.integration-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
box-shadow: 0 0.125rem 0.25rem var(--border-color);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.integration-container {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.integration-header {
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.integration-name {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.integration-header::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.integration-header::before {
|
||||
content: "▶";
|
||||
font-size: 0.7em;
|
||||
margin-right: 0.5rem;
|
||||
transition: transform 0.2s;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
details[open] > .integration-header::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.integration-content {
|
||||
padding: 0 1.25rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.integration-status {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.integration-status::before {
|
||||
content: "";
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.integration-status.connected::before,
|
||||
.integration-status.available::before {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.integration-status.connected,
|
||||
.integration-status.available {
|
||||
background: var(--success);
|
||||
color: var(--text-on-color);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.integration-status.disconnected::before {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.integration-status.disconnected {
|
||||
background: var(--error);
|
||||
color: var(--text-on-color);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.integration-status.unlinked::before {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
.integration-status.unlinked {
|
||||
background: var(--text-secondary);
|
||||
color: var(--text-on-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.link-instructions {
|
||||
|
|
@ -412,14 +471,23 @@ details[open] > .integration-header::before {
|
|||
}
|
||||
|
||||
.channel-not-configured {
|
||||
background: var(--error);
|
||||
background: var(--error-bg);
|
||||
color: var(--text-on-color);
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.integration-content code {
|
||||
background: var(--bg-primary);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.auth-form {
|
||||
margin-top: 1rem;
|
||||
max-width: 400px;
|
||||
max-width: 25rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
|
|
@ -438,13 +506,62 @@ details[open] > .integration-header::before {
|
|||
.form-group input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border: 0.0625rem solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Password Toggle */
|
||||
.password-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.password-wrapper input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group .password-wrapper input[type="text"],
|
||||
.form-group .password-wrapper input[type="email"],
|
||||
.form-group .password-wrapper input[type="password"] {
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
color: var(--text-secondary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.eye-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.eye-hide {
|
||||
display: none; /* overridden by inline style after first toggle */
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
|
|
@ -455,13 +572,19 @@ details[open] > .integration-header::before {
|
|||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: filter 0.2s ease;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-secondary:hover,
|
||||
.btn-danger:hover {
|
||||
filter: brightness(0.85);
|
||||
background-color: var(--error-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled,
|
||||
|
|
@ -473,16 +596,17 @@ details[open] > .integration-header::before {
|
|||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--text-secondary);
|
||||
color: var(--text-on-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 0.0625rem solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
background: var(--error-bg);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
|
|
@ -495,13 +619,13 @@ details[open] > .integration-header::before {
|
|||
|
||||
.auth-status.success {
|
||||
display: block;
|
||||
background: var(--success);
|
||||
background: var(--success-bg);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.auth-status.error {
|
||||
display: block;
|
||||
background: var(--error);
|
||||
background: var(--error-bg);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
|
|
@ -510,7 +634,7 @@ details[open] > .integration-header::before {
|
|||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
/* QR Code */
|
||||
|
|
@ -532,4 +656,192 @@ details[open] > .integration-header::before {
|
|||
max-width: 25rem;
|
||||
height: auto;
|
||||
display: block;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
bottom: 4rem;
|
||||
right: 1.5rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow:
|
||||
0 0.25rem 0.5rem var(--shadow-tooltip),
|
||||
0 0 0 0.0625rem var(--border-color);
|
||||
min-width: 15rem;
|
||||
max-width: 25rem;
|
||||
pointer-events: auto;
|
||||
animation: toastSlideIn 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: var(--success-bg);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: var(--error-bg);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.toast.hiding {
|
||||
animation: toastSlideOut 0.3s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes toastSlideIn {
|
||||
from {
|
||||
transform: translateX(calc(100% + 1.5rem));
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toastSlideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(100% + 1.5rem));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline confirm widget */
|
||||
.confirm-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.confirm-message {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.confirm-inline .confirm-message {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
min-height: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.noscript-msg {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.skip-nav {
|
||||
position: absolute;
|
||||
top: -100%;
|
||||
left: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--accent);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 600;
|
||||
z-index: 10000;
|
||||
transition: top 0.1s;
|
||||
}
|
||||
|
||||
.skip-nav:focus {
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
#signal-qr-code:not([src]),
|
||||
#signal-qr-code[src=""] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.app-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
.theme-toggle {
|
||||
bottom: 0.75rem;
|
||||
right: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
#toast-container {
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
bottom: 3rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms;
|
||||
animation-iteration-count: 1;
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,35 +2,46 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Prism Admin</title>
|
||||
<title>Prism</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#60c5ff">
|
||||
<meta name="theme-color" content="#0b74b5" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#71c9f8" media="(prefers-color-scheme: dark)">
|
||||
<link rel="icon" type="image/webp" href="/favicon.webp">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="stylesheet" href="/index.css?v={{.Version}}">
|
||||
<script src="/htmx.min.js"></script>
|
||||
<script src="/theme.js?v={{.Version}}"></script>
|
||||
<script src="/integration.js?v={{.Version}}"></script>
|
||||
<script src="/theme-init.js?v={{.Version}}"></script>
|
||||
<script src="/htmx.min.js" defer></script>
|
||||
<script src="/theme.js?v={{.Version}}" defer></script>
|
||||
<script src="/toast.js?v={{.Version}}" defer></script>
|
||||
<script src="/integration.js?v={{.Version}}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-nav">Skip to content</a>
|
||||
<noscript><p class="noscript-msg">JavaScript is required to use Prism.</p></noscript>
|
||||
<main id="main-content">
|
||||
<h1 class="sr-only">Prism</h1>
|
||||
<details class="card" open>
|
||||
<summary class="card-header">
|
||||
<span class="integration-name">Registered Applications</span>
|
||||
<h2>Registered Apps</h2>
|
||||
</summary>
|
||||
<div id="apps-list" class="card-content"
|
||||
hx-get="/fragment/apps"
|
||||
hx-trigger="load">
|
||||
<div class="loading">
|
||||
hx-trigger="load, reload">
|
||||
<div class="loading" role="status">
|
||||
<div class="spinner"></div>
|
||||
<span class="sr-only">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="integrations"
|
||||
hx-get="/fragment/integrations"
|
||||
hx-trigger="load">
|
||||
hx-trigger="load, reload">
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<button id="theme-toggle" class="theme-toggle"></button>
|
||||
<div id="toast-container" role="status" aria-live="polite" aria-atomic="false"></div>
|
||||
|
||||
<button type="button" id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">🌓</button>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,41 @@
|
|||
function showConfirm(btn, message, onConfirm) {
|
||||
document.querySelectorAll('.confirm-inline').forEach((el) => {
|
||||
el.replaceWith(el._original);
|
||||
});
|
||||
const wrapper = document.createElement('span');
|
||||
wrapper.className = 'confirm-inline';
|
||||
wrapper.setAttribute('role', 'group');
|
||||
wrapper.setAttribute('aria-label', message);
|
||||
wrapper.setAttribute('aria-live', 'polite');
|
||||
|
||||
const msg = document.createElement('span');
|
||||
msg.className = 'confirm-message';
|
||||
msg.textContent = message;
|
||||
|
||||
const yes = document.createElement('button');
|
||||
yes.type = 'button';
|
||||
yes.className = 'btn-danger btn-sm';
|
||||
yes.textContent = 'Yes';
|
||||
|
||||
const cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.className = 'btn-secondary btn-sm';
|
||||
cancel.textContent = 'Cancel';
|
||||
|
||||
yes.addEventListener('click', () => {
|
||||
wrapper.replaceWith(btn);
|
||||
onConfirm();
|
||||
});
|
||||
cancel.addEventListener('click', () => {
|
||||
delete btn.dataset.confirming;
|
||||
wrapper.replaceWith(btn);
|
||||
});
|
||||
|
||||
wrapper._original = btn;
|
||||
wrapper.append(msg, '\u00a0', yes, '\u00a0', cancel);
|
||||
btn.replaceWith(wrapper);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('submit', (e) => {
|
||||
const form = e.target;
|
||||
|
|
@ -17,10 +55,51 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
if (action === 'link-signal') linkSignal(btn);
|
||||
else if (action === 'delete-telegram') deleteTelegram(btn);
|
||||
else if (action === 'delete-proton') deleteProton(btn);
|
||||
else if (action === 'reload') location.reload();
|
||||
else if (action === 'reload') reloadIntegrations();
|
||||
else if (action === 'toggle-password') togglePassword(btn);
|
||||
else if (action === 'delete-app') deleteApp(btn);
|
||||
else if (action === 'delete-subscription') deleteSubscription(btn);
|
||||
});
|
||||
});
|
||||
|
||||
function togglePassword(btn) {
|
||||
const input = btn.closest('.password-wrapper').querySelector('input');
|
||||
const isHidden = input.type === 'password';
|
||||
input.type = isHidden ? 'text' : 'password';
|
||||
btn.querySelector('.eye-show').style.display = isHidden ? 'none' : 'block';
|
||||
btn.querySelector('.eye-hide').style.display = isHidden ? 'block' : 'none';
|
||||
btn.setAttribute('aria-label', isHidden ? 'Hide password' : 'Show password');
|
||||
}
|
||||
|
||||
function reloadIntegrations() {
|
||||
const integrations = document.getElementById('integrations');
|
||||
if (integrations) {
|
||||
htmx.trigger(integrations, 'reload');
|
||||
}
|
||||
const appsList = document.getElementById('apps-list');
|
||||
if (appsList) {
|
||||
htmx.trigger(appsList, 'reload');
|
||||
}
|
||||
}
|
||||
|
||||
function deleteApp(btn) {
|
||||
const appName = btn.dataset.appName;
|
||||
showConfirm(btn, `Delete ${appName}?`, () => {
|
||||
htmx.ajax('DELETE', `/apps/${appName}`, {
|
||||
target: '#apps-list',
|
||||
swap: 'innerHTML',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deleteSubscription(btn) {
|
||||
const url = btn.dataset.url;
|
||||
const anchor = btn.closest('.channel-badge') ?? btn;
|
||||
showConfirm(anchor, 'Delete this channel?', () => {
|
||||
htmx.ajax('DELETE', url, { target: '#apps-list', swap: 'innerHTML' });
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAuthForm(form, endpoint, statusId, getPayload) {
|
||||
const status = document.getElementById(statusId);
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
|
|
@ -41,7 +120,8 @@ async function handleAuthForm(form, endpoint, statusId, getPayload) {
|
|||
});
|
||||
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
checkToast(response);
|
||||
reloadIntegrations();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showError(error.error || 'Failed to save');
|
||||
|
|
@ -51,10 +131,24 @@ async function handleAuthForm(form, endpoint, statusId, getPayload) {
|
|||
}
|
||||
}
|
||||
|
||||
function checkToast(response) {
|
||||
const trigger = response.headers.get('HX-Trigger');
|
||||
if (trigger) {
|
||||
try {
|
||||
const data = JSON.parse(trigger);
|
||||
if (data.showToast && window.showToast) {
|
||||
showToast(data.showToast.message, data.showToast.type);
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTelegramAuth(e) {
|
||||
await handleAuthForm(
|
||||
e.target,
|
||||
'/api/v1/telegram/auth',
|
||||
'/api/v1/telegram/link',
|
||||
'telegram-auth-status',
|
||||
(fd) => ({
|
||||
bot_token: fd.get('bot_token'),
|
||||
|
|
@ -66,7 +160,7 @@ async function submitTelegramAuth(e) {
|
|||
async function submitTelegramChatId(e) {
|
||||
await handleAuthForm(
|
||||
e.target,
|
||||
'/api/v1/telegram/auth',
|
||||
'/api/v1/telegram/link',
|
||||
'telegram-chatid-status',
|
||||
(fd, form) => ({
|
||||
bot_token: form.dataset.botToken,
|
||||
|
|
@ -90,11 +184,38 @@ async function submitProtonAuth(e) {
|
|||
|
||||
let signalLinkingPoll = null;
|
||||
|
||||
document.addEventListener('htmx:beforeSwap', (e) => {
|
||||
if (!signalLinkingPoll) return;
|
||||
const t = e.detail.target;
|
||||
if (t && (t.id === 'integrations' || t.id === 'signal-integration')) {
|
||||
clearInterval(signalLinkingPoll);
|
||||
signalLinkingPoll = null;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:confirm', (e) => {
|
||||
if (!e.detail.question) return;
|
||||
if (e.detail.elt.dataset.confirming) return;
|
||||
e.preventDefault();
|
||||
e.detail.elt.dataset.confirming = '1';
|
||||
showConfirm(e.detail.elt, e.detail.question, () => {
|
||||
delete e.detail.elt.dataset.confirming;
|
||||
e.detail.issueRequest(true);
|
||||
});
|
||||
});
|
||||
|
||||
async function linkSignal(btn) {
|
||||
if (signalLinkingPoll) {
|
||||
clearInterval(signalLinkingPoll);
|
||||
signalLinkingPoll = null;
|
||||
}
|
||||
const qrContainer = document.getElementById('signal-qr-container');
|
||||
const qrCode = document.getElementById('signal-qr-code');
|
||||
const showQrError = (msg) => {
|
||||
qrContainer.innerHTML = `<p class="channel-not-configured">Error: ${msg}</p>`;
|
||||
const p = document.createElement('p');
|
||||
p.className = 'channel-not-configured';
|
||||
p.textContent = `Error: ${msg}`;
|
||||
qrContainer.replaceChildren(p);
|
||||
qrContainer.style.display = 'block';
|
||||
btn.style.display = 'inline-block';
|
||||
};
|
||||
|
|
@ -116,68 +237,78 @@ async function linkSignal(btn) {
|
|||
}
|
||||
|
||||
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;
|
||||
qrCode.src = data.qr_code;
|
||||
qrContainer.style.display = 'block';
|
||||
|
||||
let statusCheckInProgress = false;
|
||||
signalLinkingPoll = setInterval(async () => {
|
||||
if (statusCheckInProgress) return;
|
||||
|
||||
statusCheckInProgress = true;
|
||||
try {
|
||||
const statusResp = await fetch('/api/v1/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);
|
||||
const linked = document.createElement('p');
|
||||
linked.className = 'auth-status success';
|
||||
linked.textContent = 'Linked! Refreshing...';
|
||||
qrContainer.replaceChildren(linked);
|
||||
showToast('Signal linked', 'success');
|
||||
setTimeout(() => reloadIntegrations(), 1000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Status check failed:', err);
|
||||
} finally {
|
||||
statusCheckInProgress = false;
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
setTimeout(() => {
|
||||
if (signalLinkingPoll) {
|
||||
clearInterval(signalLinkingPoll);
|
||||
signalLinkingPoll = null;
|
||||
showQrError('QR code expired. Try again.');
|
||||
}
|
||||
}, 300000);
|
||||
} catch (err) {
|
||||
showQrError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTelegram(btn) {
|
||||
if (!confirm('Unlink Telegram integration?')) return;
|
||||
|
||||
btn.disabled = true;
|
||||
|
||||
showConfirm(btn, 'Unlink Telegram?', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/telegram/auth', { method: 'DELETE' });
|
||||
|
||||
const response = await fetch('/api/v1/telegram/link', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
checkToast(response);
|
||||
reloadIntegrations();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Error: ${error.error || 'Failed to unlink integration'}`);
|
||||
btn.disabled = false;
|
||||
showToast(error.error || 'Failed to unlink Telegram', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
btn.disabled = false;
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteProton(btn) {
|
||||
if (!confirm('Unlink Proton Mail integration?')) return;
|
||||
|
||||
btn.disabled = true;
|
||||
|
||||
showConfirm(btn, 'Unlink Proton Mail?', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/proton/auth', { method: 'DELETE' });
|
||||
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
checkToast(response);
|
||||
reloadIntegrations();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Error: ${error.error || 'Failed to unlink integration'}`);
|
||||
btn.disabled = false;
|
||||
showToast(error.error || 'Failed to unlink Proton', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
btn.disabled = false;
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,28 @@
|
|||
{
|
||||
"name": "Prism Admin",
|
||||
"name": "Prism",
|
||||
"short_name": "Prism",
|
||||
"description": "Notification gateway admin panel",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a1a",
|
||||
"theme_color": "#60c5ff",
|
||||
"background_color": "#141618",
|
||||
"theme_color": "#0b74b5",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.webp",
|
||||
"sizes": "32x32",
|
||||
"type": "image/webp",
|
||||
"purpose": "any maskable"
|
||||
"type": "image/webp"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
5
public/theme-init.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
(() => {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t && t !== 'system')
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
|
|
@ -23,8 +23,14 @@ window.cycleTheme = () => {
|
|||
function updateButtonText(theme) {
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (btn) {
|
||||
const labels = {
|
||||
system: 'Toggle theme (system)',
|
||||
light: 'Toggle theme (light)',
|
||||
dark: 'Toggle theme (dark)',
|
||||
};
|
||||
btn.textContent =
|
||||
theme === 'system' ? '🌓' : theme === 'light' ? '☀️' : '🌙';
|
||||
btn.setAttribute('aria-label', labels[theme] || 'Toggle theme');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
59
public/toast.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
toast.setAttribute('tabindex', '0');
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
let timeoutId;
|
||||
const dismiss = () => {
|
||||
toast.classList.add('hiding');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
};
|
||||
|
||||
const startTimer = () => {
|
||||
timeoutId = setTimeout(dismiss, 3000);
|
||||
};
|
||||
const pauseTimer = () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
toast.addEventListener('mouseenter', pauseTimer);
|
||||
toast.addEventListener('mouseleave', startTimer);
|
||||
toast.addEventListener('focusin', pauseTimer);
|
||||
toast.addEventListener('focusout', startTimer);
|
||||
|
||||
startTimer();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const toasts = document.querySelectorAll('.toast:not(.hiding)');
|
||||
toasts.forEach((t) => {
|
||||
t.classList.add('hiding');
|
||||
setTimeout(() => t.remove(), 300);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterRequest', (event) => {
|
||||
const xhr = event.detail.xhr;
|
||||
const triggerHeader = xhr.getResponseHeader('HX-Trigger');
|
||||
|
||||
if (triggerHeader) {
|
||||
try {
|
||||
const trigger = JSON.parse(triggerHeader);
|
||||
if (trigger.showToast) {
|
||||
showToast(trigger.showToast.message, trigger.showToast.type);
|
||||
}
|
||||
} catch (_e) {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
|
|
@ -22,11 +23,11 @@ func Load() (*Config, error) {
|
|||
APIKey: os.Getenv("API_KEY"),
|
||||
Port: getEnvInt("PORT", 8080),
|
||||
VerboseLogging: getEnvBool("VERBOSE_LOGGING", false),
|
||||
RateLimit: getEnvInt("RATE_LIMIT", 100),
|
||||
RateLimit: getEnvInt("RATE_LIMIT", 20),
|
||||
StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"),
|
||||
EnableSignal: getEnvBool("ENABLE_SIGNAL", false),
|
||||
EnableTelegram: getEnvBool("ENABLE_TELEGRAM", false),
|
||||
EnableProton: getEnvBool("ENABLE_PROTON", false),
|
||||
EnableSignal: getEnvBool("ENABLE_SIGNAL", true),
|
||||
EnableTelegram: getEnvBool("ENABLE_TELEGRAM", true),
|
||||
EnableProton: getEnvBool("ENABLE_PROTON", true),
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
|
|
@ -40,6 +41,11 @@ func (c *Config) Validate() error {
|
|||
if c.APIKey == "" {
|
||||
return fmt.Errorf("API_KEY environment variable is required")
|
||||
}
|
||||
for _, r := range c.APIKey {
|
||||
if r > unicode.MaxASCII {
|
||||
return fmt.Errorf("API_KEY must contain only ASCII characters")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +67,9 @@ func getEnvInt(key string, defaultValue int) int {
|
|||
|
||||
func getEnvBool(key string, defaultValue bool) bool {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value == "true" || value == "1"
|
||||
if b, err := strconv.ParseBool(value); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,10 +73,10 @@ func NewStoreWithLogger(db *sql.DB, masterPassword string, logger *slog.Logger)
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if err := store.CheckIntegrity(); err != nil {
|
||||
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 {
|
||||
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")
|
||||
|
|
@ -88,16 +88,15 @@ func NewStoreWithLogger(db *sql.DB, masterPassword string, logger *slog.Logger)
|
|||
}
|
||||
|
||||
func (s *Store) createTable() error {
|
||||
query := `
|
||||
_, err := s.db.Exec(`
|
||||
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)
|
||||
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
|
||||
)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -151,109 +150,72 @@ func (s *Store) decrypt(ciphertext []byte) ([]byte, error) {
|
|||
}
|
||||
|
||||
func (s *Store) SaveProton(creds *ProtonCredentials) error {
|
||||
return s.saveCredentials(IntegrationProton, creds)
|
||||
return s.save(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
|
||||
return &creds, s.load(IntegrationProton, &creds)
|
||||
}
|
||||
|
||||
func (s *Store) SaveTelegram(creds *TelegramCredentials) error {
|
||||
return s.saveCredentials(IntegrationTelegram, creds)
|
||||
return s.save(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
|
||||
return &creds, s.load(IntegrationTelegram, &creds)
|
||||
}
|
||||
|
||||
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 {
|
||||
func (s *Store) save(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 := `
|
||||
_, err = s.db.Exec(`
|
||||
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)
|
||||
`, 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
|
||||
`
|
||||
func (s *Store) load(integrationType IntegrationType, dest interface{}) error {
|
||||
var encrypted []byte
|
||||
err := s.db.QueryRow(query, string(integrationType)).Scan(&encrypted)
|
||||
err := s.db.QueryRow(`
|
||||
SELECT credentials_encrypted FROM integration_credentials
|
||||
WHERE integration_type = ? AND enabled = 1
|
||||
`, 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))
|
||||
_, err := s.db.Exec(`DELETE FROM integration_credentials WHERE integration_type = ?`, 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)
|
||||
err := s.db.QueryRow(`SELECT enabled FROM integration_credentials WHERE integration_type = ?`, string(integrationType)).Scan(&enabled)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
|
|
@ -263,15 +225,13 @@ func (s *Store) IsEnabled(integrationType IntegrationType) (bool, error) {
|
|||
return enabled, nil
|
||||
}
|
||||
|
||||
func (s *Store) ClearAll() error {
|
||||
query := `DELETE FROM integration_credentials`
|
||||
_, err := s.db.Exec(query)
|
||||
func (s *Store) clearAll() error {
|
||||
_, err := s.db.Exec(`DELETE FROM integration_credentials`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CheckIntegrity() error {
|
||||
query := `SELECT integration_type, credentials_encrypted FROM integration_credentials`
|
||||
rows, err := s.db.Query(query)
|
||||
func (s *Store) checkIntegrity() error {
|
||||
rows, err := s.db.Query(`SELECT integration_type, credentials_encrypted FROM integration_credentials`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
48
service/delivery/enrich.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package delivery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var phoneRegex = regexp.MustCompile(`(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}`)
|
||||
var emailRegex = regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`)
|
||||
|
||||
func enrichActions(notif Notification) Notification {
|
||||
if len(notif.Actions) > 0 {
|
||||
return notif
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, num := range phoneRegex.FindAllString(notif.Message, -1) {
|
||||
if seen[num] {
|
||||
continue
|
||||
}
|
||||
seen[num] = true
|
||||
notif.Actions = append(notif.Actions, Action{
|
||||
ID: fmt.Sprintf("call-%s", num),
|
||||
Label: fmt.Sprintf("Call %s", num),
|
||||
Endpoint: fmt.Sprintf("tel:%s", num),
|
||||
})
|
||||
}
|
||||
|
||||
for _, addr := range emailRegex.FindAllString(notif.Message, -1) {
|
||||
if seen[addr] {
|
||||
continue
|
||||
}
|
||||
seen[addr] = true
|
||||
local := addr
|
||||
if i := strings.Index(addr, "@"); i != -1 {
|
||||
local = addr[:i]
|
||||
}
|
||||
notif.Actions = append(notif.Actions, Action{
|
||||
ID: fmt.Sprintf("email-%s", addr),
|
||||
Label: fmt.Sprintf("Email %s", local),
|
||||
Endpoint: fmt.Sprintf("mailto:%s", addr),
|
||||
})
|
||||
}
|
||||
|
||||
return notif
|
||||
}
|
||||
24
service/delivery/errors.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package delivery
|
||||
|
||||
import "errors"
|
||||
|
||||
type PermanentError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *PermanentError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *PermanentError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func NewPermanentError(err error) error {
|
||||
return &PermanentError{Err: err}
|
||||
}
|
||||
|
||||
func IsPermanent(err error) bool {
|
||||
var permErr *PermanentError
|
||||
return errors.As(err, &permErr)
|
||||
}
|
||||
17
service/delivery/notification.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package delivery
|
||||
|
||||
type Action struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Actions []Action `json:"actions,omitempty"`
|
||||
ImageURL string `json:"image,omitempty"`
|
||||
}
|
||||
127
service/delivery/publisher.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package delivery
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
)
|
||||
|
||||
type NotificationSender interface {
|
||||
Send(sub *subscription.Subscription, notif Notification) error
|
||||
}
|
||||
|
||||
type Publisher struct {
|
||||
Store *subscription.Store
|
||||
senders map[subscription.Channel]NotificationSender
|
||||
autoSubscribeFn func(string) error
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewPublisher(store *subscription.Store, logger *slog.Logger, autoSubscribeFn func(string) error) *Publisher {
|
||||
return &Publisher{
|
||||
Store: store,
|
||||
senders: make(map[subscription.Channel]NotificationSender),
|
||||
autoSubscribeFn: autoSubscribeFn,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Publisher) RegisterSender(channel subscription.Channel, sender NotificationSender) {
|
||||
p.senders[channel] = sender
|
||||
}
|
||||
|
||||
func (p *Publisher) DeregisterSender(channel subscription.Channel) {
|
||||
delete(p.senders, channel)
|
||||
}
|
||||
|
||||
func (p *Publisher) HasChannel(channel subscription.Channel) bool {
|
||||
_, ok := p.senders[channel]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (p *Publisher) IsValidChannel(channel subscription.Channel) bool {
|
||||
return p.HasChannel(channel)
|
||||
}
|
||||
|
||||
func (p *Publisher) Publish(appName string, notif Notification) error {
|
||||
notif = enrichActions(notif)
|
||||
|
||||
app, err := p.Store.GetApp(appName)
|
||||
if err != nil {
|
||||
return util.LogError(p.logger, "Failed to get app", err, "app", appName)
|
||||
}
|
||||
|
||||
if app == nil || len(app.Subscriptions) == 0 {
|
||||
if p.autoSubscribeFn != nil {
|
||||
if err := p.autoSubscribeFn(appName); err != nil {
|
||||
p.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err)
|
||||
}
|
||||
app, err = p.Store.GetApp(appName)
|
||||
if err != nil {
|
||||
return util.LogError(p.logger, "Failed to get app", err, "app", appName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if app == nil || len(app.Subscriptions) == 0 {
|
||||
p.logger.Warn("No subscriptions found for app, dropping notification", "app", appName)
|
||||
return nil
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
successCount := 0
|
||||
|
||||
for _, sub := range app.Subscriptions {
|
||||
sender, ok := p.senders[sub.Channel]
|
||||
if !ok {
|
||||
p.logger.Debug("Skipping subscription for disabled channel", "channel", sub.Channel, "subscriptionID", sub.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := p.sendWithRetry(sender, &sub, notif, appName, sub.ID); err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
if successCount == 0 && lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Publisher) sendWithRetry(sender NotificationSender, sub *subscription.Subscription, notif Notification, appName, subscriptionID string) error {
|
||||
maxRetries := 10
|
||||
baseDelay := 500 * time.Millisecond
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
err := sender.Send(sub, notif)
|
||||
if err == nil {
|
||||
if attempt > 0 {
|
||||
p.logger.Info("Notification sent after retry", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
if IsPermanent(err) {
|
||||
p.logger.Error("Permanent error, not retrying", "app", appName, "subscriptionID", subscriptionID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if attempt < maxRetries-1 {
|
||||
delay := baseDelay * time.Duration(1<<uint(attempt))
|
||||
p.logger.Warn("Failed to send notification, retrying", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1, "error", err, "retryIn", delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
p.logger.Error("Failed to send notification after retries", "app", appName, "subscriptionID", subscriptionID, "attempts", maxRetries, "error", lastErr)
|
||||
return lastErr
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package integration
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templates embed.FS
|
||||
|
||||
func GetTemplates() embed.FS {
|
||||
return templates
|
||||
}
|
||||
|
|
@ -2,102 +2,149 @@ package integration
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"prism/service/config"
|
||||
"prism/service/delivery"
|
||||
"prism/service/integration/proton"
|
||||
"prism/service/integration/signal"
|
||||
"prism/service/integration/telegram"
|
||||
"prism/service/integration/webpush"
|
||||
"prism/service/notification"
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templates embed.FS
|
||||
|
||||
type Integration interface {
|
||||
RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger)
|
||||
RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger)
|
||||
Start(ctx context.Context, logger *slog.Logger)
|
||||
IsEnabled() bool
|
||||
Health() (linked bool, account string)
|
||||
}
|
||||
|
||||
type AutoSubscriber interface {
|
||||
AutoSubscribe(appName string, store *subscription.Store, publisher *delivery.Publisher) error
|
||||
}
|
||||
|
||||
type Integrations struct {
|
||||
Dispatcher *notification.Dispatcher
|
||||
Publisher *delivery.Publisher
|
||||
Signal *signal.Integration
|
||||
Telegram *telegram.Integration
|
||||
Proton *proton.Integration
|
||||
store *subscription.Store
|
||||
logger *slog.Logger
|
||||
integrations []Integration
|
||||
}
|
||||
|
||||
func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logger, baseTmpl *template.Template) (*Integrations, *template.Template, error) {
|
||||
fragmentTmpl := baseTmpl
|
||||
func Initialize(cfg *config.Config, store *subscription.Store, logger *slog.Logger, baseTmpl *template.Template) (*Integrations, *template.Template, error) {
|
||||
var err error
|
||||
|
||||
fragmentTmpl, err = fragmentTmpl.ParseFS(GetTemplates(), "templates/*.html")
|
||||
baseTmpl, err = baseTmpl.ParseFS(templates, "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)
|
||||
|
||||
var integrations []Integration
|
||||
|
||||
var protonIntegration *proton.Integration
|
||||
|
||||
integrations := []Integration{webpush.NewIntegration(store, logger)}
|
||||
|
||||
if cfg.EnableSignal {
|
||||
signalIntegration = signal.NewIntegration(cfg, store, logger, tmplRenderer)
|
||||
if signalSender := signalIntegration.GetSender(); signalSender != nil {
|
||||
dispatcher.RegisterSender(notification.ChannelSignal, signalSender)
|
||||
tmplRenderer, err := newIntegrationRenderer(baseTmpl, "signal", signal.Templates)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
signalIntegration = signal.NewIntegration(cfg, store, logger, tmplRenderer)
|
||||
integrations = append(integrations, signalIntegration)
|
||||
}
|
||||
|
||||
if cfg.EnableTelegram {
|
||||
telegramIntegration = telegram.NewIntegration(cfg, store, logger, tmplRenderer)
|
||||
if telegramSender := telegramIntegration.GetSender(); telegramSender != nil {
|
||||
dispatcher.RegisterSender(notification.ChannelTelegram, telegramSender)
|
||||
tmplRenderer, err := newIntegrationRenderer(baseTmpl, "telegram", telegram.Templates)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
telegramIntegration = telegram.NewIntegration(store, logger, tmplRenderer, cfg.APIKey)
|
||||
integrations = append(integrations, telegramIntegration)
|
||||
}
|
||||
|
||||
if cfg.EnableProton {
|
||||
protonIntegration = proton.NewIntegration(cfg, dispatcher, logger, tmplRenderer, store.GetDB(), cfg.APIKey)
|
||||
tmplRenderer, err := newIntegrationRenderer(baseTmpl, "proton", proton.Templates)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
protonIntegration = proton.NewIntegration(cfg, logger, tmplRenderer, store.DB, cfg.APIKey)
|
||||
integrations = append(integrations, protonIntegration)
|
||||
}
|
||||
|
||||
dispatcher.RegisterSender(notification.ChannelWebPush, webpush.NewSender(logger))
|
||||
integrations = append(integrations, webpush.NewIntegration(store, logger))
|
||||
|
||||
return &Integrations{
|
||||
Dispatcher: dispatcher,
|
||||
i := &Integrations{
|
||||
Signal: signalIntegration,
|
||||
Telegram: telegramIntegration,
|
||||
Proton: protonIntegration,
|
||||
store: store,
|
||||
logger: logger,
|
||||
integrations: integrations,
|
||||
}, fragmentTmpl, nil
|
||||
}
|
||||
|
||||
publisher := delivery.NewPublisher(store, logger, i.autoSubscribeApp)
|
||||
publisher.RegisterSender(subscription.ChannelWebPush, webpush.NewSender(logger))
|
||||
if signalIntegration != nil && signalIntegration.Sender != nil {
|
||||
if linked, err := signalIntegration.Sender.IsLinked(); err == nil && linked {
|
||||
publisher.RegisterSender(subscription.ChannelSignal, signalIntegration.Sender)
|
||||
}
|
||||
}
|
||||
if telegramIntegration != nil && telegramIntegration.Sender != nil {
|
||||
publisher.RegisterSender(subscription.ChannelTelegram, telegramIntegration.Sender)
|
||||
}
|
||||
i.Publisher = publisher
|
||||
|
||||
if telegramIntegration != nil {
|
||||
telegramIntegration.OnLink = func() {
|
||||
client := telegramIntegration.Handlers.GetClient()
|
||||
chatID := telegramIntegration.Handlers.GetChatID()
|
||||
if client != nil && chatID != 0 {
|
||||
sender := telegram.NewSender(client, store, logger, chatID)
|
||||
telegramIntegration.Sender = sender
|
||||
i.Publisher.RegisterSender(subscription.ChannelTelegram, sender)
|
||||
}
|
||||
}
|
||||
telegramIntegration.OnUnlink = func() {
|
||||
i.Publisher.DeregisterSender(subscription.ChannelTelegram)
|
||||
if err := i.store.DeleteSubscriptionsByChannel(subscription.ChannelTelegram); err != nil {
|
||||
i.logger.Error("Failed to delete Telegram subscriptions on unlink", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if protonIntegration != nil {
|
||||
protonIntegration.Publisher = publisher
|
||||
}
|
||||
|
||||
return i, baseTmpl, nil
|
||||
}
|
||||
|
||||
func (i *Integrations) Start(ctx context.Context, cfg *config.Config, logger *slog.Logger) {
|
||||
func newIntegrationRenderer(base *template.Template, name string, templates fs.FS) (*util.TemplateRenderer, error) {
|
||||
integrationTmpl, err := base.Clone()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to clone base templates for %s: %w", name, err)
|
||||
}
|
||||
integrationTmpl, err = integrationTmpl.ParseFS(templates, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse %s templates: %w", name, err)
|
||||
}
|
||||
|
||||
return util.NewTemplateRenderer(integrationTmpl), nil
|
||||
}
|
||||
|
||||
func (i *Integrations) Start(ctx context.Context, logger *slog.Logger) {
|
||||
for _, integration := range i.integrations {
|
||||
if integration.IsEnabled() {
|
||||
integration.Start(ctx, logger)
|
||||
|
|
@ -105,10 +152,54 @@ 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, logger *slog.Logger, authMiddleware func(string) func(http.Handler) http.Handler) {
|
||||
auth := authMiddleware(cfg.APIKey)
|
||||
db := store.GetDB()
|
||||
for _, integration := range integrations.integrations {
|
||||
integration.RegisterRoutes(router, auth, db, cfg.APIKey, logger)
|
||||
integration.RegisterRoutes(router, auth, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Integrations) autoSubscribeApp(appName string) error {
|
||||
app, err := i.store.GetApp(appName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if app != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := i.store.RegisterApp(appName); err != nil {
|
||||
return fmt.Errorf("failed to register app %q: %w", appName, err)
|
||||
}
|
||||
i.logger.Info("Registering new app and auto-configuring subscription", "app", appName)
|
||||
|
||||
for _, integration := range i.integrations {
|
||||
sub, ok := integration.(AutoSubscriber)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := sub.AutoSubscribe(appName, i.store, i.Publisher); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
i.logger.Warn("Auto-config failed", "integration", fmt.Sprintf("%T", integration), "app", appName, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("no integration available to auto-configure app %q", appName)
|
||||
}
|
||||
|
||||
func (i *Integrations) IsSignalLinked() bool {
|
||||
if i.Signal == nil {
|
||||
return false
|
||||
}
|
||||
linked, _ := i.Signal.Health()
|
||||
return linked
|
||||
}
|
||||
|
||||
func (i *Integrations) IsTelegramLinked() bool {
|
||||
if i.Telegram == nil {
|
||||
return false
|
||||
}
|
||||
linked, _ := i.Telegram.Health()
|
||||
return linked
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
func (m *Monitor) authenticateAndSetup(credStore *credentials.Store) error {
|
||||
creds, err := credStore.GetProton()
|
||||
if err != nil {
|
||||
m.logger.Debug("Proton credentials not configured", "error", err)
|
||||
m.logger.Info("Proton credentials not configured", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -19,8 +19,9 @@ func (m *Monitor) authenticateAndSetup(credStore *credentials.Store) error {
|
|||
m.logger.Info("Starting Proton Mail monitor", "email", creds.Email)
|
||||
|
||||
c := &protonmail.Client{
|
||||
RootURL: "https://mail.proton.me/api",
|
||||
AppVersion: "Other",
|
||||
RootURL: protonAPIURL,
|
||||
AppVersion: protonAppVersion,
|
||||
Debug: false,
|
||||
}
|
||||
|
||||
var auth *protonmail.Auth
|
||||
|
|
@ -89,7 +90,6 @@ func (m *Monitor) authenticateAndSetup(credStore *credentials.Store) error {
|
|||
|
||||
if creds.State != nil {
|
||||
m.eventID = creds.State.LastEventID
|
||||
m.logger.Info("Restored Proton state")
|
||||
} else {
|
||||
m.eventID = auth.EventID
|
||||
if err := m.saveState(creds); err != nil {
|
||||
|
|
@ -102,7 +102,7 @@ func (m *Monitor) authenticateAndSetup(credStore *credentials.Store) error {
|
|||
}
|
||||
|
||||
func (m *Monitor) refreshTokens(c *protonmail.Client, auth *protonmail.Auth, creds *credentials.ProtonCredentials) (*protonmail.Auth, error) {
|
||||
m.logger.Info("Refreshing Proton session tokens")
|
||||
m.logger.Debug("Refreshing Proton session tokens")
|
||||
newAuth, err := c.AuthRefresh(auth)
|
||||
if err != nil {
|
||||
m.logger.Error("Token refresh failed", "error", err)
|
||||
|
|
@ -129,7 +129,7 @@ func (m *Monitor) refreshTokens(c *protonmail.Client, auth *protonmail.Auth, cre
|
|||
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")
|
||||
m.logger.Debug("Proton tokens refreshed and saved")
|
||||
}
|
||||
|
||||
return newAuth, nil
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ type Handlers struct {
|
|||
username string
|
||||
logger *slog.Logger
|
||||
tmpl *util.TemplateRenderer
|
||||
db *sql.DB
|
||||
apiKey string
|
||||
DB *sql.DB
|
||||
APIKey string
|
||||
}
|
||||
|
||||
type ProtonContentData struct {
|
||||
|
|
@ -39,22 +39,17 @@ func NewHandlers(monitor *Monitor, username string, logger *slog.Logger, tmpl *u
|
|||
username: username,
|
||||
logger: logger,
|
||||
tmpl: tmpl,
|
||||
db: nil,
|
||||
apiKey: "",
|
||||
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 == "" {
|
||||
if h.DB == nil || h.APIKey == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
credStore, err := credentials.NewStore(h.db, h.apiKey)
|
||||
credStore, err := credentials.NewStore(h.DB, h.APIKey)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
|
@ -77,7 +72,7 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
|||
integData.Name = "Proton Mail"
|
||||
|
||||
if !hasCredentials {
|
||||
integData.StatusClass = "disconnected"
|
||||
integData.StatusClass = "unlinked"
|
||||
integData.StatusText = "Unlinked"
|
||||
integData.StatusTooltip = "Enter credentials to link"
|
||||
integData.Open = true
|
||||
|
|
@ -115,10 +110,6 @@ func (h *Handlers) IsEnabled() bool {
|
|||
return h.monitor != nil
|
||||
}
|
||||
|
||||
func (h *Handlers) GetMonitor() *Monitor {
|
||||
return h.monitor
|
||||
}
|
||||
|
||||
type markReadRequest struct {
|
||||
UID string `json:"uid"`
|
||||
}
|
||||
|
|
@ -146,7 +137,7 @@ func (h *Handlers) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
h.logger.Info("marked email as read", "uid", req.UID)
|
||||
h.logger.Debug("marked email as read", "uid", req.UID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil {
|
||||
|
|
@ -181,7 +172,42 @@ func (h *Handlers) HandleArchive(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
h.logger.Info("archived email", "uid", req.UID)
|
||||
h.logger.Debug("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)
|
||||
}
|
||||
}
|
||||
|
||||
type trashRequest struct {
|
||||
UID string `json:"uid"`
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleTrash(w http.ResponseWriter, r *http.Request) {
|
||||
var req trashRequest
|
||||
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.Trash(req.UID); err != nil {
|
||||
h.logger.Error("failed to trash email", "uid", req.UID, "error", err)
|
||||
util.JSONError(w, "failed to trash", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("trashed email", "uid", req.UID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
|
||||
"prism/service/config"
|
||||
"prism/service/credentials"
|
||||
"prism/service/notification"
|
||||
"prism/service/delivery"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
|
@ -16,39 +16,34 @@ import (
|
|||
|
||||
type Integration struct {
|
||||
cfg *config.Config
|
||||
dispatcher *notification.Dispatcher
|
||||
logger *slog.Logger
|
||||
handlers *Handlers
|
||||
tmpl *util.TemplateRenderer
|
||||
Publisher *delivery.Publisher
|
||||
Handlers *Handlers
|
||||
monitor *Monitor
|
||||
db *sql.DB
|
||||
apiKey string
|
||||
}
|
||||
|
||||
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)
|
||||
func NewIntegration(cfg *config.Config, logger *slog.Logger, tmpl *util.TemplateRenderer, db *sql.DB, apiKey string) *Integration {
|
||||
monitor := NewMonitor(cfg, logger)
|
||||
handlers := NewHandlers(monitor, "", logger, tmpl)
|
||||
return &Integration{
|
||||
cfg: cfg,
|
||||
dispatcher: dispatcher,
|
||||
logger: logger,
|
||||
handlers: handlers,
|
||||
tmpl: tmpl,
|
||||
Handlers: handlers,
|
||||
monitor: monitor,
|
||||
db: db,
|
||||
apiKey: apiKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
|
||||
credStore, err := credentials.NewStore(db, apiKey)
|
||||
func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
|
||||
credStore, err := credentials.NewStore(p.db, p.apiKey)
|
||||
if err == nil {
|
||||
if creds, err := credStore.GetProton(); err == nil {
|
||||
p.handlers.username = creds.Email
|
||||
p.Handlers.username = creds.Email
|
||||
}
|
||||
}
|
||||
|
||||
RegisterRoutes(router, p.handlers, auth, db, apiKey, logger, p)
|
||||
RegisterRoutes(router, p.Handlers, auth, p.db, p.apiKey, logger, p, p.cfg)
|
||||
}
|
||||
|
||||
func (p *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
||||
|
|
@ -60,17 +55,17 @@ func (p *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
|||
|
||||
creds, err := credStore.GetProton()
|
||||
if err != nil {
|
||||
logger.Debug("Proton credentials not configured", "error", err)
|
||||
logger.Info("Proton credentials not configured", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := p.monitor.Start(ctx, credStore); err != nil {
|
||||
if err := p.monitor.Start(ctx, credStore, p.Publisher); err != nil {
|
||||
logger.Error("Failed to start Proton monitor", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if p.handlers != nil {
|
||||
p.handlers.username = creds.Email
|
||||
if p.Handlers != nil {
|
||||
p.Handlers.username = creds.Email
|
||||
}
|
||||
|
||||
logger.Info("Proton Mail enabled", "email", creds.Email)
|
||||
|
|
@ -80,6 +75,10 @@ func (p *Integration) IsEnabled() bool {
|
|||
return p.cfg.EnableProton
|
||||
}
|
||||
|
||||
func (p *Integration) GetHandlers() *Handlers {
|
||||
return p.handlers
|
||||
func (p *Integration) Health() (bool, string) {
|
||||
if p.Handlers == nil {
|
||||
return false, ""
|
||||
}
|
||||
email, ok := p.Handlers.LoadFreshCredentials()
|
||||
return ok, email
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ package proton
|
|||
import (
|
||||
"time"
|
||||
|
||||
"prism/service/notification"
|
||||
"prism/service/delivery"
|
||||
"prism/service/subscription"
|
||||
|
||||
"github.com/emersion/hydroxide/protonmail"
|
||||
)
|
||||
|
|
@ -75,11 +76,11 @@ func (m *Monitor) sendNotification(msg *protonmail.Message) {
|
|||
subject = "(No subject)"
|
||||
}
|
||||
|
||||
notif := notification.Notification{
|
||||
notif := delivery.Notification{
|
||||
Title: from,
|
||||
Message: subject,
|
||||
Tag: "proton-" + msg.ID,
|
||||
Actions: []notification.Action{
|
||||
Actions: []delivery.Action{
|
||||
{
|
||||
ID: "archive",
|
||||
Label: "Archive",
|
||||
|
|
@ -89,9 +90,18 @@ func (m *Monitor) sendNotification(msg *protonmail.Message) {
|
|||
"uid": msg.ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "trash",
|
||||
Label: "Trash",
|
||||
Endpoint: "/api/v1/proton/trash",
|
||||
Method: "POST",
|
||||
Data: map[string]any{
|
||||
"uid": msg.ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "mark-read",
|
||||
Label: "Mark as Read",
|
||||
Label: "Mark read",
|
||||
Endpoint: "/api/v1/proton/mark-read",
|
||||
Method: "POST",
|
||||
Data: map[string]any{
|
||||
|
|
@ -101,7 +111,7 @@ func (m *Monitor) sendNotification(msg *protonmail.Message) {
|
|||
},
|
||||
}
|
||||
|
||||
if err := m.dispatcher.Send(prismTopic, notif); err != nil {
|
||||
if err := m.dispatcher.Publish(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)
|
||||
|
|
@ -109,22 +119,30 @@ func (m *Monitor) sendNotification(msg *protonmail.Message) {
|
|||
}
|
||||
|
||||
func (m *Monitor) clearNotification(msgID string) {
|
||||
mapping, err := m.dispatcher.GetStore().GetApp(prismTopic)
|
||||
if err != nil || mapping == nil {
|
||||
app, err := m.dispatcher.Store.GetApp(prismTopic)
|
||||
if err != nil || app == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if mapping.Channel != notification.ChannelWebPush {
|
||||
hasWebPush := false
|
||||
for _, sub := range app.Subscriptions {
|
||||
if sub.Channel == subscription.ChannelWebPush {
|
||||
hasWebPush = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasWebPush {
|
||||
return
|
||||
}
|
||||
|
||||
notif := notification.Notification{
|
||||
notif := delivery.Notification{
|
||||
Tag: "proton-" + msgID,
|
||||
Title: "",
|
||||
Message: "",
|
||||
}
|
||||
|
||||
if err := m.dispatcher.Send(prismTopic, notif); err != nil {
|
||||
if err := m.dispatcher.Publish(prismTopic, notif); err != nil {
|
||||
m.logger.Error("Failed to clear notification", "error", err, "msgID", msgID)
|
||||
} else {
|
||||
m.logger.Debug("Cleared notification", "msgID", msgID)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
|
||||
"prism/service/config"
|
||||
"prism/service/credentials"
|
||||
"prism/service/notification"
|
||||
"prism/service/delivery"
|
||||
|
||||
"github.com/emersion/hydroxide/protonmail"
|
||||
)
|
||||
|
|
@ -16,11 +16,13 @@ import (
|
|||
const (
|
||||
pollInterval = 30 * time.Second
|
||||
prismTopic = "Proton Mail"
|
||||
protonAPIURL = "https://mail.proton.me/api"
|
||||
protonAppVersion = "Other"
|
||||
)
|
||||
|
||||
type Monitor struct {
|
||||
cfg *config.Config
|
||||
dispatcher *notification.Dispatcher
|
||||
dispatcher *delivery.Publisher
|
||||
logger *slog.Logger
|
||||
credStore *credentials.Store
|
||||
client *protonmail.Client
|
||||
|
|
@ -29,17 +31,28 @@ type Monitor struct {
|
|||
startTime time.Time
|
||||
consecutiveErrs int
|
||||
lastConnected time.Time
|
||||
cancelPoll context.CancelFunc
|
||||
}
|
||||
|
||||
func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Monitor {
|
||||
func NewMonitor(cfg *config.Config, logger *slog.Logger) *Monitor {
|
||||
return &Monitor{
|
||||
cfg: cfg,
|
||||
dispatcher: dispatcher,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) Start(ctx context.Context, credStore *credentials.Store) error {
|
||||
func (m *Monitor) Stop() {
|
||||
if m.cancelPoll != nil {
|
||||
m.cancelPoll()
|
||||
m.cancelPoll = nil
|
||||
}
|
||||
m.client = nil
|
||||
}
|
||||
|
||||
func (m *Monitor) Start(ctx context.Context, credStore *credentials.Store, publisher *delivery.Publisher) error {
|
||||
m.Stop()
|
||||
|
||||
m.dispatcher = publisher
|
||||
if err := m.authenticateAndSetup(credStore); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -52,7 +65,9 @@ func (m *Monitor) Start(ctx context.Context, credStore *credentials.Store) error
|
|||
m.lastConnected = time.Now()
|
||||
m.unseenMessageIDs = make(map[string]time.Time)
|
||||
|
||||
go m.pollEvents(ctx)
|
||||
pollCtx, cancel := context.WithCancel(ctx)
|
||||
m.cancelPoll = cancel
|
||||
go m.pollEvents(pollCtx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -165,3 +180,10 @@ func (m *Monitor) Archive(msgID string) error {
|
|||
}
|
||||
return m.client.UnlabelMessages(protonmail.LabelInbox, []string{msgID})
|
||||
}
|
||||
|
||||
func (m *Monitor) Trash(msgID string) error {
|
||||
if m.client == nil {
|
||||
return nil
|
||||
}
|
||||
return m.client.DeleteMessages([]string{msgID})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import (
|
|||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"prism/service/config"
|
||||
"prism/service/credentials"
|
||||
"prism/service/notification"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/emersion/hydroxide/protonmail"
|
||||
|
|
@ -17,18 +20,14 @@ import (
|
|||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templates embed.FS
|
||||
|
||||
func GetTemplates() embed.FS {
|
||||
return templates
|
||||
}
|
||||
var Templates embed.FS
|
||||
|
||||
type authHandler struct {
|
||||
db *sql.DB
|
||||
apiKey string
|
||||
logger *slog.Logger
|
||||
integration *Integration
|
||||
dispatcher *notification.Dispatcher
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
type protonAuthRequest struct {
|
||||
|
|
@ -50,8 +49,9 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
c := &protonmail.Client{
|
||||
RootURL: "https://mail.proton.me/api",
|
||||
AppVersion: "Other",
|
||||
RootURL: protonAPIURL,
|
||||
AppVersion: protonAppVersion,
|
||||
Debug: false,
|
||||
}
|
||||
authInfo, err := c.AuthInfo(req.Email)
|
||||
if err != nil {
|
||||
|
|
@ -82,7 +82,16 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
|||
util.JSONError(w, "Invalid 2FA code. Please try again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Pre-2FA bearer is invalid for /keys/salts; refresh returns post-2FA tokens (hydroxide
|
||||
// does not apply them to the Client, so we use auth below for the salts request).
|
||||
auth.Scope = scope
|
||||
refreshed, err := c.AuthRefresh(auth)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to refresh session after 2FA", "error", err)
|
||||
util.JSONError(w, "Failed to complete authentication", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth = refreshed
|
||||
}
|
||||
|
||||
credStore, err := credentials.NewStore(h.db, h.apiKey)
|
||||
|
|
@ -92,7 +101,7 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
keySalts, err := c.ListKeySalts()
|
||||
keySalts, err := listKeySaltsWithAuth(protonAPIURL, protonAppVersion, auth)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get key salts", "error", err)
|
||||
util.JSONError(w, "Failed to complete authentication", http.StatusInternalServerError)
|
||||
|
|
@ -115,27 +124,20 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
|||
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 {
|
||||
ctx := context.Background()
|
||||
if err := h.integration.monitor.Start(ctx, credStore); err != nil {
|
||||
if err := h.integration.monitor.Start(ctx, credStore, h.integration.Publisher); err != nil {
|
||||
h.logger.Error("Failed to start Proton monitor after auth", "error", err)
|
||||
util.JSONError(w, "Authentication failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.logger.Info("Proton monitor started")
|
||||
if h.integration.handlers != nil {
|
||||
h.integration.handlers.username = req.Email
|
||||
if h.integration.Handlers != nil {
|
||||
h.integration.Handlers.username = req.Email
|
||||
}
|
||||
}
|
||||
|
||||
util.SetToast(w, "Proton Mail linked", "success")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
}
|
||||
|
|
@ -154,23 +156,77 @@ func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if h.integration != nil {
|
||||
h.integration.monitor.Stop()
|
||||
}
|
||||
|
||||
util.SetToast(w, "Proton Mail unlinked", "success")
|
||||
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) {
|
||||
func listKeySaltsWithAuth(rootURL, appVersion string, auth *protonmail.Auth) (map[string][]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, rootURL+"/keys/salts", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Pm-Appversion", appVersion)
|
||||
req.Header.Set("X-Pm-Apiversion", strconv.Itoa(protonmail.Version))
|
||||
req.Header.Set("X-Pm-Uid", auth.UID)
|
||||
req.Header.Set("Authorization", "Bearer "+auth.AccessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:101.0) Gecko/20100101 Firefox/101.0")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Code int `json:"Code"`
|
||||
Error string `json:"Error"`
|
||||
KeySalts []struct {
|
||||
ID string `json:"ID"`
|
||||
KeySalt string `json:"KeySalt"`
|
||||
} `json:"KeySalts"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Code != 1000 {
|
||||
return nil, fmt.Errorf("[%d] %s", result.Code, result.Error)
|
||||
}
|
||||
|
||||
salts := make(map[string][]byte, len(result.KeySalts))
|
||||
for _, ks := range result.KeySalts {
|
||||
if ks.KeySalt == "" {
|
||||
salts[ks.ID] = nil
|
||||
continue
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(ks.KeySalt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode key salt for %s: %w", ks.ID, err)
|
||||
}
|
||||
salts[ks.ID] = decoded
|
||||
}
|
||||
return salts, nil
|
||||
}
|
||||
|
||||
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger, integration *Integration, cfg *config.Config) {
|
||||
if handlers == nil {
|
||||
return
|
||||
}
|
||||
|
||||
handlers.SetDB(db, apiKey)
|
||||
handlers.DB = db
|
||||
handlers.APIKey = apiKey
|
||||
|
||||
authH := &authHandler{
|
||||
db: db,
|
||||
apiKey: apiKey,
|
||||
logger: logger,
|
||||
integration: integration,
|
||||
dispatcher: integration.dispatcher,
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
router.With(auth).Get("/fragment/proton", handlers.HandleFragment)
|
||||
|
|
@ -179,6 +235,7 @@ func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler)
|
|||
r.Use(auth)
|
||||
r.Post("/mark-read", handlers.HandleMarkRead)
|
||||
r.Post("/archive", handlers.HandleArchive)
|
||||
r.Post("/trash", handlers.HandleTrash)
|
||||
r.Post("/auth", authH.handleAuth)
|
||||
r.Delete("/auth", authH.handleDelete)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,15 +5,21 @@
|
|||
<form class="auth-form" data-handler="proton">
|
||||
<div class="form-group">
|
||||
<label for="proton-email">Email:</label>
|
||||
<input type="email" id="proton-email" name="email" placeholder="your-email@proton.me" required>
|
||||
<input type="email" id="proton-email" name="email" placeholder="your-email@proton.me" required autocomplete="email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="proton-password">Password:</label>
|
||||
<input type="password" id="proton-password" name="password" placeholder="Your Proton password" required>
|
||||
<div class="password-wrapper">
|
||||
<input type="password" id="proton-password" name="password" placeholder="Your Proton password" required autocomplete="current-password">
|
||||
<button type="button" class="password-toggle" data-action="toggle-password" aria-label="Show password">
|
||||
<svg class="eye-icon eye-show" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<svg class="eye-icon eye-hide" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-10-7-10-7a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 10 7 10 7a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</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}">
|
||||
<input type="text" id="proton-totp" name="totp" placeholder="123456" pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Link</button>
|
||||
<div id="proton-auth-status" class="auth-status"></div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -17,37 +19,23 @@ const (
|
|||
|
||||
type Client struct {
|
||||
ConfigPath string
|
||||
enabled bool
|
||||
}
|
||||
|
||||
func NewClient() *Client {
|
||||
configPath := filepath.Join(os.Getenv("HOME"), DefaultConfigPath)
|
||||
|
||||
enabled := false
|
||||
if _, err := exec.LookPath("signal-cli"); err == nil {
|
||||
enabled = true
|
||||
if _, err := exec.LookPath("signal-cli"); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &Client{
|
||||
ConfigPath: configPath,
|
||||
enabled: enabled,
|
||||
ConfigPath: filepath.Join(os.Getenv("HOME"), DefaultConfigPath),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) IsEnabled() bool {
|
||||
return c.enabled
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
Number string
|
||||
UUID string
|
||||
}
|
||||
|
||||
func (c *Client) exec(args ...string) ([]byte, error) {
|
||||
if !c.enabled {
|
||||
return nil, fmt.Errorf("signal-cli not found in PATH")
|
||||
}
|
||||
|
||||
baseArgs := []string{"--config", c.ConfigPath, "--output=json"}
|
||||
cmd := exec.Command("signal-cli", append(baseArgs, args...)...)
|
||||
|
||||
|
|
@ -66,7 +54,7 @@ func (c *Client) exec(args ...string) ([]byte, error) {
|
|||
}
|
||||
|
||||
func (c *Client) GetLinkedAccount() (*Account, error) {
|
||||
if c == nil || !c.enabled {
|
||||
if c == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +101,7 @@ func (c *Client) GetLinkedAccount() (*Account, error) {
|
|||
}
|
||||
|
||||
func (c *Client) CreateGroup(name string) (string, string, error) {
|
||||
if c == nil || !c.enabled {
|
||||
if c == nil {
|
||||
return "", "", fmt.Errorf("signal client not initialized")
|
||||
}
|
||||
|
||||
|
|
@ -148,7 +136,7 @@ func (c *Client) CreateGroup(name string) (string, string, error) {
|
|||
}
|
||||
|
||||
func (c *Client) SendGroupMessage(groupID, message string) error {
|
||||
if c == nil || !c.enabled {
|
||||
if c == nil {
|
||||
return fmt.Errorf("signal client not initialized")
|
||||
}
|
||||
|
||||
|
|
@ -163,3 +151,38 @@ func (c *Client) SendGroupMessage(groupID, message string) error {
|
|||
_, err = c.exec("-a", account.Number, "send", "-g", groupID, "--notify-self", "-m", message)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) SendGroupMessageWithAttachment(groupID, message, imageURL string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("signal client not initialized")
|
||||
}
|
||||
|
||||
resp, err := http.Get(imageURL) //nolint:noctx
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
tmp, err := os.CreateTemp("", "prism-signal-*.jpg")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("failed to write image: %w", err)
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
account, err := c.GetLinkedAccount()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get linked account: %w", err)
|
||||
}
|
||||
if account == nil {
|
||||
return fmt.Errorf("no linked Signal account")
|
||||
}
|
||||
|
||||
_, err = c.exec("-a", account.Number, "send", "-g", groupID, "--notify-self", "-m", message, "--attachment", tmp.Name())
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
47
service/integration/signal/groups.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"prism/service/subscription"
|
||||
)
|
||||
|
||||
type GroupCache struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewGroupCache(db *sql.DB) (*GroupCache, error) {
|
||||
c := &GroupCache{db: db}
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS signal_groups (
|
||||
appName TEXT PRIMARY KEY,
|
||||
groupId TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE
|
||||
)`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create signal_groups table: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *GroupCache) Get(appName string) (*subscription.SignalSubscription, error) {
|
||||
var groupID, account string
|
||||
err := c.db.QueryRow(`SELECT groupId, account FROM signal_groups WHERE appName = ?`, appName).Scan(&groupID, &account)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &subscription.SignalSubscription{GroupID: groupID, Account: account}, nil
|
||||
}
|
||||
|
||||
func (c *GroupCache) Save(appName string, sub *subscription.SignalSubscription) error {
|
||||
_, err := c.db.Exec(
|
||||
`INSERT INTO signal_groups (appName, groupId, account) VALUES (?, ?, ?)
|
||||
ON CONFLICT(appName) DO UPDATE SET groupId=excluded.groupId, account=excluded.account`,
|
||||
appName, sub.GroupID, sub.Account,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
|
@ -1,16 +1,21 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
qrcode "github.com/yeqown/go-qrcode/v2"
|
||||
"github.com/yeqown/go-qrcode/writer/standard"
|
||||
|
||||
"prism/service/util"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
client *Client
|
||||
Client *Client
|
||||
tmpl *util.TemplateRenderer
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
|
@ -33,7 +38,7 @@ type IntegrationData struct {
|
|||
|
||||
func NewHandlers(client *Client, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers {
|
||||
return &Handlers{
|
||||
client: client,
|
||||
Client: client,
|
||||
tmpl: tmpl,
|
||||
logger: logger,
|
||||
}
|
||||
|
|
@ -46,14 +51,14 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
|||
var integData IntegrationData
|
||||
integData.Name = "Signal"
|
||||
|
||||
if h.client == nil || !h.client.IsEnabled() {
|
||||
if h.Client == nil {
|
||||
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()
|
||||
account, err := h.Client.GetLinkedAccount()
|
||||
if err != nil {
|
||||
integData.StatusClass = "disconnected"
|
||||
integData.StatusText = "Error"
|
||||
|
|
@ -61,9 +66,9 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
|||
integData.Open = true
|
||||
contentData.Error = err.Error()
|
||||
} else if account == nil {
|
||||
integData.StatusClass = "disconnected"
|
||||
integData.StatusClass = "unlinked"
|
||||
integData.StatusText = "Unlinked"
|
||||
integData.StatusTooltip = "Click to link device with Signal"
|
||||
integData.StatusTooltip = "Click Link button below to link"
|
||||
integData.Open = true
|
||||
} else {
|
||||
integData.StatusClass = "connected"
|
||||
|
|
@ -91,16 +96,8 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
|||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
func (h *Handlers) IsEnabled() bool {
|
||||
return h.client != nil && h.client.IsEnabled()
|
||||
}
|
||||
|
||||
func (h *Handlers) GetClient() *Client {
|
||||
return h.client
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleLinkDevice(w http.ResponseWriter, r *http.Request) {
|
||||
if h.client == nil || !h.client.IsEnabled() {
|
||||
if h.Client == nil {
|
||||
util.JSONError(w, "signal-cli not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
|
@ -112,27 +109,45 @@ func (h *Handlers) HandleLinkDevice(w http.ResponseWriter, r *http.Request) {
|
|||
req.DeviceName = DefaultDeviceName
|
||||
}
|
||||
|
||||
qrCode, err := h.client.LinkDevice(req.DeviceName)
|
||||
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
|
||||
}
|
||||
|
||||
qrc, err := qrcode.NewWith(qrCode, qrcode.WithErrorCorrectionLevel(qrcode.ErrorCorrectionMedium))
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to encode QR code", "error", err)
|
||||
util.JSONError(w, "Failed to generate QR code", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w2 := standard.NewWithWriter(nopWriteCloser{&buf},
|
||||
standard.WithBuiltinImageEncoder(standard.PNG_FORMAT),
|
||||
standard.WithQRWidth(10),
|
||||
)
|
||||
if err := qrc.Save(w2); err != nil {
|
||||
h.logger.Error("Failed to render QR code", "error", err)
|
||||
util.JSONError(w, "Failed to generate QR code", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"qr_code": qrCode,
|
||||
"qr_code": dataURL,
|
||||
"status": "linking",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if h.client == nil || !h.client.IsEnabled() {
|
||||
if h.Client == nil {
|
||||
util.JSONError(w, "signal-cli not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
account, err := h.client.GetLinkedAccount()
|
||||
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)
|
||||
|
|
@ -153,3 +168,7 @@ func (h *Handlers) HandleLinkStatus(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
type nopWriteCloser struct{ *bytes.Buffer }
|
||||
|
||||
func (nopWriteCloser) Close() error { return nil }
|
||||
|
|
|
|||
|
|
@ -2,12 +2,13 @@ package signal
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"prism/service/config"
|
||||
"prism/service/notification"
|
||||
"prism/service/delivery"
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
|
@ -16,35 +17,42 @@ import (
|
|||
type Integration struct {
|
||||
cfg *config.Config
|
||||
client *Client
|
||||
handlers *Handlers
|
||||
sender *Sender
|
||||
tmpl *util.TemplateRenderer
|
||||
Handlers *Handlers
|
||||
Sender *Sender
|
||||
Groups *GroupCache
|
||||
store *subscription.Store
|
||||
logger *slog.Logger
|
||||
tmpl *util.TemplateRenderer
|
||||
}
|
||||
|
||||
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
|
||||
func NewIntegration(cfg *config.Config, store *subscription.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
|
||||
client := NewClient()
|
||||
sender := NewSender(client, store, logger)
|
||||
groups, err := NewGroupCache(store.DB)
|
||||
if err != nil {
|
||||
logger.Error("Failed to initialize Signal group cache", "error", err)
|
||||
}
|
||||
var sender *Sender
|
||||
if client != nil {
|
||||
sender = NewSender(client, groups, logger)
|
||||
}
|
||||
return &Integration{
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
sender: sender,
|
||||
tmpl: tmpl,
|
||||
Sender: sender,
|
||||
Groups: groups,
|
||||
store: store,
|
||||
logger: logger,
|
||||
tmpl: tmpl,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Integration) GetSender() *Sender {
|
||||
return s.sender
|
||||
}
|
||||
|
||||
func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
|
||||
s.handlers = RegisterRoutes(router, s.cfg, auth, s.tmpl, s.logger, s.client)
|
||||
func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
|
||||
s.Handlers = RegisterRoutes(router, s.cfg, auth, s.tmpl, logger, s.client)
|
||||
}
|
||||
|
||||
func (s *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
||||
if s.handlers != nil && s.handlers.IsEnabled() {
|
||||
client := s.handlers.GetClient()
|
||||
if s.Handlers != nil && s.Handlers.Client != nil {
|
||||
client := s.Handlers.Client
|
||||
account, _ := client.GetLinkedAccount()
|
||||
if account != nil {
|
||||
logger.Info("Signal enabled", "status", "linked", "number", FormatPhoneNumber(account.Number))
|
||||
|
|
@ -57,12 +65,58 @@ func (s *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
|||
}
|
||||
|
||||
func (s *Integration) IsEnabled() bool {
|
||||
if s.handlers == nil {
|
||||
return false
|
||||
}
|
||||
return s.handlers.IsEnabled()
|
||||
return s.Handlers != nil && s.Handlers.Client != nil
|
||||
}
|
||||
|
||||
func (s *Integration) GetHandlers() *Handlers {
|
||||
return s.handlers
|
||||
func (s *Integration) Health() (bool, string) {
|
||||
if s.Handlers == nil || s.Handlers.Client == nil {
|
||||
return false, ""
|
||||
}
|
||||
account, _ := s.Handlers.Client.GetLinkedAccount()
|
||||
if account == nil {
|
||||
return false, ""
|
||||
}
|
||||
return true, account.Number
|
||||
}
|
||||
|
||||
func (s *Integration) AutoSubscribe(appName string, store *subscription.Store, publisher *delivery.Publisher) error {
|
||||
if s.Sender == nil {
|
||||
return fmt.Errorf("signal-cli not available")
|
||||
}
|
||||
linked, err := s.Sender.IsLinked()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check Signal link status: %w", err)
|
||||
}
|
||||
if !linked {
|
||||
return fmt.Errorf("no linked Signal account")
|
||||
}
|
||||
if !publisher.HasChannel(subscription.ChannelSignal) {
|
||||
publisher.RegisterSender(subscription.ChannelSignal, s.Sender)
|
||||
}
|
||||
|
||||
var signalSub *subscription.SignalSubscription
|
||||
if cachedGroup, err := s.Groups.Get(appName); err != nil {
|
||||
s.logger.Warn("Failed to check for cached Signal group", "error", err)
|
||||
} else if cachedGroup != nil {
|
||||
s.logger.Debug("Reusing cached Signal group", "app", appName)
|
||||
signalSub = cachedGroup
|
||||
}
|
||||
|
||||
if signalSub == nil {
|
||||
if signalSub, err = s.Sender.CreateDefaultSignalSubscription(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
subID, err := store.AddSubscription(subscription.Subscription{
|
||||
AppName: appName,
|
||||
Channel: subscription.ChannelSignal,
|
||||
Signal: signalSub,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("Auto-configured Signal subscription", "app", appName, "subscriptionID", subID)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
)
|
||||
|
||||
func (c *Client) LinkDevice(deviceName string) (string, error) {
|
||||
if c == nil || !c.enabled {
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("signal-cli not found in PATH")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,7 @@ import (
|
|||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templates embed.FS
|
||||
|
||||
func GetTemplates() embed.FS {
|
||||
return templates
|
||||
}
|
||||
var Templates embed.FS
|
||||
|
||||
func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler, tmpl *util.TemplateRenderer, logger *slog.Logger, client *Client) *Handlers {
|
||||
handlers := NewHandlers(client, tmpl, logger)
|
||||
|
|
|
|||
|
|
@ -4,27 +4,67 @@ import (
|
|||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"prism/service/notification"
|
||||
"prism/service/delivery"
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
)
|
||||
|
||||
type Sender struct {
|
||||
client *Client
|
||||
store *notification.Store
|
||||
groups *GroupCache
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewSender(client *Client, store *notification.Store, logger *slog.Logger) *Sender {
|
||||
func NewSender(client *Client, groups *GroupCache, logger *slog.Logger) *Sender {
|
||||
return &Sender{
|
||||
client: client,
|
||||
store: store,
|
||||
groups: groups,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
|
||||
func (s *Sender) IsLinked() (bool, error) {
|
||||
if s.client == nil {
|
||||
return notification.NewPermanentError(fmt.Errorf("signal integration not enabled"))
|
||||
return false, nil
|
||||
}
|
||||
|
||||
account, err := s.client.GetLinkedAccount()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return account != nil, nil
|
||||
}
|
||||
|
||||
func (s *Sender) CreateDefaultSignalSubscription(appName string) (*subscription.SignalSubscription, error) {
|
||||
if s.client == nil {
|
||||
return nil, fmt.Errorf("signal integration not enabled")
|
||||
}
|
||||
|
||||
groupID, account, err := s.client.CreateGroup(appName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signalSub := &subscription.SignalSubscription{
|
||||
GroupID: groupID,
|
||||
Account: account,
|
||||
}
|
||||
|
||||
if err := s.groups.Save(appName, signalSub); err != nil {
|
||||
s.logger.Warn("Failed to cache Signal group", "error", err)
|
||||
}
|
||||
|
||||
return signalSub, nil
|
||||
}
|
||||
|
||||
func (s *Sender) Send(sub *subscription.Subscription, notif delivery.Notification) error {
|
||||
if s.client == nil {
|
||||
return delivery.NewPermanentError(fmt.Errorf("signal integration not enabled"))
|
||||
}
|
||||
|
||||
if sub.Signal == nil {
|
||||
return delivery.NewPermanentError(fmt.Errorf("no signal subscription data"))
|
||||
}
|
||||
|
||||
account, err := s.client.GetLinkedAccount()
|
||||
|
|
@ -33,42 +73,39 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
|
|||
}
|
||||
if account == nil {
|
||||
s.logger.Error("No linked Signal account found")
|
||||
return notification.NewPermanentError(fmt.Errorf("no linked Signal account"))
|
||||
return delivery.NewPermanentError(fmt.Errorf("no linked Signal account"))
|
||||
}
|
||||
|
||||
var signalGroupID string
|
||||
needsNewGroup := mapping.Signal == nil || mapping.Signal.GroupID == ""
|
||||
needsNewGroup := sub.Signal.GroupID == ""
|
||||
|
||||
if !needsNewGroup && mapping.Signal.Account != account.Number {
|
||||
if !needsNewGroup && sub.Signal.Account != account.Number {
|
||||
s.logger.Info("Signal account changed, recreating group",
|
||||
"app", mapping.AppName,
|
||||
"oldAccount", mapping.Signal.Account,
|
||||
"app", sub.AppName,
|
||||
"oldAccount", sub.Signal.Account,
|
||||
"newAccount", account.Number)
|
||||
needsNewGroup = true
|
||||
}
|
||||
|
||||
if needsNewGroup {
|
||||
newGroupID, accountNumber, err := s.client.CreateGroup(mapping.AppName)
|
||||
if err != nil {
|
||||
return util.LogError(s.logger, "Failed to create group", err, "app", mapping.AppName)
|
||||
return delivery.NewPermanentError(fmt.Errorf("signal group not configured for subscription %s", sub.ID))
|
||||
}
|
||||
signalGroupID = newGroupID
|
||||
|
||||
if err := s.store.UpdateSignal(mapping.AppName, ¬ification.SignalSubscription{
|
||||
GroupID: signalGroupID,
|
||||
Account: accountNumber,
|
||||
}); err != nil {
|
||||
return util.LogError(s.logger, "Failed to persist signal group", err)
|
||||
}
|
||||
} else {
|
||||
signalGroupID = mapping.Signal.GroupID
|
||||
}
|
||||
signalGroupID = sub.Signal.GroupID
|
||||
|
||||
message := notif.Message
|
||||
if notif.Title != "" {
|
||||
message = fmt.Sprintf("%s\n\n%s", notif.Title, notif.Message)
|
||||
}
|
||||
|
||||
if notif.ImageURL != "" {
|
||||
if err := s.client.SendGroupMessageWithAttachment(signalGroupID, message, notif.ImageURL); err != nil {
|
||||
s.logger.Error("Failed to send group message with attachment", "groupID", signalGroupID, "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.client.SendGroupMessage(signalGroupID, message); err != nil {
|
||||
s.logger.Error("Failed to send group message", "groupID", signalGroupID, "error", err)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{{if .Linked}}
|
||||
<p><strong>To unlink:</strong></p>
|
||||
<ol class="link-instructions">
|
||||
<ol class="link-instructions" aria-label="Steps to unlink Signal">
|
||||
<li>Open Signal on your phone</li>
|
||||
<li>Go to Settings → Linked Devices</li>
|
||||
<li>Find and remove this device</li>
|
||||
|
|
@ -12,14 +12,14 @@
|
|||
<button class="btn-primary" data-action="link-signal">Link</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" aria-label="Steps to link Signal">
|
||||
<li>Open Signal on your phone</li>
|
||||
<li>Go to Settings → Linked Devices</li>
|
||||
<li>Tap "+" or "Link New Device"</li>
|
||||
<li>Scan the QR code below</li>
|
||||
</ol>
|
||||
<div class="qr-code-wrapper">
|
||||
<img id="signal-qr-code" alt="Signal QR Code">
|
||||
<img id="signal-qr-code" alt="QR code to scan with Signal and link this device" width="400" height="400">
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ package telegram
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
"github.com/mymmrac/telego/telegoutil"
|
||||
|
|
@ -46,6 +49,48 @@ func (c *Client) SendMessage(chatID int64, text string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func (c *Client) SendPhoto(chatID int64, imageURL, caption string) error {
|
||||
if c == nil || c.bot == nil {
|
||||
return fmt.Errorf("telegram client not initialized")
|
||||
}
|
||||
|
||||
resp, err := http.Get(imageURL) //nolint:noctx
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
tmp, err := os.CreateTemp("", "prism-telegram-*.jpg")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("failed to write image: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Open(tmp.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open temp file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
params := &telego.SendPhotoParams{
|
||||
ChatID: telegoutil.ID(chatID),
|
||||
Photo: telego.InputFile{File: f},
|
||||
Caption: caption,
|
||||
ParseMode: telego.ModeHTML,
|
||||
}
|
||||
|
||||
_, err = c.bot.SendPhoto(context.Background(), params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) IsAvailable() bool {
|
||||
if c == nil || c.bot == nil {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ type Handlers struct {
|
|||
chatID int64
|
||||
tmpl *util.TemplateRenderer
|
||||
logger *slog.Logger
|
||||
db *sql.DB
|
||||
apiKey string
|
||||
DB *sql.DB
|
||||
APIKey string
|
||||
}
|
||||
|
||||
type TelegramContentData struct {
|
||||
|
|
@ -41,22 +41,17 @@ func NewHandlers(client *Client, chatID int64, tmpl *util.TemplateRenderer, logg
|
|||
chatID: chatID,
|
||||
tmpl: tmpl,
|
||||
logger: logger,
|
||||
db: nil,
|
||||
apiKey: "",
|
||||
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 == "" {
|
||||
if h.DB == nil || h.APIKey == "" {
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
credStore, err := credentials.NewStore(h.db, h.apiKey)
|
||||
credStore, err := credentials.NewStore(h.DB, h.APIKey)
|
||||
if err != nil {
|
||||
return nil, 0, false
|
||||
}
|
||||
|
|
@ -94,7 +89,7 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
|||
integData.Name = "Telegram"
|
||||
|
||||
if client == nil {
|
||||
integData.StatusClass = "disconnected"
|
||||
integData.StatusClass = "unlinked"
|
||||
integData.StatusText = "Unlinked"
|
||||
integData.StatusTooltip = "Enter bot token to link"
|
||||
integData.Open = true
|
||||
|
|
@ -108,7 +103,7 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
|||
integData.Open = true
|
||||
contentData.Error = err.Error()
|
||||
} else if chatID == 0 {
|
||||
integData.StatusClass = "disconnected"
|
||||
integData.StatusClass = "unlinked"
|
||||
integData.StatusText = "Needs Chat ID"
|
||||
integData.StatusTooltip = "@" + bot.Username
|
||||
integData.Open = true
|
||||
|
|
@ -137,10 +132,6 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
|||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
func (h *Handlers) IsEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handlers) GetClient() *Client {
|
||||
client, _, _ := h.loadFreshCredentials()
|
||||
return client
|
||||
|
|
|
|||
|
|
@ -3,31 +3,35 @@ package telegram
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"prism/service/config"
|
||||
"prism/service/credentials"
|
||||
"prism/service/notification"
|
||||
"prism/service/delivery"
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Integration struct {
|
||||
cfg *config.Config
|
||||
handlers *Handlers
|
||||
sender *Sender
|
||||
Handlers *Handlers
|
||||
Sender *Sender
|
||||
OnUnlink func()
|
||||
OnLink func()
|
||||
db *sql.DB
|
||||
apiKey string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
|
||||
func NewIntegration(store *subscription.Store, logger *slog.Logger, tmpl *util.TemplateRenderer, apiKey string) *Integration {
|
||||
var client *Client
|
||||
var chatID int64
|
||||
var sender *Sender
|
||||
|
||||
credStore, err := credentials.NewStoreWithLogger(store.GetDB(), cfg.APIKey, logger)
|
||||
credStore, err := credentials.NewStoreWithLogger(store.DB, apiKey, logger)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to initialize credentials store for Telegram", "error", err)
|
||||
} else {
|
||||
|
|
@ -45,34 +49,27 @@ func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.
|
|||
}
|
||||
}
|
||||
|
||||
if client != nil {
|
||||
if client != nil && chatID != 0 {
|
||||
sender = NewSender(client, store, logger, chatID)
|
||||
}
|
||||
|
||||
handlers := NewHandlers(client, chatID, tmpl, logger)
|
||||
|
||||
return &Integration{
|
||||
cfg: cfg,
|
||||
handlers: handlers,
|
||||
sender: sender,
|
||||
Handlers: handlers,
|
||||
Sender: sender,
|
||||
db: store.DB,
|
||||
apiKey: apiKey,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Integration) GetSender() *Sender {
|
||||
return t.sender
|
||||
}
|
||||
|
||||
func (t *Integration) GetHandlers() *Handlers {
|
||||
return t.handlers
|
||||
}
|
||||
|
||||
func (t *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
|
||||
RegisterRoutes(router, t.handlers, auth, db, apiKey, logger)
|
||||
func (t *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
|
||||
RegisterRoutes(router, t.Handlers, auth, t.db, t.apiKey, logger, t.OnLink, t.OnUnlink)
|
||||
}
|
||||
|
||||
func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
||||
client := t.handlers.GetClient()
|
||||
client := t.Handlers.GetClient()
|
||||
if client == nil {
|
||||
logger.Info("Telegram not configured", "action", "visit admin UI to configure")
|
||||
return
|
||||
|
|
@ -84,7 +81,7 @@ func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
|||
return
|
||||
}
|
||||
|
||||
chatID := t.handlers.chatID
|
||||
chatID := t.Handlers.chatID
|
||||
if chatID == 0 {
|
||||
logger.Info("Telegram bot connected", "bot", bot.Username, "status", "needs_chat_id")
|
||||
} else {
|
||||
|
|
@ -93,5 +90,60 @@ func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
|||
}
|
||||
|
||||
func (t *Integration) IsEnabled() bool {
|
||||
return t.handlers != nil && t.handlers.GetClient() != nil
|
||||
return t.Handlers != nil && t.Handlers.GetClient() != nil
|
||||
}
|
||||
|
||||
func (t *Integration) Health() (bool, string) {
|
||||
if t.Handlers == nil {
|
||||
return false, ""
|
||||
}
|
||||
client := t.Handlers.GetClient()
|
||||
if client == nil {
|
||||
return false, ""
|
||||
}
|
||||
bot, err := client.GetMe()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
return true, "@" + bot.Username
|
||||
}
|
||||
|
||||
func (t *Integration) AutoSubscribe(appName string, store *subscription.Store, publisher *delivery.Publisher) error {
|
||||
credStore, err := credentials.NewStore(t.db, t.apiKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load credentials: %w", err)
|
||||
}
|
||||
creds, err := credStore.GetTelegram()
|
||||
if err != nil || creds == nil {
|
||||
return fmt.Errorf("telegram not configured")
|
||||
}
|
||||
if creds.ChatID == "" {
|
||||
return fmt.Errorf("no chat ID configured")
|
||||
}
|
||||
chatID, err := strconv.ParseInt(creds.ChatID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid chat ID: %w", err)
|
||||
}
|
||||
|
||||
if !publisher.HasChannel(subscription.ChannelTelegram) {
|
||||
client, err := NewClient(creds.BotToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create telegram client: %w", err)
|
||||
}
|
||||
sender := NewSender(client, store, t.logger, chatID)
|
||||
t.Sender = sender
|
||||
publisher.RegisterSender(subscription.ChannelTelegram, sender)
|
||||
}
|
||||
|
||||
subID, err := store.AddSubscription(subscription.Subscription{
|
||||
AppName: appName,
|
||||
Channel: subscription.ChannelTelegram,
|
||||
Telegram: &subscription.TelegramSubscription{ChatID: fmt.Sprintf("%d", chatID)},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.logger.Info("Auto-configured Telegram subscription", "app", appName, "subscriptionID", subID, "chatID", chatID)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,25 +14,23 @@ import (
|
|||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templates embed.FS
|
||||
var Templates embed.FS
|
||||
|
||||
func GetTemplates() embed.FS {
|
||||
return templates
|
||||
}
|
||||
|
||||
type authHandler struct {
|
||||
type linkHandler struct {
|
||||
db *sql.DB
|
||||
apiKey string
|
||||
logger *slog.Logger
|
||||
onLink func()
|
||||
onUnlink func()
|
||||
}
|
||||
|
||||
type telegramAuthRequest struct {
|
||||
type telegramLinkRequest struct {
|
||||
BotToken string `json:"bot_token"`
|
||||
ChatID string `json:"chat_id"`
|
||||
}
|
||||
|
||||
func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
var req telegramAuthRequest
|
||||
func (h *linkHandler) handleLink(w http.ResponseWriter, r *http.Request) {
|
||||
var req telegramLinkRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.JSONError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
|
|
@ -59,11 +57,16 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if h.onLink != nil {
|
||||
h.onLink()
|
||||
}
|
||||
|
||||
util.SetToast(w, "Telegram linked", "success")
|
||||
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) {
|
||||
func (h *linkHandler) handleUnlink(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)
|
||||
|
|
@ -75,20 +78,23 @@ func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
h.onUnlink()
|
||||
util.SetToast(w, "Telegram unlinked", "success")
|
||||
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) {
|
||||
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger, onLink func(), onUnlink func()) {
|
||||
if handlers == nil {
|
||||
return
|
||||
}
|
||||
|
||||
handlers.SetDB(db, apiKey)
|
||||
handlers.DB = db
|
||||
handlers.APIKey = apiKey
|
||||
|
||||
authH := &authHandler{db: db, apiKey: apiKey, logger: logger}
|
||||
linkH := &linkHandler{db: db, apiKey: apiKey, logger: logger, onLink: onLink, onUnlink: onUnlink}
|
||||
|
||||
router.With(auth).Get("/fragment/telegram", handlers.HandleFragment)
|
||||
router.With(auth).Post("/api/v1/telegram/auth", authH.handleAuth)
|
||||
router.With(auth).Delete("/api/v1/telegram/auth", authH.handleDelete)
|
||||
router.With(auth).Post("/api/v1/telegram/link", linkH.handleLink)
|
||||
router.With(auth).Delete("/api/v1/telegram/link", linkH.handleUnlink)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,18 +3,24 @@ package telegram
|
|||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"prism/service/notification"
|
||||
"prism/service/delivery"
|
||||
"prism/service/subscription"
|
||||
)
|
||||
|
||||
func parseInt64(s string) (int64, error) {
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
|
||||
type Sender struct {
|
||||
client *Client
|
||||
store *notification.Store
|
||||
store *subscription.Store
|
||||
logger *slog.Logger
|
||||
DefaultChatID int64
|
||||
}
|
||||
|
||||
func NewSender(client *Client, store *notification.Store, logger *slog.Logger, defaultChatID int64) *Sender {
|
||||
func NewSender(client *Client, store *subscription.Store, logger *slog.Logger, defaultChatID int64) *Sender {
|
||||
return &Sender{
|
||||
client: client,
|
||||
store: store,
|
||||
|
|
@ -23,13 +29,33 @@ func NewSender(client *Client, store *notification.Store, logger *slog.Logger, d
|
|||
}
|
||||
}
|
||||
|
||||
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
|
||||
func (s *Sender) GetChatID() int64 {
|
||||
return s.DefaultChatID
|
||||
}
|
||||
|
||||
func (s *Sender) IsLinked() (bool, error) {
|
||||
if s.client == nil {
|
||||
return notification.NewPermanentError(fmt.Errorf("telegram integration not enabled"))
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if !s.client.IsAvailable() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if s.DefaultChatID == 0 {
|
||||
return notification.NewPermanentError(fmt.Errorf("no telegram chat configured (set TELEGRAM_CHAT_ID in .env)"))
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Sender) Send(sub *subscription.Subscription, notif delivery.Notification) error {
|
||||
if s.client == nil {
|
||||
return delivery.NewPermanentError(fmt.Errorf("telegram integration not enabled"))
|
||||
}
|
||||
|
||||
if sub.Telegram == nil || sub.Telegram.ChatID == "" {
|
||||
return delivery.NewPermanentError(fmt.Errorf("no telegram chat configured for subscription"))
|
||||
}
|
||||
|
||||
message := notif.Message
|
||||
|
|
@ -37,10 +63,23 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
|
|||
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", sub.AppName, message)
|
||||
|
||||
if err := s.client.SendMessage(s.DefaultChatID, fullMessage); err != nil {
|
||||
s.logger.Error("Failed to send telegram message", "chatID", s.DefaultChatID, "error", err)
|
||||
chatID, err := parseInt64(sub.Telegram.ChatID)
|
||||
if err != nil {
|
||||
return delivery.NewPermanentError(fmt.Errorf("invalid chat ID: %w", err))
|
||||
}
|
||||
|
||||
if notif.ImageURL != "" {
|
||||
if err := s.client.SendPhoto(chatID, notif.ImageURL, fullMessage); err != nil {
|
||||
s.logger.Error("Failed to send telegram photo", "chatID", chatID, "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.client.SendMessage(chatID, fullMessage); err != nil {
|
||||
s.logger.Error("Failed to send telegram message", "chatID", chatID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
{{if .NotConfigured}}
|
||||
<p><strong>Setup Telegram Bot:</strong></p>
|
||||
<ol class="link-instructions">
|
||||
<li>Message <a href="https://t.me/BotFather" target="_blank">@BotFather</a> on Telegram</li>
|
||||
<ol class="link-instructions" aria-label="Steps to set up Telegram bot">
|
||||
<li>Message <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer">@BotFather</a> on Telegram</li>
|
||||
<li>Send <code>/newbot</code> and follow the prompts</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" rel="noopener noreferrer">@userinfobot</a> to get your Chat ID</li>
|
||||
<li>Enter both below:</li>
|
||||
</ol>
|
||||
<form class="auth-form" data-handler="telegram">
|
||||
<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 class="password-wrapper">
|
||||
<input type="password" id="telegram-bot-token" name="bot_token" placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz" required autocomplete="off">
|
||||
<button type="button" class="password-toggle" data-action="toggle-password" aria-label="Show password">
|
||||
<svg class="eye-icon eye-show" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<svg class="eye-icon eye-hide" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-10-7-10-7a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 10 7 10 7a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="telegram-chat-id">Chat ID:</label>
|
||||
|
|
|
|||
|
|
@ -1,17 +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 class="loading" role="status"><div class="spinner"></div><span class="sr-only">Loading…</span></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 class="loading" role="status"><div class="spinner"></div><span class="sr-only">Loading…</span></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 class="loading" role="status"><div class="spinner"></div><span class="sr-only">Loading…</span></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -4,20 +4,19 @@ import (
|
|||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"prism/service/notification"
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
store *notification.Store
|
||||
store *subscription.Store
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHandlers(store *notification.Store, logger *slog.Logger) *Handlers {
|
||||
func NewHandlers(store *subscription.Store, logger *slog.Logger) *Handlers {
|
||||
return &Handlers{
|
||||
store: store,
|
||||
logger: logger,
|
||||
|
|
@ -35,7 +34,7 @@ type registerRequest struct {
|
|||
func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
util.JSONError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -44,72 +43,100 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if _, err := url.Parse(req.PushEndpoint); err != nil {
|
||||
util.JSONError(w, "Invalid pushEndpoint URL", http.StatusBadRequest)
|
||||
encryptedFieldCount := 0
|
||||
if req.P256dh != nil {
|
||||
encryptedFieldCount++
|
||||
}
|
||||
if req.Auth != nil {
|
||||
encryptedFieldCount++
|
||||
}
|
||||
if req.VapidPrivateKey != nil {
|
||||
encryptedFieldCount++
|
||||
}
|
||||
if encryptedFieldCount > 0 && encryptedFieldCount < 3 {
|
||||
util.JSONError(w, "p256dh, auth, and vapidPrivateKey must all be provided together", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := h.store.GetApp(req.AppName)
|
||||
if err != nil {
|
||||
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
|
||||
if err := validatePushEndpoint(req.PushEndpoint, encryptedFieldCount == 3); err != nil {
|
||||
util.JSONError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var webPush *notification.WebPushSubscription
|
||||
var webPush *subscription.WebPushSubscription
|
||||
if req.P256dh != nil && req.Auth != nil && req.VapidPrivateKey != nil {
|
||||
webPush = ¬ification.WebPushSubscription{
|
||||
normalizedP256dh, err := normalizeP256DH(*req.P256dh)
|
||||
if err != nil {
|
||||
util.JSONError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
normalizedAuth, err := normalizeAuthSecret(*req.Auth)
|
||||
if err != nil {
|
||||
util.JSONError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
normalizedKey, err := normalizeVAPIDPrivateKey(*req.VapidPrivateKey)
|
||||
if err != nil {
|
||||
util.JSONError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
webPush = &subscription.WebPushSubscription{
|
||||
Endpoint: req.PushEndpoint,
|
||||
P256dh: *req.P256dh,
|
||||
Auth: *req.Auth,
|
||||
VapidPrivateKey: *req.VapidPrivateKey,
|
||||
P256dh: normalizedP256dh,
|
||||
Auth: normalizedAuth,
|
||||
VapidPrivateKey: normalizedKey,
|
||||
}
|
||||
} else {
|
||||
webPush = ¬ification.WebPushSubscription{
|
||||
webPush = &subscription.WebPushSubscription{
|
||||
Endpoint: req.PushEndpoint,
|
||||
}
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
if err := h.store.UpdateWebPush(req.AppName, webPush); err != nil {
|
||||
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
|
||||
sub := subscription.Subscription{
|
||||
AppName: req.AppName,
|
||||
Channel: subscription.ChannelWebPush,
|
||||
WebPush: webPush,
|
||||
}
|
||||
|
||||
subID, err := h.store.AddSubscription(sub)
|
||||
if err != nil {
|
||||
h.logger.Warn("Failed to add webpush subscription", "app", req.AppName, "error", err)
|
||||
util.LogAndError(w, h.logger, "Failed to add subscription", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
h.logger.Info("Updated webpush for existing endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint)
|
||||
} else {
|
||||
channel := notification.ChannelWebPush
|
||||
if err := h.store.Register(req.AppName, &channel, nil, webPush); err != nil {
|
||||
util.LogAndError(w, h.logger, "Failed to register webpush endpoint", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
h.logger.Info("Registered new webpush endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint)
|
||||
}
|
||||
|
||||
h.logger.Info("Added webpush subscription", "app", req.AppName, "subscriptionID", subID, "pushEndpoint", req.PushEndpoint)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
response := map[string]string{
|
||||
"appName": req.AppName,
|
||||
"channel": notification.ChannelWebPush.String(),
|
||||
"channel": subscription.ChannelWebPush.String(),
|
||||
"subscriptionId": subID,
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) {
|
||||
appName := chi.URLParam(r, "appName")
|
||||
if appName == "" {
|
||||
http.Error(w, "appName is required", http.StatusBadRequest)
|
||||
subscriptionID := chi.URLParam(r, "subscriptionId")
|
||||
if subscriptionID == "" {
|
||||
util.JSONError(w, "subscriptionId is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.ClearWebPush(appName); err != nil {
|
||||
util.LogAndError(w, h.logger, "Failed to clear webpush endpoint", http.StatusInternalServerError, err)
|
||||
if err := h.store.DeleteSubscription(subscriptionID); err != nil {
|
||||
util.LogAndError(w, h.logger, "Failed to delete subscription", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("Cleared webpush subscription", "app", appName)
|
||||
h.logger.Info("Deleted webpush subscription", "subscriptionID", subscriptionID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
response := map[string]string{
|
||||
"status": "unregistered",
|
||||
"appName": appName,
|
||||
"status": "deleted",
|
||||
"subscriptionId": subscriptionID,
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,28 +2,27 @@ package webpush
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"prism/service/notification"
|
||||
"prism/service/subscription"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Integration struct {
|
||||
store *notification.Store
|
||||
store *subscription.Store
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewIntegration(store *notification.Store, logger *slog.Logger) *Integration {
|
||||
func NewIntegration(store *subscription.Store, logger *slog.Logger) *Integration {
|
||||
return &Integration{
|
||||
store: store,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
|
||||
func (w *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
|
||||
RegisterRoutes(router, w.store, w.logger, auth)
|
||||
}
|
||||
|
||||
|
|
@ -32,3 +31,5 @@ func (w *Integration) Start(ctx context.Context, logger *slog.Logger) {}
|
|||
func (w *Integration) IsEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *Integration) Health() (bool, string) { return false, "" }
|
||||
|
|
|
|||
|
|
@ -4,17 +4,17 @@ import (
|
|||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"prism/service/notification"
|
||||
"prism/service/subscription"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func RegisterRoutes(router *chi.Mux, store *notification.Store, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) {
|
||||
func RegisterRoutes(router *chi.Mux, store *subscription.Store, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) {
|
||||
handlers := NewHandlers(store, logger)
|
||||
|
||||
router.Route("/api/v1/webpush/app", func(r chi.Router) {
|
||||
router.Route("/api/v1/webpush/subscriptions", func(r chi.Router) {
|
||||
r.Use(authMiddleware)
|
||||
r.Post("/", handlers.HandleRegister)
|
||||
r.Delete("/{appName}", handlers.HandleUnregister)
|
||||
r.Delete("/{subscriptionId}", handlers.HandleUnregister)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import (
|
|||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"prism/service/notification"
|
||||
"prism/service/delivery"
|
||||
"prism/service/subscription"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
)
|
||||
|
|
@ -17,14 +18,12 @@ type Sender struct {
|
|||
}
|
||||
|
||||
func NewSender(logger *slog.Logger) *Sender {
|
||||
return &Sender{
|
||||
logger: logger,
|
||||
}
|
||||
return &Sender{logger: logger}
|
||||
}
|
||||
|
||||
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
|
||||
if mapping.WebPush == nil {
|
||||
return notification.NewPermanentError(fmt.Errorf("no push endpoint configured for %s", mapping.AppName))
|
||||
func (s *Sender) Send(sub *subscription.Subscription, notif delivery.Notification) error {
|
||||
if sub.WebPush == nil {
|
||||
return delivery.NewPermanentError(fmt.Errorf("no push endpoint configured for subscription %s", sub.ID))
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(notif)
|
||||
|
|
@ -32,17 +31,24 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
|
|||
return fmt.Errorf("failed to marshal notification: %w", err)
|
||||
}
|
||||
|
||||
if mapping.WebPush.HasEncryption() {
|
||||
if sub.WebPush.HasEncryption() {
|
||||
vapidPublicKey, err := deriveVAPIDPublicKey(sub.WebPush.VapidPrivateKey)
|
||||
if err != nil {
|
||||
return delivery.NewPermanentError(fmt.Errorf("invalid webpush VAPID key for subscription %s: %w", sub.ID, err))
|
||||
}
|
||||
|
||||
subscription := &webpush.Subscription{
|
||||
Endpoint: mapping.WebPush.Endpoint,
|
||||
Endpoint: sub.WebPush.Endpoint,
|
||||
Keys: webpush.Keys{
|
||||
P256dh: mapping.WebPush.P256dh,
|
||||
Auth: mapping.WebPush.Auth,
|
||||
P256dh: sub.WebPush.P256dh,
|
||||
Auth: sub.WebPush.Auth,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{
|
||||
VAPIDPrivateKey: mapping.WebPush.VapidPrivateKey,
|
||||
Subscriber: "https://github.com/lone-cloud/prism",
|
||||
VAPIDPublicKey: vapidPublicKey,
|
||||
VAPIDPrivateKey: sub.WebPush.VapidPrivateKey,
|
||||
TTL: 86400,
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -54,9 +60,9 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
|
|||
return fmt.Errorf("webpush returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
s.logger.Debug("Sent encrypted webpush notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint)
|
||||
s.logger.Debug("Sent encrypted webpush notification", "app", sub.AppName, "url", sub.WebPush.Endpoint)
|
||||
} else {
|
||||
resp, err := http.Post(mapping.WebPush.Endpoint, "application/json", bytes.NewBuffer(payload))
|
||||
resp, err := http.Post(sub.WebPush.Endpoint, "application/json", bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send webhook: %w", err)
|
||||
}
|
||||
|
|
@ -66,7 +72,7 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
|
|||
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
s.logger.Debug("Sent plain webhook notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint)
|
||||
s.logger.Debug("Sent plain webhook notification", "app", sub.AppName, "url", sub.WebPush.Endpoint)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
101
service/integration/webpush/validation.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package webpush
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validatePushEndpoint(raw string, requireHTTPS bool) error {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u == nil || u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("invalid pushEndpoint URL")
|
||||
}
|
||||
|
||||
if u.Scheme != "https" && u.Scheme != "http" {
|
||||
return fmt.Errorf("pushEndpoint must use http or https")
|
||||
}
|
||||
|
||||
if requireHTTPS && u.Scheme != "https" {
|
||||
return fmt.Errorf("encrypted webpush endpoint must use https")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeVAPIDPrivateKey(raw string) (string, error) {
|
||||
decoded, err := decodeBase64URL(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid VAPID private key encoding")
|
||||
}
|
||||
|
||||
if _, err := ecdh.P256().NewPrivateKey(decoded); err != nil {
|
||||
return "", fmt.Errorf("invalid VAPID private key scalar")
|
||||
}
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(decoded), nil
|
||||
}
|
||||
|
||||
func deriveVAPIDPublicKey(privateKey string) (string, error) {
|
||||
normalizedPrivateKey, err := normalizeVAPIDPrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
privateBytes, err := decodeBase64URL(normalizedPrivateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid VAPID private key encoding")
|
||||
}
|
||||
|
||||
privateECKey, err := ecdh.P256().NewPrivateKey(privateBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid VAPID private key scalar")
|
||||
}
|
||||
publicBytes := privateECKey.PublicKey().Bytes()
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(publicBytes), nil
|
||||
}
|
||||
|
||||
func normalizeP256DH(raw string) (string, error) {
|
||||
decoded, err := decodeBase64URL(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid p256dh encoding")
|
||||
}
|
||||
|
||||
if len(decoded) != 65 || decoded[0] != 0x04 {
|
||||
return "", fmt.Errorf("invalid p256dh key format")
|
||||
}
|
||||
|
||||
if _, err := ecdh.P256().NewPublicKey(decoded); err != nil {
|
||||
return "", fmt.Errorf("invalid p256dh point")
|
||||
}
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(decoded), nil
|
||||
}
|
||||
|
||||
func normalizeAuthSecret(raw string) (string, error) {
|
||||
decoded, err := decodeBase64URL(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid auth encoding")
|
||||
}
|
||||
|
||||
if len(decoded) != 16 {
|
||||
return "", fmt.Errorf("invalid auth length: expected 16 bytes, got %d", len(decoded))
|
||||
}
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(decoded), nil
|
||||
}
|
||||
|
||||
func decodeBase64URL(raw string) ([]byte, error) {
|
||||
key := strings.TrimSpace(raw)
|
||||
if decoded, err := base64.RawURLEncoding.DecodeString(key); err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
decoded, err := base64.URLEncoding.DecodeString(key)
|
||||
if err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"prism/service/util"
|
||||
)
|
||||
|
||||
type NotificationSender interface {
|
||||
Send(mapping *Mapping, notif Notification) error
|
||||
}
|
||||
|
||||
type Dispatcher struct {
|
||||
store *Store
|
||||
senders map[Channel]NotificationSender
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewDispatcher(store *Store, logger *slog.Logger) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
store: store,
|
||||
senders: make(map[Channel]NotificationSender),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) RegisterSender(channel Channel, sender NotificationSender) {
|
||||
d.senders[channel] = sender
|
||||
}
|
||||
|
||||
func (d *Dispatcher) GetStore() *Store {
|
||||
return d.store
|
||||
}
|
||||
|
||||
func (d *Dispatcher) HasSignal() bool {
|
||||
_, ok := d.senders[ChannelSignal]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (d *Dispatcher) HasTelegram() bool {
|
||||
_, ok := d.senders[ChannelTelegram]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (d *Dispatcher) GetAvailableChannels() []Channel {
|
||||
var channels []Channel
|
||||
if d.HasSignal() {
|
||||
channels = append(channels, ChannelSignal)
|
||||
}
|
||||
if d.HasTelegram() {
|
||||
channels = append(channels, ChannelTelegram)
|
||||
}
|
||||
channels = append(channels, ChannelWebPush)
|
||||
return channels
|
||||
}
|
||||
|
||||
func (d *Dispatcher) IsValidChannel(channel Channel) bool {
|
||||
return channel.IsAvailable(d.HasSignal(), d.HasTelegram())
|
||||
}
|
||||
|
||||
func (d *Dispatcher) RegisterApp(appName string) error {
|
||||
availableChannels := d.GetAvailableChannels()
|
||||
return d.store.RegisterDefault(appName, availableChannels)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Send(appName string, notif Notification) error {
|
||||
mapping, err := d.store.GetApp(appName)
|
||||
if err != nil {
|
||||
return util.LogError(d.logger, "Failed to get app mapping", err, "app", appName)
|
||||
}
|
||||
|
||||
if mapping == nil {
|
||||
d.logger.Info("Registering new app", "app", appName)
|
||||
|
||||
availableChannels := d.GetAvailableChannels()
|
||||
if err := d.store.RegisterDefault(appName, availableChannels); err != nil {
|
||||
return util.LogError(d.logger, "Failed to register app", err, "app", appName)
|
||||
}
|
||||
|
||||
mapping, err = d.store.GetApp(appName)
|
||||
if err != nil {
|
||||
return util.LogError(d.logger, "Failed to get mapping after registration", err, "app", appName)
|
||||
}
|
||||
|
||||
if mapping == nil {
|
||||
return fmt.Errorf("mapping still nil after registration for app: %s", appName)
|
||||
}
|
||||
}
|
||||
|
||||
sender, ok := d.senders[mapping.Channel]
|
||||
if !ok {
|
||||
d.logger.Error("No sender registered for channel", "channel", mapping.Channel)
|
||||
return fmt.Errorf("no sender for channel: %s", mapping.Channel)
|
||||
}
|
||||
|
||||
return d.sendWithRetry(sender, mapping, notif, appName)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) sendWithRetry(sender NotificationSender, mapping *Mapping, notif Notification, appName string) error {
|
||||
maxRetries := 10
|
||||
baseDelay := 500 * time.Millisecond
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
err := sender.Send(mapping, notif)
|
||||
if err == nil {
|
||||
if attempt > 0 {
|
||||
d.logger.Info("Notification sent after retry", "app", appName, "attempt", attempt+1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
if IsPermanent(err) {
|
||||
d.logger.Error("Permanent error, not retrying", "app", appName, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if attempt < maxRetries-1 {
|
||||
delay := baseDelay * time.Duration(1<<uint(attempt))
|
||||
d.logger.Warn("Failed to send notification, retrying", "app", appName, "attempt", attempt+1, "error", err, "retryIn", delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
d.logger.Error("Failed to send notification after retries", "app", appName, "attempts", maxRetries, "error", lastErr)
|
||||
return lastErr
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
package notification
|
||||
|
||||
import "errors"
|
||||
|
||||
type PermanentError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *PermanentError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *PermanentError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func NewPermanentError(err error) error {
|
||||
return &PermanentError{Err: err}
|
||||
}
|
||||
|
||||
func IsPermanent(err error) bool {
|
||||
var permErr *PermanentError
|
||||
return errors.As(err, &permErr)
|
||||
}
|
||||
|
||||
type Action struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Method string `json:"method"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Actions []Action `json:"actions,omitempty"`
|
||||
}
|
||||
|
||||
type Channel string
|
||||
|
||||
const (
|
||||
ChannelSignal Channel = "signal"
|
||||
ChannelWebPush Channel = "webpush"
|
||||
ChannelTelegram Channel = "telegram"
|
||||
)
|
||||
|
||||
func (c Channel) String() string {
|
||||
return string(c)
|
||||
}
|
||||
|
||||
func (c Channel) Label() string {
|
||||
switch c {
|
||||
case ChannelSignal:
|
||||
return "Signal"
|
||||
case ChannelWebPush:
|
||||
return "WebPush"
|
||||
case ChannelTelegram:
|
||||
return "Telegram"
|
||||
default:
|
||||
return string(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (c Channel) IsAvailable(signalEnabled bool, telegramEnabled bool) bool {
|
||||
switch c {
|
||||
case ChannelWebPush:
|
||||
return true
|
||||
case ChannelSignal:
|
||||
return signalEnabled
|
||||
case ChannelTelegram:
|
||||
return telegramEnabled
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type WebPushSubscription struct {
|
||||
Endpoint string
|
||||
P256dh string
|
||||
Auth string
|
||||
VapidPrivateKey string
|
||||
}
|
||||
|
||||
func (w *WebPushSubscription) HasEncryption() bool {
|
||||
return w.P256dh != "" && w.Auth != "" && w.VapidPrivateKey != ""
|
||||
}
|
||||
|
||||
type SignalSubscription struct {
|
||||
GroupID string
|
||||
Account string
|
||||
}
|
||||
|
||||
type Mapping struct {
|
||||
Signal *SignalSubscription
|
||||
WebPush *WebPushSubscription
|
||||
AppName string
|
||||
Channel Channel
|
||||
}
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
package notification
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStore(dbPath string) (*Store, error) {
|
||||
dir := filepath.Dir(dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath+"?_busy_timeout=5000&cache=shared")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
store := &Store{db: db}
|
||||
if err := store.createTables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) createTables() error {
|
||||
query := `
|
||||
CREATE TABLE IF NOT EXISTS mappings (
|
||||
appName TEXT PRIMARY KEY,
|
||||
signalGroupId TEXT,
|
||||
signalAccount TEXT,
|
||||
channel TEXT NOT NULL DEFAULT 'webpush',
|
||||
pushEndpoint TEXT,
|
||||
p256dh TEXT,
|
||||
auth TEXT,
|
||||
vapidPrivateKey TEXT
|
||||
)
|
||||
`
|
||||
_, err := s.db.Exec(query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tables: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) GetDB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscription, webPush *WebPushSubscription) error {
|
||||
var signalGroupID, signalAccount *string
|
||||
if signal != nil {
|
||||
signalGroupID = &signal.GroupID
|
||||
signalAccount = &signal.Account
|
||||
}
|
||||
|
||||
var pushEndpoint, p256dh, auth, vapidPrivateKey *string
|
||||
if webPush != nil {
|
||||
pushEndpoint = &webPush.Endpoint
|
||||
p256dh = &webPush.P256dh
|
||||
auth = &webPush.Auth
|
||||
vapidPrivateKey = &webPush.VapidPrivateKey
|
||||
}
|
||||
|
||||
ch := ChannelWebPush
|
||||
if channel != nil {
|
||||
ch = *channel
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO mappings (appName, signalGroupId, signalAccount, channel, pushEndpoint, p256dh, auth, vapidPrivateKey)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(appName) DO UPDATE SET
|
||||
channel = excluded.channel,
|
||||
signalGroupId = COALESCE(excluded.signalGroupId, mappings.signalGroupId),
|
||||
signalAccount = COALESCE(excluded.signalAccount, mappings.signalAccount),
|
||||
pushEndpoint = COALESCE(excluded.pushEndpoint, mappings.pushEndpoint),
|
||||
p256dh = COALESCE(excluded.p256dh, mappings.p256dh),
|
||||
auth = COALESCE(excluded.auth, mappings.auth),
|
||||
vapidPrivateKey = COALESCE(excluded.vapidPrivateKey, mappings.vapidPrivateKey)
|
||||
`
|
||||
_, err := s.db.Exec(query, appName, signalGroupID, signalAccount, ch, pushEndpoint, p256dh, auth, vapidPrivateKey)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RegisterDefault(appName string, availableChannels []Channel) error {
|
||||
existing, _ := s.GetApp(appName)
|
||||
if existing != nil && (existing.Signal != nil || existing.WebPush != nil) {
|
||||
return fmt.Errorf("app %s already exists with subscriptions, refusing to overwrite", appName)
|
||||
}
|
||||
|
||||
var channel Channel
|
||||
if len(availableChannels) > 0 {
|
||||
channel = availableChannels[0]
|
||||
} else {
|
||||
channel = ChannelWebPush
|
||||
}
|
||||
|
||||
return s.Register(appName, &channel, nil, nil)
|
||||
}
|
||||
|
||||
func (s *Store) GetApp(appName string) (*Mapping, error) {
|
||||
query := `
|
||||
SELECT appName, signalGroupId, signalAccount, channel, pushEndpoint, p256dh, auth, vapidPrivateKey
|
||||
FROM mappings
|
||||
WHERE appName = ?
|
||||
`
|
||||
row := s.db.QueryRow(query, appName)
|
||||
|
||||
var m Mapping
|
||||
var signalGroupID, signalAccount, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString
|
||||
err := row.Scan(&m.AppName, &signalGroupID, &signalAccount, &m.Channel, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if signalGroupID.Valid && signalAccount.Valid {
|
||||
m.Signal = &SignalSubscription{
|
||||
GroupID: signalGroupID.String,
|
||||
Account: signalAccount.String,
|
||||
}
|
||||
}
|
||||
if pushEndpoint.Valid {
|
||||
m.WebPush = &WebPushSubscription{
|
||||
Endpoint: pushEndpoint.String,
|
||||
}
|
||||
if p256dh.Valid {
|
||||
m.WebPush.P256dh = p256dh.String
|
||||
}
|
||||
if auth.Valid {
|
||||
m.WebPush.Auth = auth.String
|
||||
}
|
||||
if vapidPrivateKey.Valid {
|
||||
m.WebPush.VapidPrivateKey = vapidPrivateKey.String
|
||||
}
|
||||
}
|
||||
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAllMappings() ([]Mapping, error) {
|
||||
query := `
|
||||
SELECT appName, signalGroupId, signalAccount, channel, pushEndpoint, p256dh, auth, vapidPrivateKey
|
||||
FROM mappings
|
||||
`
|
||||
rows, err := s.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var mappings []Mapping
|
||||
for rows.Next() {
|
||||
var m Mapping
|
||||
var signalGroupID, signalAccount, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString
|
||||
if err := rows.Scan(&m.AppName, &signalGroupID, &signalAccount, &m.Channel, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if signalGroupID.Valid && signalAccount.Valid {
|
||||
m.Signal = &SignalSubscription{
|
||||
GroupID: signalGroupID.String,
|
||||
Account: signalAccount.String,
|
||||
}
|
||||
}
|
||||
if pushEndpoint.Valid {
|
||||
m.WebPush = &WebPushSubscription{
|
||||
Endpoint: pushEndpoint.String,
|
||||
}
|
||||
if p256dh.Valid {
|
||||
m.WebPush.P256dh = p256dh.String
|
||||
}
|
||||
if auth.Valid {
|
||||
m.WebPush.Auth = auth.String
|
||||
}
|
||||
if vapidPrivateKey.Valid {
|
||||
m.WebPush.VapidPrivateKey = vapidPrivateKey.String
|
||||
}
|
||||
}
|
||||
|
||||
mappings = append(mappings, m)
|
||||
}
|
||||
|
||||
return mappings, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateChannel(appName string, channel Channel) error {
|
||||
query := `UPDATE mappings SET channel = ? WHERE appName = ?`
|
||||
_, err := s.db.Exec(query, channel, appName)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSignal(appName string, signal *SignalSubscription) error {
|
||||
if signal == nil {
|
||||
return fmt.Errorf("signal cannot be nil")
|
||||
}
|
||||
|
||||
query := `UPDATE mappings SET signalGroupId = ?, signalAccount = ? WHERE appName = ?`
|
||||
_, err := s.db.Exec(query, signal.GroupID, signal.Account, appName)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateWebPush(appName string, webPush *WebPushSubscription) error {
|
||||
if webPush == nil {
|
||||
return fmt.Errorf("webPush cannot be nil")
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE mappings
|
||||
SET pushEndpoint = ?, p256dh = ?, auth = ?, vapidPrivateKey = ?
|
||||
WHERE appName = ?
|
||||
`
|
||||
_, err := s.db.Exec(query, webPush.Endpoint, webPush.P256dh, webPush.Auth, webPush.VapidPrivateKey, appName)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RemoveApp(appName string) error {
|
||||
query := `DELETE FROM mappings WHERE appName = ?`
|
||||
_, err := s.db.Exec(query, appName)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ClearWebPush(appName string) error {
|
||||
query := `
|
||||
UPDATE mappings
|
||||
SET pushEndpoint = NULL, p256dh = NULL, auth = NULL, vapidPrivateKey = NULL, channel = 'signal'
|
||||
WHERE appName = ?
|
||||
`
|
||||
_, err := s.db.Exec(query, appName)
|
||||
return err
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"prism/service/notification"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func (s *Server) handleDeleteAppAction(w http.ResponseWriter, r *http.Request) {
|
||||
app := chi.URLParam(r, "appName")
|
||||
if app == "" {
|
||||
http.Error(w, "Missing app parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.RemoveApp(app); err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to delete app", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
s.handleFragmentApps(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleToggleChannelAction(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Invalid form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
app := r.FormValue("app")
|
||||
channel := r.FormValue("channel")
|
||||
|
||||
if app == "" || channel == "" {
|
||||
http.Error(w, "Missing app or channel parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.dispatcher.IsValidChannel(notification.Channel(channel)) {
|
||||
http.Error(w, "Invalid channel", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.UpdateChannel(app, notification.Channel(channel)); err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to update channel", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
s.handleFragmentApps(w, r)
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"prism/service/notification"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetMappings(w http.ResponseWriter, r *http.Request) {
|
||||
mappings, err := s.store.GetAllMappings()
|
||||
if err != nil {
|
||||
util.LogAndError(w, s.logger, "Internal server error", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(mappings); err != nil {
|
||||
s.logger.Error("Failed to encode response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
type createMappingRequest struct {
|
||||
App string `json:"app"`
|
||||
Channel notification.Channel `json:"channel"`
|
||||
PushEndpoint *string `json:"pushEndpoint,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateMapping(w http.ResponseWriter, r *http.Request) {
|
||||
var req createMappingRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.App == "" {
|
||||
http.Error(w, "Missing required fields", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Channel == "" {
|
||||
req.Channel = notification.ChannelWebPush
|
||||
}
|
||||
|
||||
if req.Channel == notification.ChannelWebPush && req.PushEndpoint == nil {
|
||||
http.Error(w, "pushEndpoint required for webpush channel", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var webPush *notification.WebPushSubscription
|
||||
if req.Channel == notification.ChannelWebPush && req.PushEndpoint != nil {
|
||||
webPush = ¬ification.WebPushSubscription{
|
||||
Endpoint: *req.PushEndpoint,
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.store.Register(req.App, &req.Channel, nil, webPush); err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to create mapping", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"status": "created"}); err != nil {
|
||||
s.logger.Error("Failed to encode response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteMapping(w http.ResponseWriter, r *http.Request) {
|
||||
appName := chi.URLParam(r, "appName")
|
||||
if appName == "" {
|
||||
http.Error(w, "Missing appName parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.RemoveApp(appName); err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to delete mapping", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type updateChannelRequest struct {
|
||||
Channel notification.Channel `json:"channel"`
|
||||
UpEndpoint *string `json:"upEndpoint,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateChannel(w http.ResponseWriter, r *http.Request) {
|
||||
appName := chi.URLParam(r, "appName")
|
||||
if appName == "" {
|
||||
http.Error(w, "Missing appName parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req updateChannelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Channel == notification.ChannelWebPush && req.UpEndpoint == nil {
|
||||
http.Error(w, "upEndpoint required for webpush channel", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.UpdateChannel(appName, req.Channel); err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to update channel", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"status": "updated"}); err != nil {
|
||||
s.logger.Error("Failed to encode response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
|
||||
mappings, err := s.store.GetAllMappings()
|
||||
if err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to get mappings", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
uptime := time.Since(s.startTime)
|
||||
|
||||
stats := map[string]any{
|
||||
"uptime": util.FormatUptime(uptime),
|
||||
"uptimeSeconds": int(uptime.Seconds()),
|
||||
"mappingsCount": len(mappings),
|
||||
"signalCount": countByChannel(mappings, notification.ChannelSignal),
|
||||
"webpushCount": countByChannel(mappings, notification.ChannelWebPush),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(stats); err != nil {
|
||||
s.logger.Error("Failed to encode response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func countByChannel(mappings []notification.Mapping, channel notification.Channel) int {
|
||||
count := 0
|
||||
for _, m := range mappings {
|
||||
if m.Channel == channel {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
58
service/server/handlers_apps.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func (s *Server) handleDeleteApp(w http.ResponseWriter, r *http.Request) {
|
||||
app := chi.URLParam(r, "appName")
|
||||
if app == "" {
|
||||
http.Error(w, "Missing app parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.RemoveApp(app); err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to delete app", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
util.SetToast(w, fmt.Sprintf("App %s deleted", app), "success")
|
||||
s.handleFragmentApps(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetApps(w http.ResponseWriter, r *http.Request) {
|
||||
apps, err := s.store.GetAllApps()
|
||||
if err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to get apps", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(sanitizeApps(apps))
|
||||
}
|
||||
|
||||
func sanitizeApps(apps []subscription.App) []subscription.App {
|
||||
sanitized := make([]subscription.App, len(apps))
|
||||
for i, app := range apps {
|
||||
sanitized[i] = subscription.App{
|
||||
AppName: app.AppName,
|
||||
Subscriptions: make([]subscription.Subscription, len(app.Subscriptions)),
|
||||
}
|
||||
for j, sub := range app.Subscriptions {
|
||||
sanitized[i].Subscriptions[j] = sub
|
||||
if sub.WebPush != nil {
|
||||
sanitized[i].Subscriptions[j].WebPush = &subscription.WebPushSubscription{
|
||||
Endpoint: sub.WebPush.Endpoint,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
|
@ -2,32 +2,34 @@ package server
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"prism/service/notification"
|
||||
"prism/service/integration/signal"
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
)
|
||||
|
||||
type AppListItem struct {
|
||||
AppName string
|
||||
Channel string
|
||||
ChannelBadge string
|
||||
ChannelConfigured bool
|
||||
Tooltip string
|
||||
Hostname string
|
||||
ChannelOptions []SelectOption
|
||||
Channels []ChannelState
|
||||
}
|
||||
|
||||
type SelectOption struct {
|
||||
Value string
|
||||
type ChannelState struct {
|
||||
Channel string
|
||||
Label string
|
||||
Selected bool
|
||||
Active bool
|
||||
Toggleable bool
|
||||
Subscriptions []SubscriptionItem
|
||||
}
|
||||
|
||||
type SubscriptionItem struct {
|
||||
ID string
|
||||
Tooltip string
|
||||
Hostname string
|
||||
}
|
||||
|
||||
func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
|
||||
mappings, err := s.store.GetAllMappings()
|
||||
apps, err := s.store.GetAllApps()
|
||||
if err != nil {
|
||||
util.LogAndError(w, s.logger, "Internal server error", http.StatusInternalServerError, err)
|
||||
return
|
||||
|
|
@ -35,8 +37,10 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
||||
data := s.buildAppListData(apps)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := s.fragmentTmpl.ExecuteTemplate(&buf, "app-list.html", s.buildAppListData(mappings)); err != nil {
|
||||
if err := s.fragmentTmpl.ExecuteTemplate(&buf, "app-list.html", data); err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to execute template", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
|
@ -44,112 +48,106 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
|
|||
_, _ = w.Write(buf.Bytes())
|
||||
}
|
||||
|
||||
func (s *Server) buildAppListData(mappings []notification.Mapping) []AppListItem {
|
||||
if len(mappings) == 0 {
|
||||
func (s *Server) buildAppListData(apps []subscription.App) []AppListItem {
|
||||
if len(apps) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
signalLinked := false
|
||||
if s.integrations.Signal != nil {
|
||||
handlers := s.integrations.Signal.GetHandlers()
|
||||
signalEnabled := s.integrations.IsSignalLinked()
|
||||
telegramEnabled := s.integrations.IsTelegramLinked()
|
||||
telegramBotName := ""
|
||||
if telegramEnabled {
|
||||
handlers := s.integrations.Telegram.Handlers
|
||||
if handlers != nil {
|
||||
account, _ := handlers.GetClient().GetLinkedAccount()
|
||||
signalLinked = account != nil
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
client := handlers.GetClient()
|
||||
if client != nil {
|
||||
if bot, err := client.GetMe(); err == nil && bot != nil && bot.Username != "" {
|
||||
telegramBotName = "@" + bot.Username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]AppListItem, 0, len(mappings))
|
||||
for _, m := range mappings {
|
||||
item := s.buildAppListItem(m, signalLinked, telegramLinked, telegramInfo)
|
||||
items := make([]AppListItem, 0, len(apps))
|
||||
for _, app := range apps {
|
||||
channels := []ChannelState{}
|
||||
|
||||
if signalEnabled {
|
||||
var signalSub *subscription.Subscription
|
||||
for i := range app.Subscriptions {
|
||||
if app.Subscriptions[i].Channel == subscription.ChannelSignal {
|
||||
signalSub = &app.Subscriptions[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
state := ChannelState{
|
||||
Channel: string(subscription.ChannelSignal),
|
||||
Label: subscription.ChannelSignal.Label(),
|
||||
Active: signalSub != nil,
|
||||
Toggleable: true,
|
||||
}
|
||||
|
||||
if signalSub != nil && signalSub.Signal != nil {
|
||||
state.Subscriptions = []SubscriptionItem{{
|
||||
ID: signalSub.ID,
|
||||
Tooltip: signal.FormatPhoneNumber(signalSub.Signal.Account),
|
||||
}}
|
||||
}
|
||||
|
||||
channels = append(channels, state)
|
||||
}
|
||||
|
||||
if telegramEnabled {
|
||||
var telegramSub *subscription.Subscription
|
||||
for i := range app.Subscriptions {
|
||||
if app.Subscriptions[i].Channel == subscription.ChannelTelegram {
|
||||
telegramSub = &app.Subscriptions[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
state := ChannelState{
|
||||
Channel: string(subscription.ChannelTelegram),
|
||||
Label: subscription.ChannelTelegram.Label(),
|
||||
Active: telegramSub != nil,
|
||||
Toggleable: true,
|
||||
}
|
||||
|
||||
if telegramSub != nil {
|
||||
state.Subscriptions = []SubscriptionItem{{
|
||||
ID: telegramSub.ID,
|
||||
Tooltip: telegramBotName,
|
||||
}}
|
||||
}
|
||||
|
||||
channels = append(channels, state)
|
||||
}
|
||||
|
||||
webPushSubs := []SubscriptionItem{}
|
||||
for _, sub := range app.Subscriptions {
|
||||
if sub.Channel == subscription.ChannelWebPush && sub.WebPush != nil {
|
||||
webPushSubs = append(webPushSubs, SubscriptionItem{
|
||||
ID: sub.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(webPushSubs) > 0 {
|
||||
channels = append(channels, ChannelState{
|
||||
Channel: string(subscription.ChannelWebPush),
|
||||
Label: subscription.ChannelWebPush.Label(),
|
||||
Active: true,
|
||||
Toggleable: false,
|
||||
Subscriptions: webPushSubs,
|
||||
})
|
||||
}
|
||||
|
||||
item := AppListItem{
|
||||
AppName: app.AppName,
|
||||
Channels: channels,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *Server) buildAppListItem(m notification.Mapping, signalLinked, telegramLinked bool, telegramInfo string) AppListItem {
|
||||
isSignal := m.Channel == notification.ChannelSignal
|
||||
isWebPush := m.Channel == notification.ChannelWebPush
|
||||
isTelegram := m.Channel == notification.ChannelTelegram
|
||||
|
||||
item := AppListItem{
|
||||
AppName: m.AppName,
|
||||
Channel: string(m.Channel),
|
||||
}
|
||||
|
||||
if isSignal && m.Signal != nil && m.Signal.GroupID != "" {
|
||||
item.ChannelBadge = m.Channel.Label()
|
||||
item.Tooltip = fmt.Sprintf("Group ID: %s", m.Signal.GroupID)
|
||||
item.ChannelConfigured = true
|
||||
} else if isWebPush && m.WebPush != nil && m.WebPush.Endpoint != "" {
|
||||
item.ChannelBadge = m.Channel.Label()
|
||||
item.Tooltip = m.WebPush.Endpoint
|
||||
item.ChannelConfigured = true
|
||||
if u, err := url.Parse(m.WebPush.Endpoint); err == nil {
|
||||
item.Hostname = u.Hostname()
|
||||
}
|
||||
} else if isTelegram && telegramLinked {
|
||||
item.ChannelBadge = m.Channel.Label()
|
||||
item.Tooltip = telegramInfo
|
||||
item.ChannelConfigured = true
|
||||
} else {
|
||||
item.ChannelBadge = "Unlinked"
|
||||
item.ChannelConfigured = false
|
||||
if isSignal {
|
||||
item.Tooltip = "No Signal group created yet. Will auto-create on first notification."
|
||||
} else if isTelegram {
|
||||
item.Tooltip = "Set TELEGRAM_CHAT_ID in .env"
|
||||
} else {
|
||||
item.Tooltip = "No WebPush endpoint registered."
|
||||
}
|
||||
}
|
||||
|
||||
item.ChannelOptions = s.buildChannelOptions(m, signalLinked, telegramLinked)
|
||||
return item
|
||||
}
|
||||
|
||||
func (s *Server) buildChannelOptions(m notification.Mapping, signalLinked, telegramConfigured bool) []SelectOption {
|
||||
isSignal := m.Channel == notification.ChannelSignal
|
||||
isWebPush := m.Channel == notification.ChannelWebPush
|
||||
isTelegram := m.Channel == notification.ChannelTelegram
|
||||
|
||||
var options []SelectOption
|
||||
|
||||
if s.integrations.Signal != nil {
|
||||
options = append(options, SelectOption{
|
||||
Value: notification.ChannelSignal.String(),
|
||||
Label: notification.ChannelSignal.Label(),
|
||||
Selected: isSignal,
|
||||
})
|
||||
}
|
||||
if s.integrations.Telegram != nil {
|
||||
options = append(options, SelectOption{
|
||||
Value: notification.ChannelTelegram.String(),
|
||||
Label: notification.ChannelTelegram.Label(),
|
||||
Selected: isTelegram,
|
||||
})
|
||||
}
|
||||
if m.WebPush != nil && m.WebPush.Endpoint != "" {
|
||||
options = append(options, SelectOption{
|
||||
Value: notification.ChannelWebPush.String(),
|
||||
Label: notification.ChannelWebPush.Label(),
|
||||
Selected: isWebPush,
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ package server
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"prism/service/util"
|
||||
)
|
||||
|
||||
type healthResponse struct {
|
||||
|
|
@ -30,47 +30,23 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
resp := healthResponse{
|
||||
Version: s.version,
|
||||
Uptime: util.FormatUptime(uptime),
|
||||
Uptime: formatUptime(uptime),
|
||||
}
|
||||
|
||||
if s.integrations.Signal != nil && s.integrations.Signal.IsEnabled() {
|
||||
signalClient := s.integrations.Signal.GetHandlers().GetClient()
|
||||
account, _ := signalClient.GetLinkedAccount()
|
||||
if account != nil {
|
||||
resp.Signal = &integrationHealth{
|
||||
Linked: true,
|
||||
Account: account.Number,
|
||||
}
|
||||
} else {
|
||||
resp.Signal = &integrationHealth{
|
||||
Linked: false,
|
||||
}
|
||||
}
|
||||
linked, account := s.integrations.Signal.Health()
|
||||
resp.Signal = &integrationHealth{Linked: linked, Account: account}
|
||||
}
|
||||
|
||||
if s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled() {
|
||||
telegramClient := s.integrations.Telegram.GetHandlers().GetClient()
|
||||
if telegramClient != nil {
|
||||
bot, err := telegramClient.GetMe()
|
||||
if err == nil {
|
||||
resp.Telegram = &integrationHealth{
|
||||
Linked: true,
|
||||
Account: "@" + bot.Username,
|
||||
}
|
||||
}
|
||||
if linked, account := s.integrations.Telegram.Health(); linked {
|
||||
resp.Telegram = &integrationHealth{Linked: true, Account: account}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
if linked, account := s.integrations.Proton.Health(); linked {
|
||||
resp.Proton = &integrationHealth{Linked: true, Account: account}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -79,3 +55,26 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||
s.logger.Error("Failed to encode health response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func formatUptime(d time.Duration) string {
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
minutes := int(d.Minutes()) % 60
|
||||
seconds := int(d.Seconds()) % 60
|
||||
|
||||
parts := []string{}
|
||||
if days > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dd", days))
|
||||
}
|
||||
if hours > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dh", hours))
|
||||
}
|
||||
if minutes > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dm", minutes))
|
||||
}
|
||||
if seconds > 0 || len(parts) == 0 {
|
||||
parts = append(parts, fmt.Sprintf("%ds", seconds))
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"prism/service/notification"
|
||||
"prism/service/delivery"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
|
@ -16,67 +18,27 @@ import (
|
|||
|
||||
func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
|
||||
appName := chi.URLParam(r, "appName")
|
||||
if appName == "" {
|
||||
appName = chi.URLParam(r, "endpoint")
|
||||
decodedAppName, err := url.PathUnescape(appName)
|
||||
if err != nil {
|
||||
util.JSONError(w, "Invalid app name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
appName, _ = url.QueryUnescape(appName)
|
||||
appName = decodedAppName
|
||||
if appName == "" || strings.Contains(appName, "/") {
|
||||
http.Error(w, "Invalid app name", http.StatusBadRequest)
|
||||
util.JSONError(w, "Invalid app name", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||
util.JSONError(w, "Failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var message, title string
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
|
||||
if strings.Contains(contentType, "application/json") {
|
||||
var payload struct {
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err == nil {
|
||||
message = payload.Message
|
||||
title = payload.Title
|
||||
} else {
|
||||
message = string(body)
|
||||
}
|
||||
} else if strings.Contains(contentType, "application/x-www-form-urlencoded") {
|
||||
if values, err := url.ParseQuery(string(body)); err == nil {
|
||||
message = values.Get("message")
|
||||
if message == "" {
|
||||
message = string(body)
|
||||
}
|
||||
if title == "" {
|
||||
if t := values.Get("title"); t != "" {
|
||||
title = t
|
||||
} else if t := values.Get("t"); t != "" {
|
||||
title = t
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message = string(body)
|
||||
}
|
||||
} else {
|
||||
message = string(body)
|
||||
}
|
||||
|
||||
if title == "" {
|
||||
title = r.Header.Get("X-Title")
|
||||
if title == "" {
|
||||
title = r.Header.Get("Title")
|
||||
}
|
||||
if title == "" {
|
||||
title = r.Header.Get("t")
|
||||
}
|
||||
}
|
||||
message, title, imageURL := parseNtfyPayload(r, body)
|
||||
|
||||
if message == "" {
|
||||
http.Error(w, "Message required", http.StatusBadRequest)
|
||||
util.JSONError(w, "Message required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -84,17 +46,18 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
|
|||
title = ""
|
||||
}
|
||||
|
||||
notif := notification.Notification{
|
||||
notif := delivery.Notification{
|
||||
Title: title,
|
||||
Message: message,
|
||||
ImageURL: imageURL,
|
||||
}
|
||||
|
||||
if err := s.dispatcher.Send(appName, notif); err != nil {
|
||||
if err := s.publisher.Publish(appName, notif); err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to send notification", http.StatusInternalServerError, err, "app", appName)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Debug("Sent ntfy message", "app", appName, "preview", truncate(message, 50))
|
||||
s.logger.Debug("Sent ntfy message", "app", appName, "title", title, "message", message, "image", imageURL)
|
||||
|
||||
now := time.Now()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -110,9 +73,60 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
func parseNtfyPayload(r *http.Request, body []byte) (string, string, string) {
|
||||
var message, title, imageURL string
|
||||
|
||||
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
mediaType = ""
|
||||
}
|
||||
return s[:max]
|
||||
|
||||
switch mediaType {
|
||||
case "application/json":
|
||||
var payload struct {
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Attach string `json:"attach"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err == nil {
|
||||
message = payload.Message
|
||||
title = payload.Title
|
||||
imageURL = firstNonEmpty(payload.Attach, payload.Image)
|
||||
} else {
|
||||
message = string(body)
|
||||
}
|
||||
case "application/x-www-form-urlencoded":
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
if err := r.ParseForm(); err == nil {
|
||||
message = r.PostForm.Get("message")
|
||||
if message == "" {
|
||||
message = string(body)
|
||||
}
|
||||
title = firstNonEmpty(r.PostForm.Get("title"), r.PostForm.Get("t"))
|
||||
} else {
|
||||
message = string(body)
|
||||
}
|
||||
default:
|
||||
message = string(body)
|
||||
}
|
||||
|
||||
if title == "" {
|
||||
title = firstNonEmpty(r.Header.Get("X-Title"), r.Header.Get("Title"), r.Header.Get("t"))
|
||||
}
|
||||
|
||||
if imageURL == "" {
|
||||
imageURL = firstNonEmpty(r.Header.Get("X-Attach"), r.Header.Get("Attach"))
|
||||
}
|
||||
|
||||
return message, title, imageURL
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
161
service/server/handlers_subscriptions.go
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type subscriptionFormData struct {
|
||||
Channel string
|
||||
GroupID string
|
||||
ChatID string
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
appName := chi.URLParam(r, "appName")
|
||||
if appName == "" {
|
||||
http.Error(w, "Missing app parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
util.JSONError(w, "Invalid form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form := subscriptionFormData{
|
||||
Channel: r.FormValue("channel"),
|
||||
GroupID: r.FormValue("group_id"),
|
||||
ChatID: r.FormValue("chat_id"),
|
||||
}
|
||||
|
||||
channel := subscription.Channel(form.Channel)
|
||||
if !s.publisher.IsValidChannel(channel) {
|
||||
util.SetToast(w, fmt.Sprintf("Invalid or unavailable channel: %s", form.Channel), "error")
|
||||
s.handleFragmentApps(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
app, err := s.store.GetApp(appName)
|
||||
if err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to load app subscriptions", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if app != nil {
|
||||
for _, existingSub := range app.Subscriptions {
|
||||
if existingSub.Channel == channel {
|
||||
util.SetToast(w, fmt.Sprintf("%s already enabled", channel.Label()), "error")
|
||||
s.handleFragmentApps(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sub := subscription.Subscription{
|
||||
AppName: appName,
|
||||
Channel: channel,
|
||||
}
|
||||
|
||||
switch channel {
|
||||
case subscription.ChannelSignal:
|
||||
if s.integrations.Signal == nil || !s.integrations.Signal.IsEnabled() {
|
||||
util.SetToast(w, "Signal not configured", "error")
|
||||
s.handleFragmentApps(w, r)
|
||||
return
|
||||
}
|
||||
client := s.integrations.Signal.Handlers.Client
|
||||
account, err := client.GetLinkedAccount()
|
||||
if err != nil || account == nil {
|
||||
util.SetToast(w, "Signal not linked - configure its integration below", "error")
|
||||
s.handleFragmentApps(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if form.GroupID != "" {
|
||||
sub.Signal = &subscription.SignalSubscription{
|
||||
GroupID: form.GroupID,
|
||||
Account: account.Number,
|
||||
}
|
||||
} else {
|
||||
cachedGroup, err := s.integrations.Signal.Groups.Get(appName)
|
||||
if err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to check for cached Signal group", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if cachedGroup != nil && cachedGroup.Account == account.Number {
|
||||
sub.Signal = cachedGroup
|
||||
} else {
|
||||
signalSub, err := s.integrations.Signal.Sender.CreateDefaultSignalSubscription(appName)
|
||||
if err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to create Signal subscription", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
sub.Signal = signalSub
|
||||
}
|
||||
}
|
||||
|
||||
case subscription.ChannelTelegram:
|
||||
if s.integrations.Telegram == nil || !s.integrations.Telegram.IsEnabled() {
|
||||
util.SetToast(w, "Telegram not configured", "error")
|
||||
s.handleFragmentApps(w, r)
|
||||
return
|
||||
}
|
||||
chatID := s.integrations.Telegram.Handlers.GetChatID()
|
||||
if chatID == 0 {
|
||||
util.SetToast(w, "Telegram not linked - configure its integration below", "error")
|
||||
s.handleFragmentApps(w, r)
|
||||
return
|
||||
}
|
||||
if form.ChatID != "" {
|
||||
chatID = 0
|
||||
fmt.Sscanf(form.ChatID, "%d", &chatID)
|
||||
}
|
||||
sub.Telegram = &subscription.TelegramSubscription{
|
||||
ChatID: fmt.Sprintf("%d", chatID),
|
||||
}
|
||||
|
||||
default:
|
||||
util.JSONError(w, "Unsupported channel for manual subscription", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := s.store.AddSubscription(sub); err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to create subscription", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
util.SetToast(w, fmt.Sprintf("%s enabled", channel.Label()), "success")
|
||||
s.handleFragmentApps(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
subscriptionID := chi.URLParam(r, "subscriptionId")
|
||||
if subscriptionID == "" {
|
||||
http.Error(w, "Missing subscription ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sub, err := s.store.GetSubscription(subscriptionID)
|
||||
if err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to get subscription", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.DeleteSubscription(subscriptionID); err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to delete subscription", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("%s channel disabled", sub.Channel.Label())
|
||||
if sub.Channel == subscription.ChannelWebPush {
|
||||
message = fmt.Sprintf("%s channel deleted", sub.Channel.Label())
|
||||
}
|
||||
util.SetToast(w, message, "success")
|
||||
s.handleFragmentApps(w, r)
|
||||
}
|
||||
|
|
@ -3,13 +3,12 @@ package server
|
|||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"golang.org/x/time/rate"
|
||||
"github.com/go-chi/httprate"
|
||||
)
|
||||
|
||||
var noisyPaths = map[string]bool{
|
||||
|
|
@ -21,7 +20,7 @@ func authMiddleware(apiKey string) func(http.Handler) http.Handler {
|
|||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !util.VerifyAPIKey(r, apiKey) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Prism Admin - Username: any, Password: API_KEY"`)
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Prism - Username: any, Password: API_KEY"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
|
@ -63,63 +62,23 @@ func securityHeadersMiddleware() func(http.Handler) http.Handler {
|
|||
}
|
||||
}
|
||||
|
||||
type visitor struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
visitors = make(map[string]*visitor)
|
||||
visitorsMu sync.RWMutex
|
||||
)
|
||||
|
||||
func rateLimitMiddleware(rps int) func(http.Handler) http.Handler {
|
||||
go cleanupVisitors()
|
||||
|
||||
if rps <= 0 {
|
||||
return func(next http.Handler) http.Handler { return next }
|
||||
}
|
||||
rl := httprate.LimitByIP(rps, time.Second)
|
||||
return func(next http.Handler) http.Handler {
|
||||
limited := rl(next)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := util.GetClientIP(r)
|
||||
|
||||
if util.IsLocalhost(ip) {
|
||||
if util.IsLocalhost(util.GetClientIP(r)) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
visitorsMu.Lock()
|
||||
v, exists := visitors[ip]
|
||||
if !exists {
|
||||
limiter := rate.NewLimiter(rate.Limit(rps), rps*2)
|
||||
visitors[ip] = &visitor{limiter: limiter, lastSeen: time.Now()}
|
||||
v = visitors[ip]
|
||||
}
|
||||
v.lastSeen = time.Now()
|
||||
visitorsMu.Unlock()
|
||||
|
||||
if !v.limiter.Allow() {
|
||||
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
limited.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupVisitors() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
visitorsMu.Lock()
|
||||
for ip, v := range visitors {
|
||||
if time.Since(v.lastSeen) > 10*time.Minute {
|
||||
delete(visitors, ip)
|
||||
}
|
||||
}
|
||||
visitorsMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func maxBodySizeMiddleware(maxBytes int64) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package server
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
|
@ -9,16 +10,19 @@ import (
|
|||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"prism/service/config"
|
||||
"prism/service/delivery"
|
||||
"prism/service/integration"
|
||||
"prism/service/notification"
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
|
|
@ -28,8 +32,8 @@ type Server struct {
|
|||
startTime time.Time
|
||||
publicAssets embed.FS
|
||||
cfg *config.Config
|
||||
store *notification.Store
|
||||
dispatcher *notification.Dispatcher
|
||||
store *subscription.Store
|
||||
publisher *delivery.Publisher
|
||||
integrations *integration.Integrations
|
||||
logger *slog.Logger
|
||||
router *chi.Mux
|
||||
|
|
@ -39,10 +43,26 @@ type Server struct {
|
|||
version string
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, publicAssets embed.FS) (*Server, error) {
|
||||
logger := util.NewLogger(cfg.VerboseLogging)
|
||||
func openDB(dbPath string) (*sql.DB, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
store, err := notification.NewStore(cfg.StoragePath)
|
||||
func New(cfg *config.Config, publicAssets embed.FS, version string, logger *slog.Logger) (*Server, error) {
|
||||
db, err := openDB(cfg.StoragePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
store, err := subscription.NewStore(db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create store: %w", err)
|
||||
}
|
||||
|
|
@ -58,11 +78,6 @@ func New(cfg *config.Config, publicAssets embed.FS) (*Server, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
version := "dev"
|
||||
if versionBytes, err := os.ReadFile("VERSION"); err == nil {
|
||||
version = strings.TrimSpace(string(versionBytes))
|
||||
}
|
||||
|
||||
tmpl, err := template.ParseFS(publicAssets, "public/index.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse index template: %w", err)
|
||||
|
|
@ -71,7 +86,7 @@ func New(cfg *config.Config, publicAssets embed.FS) (*Server, error) {
|
|||
s := &Server{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
dispatcher: integrations.Dispatcher,
|
||||
publisher: integrations.Publisher,
|
||||
integrations: integrations,
|
||||
logger: logger,
|
||||
startTime: time.Now(),
|
||||
|
|
@ -89,7 +104,6 @@ func (s *Server) setupRoutes() {
|
|||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(loggingMiddleware(s.logger))
|
||||
r.Use(securityHeadersMiddleware())
|
||||
|
|
@ -106,30 +120,20 @@ func (s *Server) setupRoutes() {
|
|||
util.LogAndError(w, s.logger, "Internal Server Error", http.StatusInternalServerError, err)
|
||||
}
|
||||
})
|
||||
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/favicon.webp", http.StatusMovedPermanently)
|
||||
})
|
||||
|
||||
integration.RegisterAll(s.integrations, r, s.cfg, s.store, s.logger, authMiddleware)
|
||||
integration.RegisterAll(s.integrations, r, s.cfg, s.logger, authMiddleware)
|
||||
|
||||
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/apps", s.handleFragmentApps)
|
||||
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/integrations", s.handleFragmentIntegrations)
|
||||
|
||||
r.Route("/action", func(r chi.Router) {
|
||||
r.Route("/apps", func(r chi.Router) {
|
||||
r.Use(authMiddleware(s.cfg.APIKey))
|
||||
r.Delete("/app/{appName}", s.handleDeleteAppAction)
|
||||
r.Post("/toggle-channel", s.handleToggleChannelAction)
|
||||
})
|
||||
|
||||
r.Route("/api/v1/admin", func(r chi.Router) {
|
||||
r.Use(authMiddleware(s.cfg.APIKey))
|
||||
r.Get("/mappings", s.handleGetMappings)
|
||||
r.Post("/mappings", s.handleCreateMapping)
|
||||
r.Delete("/mappings/{appName}", s.handleDeleteMapping)
|
||||
r.Put("/mappings/{appName}/channel", s.handleUpdateChannel)
|
||||
r.Get("/stats", s.handleGetStats)
|
||||
r.Delete("/{appName}", s.handleDeleteApp)
|
||||
r.Post("/{appName}/subscriptions", s.handleCreateSubscription)
|
||||
r.Delete("/{appName}/subscriptions/{subscriptionId}", s.handleDeleteSubscription)
|
||||
})
|
||||
|
||||
r.With(authMiddleware(s.cfg.APIKey)).Get("/api/v1/apps", s.handleGetApps)
|
||||
r.With(authMiddleware(s.cfg.APIKey)).Get("/api/v1/health", s.handleHealth)
|
||||
|
||||
r.With(authMiddleware(s.cfg.APIKey)).Post("/{appName}", s.handleNtfyPublish)
|
||||
|
|
@ -145,7 +149,7 @@ func (s *Server) setupRoutes() {
|
|||
}
|
||||
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
s.integrations.Start(ctx, s.cfg, s.logger)
|
||||
s.integrations.Start(ctx, s.logger)
|
||||
|
||||
addr := fmt.Sprintf(":%d", s.cfg.Port)
|
||||
s.httpServer = &http.Server{
|
||||
|
|
@ -187,7 +191,7 @@ func (s *Server) Shutdown() error {
|
|||
return fmt.Errorf("failed to shutdown http server: %w", err)
|
||||
}
|
||||
|
||||
if err := s.store.Close(); err != nil {
|
||||
if err := s.store.DB.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close store: %w", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,38 +1,60 @@
|
|||
{{if .}}
|
||||
<ul class="app-list">
|
||||
{{range .}}
|
||||
{{$app := .}}
|
||||
<li class="app-item">
|
||||
<div class="app-info">
|
||||
<div class="app-name"><strong>{{.AppName}}</strong></div>
|
||||
<div class="app-channel">
|
||||
{{if .ChannelConfigured}}
|
||||
<span class="channel-badge channel-{{.Channel}}">
|
||||
{{.ChannelBadge}}
|
||||
{{if .Tooltip}}<span class="tooltip">{{.Tooltip}}</span>{{end}}
|
||||
</span>
|
||||
{{if .Channels}}
|
||||
<div class="app-subscriptions">
|
||||
{{range .Channels}}
|
||||
{{$channel := .}}
|
||||
{{if .Toggleable}}
|
||||
{{if .Active}}
|
||||
<button type="button"
|
||||
class="channel-badge channel-{{.Channel}} badge-active"
|
||||
hx-delete="/apps/{{$app.AppName}}/subscriptions/{{(index .Subscriptions 0).ID}}"
|
||||
hx-target="#apps-list"
|
||||
hx-swap="innerHTML"
|
||||
aria-label="Disable {{.Label}} for {{$app.AppName}}">
|
||||
{{.Label}}
|
||||
<span class="tooltip">Channel ID: {{(index .Subscriptions 0).ID}}</span>
|
||||
</button>
|
||||
{{else}}
|
||||
<span class="channel-badge channel-not-configured">
|
||||
{{.ChannelBadge}}
|
||||
{{if .Tooltip}}<span class="tooltip">{{.Tooltip}}</span>{{end}}
|
||||
<button type="button"
|
||||
class="channel-badge channel-{{.Channel}} badge-inactive"
|
||||
hx-post="/apps/{{$app.AppName}}/subscriptions"
|
||||
hx-target="#apps-list"
|
||||
hx-swap="innerHTML"
|
||||
hx-vals='{"channel":"{{.Channel}}"}'
|
||||
aria-label="Enable {{.Label}} for {{$app.AppName}}">
|
||||
+ {{.Label}}
|
||||
<span class="tooltip">Click to enable</span>
|
||||
</button>
|
||||
{{end}}
|
||||
{{else}}
|
||||
{{range .Subscriptions}}
|
||||
<span class="channel-badge channel-{{$channel.Channel}} badge-subscribed">
|
||||
{{$channel.Label}}
|
||||
<span class="tooltip">Channel ID: {{.ID}}</span>
|
||||
<button type="button"
|
||||
class="btn-delete-sub"
|
||||
data-action="delete-subscription"
|
||||
data-url="/apps/{{$app.AppName}}/subscriptions/{{.ID}}"
|
||||
aria-label="Remove {{$channel.Channel}} channel from {{$app.AppName}}">×</button>
|
||||
</span>
|
||||
{{end}}
|
||||
{{if .Hostname}}
|
||||
<span class="app-detail">{{.Hostname}}</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="app-no-subscriptions">
|
||||
<span class="channel-badge channel-not-configured">No integrations linked</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="app-actions">
|
||||
{{if gt (len .ChannelOptions) 1}}
|
||||
<form class="channel-form">
|
||||
<input type="hidden" name="app" value="{{.AppName}}" />
|
||||
<select class="channel-select" name="channel" hx-post="/action/toggle-channel" hx-target="#apps-list" hx-swap="innerHTML" hx-include="closest form">
|
||||
{{range .ChannelOptions}}
|
||||
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</form>
|
||||
{{end}}
|
||||
<button class="btn-delete" hx-delete="/action/app/{{.AppName}}" hx-target="#apps-list" hx-swap="innerHTML" hx-confirm="Delete {{.AppName}}?">Delete</button>
|
||||
<button type="button" class="btn-danger" data-action="delete-app" data-app-name="{{.AppName}}">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<details class="integration-card"{{if .Open}} open{{end}}>
|
||||
<summary class="integration-header">
|
||||
<span class="integration-name">{{.Name}}</span>
|
||||
<h3>{{.Name}}</h3>
|
||||
<span class="integration-status {{.StatusClass}}">
|
||||
{{.StatusText}}
|
||||
{{if .StatusTooltip}}<span class="tooltip">{{.StatusTooltip}}</span>{{end}}
|
||||
|
|
|
|||
39
service/subscription/channel.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package subscription
|
||||
|
||||
type Channel string
|
||||
|
||||
const (
|
||||
ChannelSignal Channel = "signal"
|
||||
ChannelWebPush Channel = "webpush"
|
||||
ChannelTelegram Channel = "telegram"
|
||||
)
|
||||
|
||||
func (c Channel) String() string {
|
||||
return string(c)
|
||||
}
|
||||
|
||||
func (c Channel) Label() string {
|
||||
switch c {
|
||||
case ChannelSignal:
|
||||
return "Signal"
|
||||
case ChannelWebPush:
|
||||
return "WebPush"
|
||||
case ChannelTelegram:
|
||||
return "Telegram"
|
||||
default:
|
||||
return string(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (c Channel) IsAvailable(signalEnabled bool, telegramEnabled bool) bool {
|
||||
switch c {
|
||||
case ChannelWebPush:
|
||||
return true
|
||||
case ChannelSignal:
|
||||
return signalEnabled
|
||||
case ChannelTelegram:
|
||||
return telegramEnabled
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
255
service/subscription/store.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
package subscription
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB) (*Store, error) {
|
||||
store := &Store{DB: db}
|
||||
if err := store.createTables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) createTables() error {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS apps (
|
||||
appName TEXT PRIMARY KEY
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id TEXT PRIMARY KEY DEFAULT (hex(randomblob(16))),
|
||||
appName TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
signalGroupId TEXT,
|
||||
signalAccount TEXT,
|
||||
telegramChatId TEXT,
|
||||
pushEndpoint TEXT,
|
||||
p256dh TEXT,
|
||||
auth TEXT,
|
||||
vapidPrivateKey TEXT,
|
||||
FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_subscriptions_appName ON subscriptions(appName)`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
if _, err := s.DB.Exec(query); err != nil {
|
||||
return fmt.Errorf("failed to create tables: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) RegisterApp(appName string) error {
|
||||
_, err := s.DB.Exec(`INSERT INTO apps (appName) VALUES (?) ON CONFLICT(appName) DO NOTHING`, appName)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AddSubscription(sub Subscription) (string, error) {
|
||||
if err := s.RegisterApp(sub.AppName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey *string
|
||||
|
||||
if sub.Signal != nil {
|
||||
signalGroupID = &sub.Signal.GroupID
|
||||
signalAccount = &sub.Signal.Account
|
||||
}
|
||||
if sub.Telegram != nil {
|
||||
telegramChatID = &sub.Telegram.ChatID
|
||||
}
|
||||
if sub.WebPush != nil {
|
||||
pushEndpoint = &sub.WebPush.Endpoint
|
||||
p256dh = &sub.WebPush.P256dh
|
||||
auth = &sub.WebPush.Auth
|
||||
vapidPrivateKey = &sub.WebPush.VapidPrivateKey
|
||||
}
|
||||
|
||||
var id string
|
||||
err := s.DB.QueryRow(`
|
||||
INSERT INTO subscriptions (appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id
|
||||
`, sub.AppName, sub.Channel, signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey).Scan(&id)
|
||||
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) GetApp(appName string) (*App, error) {
|
||||
row := s.DB.QueryRow(`SELECT appName FROM apps WHERE appName = ?`, appName)
|
||||
|
||||
var app App
|
||||
if err := row.Scan(&app.AppName); err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
subs, err := s.GetSubscriptions(appName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app.Subscriptions = subs
|
||||
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetSubscriptions(appName string) ([]Subscription, error) {
|
||||
rows, err := s.DB.Query(`
|
||||
SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey
|
||||
FROM subscriptions
|
||||
WHERE appName = ?
|
||||
`, appName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var subscriptions []Subscription
|
||||
for rows.Next() {
|
||||
var sub Subscription
|
||||
var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString
|
||||
|
||||
if err := rows.Scan(&sub.ID, &sub.AppName, &sub.Channel, &signalGroupID, &signalAccount, &telegramChatID, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if signalGroupID.Valid && signalAccount.Valid {
|
||||
sub.Signal = &SignalSubscription{
|
||||
GroupID: signalGroupID.String,
|
||||
Account: signalAccount.String,
|
||||
}
|
||||
}
|
||||
if telegramChatID.Valid {
|
||||
sub.Telegram = &TelegramSubscription{
|
||||
ChatID: telegramChatID.String,
|
||||
}
|
||||
}
|
||||
if pushEndpoint.Valid {
|
||||
sub.WebPush = &WebPushSubscription{
|
||||
Endpoint: pushEndpoint.String,
|
||||
}
|
||||
if p256dh.Valid {
|
||||
sub.WebPush.P256dh = p256dh.String
|
||||
}
|
||||
if auth.Valid {
|
||||
sub.WebPush.Auth = auth.String
|
||||
}
|
||||
if vapidPrivateKey.Valid {
|
||||
sub.WebPush.VapidPrivateKey = vapidPrivateKey.String
|
||||
}
|
||||
}
|
||||
|
||||
subscriptions = append(subscriptions, sub)
|
||||
}
|
||||
|
||||
return subscriptions, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetAllApps() ([]App, error) {
|
||||
rows, err := s.DB.Query(`SELECT appName FROM apps ORDER BY appName`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
appNames := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var appName string
|
||||
if err := rows.Scan(&appName); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
appNames = append(appNames, appName)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apps := make([]App, 0, len(appNames))
|
||||
for _, appName := range appNames {
|
||||
app := App{AppName: appName}
|
||||
|
||||
subs, err := s.GetSubscriptions(appName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app.Subscriptions = subs
|
||||
|
||||
apps = append(apps, app)
|
||||
}
|
||||
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSubscription(subscriptionID string) error {
|
||||
_, err := s.DB.Exec(`DELETE FROM subscriptions WHERE id = ?`, subscriptionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSubscriptionsByChannel(channel Channel) error {
|
||||
_, err := s.DB.Exec(`DELETE FROM subscriptions WHERE channel = ?`, channel)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetSubscription(subscriptionID string) (*Subscription, error) {
|
||||
row := s.DB.QueryRow(`
|
||||
SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey
|
||||
FROM subscriptions
|
||||
WHERE id = ?
|
||||
`, subscriptionID)
|
||||
|
||||
var sub Subscription
|
||||
var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString
|
||||
|
||||
err := row.Scan(&sub.ID, &sub.AppName, &sub.Channel, &signalGroupID, &signalAccount, &telegramChatID, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if signalGroupID.Valid && signalAccount.Valid {
|
||||
sub.Signal = &SignalSubscription{
|
||||
GroupID: signalGroupID.String,
|
||||
Account: signalAccount.String,
|
||||
}
|
||||
}
|
||||
if telegramChatID.Valid {
|
||||
sub.Telegram = &TelegramSubscription{
|
||||
ChatID: telegramChatID.String,
|
||||
}
|
||||
}
|
||||
if pushEndpoint.Valid {
|
||||
sub.WebPush = &WebPushSubscription{
|
||||
Endpoint: pushEndpoint.String,
|
||||
}
|
||||
if p256dh.Valid {
|
||||
sub.WebPush.P256dh = p256dh.String
|
||||
}
|
||||
if auth.Valid {
|
||||
sub.WebPush.Auth = auth.String
|
||||
}
|
||||
if vapidPrivateKey.Valid {
|
||||
sub.WebPush.VapidPrivateKey = vapidPrivateKey.String
|
||||
}
|
||||
}
|
||||
|
||||
return &sub, nil
|
||||
}
|
||||
|
||||
func (s *Store) RemoveApp(appName string) error {
|
||||
_, err := s.DB.Exec(`DELETE FROM apps WHERE appName = ?`, appName)
|
||||
return err
|
||||
}
|
||||
35
service/subscription/subscription.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package subscription
|
||||
|
||||
type WebPushSubscription struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
P256dh string `json:"p256dh,omitempty"`
|
||||
Auth string `json:"auth,omitempty"`
|
||||
VapidPrivateKey string `json:"vapidPrivateKey,omitempty"`
|
||||
}
|
||||
|
||||
func (w *WebPushSubscription) HasEncryption() bool {
|
||||
return w.P256dh != "" && w.Auth != "" && w.VapidPrivateKey != ""
|
||||
}
|
||||
|
||||
type SignalSubscription struct {
|
||||
GroupID string `json:"groupId"`
|
||||
Account string `json:"account"`
|
||||
}
|
||||
|
||||
type TelegramSubscription struct {
|
||||
ChatID string `json:"chatId"`
|
||||
}
|
||||
|
||||
type Subscription struct {
|
||||
ID string `json:"id"`
|
||||
AppName string `json:"appName"`
|
||||
Channel Channel `json:"channel"`
|
||||
Signal *SignalSubscription `json:"signal,omitempty"`
|
||||
WebPush *WebPushSubscription `json:"webPush,omitempty"`
|
||||
Telegram *TelegramSubscription `json:"telegram,omitempty"`
|
||||
}
|
||||
|
||||
type App struct {
|
||||
AppName string `json:"appName"`
|
||||
Subscriptions []Subscription `json:"subscriptions"`
|
||||
}
|
||||
|
|
@ -34,51 +34,12 @@ func VerifyAPIKey(r *http.Request, apiKey string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
if len(password) != len(apiKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
proto := r.Header.Get("X-Forwarded-Proto")
|
||||
if proto == "" {
|
||||
if r.TLS != nil {
|
||||
proto = "https"
|
||||
} else {
|
||||
proto = "http"
|
||||
}
|
||||
}
|
||||
|
||||
clientIP := GetClientIP(r)
|
||||
if proto != "https" && !isLocalIP(clientIP) {
|
||||
return false
|
||||
}
|
||||
|
||||
return subtle.ConstantTimeCompare([]byte(password), []byte(apiKey)) == 1
|
||||
}
|
||||
|
||||
func isLocalIP(addr string) bool {
|
||||
if addr == "" || addr == "::1" || addr == "localhost" {
|
||||
return true
|
||||
}
|
||||
|
||||
ip := net.ParseIP(addr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if ip.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
|
||||
if ip.To4() != nil {
|
||||
return ip.IsPrivate()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func GetClientIP(r *http.Request) string {
|
||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
if host == "" {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil || host == "" {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func FormatUptime(d time.Duration) string {
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
minutes := int(d.Minutes()) % 60
|
||||
seconds := int(d.Seconds()) % 60
|
||||
|
||||
parts := []string{}
|
||||
if days > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dd", days))
|
||||
}
|
||||
if hours > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dh", hours))
|
||||
}
|
||||
if minutes > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%dm", minutes))
|
||||
}
|
||||
if seconds > 0 || len(parts) == 0 {
|
||||
parts = append(parts, fmt.Sprintf("%ds", seconds))
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
18
service/util/htmx.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func SetToast(w http.ResponseWriter, message, toastType string) {
|
||||
trigger := map[string]interface{}{
|
||||
"showToast": map[string]string{
|
||||
"message": message,
|
||||
"type": toastType,
|
||||
},
|
||||
}
|
||||
if data, err := json.Marshal(trigger); err == nil {
|
||||
w.Header().Set("HX-Trigger", string(data))
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ const (
|
|||
type ColorHandler struct {
|
||||
w io.Writer
|
||||
level slog.Level
|
||||
preAttrs []slog.Attr
|
||||
}
|
||||
|
||||
func NewColorHandler(w io.Writer, opts *slog.HandlerOptions) *ColorHandler {
|
||||
|
|
@ -38,7 +39,9 @@ func (h *ColorHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
|||
}
|
||||
|
||||
func (h *ColorHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return h
|
||||
newH := *h
|
||||
newH.preAttrs = append(append([]slog.Attr{}, h.preAttrs...), attrs...)
|
||||
return &newH
|
||||
}
|
||||
|
||||
func (h *ColorHandler) WithGroup(name string) slog.Handler {
|
||||
|
|
@ -62,12 +65,16 @@ func (h *ColorHandler) Handle(ctx context.Context, r slog.Record) error {
|
|||
color = colorReset
|
||||
}
|
||||
|
||||
timestamp := r.Time.Format("15:04:05")
|
||||
timestamp := r.Time.Format("3:04:05 PM")
|
||||
_, _ = fmt.Fprintf(h.w, "%s%s%s [%s%s%s] %s", //nolint:errcheck
|
||||
colorGray, timestamp, colorReset,
|
||||
color, level, colorReset,
|
||||
r.Message)
|
||||
|
||||
for _, a := range h.preAttrs {
|
||||
_, _ = fmt.Fprintf(h.w, " %s=%v", a.Key, a.Value) //nolint:errcheck
|
||||
}
|
||||
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
_, _ = fmt.Fprintf(h.w, " %s=%v", a.Key, a.Value) //nolint:errcheck
|
||||
return true
|
||||
|
|
|
|||
3
signal-cli/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# signal-cli binaries
|
||||
|
||||
GraalVM native builds from https://media.projektzentrisch.de/temp/signal-cli/
|
||||