Compare commits

...

No commits in common. "v0.4.0" and "master" have entirely different histories.

83 changed files with 2167 additions and 1653 deletions

View file

@ -3,16 +3,16 @@
# Optional Configuration # Optional Configuration
# Optional: Enable integrations (default: false - only enable what you need) # Optional: Disable integrations (default: all enabled)
# ENABLE_SIGNAL=true # ENABLE_SIGNAL=false
# ENABLE_TELEGRAM=true # ENABLE_TELEGRAM=false
# ENABLE_PROTON=true # ENABLE_PROTON=false
# Optional: Server port (default: 8080) # Optional: Server port (default: 8080)
# PORT=8080 # PORT=8080
# Optional: Rate limit for requests (default: 100 per 15 minute window) # 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=100 # RATE_LIMIT=20
# Optional: Enable verbose logging (default: false) # Optional: Enable verbose logging (default: false)
# VERBOSE_LOGGING=false # VERBOSE_LOGGING=false

View file

@ -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. - **NO USELESS COMMENTS**: Don't add comments that just restate what the code does. If the code needs a comment to be understood, refactor it to be clearer instead.
- **Self-documenting code**: Use clear variable and function names. The code should explain itself. - **Self-documenting code**: Use clear variable and function names. The code should explain itself.
- **Only comment WHY, not WHAT**: If you must add a comment, explain WHY something is done, not WHAT is being done. - **Only comment WHY, not WHAT**: If you must add a comment, explain WHY something is done, not WHAT is being done.
- all terminal commands must work for zsh
### Bad Comments (Don't Do This) ### Bad Comments (Don't Do This)
@ -34,3 +35,23 @@ if time.Since(l.generatedAt) < l.ttl {
- Prefer composition over inheritance - Prefer composition over inheritance
- Handle errors properly, don't ignore them - 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.

View file

@ -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

View file

@ -6,6 +6,9 @@ on:
pull_request: pull_request:
branches: [master] branches: [master]
env:
GOLANGCI_LINT_VERSION: v2.8.0
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -15,20 +18,19 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version: '1.25' go-version: '1.26'
cache: true
- name: Cache Go modules
- name: Cache Go tools
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
path: ~/go/pkg/mod path: ~/go/bin
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} key: go-tools-${{ runner.os }}-goimports-latest-golangci-lint-${{ env.GOLANGCI_LINT_VERSION }}
restore-keys: |
${{ runner.os }}-go-
- name: Install tools - name: Install tools
run: | run: |
go install golang.org/x/tools/cmd/goimports@latest which goimports || go install golang.org/x/tools/cmd/goimports@latest
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0 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 run: make all

View file

@ -30,4 +30,6 @@ jobs:
push: true push: true
build-args: | build-args: |
VERSION=dev VERSION=dev
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ghcr.io/lone-cloud/prism:dev tags: ghcr.io/lone-cloud/prism:dev

View file

@ -16,7 +16,8 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6
with: with:
go-version: '1.25' go-version: '1.26'
cache: true
- name: Extract version - name: Extract version
id: version id: version
@ -28,14 +29,16 @@ jobs:
VERSION=$(cat VERSION) VERSION=$(cat VERSION)
# Linux AMD64 # Linux AMD64
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOTOOLCHAIN=local go build \
-trimpath \ -trimpath \
-buildvcs=false \
-ldflags="-s -w -X main.version=$VERSION" \ -ldflags="-s -w -X main.version=$VERSION" \
-o prism-linux-amd64 . -o prism-linux-amd64 .
# Linux ARM64 # Linux ARM64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build \ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 GOTOOLCHAIN=local go build \
-trimpath \ -trimpath \
-buildvcs=false \
-ldflags="-s -w -X main.version=$VERSION" \ -ldflags="-s -w -X main.version=$VERSION" \
-o prism-linux-arm64 . -o prism-linux-arm64 .
@ -57,6 +60,8 @@ jobs:
push: true push: true
build-args: | build-args: |
VERSION=${{ steps.version.outputs.tag }} VERSION=${{ steps.version.outputs.tag }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: | tags: |
ghcr.io/lone-cloud/prism:${{ steps.version.outputs.tag }} ghcr.io/lone-cloud/prism:${{ steps.version.outputs.tag }}
ghcr.io/lone-cloud/prism:latest ghcr.io/lone-cloud/prism:latest

3
.gitignore vendored
View file

@ -5,3 +5,6 @@ data/
prism-* prism-*
tmp tmp
.VSCodeCounter/ .VSCodeCounter/
.image-digests
.github/skills/
.impeccable.md

View file

@ -2,16 +2,18 @@ FROM golang:1.26-alpine3.23 AS builder
WORKDIR /build WORKDIR /build
RUN apk add --no-cache git ca-certificates RUN apk add --no-cache ca-certificates
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN VERSION=$(cat VERSION 2>/dev/null | tr -d '\n' || echo "dev") && \ RUN --mount=type=cache,target=/root/.cache/go \
CGO_ENABLED=0 GOOS=linux go build \ VERSION=$(cat VERSION 2>/dev/null | tr -d '\n' || echo "dev") && \
CGO_ENABLED=0 GOOS=linux GOTOOLCHAIN=local go build \
-trimpath \ -trimpath \
-buildvcs=false \
-ldflags="-w -s -X main.version=${VERSION}" \ -ldflags="-w -s -X main.version=${VERSION}" \
-o prism . -o prism .

View file

@ -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 BINARY_NAME=prism
VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev") VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev")
@ -18,18 +18,13 @@ dev:
air air
fix: fix:
go mod download
go mod tidy
gofmt -s -w . gofmt -s -w .
goimports -w . goimports -w .
golangci-lint run --fix golangci-lint run --fix
npx @biomejs/biome@latest check --write --unsafe . npx @biomejs/biome@latest check --write --unsafe .
vet:
go vet ./...
clean:
rm -f $(BINARY_NAME) $(BINARY_NAME)-*
rm -rf data/
install-tools: install-tools:
@echo "Installing Go tools to $(GOBIN)..." @echo "Installing Go tools to $(GOBIN)..."
go install golang.org/x/tools/cmd/goimports@latest go install golang.org/x/tools/cmd/goimports@latest
@ -47,23 +42,32 @@ install-tools:
signal-cli --version && \ signal-cli --version && \
echo "signal-cli installed successfully to /usr/local/bin/signal-cli" echo "signal-cli installed successfully to /usr/local/bin/signal-cli"
deps:
go mod download
go mod tidy
check-updates: 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" @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"
update: @echo ""
go get -u ./... @echo "=== Dockerfile base image updates ==="
go mod tidy @for image in $$(grep -E '^FROM ' Dockerfile | awk '{print $$2}' | grep -v 'AS'); do \
echo "Checking $$image..."; \
update-all: name=$$(echo $$image | cut -d: -f1); \
go get -u all tag=$$(echo $$image | cut -d: -f2); \
go mod tidy digest=$$(curl -sf "https://hub.docker.com/v2/repositories/library/$$name/tags/$$tag" | jq -r '.digest // empty'); \
if [ -z "$$digest" ]; then \
docker-up: echo " $$image: could not fetch digest (offline or image not found)"; \
docker compose -f docker-compose.dev.yml up -d 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: release:
@if [ ! -f VERSION ]; then \ @if [ ! -f VERSION ]; then \

169
README.md
View file

@ -1,67 +1,64 @@
<div align="center"> <div align="center">
<img src="assets/prism.webp" alt="Prism Icon" width="160" height="160" /> <img src="assets/prism.webp" alt="Prism Icon" width="80" height="80" />
# Prism # Prism
**Self-hosted notification gateway with email monitoring** **Private notification gateway**
[Setup](#setup) • [Integrations](#integrations) • [API](#api) • [Examples](#real-world-examples) [Setup](#setup) • [Integrations](#integrations) • [API](#api) • [Examples](#real-world-examples) • [Monitoring](#monitoring)
</div> </div>
<!-- markdownlint-enable MD033 --> <!-- markdownlint-enable MD033 -->
Prism is a self-hosted notification gateway. Prism can receive messages and route them to Signal, Telegram or WebPush URLs. Messages can be sent via webhooks or 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.
Prism also comes with an Android companion app: [prism-android](https://github.com/lone-cloud/prism-android) 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 ## Setup
### Docker (Recommended) ### Docker (Recommended)
```bash ```bash
# Create .env file
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example
mv .env.example .env mv .env.example .env
nano .env # Set API_KEY=your-secret-key-here nano .env # Set API_KEY=your-secret-key-here
# Run Prism curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/docker-compose.yml
docker run -d \ docker compose up -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
``` ```
### Binary (Alternative) ### Binary (Alternative)
```bash ```bash
# Download latest release
curl -L -O https://github.com/lone-cloud/prism/releases/latest/download/prism-linux-amd64 curl -L -O https://github.com/lone-cloud/prism/releases/latest/download/prism-linux-amd64
chmod +x prism-linux-amd64 chmod +x prism-linux-amd64
mv prism-linux-amd64 prism mv prism-linux-amd64 prism
# Create .env file
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example
mv .env.example .env mv .env.example .env
nano .env # Set API_KEY=your-secret-key-here nano .env # Set API_KEY=your-secret-key-here
# Run Prism
./prism ./prism
``` ```
Prism is now running at <http://localhost:8080>. Prism is now running at <http://localhost:8080>.
![Prism Dashboard](assets/screenshots/dashboard.webp) ## 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 ## Integrations
All integrations are configured through the web UI - no environment variables or command-line setup needed! All integrations are configured through the web UI. Authenticate with your `API_KEY` as the password (username can be anything).
Authenticate using your `API_KEY` as the password (username can be anything).
### Signal ### Signal
@ -77,9 +74,7 @@ Send notifications through Signal Messenger.
- Scan the displayed QR code - Scan the displayed QR code
5. Your device will link automatically 5. Your device will link automatically
All notifications will be sent via Signal. > **Note:** Binary installs require [signal-cli](https://github.com/AsamK/signal-cli/releases) in your PATH. Docker includes it automatically.
**Note:** If running the binary directly (not Docker), you'll need [signal-cli](https://github.com/AsamK/signal-cli/releases) installed and in your PATH. Docker images include signal-cli automatically.
### Telegram ### Telegram
@ -102,20 +97,10 @@ Send notifications through a Telegram bot.
- Enter your bot token and chat ID - Enter your bot token and chat ID
- Click "Configure" - Click "Configure"
All notifications will be sent to your Telegram chat.
### Proton Mail ### Proton Mail
Monitor a Proton Mail account and forward new emails as notifications through Signal or Telegram. 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:** **Setup:**
1. Visit <http://localhost:8080> and authenticate with your API_KEY 1. Visit <http://localhost:8080> and authenticate with your API_KEY
@ -127,9 +112,7 @@ Monitor a Proton Mail account and forward new emails as notifications through Si
- 2FA code (if enabled) - 2FA code (if enabled)
5. Click "Link" 5. Click "Link"
Proton Mail will connect and begin monitoring. New emails will appear as notifications from the "Proton Mail" app. New emails appear as notifications from the "Proton Mail" app. Credentials are encrypted (AES-256-GCM) and tokens refresh automatically.
**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.
### WebPush ### WebPush
@ -141,40 +124,6 @@ Send notifications directly to your browser.
2. Allow browser notifications when prompted 2. Allow browser notifications when prompted
3. Apps without Signal or Telegram configured will automatically use WebPush 3. Apps without Signal or Telegram configured will automatically use WebPush
You'll receive browser notifications when messages arrive.
## 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:
```bash
prism_api_key: "Bearer YOUR_API_KEY_HERE"
```
Reboot your Home Assistant system and you'll then be able to send Signal notifications to yourself by using this notify prism action.
## API ## API
All API endpoints require authentication with your API key: All API endpoints require authentication with your API key:
@ -198,6 +147,15 @@ curl -X POST http://localhost:8080/my-app \
-d '{"title": "Alert", "message": "Something happened"}' -d '{"title": "Alert", "message": "Something happened"}'
``` ```
**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:** **Plain text payload:**
```bash ```bash
@ -259,13 +217,72 @@ curl -X DELETE http://localhost:8080/api/v1/webpush/subscriptions/SUBSCRIPTION_I
-H "Authorization: Bearer YOUR_API_KEY" -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 ## Monitoring
### Health Endpoints ### Health Endpoints
#### GET /health #### 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 ```bash
curl http://localhost:8080/health curl http://localhost:8080/health
@ -273,7 +290,7 @@ curl http://localhost:8080/health
#### GET /api/v1/health #### GET /api/v1/health
Detailed health endpoint (requires authentication). Returns JSON with uptime and integration status: Authenticated. Returns uptime and integration status:
```bash ```bash
curl http://localhost:8080/api/v1/health \ curl http://localhost:8080/api/v1/health \
@ -282,14 +299,10 @@ curl http://localhost:8080/api/v1/health \
```json ```json
{ {
"version": "0.2.0", "version": "1.2.0",
"uptime": "2h15m", "uptime": "2h15m",
"signal": {"linked": true, "account": "+1234567890"}, "signal": {"linked": true, "account": "+1234567890"},
"telegram": {"linked": true, "account": "123456789"}, "telegram": {"linked": true, "account": "123456789"},
"proton": {"linked": true, "account": "user@proton.me"} "proton": {"linked": true, "account": "user@proton.me"}
} }
``` ```
## API Key Security
Your API_KEY is both the login password and the master encryption key for all integration credentials. Use a strong unique password. Changing it will make all encrypted credentials unrecoverable.

View file

@ -1 +1 @@
0.4.0 1.4.2

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

48
go.mod
View file

@ -1,45 +1,51 @@
module prism module prism
go 1.25.6 go 1.26
require ( require (
github.com/SherClockHolmes/webpush-go v1.4.0 github.com/SherClockHolmes/webpush-go v1.4.0
github.com/emersion/hydroxide v0.2.31 github.com/emersion/hydroxide v0.2.32
github.com/go-chi/chi/v5 v5.2.5 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/joho/godotenv v1.5.1
github.com/mymmrac/telego v1.6.0 github.com/mymmrac/telego v1.9.0
golang.org/x/crypto v0.48.0 github.com/yeqown/go-qrcode/v2 v2.2.5
golang.org/x/time v0.14.0 github.com/yeqown/go-qrcode/writer/standard v1.3.0
modernc.org/sqlite v1.45.0 golang.org/x/crypto v0.52.0
modernc.org/sqlite v1.50.1
) )
require ( require (
github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.1 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 // 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-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/google/uuid v1.6.0 // indirect
github.com/grbit/go-json v0.11.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/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/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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect github.com/valyala/fasthttp v1.71.0 // indirect
github.com/valyala/fastjson v1.6.7 // indirect github.com/valyala/fastjson v1.6.10 // indirect
golang.org/x/arch v0.24.0 // indirect github.com/yeqown/reedsolomon v1.0.0 // indirect
golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/arch v0.26.0 // indirect
modernc.org/gc/v3 v3.1.2 // indirect golang.org/x/image v0.39.0 // indirect
modernc.org/libc v1.67.7 // 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/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
) )

113
go.sum
View file

@ -1,15 +1,15 @@
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 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 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= 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 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
@ -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/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 h1:7aCSuwTBzg7BCPRRaBJD0weKZYdeAykOrY6ktpx8Vvc=
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63/go.mod h1:eRwwJnuLVFtYTC+AI2JDJTMcuQUTYhBIK4I6bC5tpqw= 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.32 h1:Lg6GtUDgG+pGoKgKwlTxgTr4jL/Of9iqsqFhhN5RLDA=
github.com/emersion/hydroxide v0.2.31/go.mod h1:jhoMVyP0z2GACrmFkL0ppcWKt2LbC02auADSSsB4vH4= github.com/emersion/hydroxide v0.2.32/go.mod h1:ENJLlgG+CrTEhAuBARo7LXNsNkhsm+8FvKh1EJqosMg=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= 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.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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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/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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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/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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= github.com/mymmrac/telego v1.9.0 h1:ZUJxZaPx/1IgRvVb5lXnUB8FgW5rNYfRe6Q2EJ4OJ+Y=
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= 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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 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/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 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 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.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= 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 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 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/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 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= 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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 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.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260209203927-2842357ff358 h1:kpfSV7uLwKJbFSEgNhWzGSL47NDSF/5pYYQw1V0ub6c= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8= 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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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-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-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-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-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.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.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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.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.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.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/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-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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 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 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= 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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 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.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 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 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs= modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= 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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View file

@ -29,7 +29,7 @@ func init() {
func main() { func main() {
if len(os.Args) > 1 && os.Args[1] == "version" { if len(os.Args) > 1 && os.Args[1] == "version" {
fmt.Printf("Prism %s\n", version) fmt.Println("Prism v", version)
return return
} }
@ -49,7 +49,7 @@ func main() {
} }
func runServer(cfg *config.Config, logger *slog.Logger) error { func runServer(cfg *config.Config, logger *slog.Logger) error {
srv, err := server.New(cfg, publicAssets, version) srv, err := server.New(cfg, publicAssets, version, logger)
if err != nil { if err != nil {
return fmt.Errorf("failed to create server: %w", err) return fmt.Errorf("failed to create server: %w", err)
} }

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View file

@ -1,39 +1,34 @@
/* Variables */ /* Variables */
:root { :root {
--bg-primary: #f5f5f5; color-scheme: light dark;
--bg-secondary: #fdfdfd; --bg-primary: light-dark(#f5f7fa, #141618);
--text-primary: #000; --bg-secondary: light-dark(#fafbfd, #1e2124);
--text-secondary: #666; --text-primary: light-dark(#0f1115, #dde1e7);
--text-on-color: #fff; --text-secondary: light-dark(#566070, #8ea0b5);
--border-color: rgba(0, 0, 0, 0.1); --text-on-color: #f8fafc;
--accent: #56c3de; --border-color: light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1));
--success: #06d6a0; --accent: light-dark(#0b74b5, #71c9f8);
--error: #ef476f; --accent-hover: light-dark(#0a6399, #4db8f5);
--spinner-track: #e0e0e0; --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[data-theme="light"] {
:root:not([data-theme="light"]) { color-scheme: 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: #56c3de;
--spinner-track: #4a4a4a;
}
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
--bg-primary: #1a1a1a; color-scheme: dark;
--bg-secondary: #2d2d2d;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-on-color: #fff;
--border-color: rgba(255, 255, 255, 0.1);
--accent: #56c3de;
--spinner-track: #4a4a4a;
} }
/* Reset */ /* Reset */
@ -81,9 +76,8 @@ h6 {
/* Base */ /* Base */
body { body {
font-family: system-ui; font-family:
line-height: 1.5; ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
max-width: 50rem; max-width: 50rem;
margin: 1rem auto; margin: 1rem auto;
padding: 0 1.25rem 1.25rem; padding: 0 1.25rem 1.25rem;
@ -101,21 +95,23 @@ a {
} }
a:hover { a:hover {
opacity: 0.8; color: var(--accent-hover);
} }
/* Components */ /* Components */
/* Cards */ /* Cards */
.card { .card,
.integration-card {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 0.5rem; border-radius: 0.5rem;
padding: 0; padding: 0;
margin-bottom: 1rem; margin-bottom: 1.25rem;
box-shadow: 0 0.125rem 0.25rem var(--border-color); box-shadow: 0 0.125rem 0.25rem var(--border-color);
} }
.card-header { .card-header,
.integration-header {
font-weight: 600; font-weight: 600;
font-size: 1.1em; font-size: 1.1em;
display: flex; display: flex;
@ -127,11 +123,13 @@ a:hover {
user-select: none; user-select: none;
} }
.card-header::-webkit-details-marker { .card-header::-webkit-details-marker,
.integration-header::-webkit-details-marker {
display: none; display: none;
} }
.card-header::before { .card-header::before,
.integration-header::before {
content: "▶"; content: "▶";
font-size: 0.7em; font-size: 0.7em;
margin-right: 0.5rem; margin-right: 0.5rem;
@ -139,14 +137,39 @@ a:hover {
display: inline-block; display: inline-block;
} }
details[open] > .card-header::before { details[open] > .card-header::before,
details[open] > .integration-header::before {
transform: rotate(90deg); transform: rotate(90deg);
} }
.card-content { .card-content,
.integration-content {
padding: 0 1.25rem 1.25rem 1.25rem; 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 */ /* Tooltips */
.integration-status:has(.tooltip) { .integration-status:has(.tooltip) {
cursor: help; cursor: help;
@ -160,18 +183,18 @@ details[open] > .card-header::before {
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
background: var(--bg-secondary); background: #1e2124;
color: var(--text-primary); color: #fafbfd;
padding: 0.375rem 0.625rem; padding: 0.375rem 0.625rem;
border-radius: 0.25rem; border-radius: 0.25rem;
border: 0.0625rem solid var(--border-color); border: none;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 400; font-weight: 400;
white-space: nowrap; white-space: nowrap;
transition: opacity 0.2s; transition: opacity 0.2s;
pointer-events: none; pointer-events: none;
z-index: 10; z-index: 10;
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.3); box-shadow: 0 0.25rem 0.5rem var(--shadow-tooltip);
} }
.tooltip::after { .tooltip::after {
@ -181,10 +204,11 @@ details[open] > .card-header::before {
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
border: 0.3125rem solid transparent; border: 0.3125rem solid transparent;
border-top-color: var(--bg-secondary); border-top-color: #1e2124;
} }
:hover > .tooltip { :hover > .tooltip,
:focus-within > .tooltip {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
} }
@ -202,6 +226,10 @@ details[open] > .card-header::before {
line-height: 1; line-height: 1;
opacity: 0.6; opacity: 0.6;
transition: opacity 0.2s; transition: opacity 0.2s;
min-width: 2.75rem;
min-height: 2.75rem;
display: grid;
place-items: center;
} }
.theme-toggle:hover { .theme-toggle:hover {
@ -212,8 +240,6 @@ details[open] > .card-header::before {
.app-list { .app-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
max-height: 18.75rem;
overflow: visible;
} }
.app-item { .app-item {
@ -231,17 +257,23 @@ details[open] > .card-header::before {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.75rem;
} }
.app-name { .app-name {
font-size: 1.2em; font-size: 1.2em;
} }
.app-subscriptions {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.channel-badge { .channel-badge {
padding: 0.15rem 0.5rem; padding: 0.375rem 0.75rem;
border-radius: 0.25rem; border-radius: 1rem;
font-size: 0.85em; font-size: 0.875em;
font-weight: 500; font-weight: 500;
position: relative; position: relative;
border: none; border: none;
@ -250,16 +282,20 @@ details[open] > .card-header::before {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.25rem; gap: 0.25rem;
min-width: 5rem;
} }
.badge-active { .badge-active {
cursor: pointer; cursor: pointer;
transition: filter 0.2s ease; transition: background-color 0.2s ease;
min-height: 2.75rem;
} }
.badge-active:hover { .badge-active:hover {
filter: brightness(0.9); background-color: var(--channel-signal-hover);
}
.channel-webpush.badge-active:hover {
background-color: var(--channel-webpush-hover);
} }
.badge-active.htmx-request { .badge-active.htmx-request {
@ -272,10 +308,11 @@ details[open] > .card-header::before {
cursor: pointer; cursor: pointer;
border: 0.0625rem dashed currentColor; border: 0.0625rem dashed currentColor;
transition: filter 0.2s ease; transition: filter 0.2s ease;
min-height: 2.75rem;
} }
.badge-inactive:hover { .badge-inactive:hover {
filter: brightness(1.2); filter: brightness(1.15);
} }
.badge-inactive.htmx-request { .badge-inactive.htmx-request {
@ -289,8 +326,8 @@ details[open] > .card-header::before {
} }
.channel-signal.badge-active { .channel-signal.badge-active {
background: var(--accent); background: var(--channel-signal-bg);
color: var(--text-on-color); color: var(--accent);
} }
.channel-signal.badge-inactive { .channel-signal.badge-inactive {
@ -299,23 +336,23 @@ details[open] > .card-header::before {
} }
.channel-signal.badge-subscribed { .channel-signal.badge-subscribed {
background: var(--accent); background: var(--channel-signal-bg);
color: var(--text-on-color); color: var(--accent);
} }
.channel-webpush.badge-active { .channel-webpush.badge-active {
background: var(--success); background: var(--channel-webpush-bg);
color: var(--text-on-color); color: var(--success);
} }
.channel-webpush.badge-subscribed { .channel-webpush.badge-subscribed {
background: var(--accent); background: var(--channel-signal-bg);
color: var(--text-on-color); color: var(--accent);
} }
.channel-telegram.badge-active { .channel-telegram.badge-active {
background: var(--accent); background: var(--channel-signal-bg);
color: var(--text-on-color); color: var(--accent);
} }
.channel-telegram.badge-inactive { .channel-telegram.badge-inactive {
@ -324,8 +361,8 @@ details[open] > .card-header::before {
} }
.channel-telegram.badge-subscribed { .channel-telegram.badge-subscribed {
background: var(--accent); background: var(--channel-signal-bg);
color: var(--text-on-color); color: var(--accent);
} }
.app-actions { .app-actions {
@ -334,21 +371,6 @@ details[open] > .card-header::before {
align-items: center; align-items: center;
} }
.btn-delete {
padding: 0.375rem 0.75rem;
background: var(--error);
color: var(--text-on-color);
border: none;
border-radius: 0.25rem;
cursor: pointer;
transition: filter 0.2s ease;
white-space: nowrap;
}
.btn-delete:hover {
filter: brightness(0.85);
}
.btn-delete-sub { .btn-delete-sub {
background: none; background: none;
border: none; border: none;
@ -356,10 +378,15 @@ details[open] > .card-header::before {
font-size: 1em; font-size: 1em;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
padding: 0 0.25rem; padding: 0.125rem 0.25rem;
margin-left: 0.25rem; margin-left: 0.125rem;
opacity: 0.7; opacity: 0.7;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
line-height: 1;
min-width: 1rem;
min-height: 1.5rem;
display: inline-grid;
place-items: center;
} }
.btn-delete-sub:hover { .btn-delete-sub:hover {
@ -390,77 +417,51 @@ details[open] > .card-header::before {
} }
/* Integrations */ /* 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 { .integration-container {
margin-bottom: 1.25rem; 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 { .integration-status {
padding: 0.25rem 0.625rem;
border-radius: 0.25rem;
font-size: 0.875em; font-size: 0.875em;
font-weight: 500; font-weight: 500;
position: relative; 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.connected,
.integration-status.available { .integration-status.available {
background: var(--success); color: var(--success);
color: var(--text-on-color); }
.integration-status.disconnected::before {
background: var(--error);
} }
.integration-status.disconnected { .integration-status.disconnected {
background: var(--error); color: var(--error);
color: var(--text-on-color); }
.integration-status.unlinked::before {
background: var(--text-secondary);
} }
.integration-status.unlinked { .integration-status.unlinked {
background: var(--bg-secondary); color: var(--text-secondary);
color: var(--text-primary);
border: 0.0625rem solid var(--border-color);
} }
.link-instructions { .link-instructions {
@ -470,14 +471,23 @@ details[open] > .integration-header::before {
} }
.channel-not-configured { .channel-not-configured {
background: var(--error); background: var(--error-bg);
color: var(--text-on-color); 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 */ /* Forms */
.auth-form { .auth-form {
margin-top: 1rem; margin-top: 1rem;
max-width: 400px; max-width: 25rem;
} }
.form-group { .form-group {
@ -496,13 +506,62 @@ details[open] > .integration-header::before {
.form-group input[type="password"] { .form-group input[type="password"] {
width: 100%; width: 100%;
padding: 0.5rem; padding: 0.5rem;
border: 1px solid var(--border-color); border: 0.0625rem solid var(--border-color);
border-radius: 0.25rem; border-radius: 0.25rem;
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-primary); color: var(--text-primary);
font-size: 1rem; 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 */ /* Buttons */
.btn-primary, .btn-primary,
.btn-secondary, .btn-secondary,
@ -513,13 +572,19 @@ details[open] > .integration-header::before {
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
font-weight: 500; 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 { .btn-danger:hover {
filter: brightness(0.85); background-color: var(--error-hover);
} }
.btn-primary:disabled, .btn-primary:disabled,
@ -531,7 +596,7 @@ details[open] > .integration-header::before {
.btn-primary { .btn-primary {
background: var(--accent); background: var(--accent);
color: var(--text-on-color); color: var(--text-on-accent);
} }
.btn-secondary { .btn-secondary {
@ -541,7 +606,7 @@ details[open] > .integration-header::before {
} }
.btn-danger { .btn-danger {
background: var(--error); background: var(--error-bg);
color: var(--text-on-color); color: var(--text-on-color);
} }
@ -554,13 +619,13 @@ details[open] > .integration-header::before {
.auth-status.success { .auth-status.success {
display: block; display: block;
background: var(--success); background: var(--success-bg);
color: var(--text-on-color); color: var(--text-on-color);
} }
.auth-status.error { .auth-status.error {
display: block; display: block;
background: var(--error); background: var(--error-bg);
color: var(--text-on-color); color: var(--text-on-color);
} }
@ -569,7 +634,7 @@ details[open] > .integration-header::before {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
background: var(--accent); background: var(--accent);
color: var(--text-on-color); color: var(--text-on-accent);
} }
/* QR Code */ /* QR Code */
@ -591,12 +656,13 @@ details[open] > .integration-header::before {
max-width: 25rem; max-width: 25rem;
height: auto; height: auto;
display: block; display: block;
aspect-ratio: 1;
} }
/* Toast Notifications */ /* Toast Notifications */
#toast-container { #toast-container {
position: fixed; position: fixed;
bottom: 1.5rem; bottom: 4rem;
right: 1.5rem; right: 1.5rem;
z-index: 9999; z-index: 9999;
display: flex; display: flex;
@ -611,7 +677,7 @@ details[open] > .integration-header::before {
padding: 0.875rem 1.25rem; padding: 0.875rem 1.25rem;
border-radius: 0.375rem; border-radius: 0.375rem;
box-shadow: box-shadow:
0 0.25rem 0.5rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.5rem var(--shadow-tooltip),
0 0 0 0.0625rem var(--border-color); 0 0 0 0.0625rem var(--border-color);
min-width: 15rem; min-width: 15rem;
max-width: 25rem; max-width: 25rem;
@ -623,18 +689,18 @@ details[open] > .integration-header::before {
} }
.toast.success { .toast.success {
background: var(--success); background: var(--success-bg);
color: var(--text-on-color); color: var(--text-on-color);
} }
.toast.error { .toast.error {
background: var(--error); background: var(--error-bg);
color: var(--text-on-color); color: var(--text-on-color);
} }
.toast.info { .toast.info {
background: var(--accent); background: var(--accent);
color: var(--text-on-color); color: var(--text-on-accent);
} }
.toast.hiding { .toast.hiding {
@ -662,3 +728,120 @@ details[open] > .integration-header::before {
opacity: 0; 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;
}
}

View file

@ -2,38 +2,46 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Prism Admin</title> <title>Prism</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#56C3DE"> <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="icon" type="image/webp" href="/favicon.webp">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/index.css?v={{.Version}}"> <link rel="stylesheet" href="/index.css?v={{.Version}}">
<script src="/htmx.min.js"></script> <script src="/theme-init.js?v={{.Version}}"></script>
<script src="/theme.js?v={{.Version}}"></script> <script src="/htmx.min.js" defer></script>
<script src="/toast.js?v={{.Version}}"></script> <script src="/theme.js?v={{.Version}}" defer></script>
<script src="/integration.js?v={{.Version}}"></script> <script src="/toast.js?v={{.Version}}" defer></script>
<script src="/integration.js?v={{.Version}}" defer></script>
</head> </head>
<body> <body>
<details class="card" open> <a href="#main-content" class="skip-nav">Skip to content</a>
<summary class="card-header"> <noscript><p class="noscript-msg">JavaScript is required to use Prism.</p></noscript>
<span class="integration-name">Registered Apps</span> <main id="main-content">
</summary> <h1 class="sr-only">Prism</h1>
<div id="apps-list" class="card-content" <details class="card" open>
hx-get="/fragment/apps" <summary class="card-header">
hx-trigger="load"> <h2>Registered Apps</h2>
<div class="loading"> </summary>
<div class="spinner"></div> <div id="apps-list" class="card-content"
hx-get="/fragment/apps"
hx-trigger="load, reload">
<div class="loading" role="status">
<div class="spinner"></div>
<span class="sr-only">Loading…</span>
</div>
</div> </div>
</details>
<div id="integrations"
hx-get="/fragment/integrations"
hx-trigger="load, reload">
</div> </div>
</details> </main>
<div id="integrations" <div id="toast-container" role="status" aria-live="polite" aria-atomic="false"></div>
hx-get="/fragment/integrations"
hx-trigger="load, reload">
</div>
<div id="toast-container"></div> <button type="button" id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">🌓</button>
<button type="button" id="theme-toggle" class="theme-toggle"></button>
</body> </body>
</html> </html>

View file

@ -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('DOMContentLoaded', () => {
document.addEventListener('submit', (e) => { document.addEventListener('submit', (e) => {
const form = e.target; const form = e.target;
@ -18,14 +56,48 @@ document.addEventListener('DOMContentLoaded', () => {
else if (action === 'delete-telegram') deleteTelegram(btn); else if (action === 'delete-telegram') deleteTelegram(btn);
else if (action === 'delete-proton') deleteProton(btn); else if (action === 'delete-proton') deleteProton(btn);
else if (action === 'reload') reloadIntegrations(); 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() { function reloadIntegrations() {
const integrations = document.getElementById('integrations'); const integrations = document.getElementById('integrations');
if (integrations) { if (integrations) {
htmx.trigger(integrations, 'reload'); 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) { async function handleAuthForm(form, endpoint, statusId, getPayload) {
@ -112,11 +184,38 @@ async function submitProtonAuth(e) {
let signalLinkingPoll = null; 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) { async function linkSignal(btn) {
if (signalLinkingPoll) {
clearInterval(signalLinkingPoll);
signalLinkingPoll = null;
}
const qrContainer = document.getElementById('signal-qr-container'); const qrContainer = document.getElementById('signal-qr-container');
const qrCode = document.getElementById('signal-qr-code'); const qrCode = document.getElementById('signal-qr-code');
const showQrError = (msg) => { 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'; qrContainer.style.display = 'block';
btn.style.display = 'inline-block'; btn.style.display = 'inline-block';
}; };
@ -138,8 +237,7 @@ async function linkSignal(btn) {
} }
const data = await response.json(); const data = await response.json();
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(data.qr_code)}`; qrCode.src = data.qr_code;
qrCode.src = qrUrl;
qrContainer.style.display = 'block'; qrContainer.style.display = 'block';
let statusCheckInProgress = false; let statusCheckInProgress = false;
@ -153,8 +251,10 @@ async function linkSignal(btn) {
if (statusData.linked) { if (statusData.linked) {
clearInterval(signalLinkingPoll); clearInterval(signalLinkingPoll);
qrContainer.innerHTML = const linked = document.createElement('p');
'<p class="auth-status success">Linked! Refreshing...</p>'; linked.className = 'auth-status success';
linked.textContent = 'Linked! Refreshing...';
qrContainer.replaceChildren(linked);
showToast('Signal linked', 'success'); showToast('Signal linked', 'success');
setTimeout(() => reloadIntegrations(), 1000); setTimeout(() => reloadIntegrations(), 1000);
} }
@ -164,51 +264,51 @@ async function linkSignal(btn) {
statusCheckInProgress = false; statusCheckInProgress = false;
} }
}, 2000); }, 2000);
setTimeout(() => {
if (signalLinkingPoll) {
clearInterval(signalLinkingPoll);
signalLinkingPoll = null;
showQrError('QR code expired. Try again.');
}
}, 300000);
} catch (err) { } catch (err) {
showQrError(err.message); showQrError(err.message);
} }
} }
async function deleteTelegram(btn) { async function deleteTelegram(btn) {
if (!confirm('Unlink Telegram integration?')) return; showConfirm(btn, 'Unlink Telegram?', async () => {
try {
btn.disabled = true; const response = await fetch('/api/v1/telegram/link', {
method: 'DELETE',
try { });
const response = await fetch('/api/v1/telegram/link', { method: 'DELETE' }); if (response.ok) {
checkToast(response);
if (response.ok) { reloadIntegrations();
checkToast(response); } else {
reloadIntegrations(); const error = await response.json();
} else { showToast(error.error || 'Failed to unlink Telegram', 'error');
const error = await response.json(); }
alert(`Error: ${error.error || 'Failed to unlink integration'}`); } catch (err) {
btn.disabled = false; showToast(err.message, 'error');
} }
} catch (err) { });
alert(`Error: ${err.message}`);
btn.disabled = false;
}
} }
async function deleteProton(btn) { async function deleteProton(btn) {
if (!confirm('Unlink Proton Mail integration?')) return; showConfirm(btn, 'Unlink Proton Mail?', async () => {
try {
btn.disabled = true; const response = await fetch('/api/v1/proton/auth', { method: 'DELETE' });
if (response.ok) {
try { checkToast(response);
const response = await fetch('/api/v1/proton/auth', { method: 'DELETE' }); reloadIntegrations();
} else {
if (response.ok) { const error = await response.json();
checkToast(response); showToast(error.error || 'Failed to unlink Proton', 'error');
reloadIntegrations(); }
} else { } catch (err) {
const error = await response.json(); showToast(err.message, 'error');
alert(`Error: ${error.error || 'Failed to unlink integration'}`);
btn.disabled = false;
} }
} catch (err) { });
alert(`Error: ${err.message}`);
btn.disabled = false;
}
} }

View file

@ -1,17 +1,28 @@
{ {
"name": "Prism Admin", "name": "Prism",
"short_name": "Prism", "short_name": "Prism",
"description": "Notification gateway admin panel", "description": "Notification gateway admin panel",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"background_color": "#1a1a1a", "background_color": "#141618",
"theme_color": "#56C3DE", "theme_color": "#0b74b5",
"icons": [ "icons": [
{ {
"src": "/favicon.webp", "src": "/favicon.webp",
"sizes": "32x32", "sizes": "32x32",
"type": "image/webp", "type": "image/webp"
"purpose": "any maskable" },
{
"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
View file

@ -0,0 +1,5 @@
(() => {
var t = localStorage.getItem('theme');
if (t && t !== 'system')
document.documentElement.setAttribute('data-theme', t);
})();

View file

@ -23,8 +23,14 @@ window.cycleTheme = () => {
function updateButtonText(theme) { function updateButtonText(theme) {
const btn = document.getElementById('theme-toggle'); const btn = document.getElementById('theme-toggle');
if (btn) { if (btn) {
const labels = {
system: 'Toggle theme (system)',
light: 'Toggle theme (light)',
dark: 'Toggle theme (dark)',
};
btn.textContent = btn.textContent =
theme === 'system' ? '🌓' : theme === 'light' ? '☀️' : '🌙'; theme === 'system' ? '🌓' : theme === 'light' ? '☀️' : '🌙';
btn.setAttribute('aria-label', labels[theme] || 'Toggle theme');
} }
} }

View file

@ -5,16 +5,42 @@ function showToast(message, type = 'info') {
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.className = `toast ${type}`; toast.className = `toast ${type}`;
toast.textContent = message; toast.textContent = message;
toast.setAttribute('tabindex', '0');
container.appendChild(toast); container.appendChild(toast);
setTimeout(() => { let timeoutId;
const dismiss = () => {
toast.classList.add('hiding'); toast.classList.add('hiding');
setTimeout(() => toast.remove(), 300); setTimeout(() => toast.remove(), 300);
}, 3000); };
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('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) => { document.body.addEventListener('htmx:afterRequest', (event) => {
const xhr = event.detail.xhr; const xhr = event.detail.xhr;
const triggerHeader = xhr.getResponseHeader('HX-Trigger'); const triggerHeader = xhr.getResponseHeader('HX-Trigger');

View file

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"unicode"
) )
type Config struct { type Config struct {
@ -22,11 +23,11 @@ func Load() (*Config, error) {
APIKey: os.Getenv("API_KEY"), APIKey: os.Getenv("API_KEY"),
Port: getEnvInt("PORT", 8080), Port: getEnvInt("PORT", 8080),
VerboseLogging: getEnvBool("VERBOSE_LOGGING", false), VerboseLogging: getEnvBool("VERBOSE_LOGGING", false),
RateLimit: getEnvInt("RATE_LIMIT", 100), RateLimit: getEnvInt("RATE_LIMIT", 20),
StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"), StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"),
EnableSignal: getEnvBool("ENABLE_SIGNAL", false), EnableSignal: getEnvBool("ENABLE_SIGNAL", true),
EnableTelegram: getEnvBool("ENABLE_TELEGRAM", false), EnableTelegram: getEnvBool("ENABLE_TELEGRAM", true),
EnableProton: getEnvBool("ENABLE_PROTON", false), EnableProton: getEnvBool("ENABLE_PROTON", true),
} }
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
@ -40,6 +41,11 @@ func (c *Config) Validate() error {
if c.APIKey == "" { if c.APIKey == "" {
return fmt.Errorf("API_KEY environment variable is required") 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 return nil
} }
@ -61,7 +67,9 @@ func getEnvInt(key string, defaultValue int) int {
func getEnvBool(key string, defaultValue bool) bool { func getEnvBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" { if value := os.Getenv(key); value != "" {
return value == "true" || value == "1" if b, err := strconv.ParseBool(value); err == nil {
return b
}
} }
return defaultValue return defaultValue
} }

View file

@ -73,10 +73,10 @@ func NewStoreWithLogger(db *sql.DB, masterPassword string, logger *slog.Logger)
return nil, err return nil, err
} }
if err := store.CheckIntegrity(); err != nil { if err := store.checkIntegrity(); err != nil {
if strings.Contains(err.Error(), "corrupted") { if strings.Contains(err.Error(), "corrupted") {
logger.Warn("Credentials corrupted (API_KEY likely changed), clearing all integration credentials", "error", err) 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) logger.Error("Failed to clear corrupted credentials", "error", clearErr)
} else { } else {
logger.Info("Cleared all integration credentials - please reconfigure integrations") 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 { func (s *Store) createTable() error {
query := ` _, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS integration_credentials ( CREATE TABLE IF NOT EXISTS integration_credentials (
integration_type TEXT PRIMARY KEY, integration_type TEXT PRIMARY KEY,
credentials_encrypted BLOB NOT NULL, credentials_encrypted BLOB NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT 1, enabled BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) )
` `)
_, err := s.db.Exec(query)
return err return err
} }
@ -151,109 +150,72 @@ func (s *Store) decrypt(ciphertext []byte) ([]byte, error) {
} }
func (s *Store) SaveProton(creds *ProtonCredentials) error { func (s *Store) SaveProton(creds *ProtonCredentials) error {
return s.saveCredentials(IntegrationProton, creds) return s.save(IntegrationProton, creds)
} }
func (s *Store) GetProton() (*ProtonCredentials, error) { func (s *Store) GetProton() (*ProtonCredentials, error) {
var creds ProtonCredentials var creds ProtonCredentials
err := s.getCredentials(IntegrationProton, &creds) return &creds, s.load(IntegrationProton, &creds)
if err != nil {
return nil, err
}
return &creds, nil
} }
func (s *Store) SaveTelegram(creds *TelegramCredentials) error { func (s *Store) SaveTelegram(creds *TelegramCredentials) error {
return s.saveCredentials(IntegrationTelegram, creds) return s.save(IntegrationTelegram, creds)
} }
func (s *Store) GetTelegram() (*TelegramCredentials, error) { func (s *Store) GetTelegram() (*TelegramCredentials, error) {
var creds TelegramCredentials var creds TelegramCredentials
err := s.getCredentials(IntegrationTelegram, &creds) return &creds, s.load(IntegrationTelegram, &creds)
if err != nil {
return nil, err
}
return &creds, nil
} }
func (s *Store) SaveSignal(creds *SignalCredentials) error { func (s *Store) save(integrationType IntegrationType, credentials interface{}) error {
return s.saveCredentials(IntegrationSignal, creds)
}
func (s *Store) GetSignal() (*SignalCredentials, error) {
var creds SignalCredentials
err := s.getCredentials(IntegrationSignal, &creds)
if err != nil {
return nil, err
}
return &creds, nil
}
func (s *Store) saveCredentials(integrationType IntegrationType, credentials interface{}) error {
jsonData, err := json.Marshal(credentials) jsonData, err := json.Marshal(credentials)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal credentials: %w", err) return fmt.Errorf("failed to marshal credentials: %w", err)
} }
encrypted, err := s.encrypt(jsonData) encrypted, err := s.encrypt(jsonData)
if err != nil { if err != nil {
return fmt.Errorf("failed to encrypt credentials: %w", err) return fmt.Errorf("failed to encrypt credentials: %w", err)
} }
_, err = s.db.Exec(`
query := `
INSERT INTO integration_credentials (integration_type, credentials_encrypted, updated_at) INSERT INTO integration_credentials (integration_type, credentials_encrypted, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(integration_type) DO UPDATE SET ON CONFLICT(integration_type) DO UPDATE SET
credentials_encrypted = excluded.credentials_encrypted, credentials_encrypted = excluded.credentials_encrypted,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
` `, string(integrationType), encrypted)
_, err = s.db.Exec(query, string(integrationType), encrypted)
return err return err
} }
func (s *Store) getCredentials(integrationType IntegrationType, dest interface{}) error { func (s *Store) load(integrationType IntegrationType, dest interface{}) error {
query := `
SELECT credentials_encrypted
FROM integration_credentials
WHERE integration_type = ? AND enabled = 1
`
var encrypted []byte 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 { if err == sql.ErrNoRows {
return fmt.Errorf("integration %s not configured", integrationType) return fmt.Errorf("integration %s not configured", integrationType)
} }
if err != nil { if err != nil {
return err return err
} }
decrypted, err := s.decrypt(encrypted) decrypted, err := s.decrypt(encrypted)
if err != nil { if err != nil {
return fmt.Errorf("failed to decrypt credentials: %w", err) return fmt.Errorf("failed to decrypt credentials: %w", err)
} }
if err := json.Unmarshal(decrypted, dest); err != nil { if err := json.Unmarshal(decrypted, dest); err != nil {
return fmt.Errorf("failed to unmarshal credentials: %w", err) return fmt.Errorf("failed to unmarshal credentials: %w", err)
} }
return nil return nil
} }
func (s *Store) DeleteIntegration(integrationType IntegrationType) error { func (s *Store) DeleteIntegration(integrationType IntegrationType) error {
query := `DELETE FROM integration_credentials WHERE integration_type = ?` _, err := s.db.Exec(`DELETE FROM integration_credentials WHERE integration_type = ?`, string(integrationType))
_, err := s.db.Exec(query, string(integrationType))
return err
}
func (s *Store) SetEnabled(integrationType IntegrationType, enabled bool) error {
query := `UPDATE integration_credentials SET enabled = ? WHERE integration_type = ?`
_, err := s.db.Exec(query, enabled, string(integrationType))
return err return err
} }
func (s *Store) IsEnabled(integrationType IntegrationType) (bool, error) { func (s *Store) IsEnabled(integrationType IntegrationType) (bool, error) {
query := `SELECT enabled FROM integration_credentials WHERE integration_type = ?`
var enabled bool 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 { if err == sql.ErrNoRows {
return false, nil return false, nil
} }
@ -263,15 +225,13 @@ func (s *Store) IsEnabled(integrationType IntegrationType) (bool, error) {
return enabled, nil return enabled, nil
} }
func (s *Store) ClearAll() error { func (s *Store) clearAll() error {
query := `DELETE FROM integration_credentials` _, err := s.db.Exec(`DELETE FROM integration_credentials`)
_, err := s.db.Exec(query)
return err return err
} }
func (s *Store) CheckIntegrity() error { func (s *Store) checkIntegrity() error {
query := `SELECT integration_type, credentials_encrypted FROM integration_credentials` rows, err := s.db.Query(`SELECT integration_type, credentials_encrypted FROM integration_credentials`)
rows, err := s.db.Query(query)
if err != nil { if err != nil {
return err return err
} }

View 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
}

View 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)
}

View 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"`
}

View 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
}

View file

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

View file

@ -2,102 +2,149 @@ package integration
import ( import (
"context" "context"
"database/sql" "embed"
"fmt" "fmt"
"html/template" "html/template"
"io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"prism/service/config" "prism/service/config"
"prism/service/delivery"
"prism/service/integration/proton" "prism/service/integration/proton"
"prism/service/integration/signal" "prism/service/integration/signal"
"prism/service/integration/telegram" "prism/service/integration/telegram"
"prism/service/integration/webpush" "prism/service/integration/webpush"
"prism/service/notification" "prism/service/subscription"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
//go:embed templates/*.html
var templates embed.FS
type Integration interface { 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) Start(ctx context.Context, logger *slog.Logger)
IsEnabled() bool IsEnabled() bool
Health() (linked bool, account string)
}
type AutoSubscriber interface {
AutoSubscribe(appName string, store *subscription.Store, publisher *delivery.Publisher) error
} }
type Integrations struct { type Integrations struct {
Dispatcher *notification.Dispatcher Publisher *delivery.Publisher
Signal *signal.Integration Signal *signal.Integration
Telegram *telegram.Integration Telegram *telegram.Integration
Proton *proton.Integration Proton *proton.Integration
store *subscription.Store
logger *slog.Logger
integrations []Integration integrations []Integration
} }
func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logger, baseTmpl *template.Template) (*Integrations, *template.Template, error) { func Initialize(cfg *config.Config, store *subscription.Store, logger *slog.Logger, baseTmpl *template.Template) (*Integrations, *template.Template, error) {
fragmentTmpl := baseTmpl
var err error var err error
baseTmpl, err = baseTmpl.ParseFS(templates, "templates/*.html")
fragmentTmpl, err = fragmentTmpl.ParseFS(GetTemplates(), "templates/*.html")
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to parse integration templates: %w", err) 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 signalIntegration *signal.Integration
var telegramIntegration *telegram.Integration var telegramIntegration *telegram.Integration
dispatcher := notification.NewDispatcher(store, logger)
var integrations []Integration
var protonIntegration *proton.Integration var protonIntegration *proton.Integration
integrations := []Integration{webpush.NewIntegration(store, logger)}
if cfg.EnableSignal { if cfg.EnableSignal {
signalIntegration = signal.NewIntegration(cfg, store, logger, tmplRenderer) tmplRenderer, err := newIntegrationRenderer(baseTmpl, "signal", signal.Templates)
if signalSender := signalIntegration.GetSender(); signalSender != nil { if err != nil {
dispatcher.RegisterSender(notification.ChannelSignal, signalSender) return nil, nil, err
} }
signalIntegration = signal.NewIntegration(cfg, store, logger, tmplRenderer)
integrations = append(integrations, signalIntegration) integrations = append(integrations, signalIntegration)
} }
if cfg.EnableTelegram { if cfg.EnableTelegram {
telegramIntegration = telegram.NewIntegration(cfg, store, logger, tmplRenderer) tmplRenderer, err := newIntegrationRenderer(baseTmpl, "telegram", telegram.Templates)
if telegramSender := telegramIntegration.GetSender(); telegramSender != nil { if err != nil {
dispatcher.RegisterSender(notification.ChannelTelegram, telegramSender) return nil, nil, err
} }
telegramIntegration = telegram.NewIntegration(store, logger, tmplRenderer, cfg.APIKey)
integrations = append(integrations, telegramIntegration) integrations = append(integrations, telegramIntegration)
} }
if cfg.EnableProton { 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) integrations = append(integrations, protonIntegration)
} }
dispatcher.RegisterSender(notification.ChannelWebPush, webpush.NewSender(logger)) i := &Integrations{
integrations = append(integrations, webpush.NewIntegration(store, logger))
return &Integrations{
Dispatcher: dispatcher,
Signal: signalIntegration, Signal: signalIntegration,
Telegram: telegramIntegration, Telegram: telegramIntegration,
Proton: protonIntegration, Proton: protonIntegration,
store: store,
logger: logger,
integrations: integrations, 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 { for _, integration := range i.integrations {
if integration.IsEnabled() { if integration.IsEnabled() {
integration.Start(ctx, logger) 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) auth := authMiddleware(cfg.APIKey)
db := store.GetDB()
for _, integration := range integrations.integrations { 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
}

View file

@ -11,7 +11,7 @@ import (
func (m *Monitor) authenticateAndSetup(credStore *credentials.Store) error { func (m *Monitor) authenticateAndSetup(credStore *credentials.Store) error {
creds, err := credStore.GetProton() creds, err := credStore.GetProton()
if err != nil { if err != nil {
m.logger.Debug("Proton credentials not configured", "error", err) m.logger.Info("Proton credentials not configured", "error", err)
return nil return nil
} }
@ -19,8 +19,9 @@ func (m *Monitor) authenticateAndSetup(credStore *credentials.Store) error {
m.logger.Info("Starting Proton Mail monitor", "email", creds.Email) m.logger.Info("Starting Proton Mail monitor", "email", creds.Email)
c := &protonmail.Client{ c := &protonmail.Client{
RootURL: "https://mail.proton.me/api", RootURL: protonAPIURL,
AppVersion: "Other", AppVersion: protonAppVersion,
Debug: false,
} }
var auth *protonmail.Auth var auth *protonmail.Auth
@ -101,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) { 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) newAuth, err := c.AuthRefresh(auth)
if err != nil { if err != nil {
m.logger.Error("Token refresh failed", "error", err) m.logger.Error("Token refresh failed", "error", err)
@ -128,7 +129,7 @@ func (m *Monitor) refreshTokens(c *protonmail.Client, auth *protonmail.Auth, cre
if err := m.credStore.SaveProton(updatedCreds); err != nil { if err := m.credStore.SaveProton(updatedCreds); err != nil {
m.logger.Warn("Failed to save refreshed tokens", "error", err) m.logger.Warn("Failed to save refreshed tokens", "error", err)
} else { } else {
m.logger.Info("Proton tokens refreshed and saved") m.logger.Debug("Proton tokens refreshed and saved")
} }
return newAuth, nil return newAuth, nil

View file

@ -16,8 +16,8 @@ type Handlers struct {
username string username string
logger *slog.Logger logger *slog.Logger
tmpl *util.TemplateRenderer tmpl *util.TemplateRenderer
db *sql.DB DB *sql.DB
apiKey string APIKey string
} }
type ProtonContentData struct { type ProtonContentData struct {
@ -39,22 +39,17 @@ func NewHandlers(monitor *Monitor, username string, logger *slog.Logger, tmpl *u
username: username, username: username,
logger: logger, logger: logger,
tmpl: tmpl, tmpl: tmpl,
db: nil, DB: nil,
apiKey: "", APIKey: "",
} }
} }
func (h *Handlers) SetDB(db *sql.DB, apiKey string) {
h.db = db
h.apiKey = apiKey
}
func (h *Handlers) LoadFreshCredentials() (string, bool) { func (h *Handlers) LoadFreshCredentials() (string, bool) {
if h.db == nil || h.apiKey == "" { if h.DB == nil || h.APIKey == "" {
return "", false return "", false
} }
credStore, err := credentials.NewStore(h.db, h.apiKey) credStore, err := credentials.NewStore(h.DB, h.APIKey)
if err != nil { if err != nil {
return "", false return "", false
} }
@ -77,7 +72,7 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
integData.Name = "Proton Mail" integData.Name = "Proton Mail"
if !hasCredentials { if !hasCredentials {
integData.StatusClass = "disconnected" integData.StatusClass = "unlinked"
integData.StatusText = "Unlinked" integData.StatusText = "Unlinked"
integData.StatusTooltip = "Enter credentials to link" integData.StatusTooltip = "Enter credentials to link"
integData.Open = true integData.Open = true
@ -115,10 +110,6 @@ func (h *Handlers) IsEnabled() bool {
return h.monitor != nil return h.monitor != nil
} }
func (h *Handlers) GetMonitor() *Monitor {
return h.monitor
}
type markReadRequest struct { type markReadRequest struct {
UID string `json:"uid"` UID string `json:"uid"`
} }
@ -146,7 +137,7 @@ func (h *Handlers) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
return 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") w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil { 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 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") w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil { if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil {

View file

@ -8,47 +8,42 @@ import (
"prism/service/config" "prism/service/config"
"prism/service/credentials" "prism/service/credentials"
"prism/service/notification" "prism/service/delivery"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type Integration struct { type Integration struct {
cfg *config.Config cfg *config.Config
dispatcher *notification.Dispatcher Publisher *delivery.Publisher
logger *slog.Logger Handlers *Handlers
handlers *Handlers monitor *Monitor
tmpl *util.TemplateRenderer db *sql.DB
monitor *Monitor apiKey string
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 { func NewIntegration(cfg *config.Config, logger *slog.Logger, tmpl *util.TemplateRenderer, db *sql.DB, apiKey string) *Integration {
monitor := NewMonitor(cfg, dispatcher, logger) monitor := NewMonitor(cfg, logger)
handlers := NewHandlers(monitor, "", logger, tmpl) handlers := NewHandlers(monitor, "", logger, tmpl)
return &Integration{ return &Integration{
cfg: cfg, cfg: cfg,
dispatcher: dispatcher, Handlers: handlers,
logger: logger, monitor: monitor,
handlers: handlers, db: db,
tmpl: tmpl, apiKey: apiKey,
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) { func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
credStore, err := credentials.NewStore(db, apiKey) credStore, err := credentials.NewStore(p.db, p.apiKey)
if err == nil { if err == nil {
if creds, err := credStore.GetProton(); 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) { 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() creds, err := credStore.GetProton()
if err != nil { if err != nil {
logger.Debug("Proton credentials not configured", "error", err) logger.Info("Proton credentials not configured", "error", err)
return 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) logger.Error("Failed to start Proton monitor", "error", err)
return return
} }
if p.handlers != nil { if p.Handlers != nil {
p.handlers.username = creds.Email p.Handlers.username = creds.Email
} }
logger.Info("Proton Mail enabled", "email", creds.Email) logger.Info("Proton Mail enabled", "email", creds.Email)
@ -80,6 +75,10 @@ func (p *Integration) IsEnabled() bool {
return p.cfg.EnableProton return p.cfg.EnableProton
} }
func (p *Integration) GetHandlers() *Handlers { func (p *Integration) Health() (bool, string) {
return p.handlers if p.Handlers == nil {
return false, ""
}
email, ok := p.Handlers.LoadFreshCredentials()
return ok, email
} }

View file

@ -3,7 +3,8 @@ package proton
import ( import (
"time" "time"
"prism/service/notification" "prism/service/delivery"
"prism/service/subscription"
"github.com/emersion/hydroxide/protonmail" "github.com/emersion/hydroxide/protonmail"
) )
@ -75,11 +76,11 @@ func (m *Monitor) sendNotification(msg *protonmail.Message) {
subject = "(No subject)" subject = "(No subject)"
} }
notif := notification.Notification{ notif := delivery.Notification{
Title: from, Title: from,
Message: subject, Message: subject,
Tag: "proton-" + msg.ID, Tag: "proton-" + msg.ID,
Actions: []notification.Action{ Actions: []delivery.Action{
{ {
ID: "archive", ID: "archive",
Label: "Archive", Label: "Archive",
@ -89,9 +90,18 @@ func (m *Monitor) sendNotification(msg *protonmail.Message) {
"uid": msg.ID, "uid": msg.ID,
}, },
}, },
{
ID: "trash",
Label: "Trash",
Endpoint: "/api/v1/proton/trash",
Method: "POST",
Data: map[string]any{
"uid": msg.ID,
},
},
{ {
ID: "mark-read", ID: "mark-read",
Label: "Mark as Read", Label: "Mark read",
Endpoint: "/api/v1/proton/mark-read", Endpoint: "/api/v1/proton/mark-read",
Method: "POST", Method: "POST",
Data: map[string]any{ 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) m.logger.Error("Failed to send notification", "error", err)
} else { } else {
m.logger.Info("Sent notification", "from", from, "subject", subject, "msgID", msg.ID) m.logger.Info("Sent notification", "from", from, "subject", subject, "msgID", msg.ID)
@ -109,14 +119,14 @@ func (m *Monitor) sendNotification(msg *protonmail.Message) {
} }
func (m *Monitor) clearNotification(msgID string) { func (m *Monitor) clearNotification(msgID string) {
app, err := m.dispatcher.GetStore().GetApp(prismTopic) app, err := m.dispatcher.Store.GetApp(prismTopic)
if err != nil || app == nil { if err != nil || app == nil {
return return
} }
hasWebPush := false hasWebPush := false
for _, sub := range app.Subscriptions { for _, sub := range app.Subscriptions {
if sub.Channel == notification.ChannelWebPush { if sub.Channel == subscription.ChannelWebPush {
hasWebPush = true hasWebPush = true
break break
} }
@ -126,13 +136,13 @@ func (m *Monitor) clearNotification(msgID string) {
return return
} }
notif := notification.Notification{ notif := delivery.Notification{
Tag: "proton-" + msgID, Tag: "proton-" + msgID,
Title: "", Title: "",
Message: "", 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) m.logger.Error("Failed to clear notification", "error", err, "msgID", msgID)
} else { } else {
m.logger.Debug("Cleared notification", "msgID", msgID) m.logger.Debug("Cleared notification", "msgID", msgID)

View file

@ -8,19 +8,21 @@ import (
"prism/service/config" "prism/service/config"
"prism/service/credentials" "prism/service/credentials"
"prism/service/notification" "prism/service/delivery"
"github.com/emersion/hydroxide/protonmail" "github.com/emersion/hydroxide/protonmail"
) )
const ( const (
pollInterval = 30 * time.Second pollInterval = 30 * time.Second
prismTopic = "Proton Mail" prismTopic = "Proton Mail"
protonAPIURL = "https://mail.proton.me/api"
protonAppVersion = "Other"
) )
type Monitor struct { type Monitor struct {
cfg *config.Config cfg *config.Config
dispatcher *notification.Dispatcher dispatcher *delivery.Publisher
logger *slog.Logger logger *slog.Logger
credStore *credentials.Store credStore *credentials.Store
client *protonmail.Client client *protonmail.Client
@ -29,17 +31,28 @@ type Monitor struct {
startTime time.Time startTime time.Time
consecutiveErrs int consecutiveErrs int
lastConnected time.Time 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{ return &Monitor{
cfg: cfg, cfg: cfg,
dispatcher: dispatcher, logger: logger,
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 { if err := m.authenticateAndSetup(credStore); err != nil {
return err return err
} }
@ -52,7 +65,9 @@ func (m *Monitor) Start(ctx context.Context, credStore *credentials.Store) error
m.lastConnected = time.Now() m.lastConnected = time.Now()
m.unseenMessageIDs = make(map[string]time.Time) 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 return nil
} }
@ -165,3 +180,10 @@ func (m *Monitor) Archive(msgID string) error {
} }
return m.client.UnlabelMessages(protonmail.LabelInbox, []string{msgID}) 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})
}

View file

@ -4,12 +4,15 @@ import (
"context" "context"
"database/sql" "database/sql"
"embed" "embed"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv"
"prism/service/config"
"prism/service/credentials" "prism/service/credentials"
"prism/service/notification"
"prism/service/util" "prism/service/util"
"github.com/emersion/hydroxide/protonmail" "github.com/emersion/hydroxide/protonmail"
@ -17,18 +20,14 @@ import (
) )
//go:embed templates/*.html //go:embed templates/*.html
var templates embed.FS var Templates embed.FS
func GetTemplates() embed.FS {
return templates
}
type authHandler struct { type authHandler struct {
db *sql.DB db *sql.DB
apiKey string apiKey string
logger *slog.Logger logger *slog.Logger
integration *Integration integration *Integration
dispatcher *notification.Dispatcher cfg *config.Config
} }
type protonAuthRequest struct { type protonAuthRequest struct {
@ -50,8 +49,9 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
} }
c := &protonmail.Client{ c := &protonmail.Client{
RootURL: "https://mail.proton.me/api", RootURL: protonAPIURL,
AppVersion: "Other", AppVersion: protonAppVersion,
Debug: false,
} }
authInfo, err := c.AuthInfo(req.Email) authInfo, err := c.AuthInfo(req.Email)
if err != nil { 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) util.JSONError(w, "Invalid 2FA code. Please try again", http.StatusBadRequest)
return 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 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) credStore, err := credentials.NewStore(h.db, h.apiKey)
@ -92,7 +101,7 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
keySalts, err := c.ListKeySalts() keySalts, err := listKeySaltsWithAuth(protonAPIURL, protonAppVersion, auth)
if err != nil { if err != nil {
h.logger.Error("Failed to get key salts", "error", err) h.logger.Error("Failed to get key salts", "error", err)
util.JSONError(w, "Failed to complete authentication", http.StatusInternalServerError) util.JSONError(w, "Failed to complete authentication", http.StatusInternalServerError)
@ -115,24 +124,16 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
if h.dispatcher != nil {
if _, err := h.dispatcher.CreateAppWithDefaultSubscription(prismTopic); err != nil {
h.logger.Warn("Failed to auto-create Proton Mail app with subscription", "error", err)
} else {
h.logger.Info("Created Proton Mail app with default subscription")
}
}
if h.integration != nil { if h.integration != nil {
ctx := context.Background() 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) h.logger.Error("Failed to start Proton monitor after auth", "error", err)
util.JSONError(w, "Authentication failed: "+err.Error(), http.StatusInternalServerError) util.JSONError(w, "Authentication failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
h.logger.Info("Proton monitor started") h.logger.Info("Proton monitor started")
if h.integration.handlers != nil { if h.integration.Handlers != nil {
h.integration.handlers.username = req.Email h.integration.Handlers.username = req.Email
} }
} }
@ -155,24 +156,77 @@ func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
return return
} }
if h.integration != nil {
h.integration.monitor.Stop()
}
util.SetToast(w, "Proton Mail unlinked", "success") util.SetToast(w, "Proton Mail unlinked", "success")
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) 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 { if handlers == nil {
return return
} }
handlers.SetDB(db, apiKey) handlers.DB = db
handlers.APIKey = apiKey
authH := &authHandler{ authH := &authHandler{
db: db, db: db,
apiKey: apiKey, apiKey: apiKey,
logger: logger, logger: logger,
integration: integration, integration: integration,
dispatcher: integration.dispatcher, cfg: cfg,
} }
router.With(auth).Get("/fragment/proton", handlers.HandleFragment) router.With(auth).Get("/fragment/proton", handlers.HandleFragment)
@ -181,6 +235,7 @@ func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler)
r.Use(auth) r.Use(auth)
r.Post("/mark-read", handlers.HandleMarkRead) r.Post("/mark-read", handlers.HandleMarkRead)
r.Post("/archive", handlers.HandleArchive) r.Post("/archive", handlers.HandleArchive)
r.Post("/trash", handlers.HandleTrash)
r.Post("/auth", authH.handleAuth) r.Post("/auth", authH.handleAuth)
r.Delete("/auth", authH.handleDelete) r.Delete("/auth", authH.handleDelete)
}) })

View file

@ -5,15 +5,21 @@
<form class="auth-form" data-handler="proton"> <form class="auth-form" data-handler="proton">
<div class="form-group"> <div class="form-group">
<label for="proton-email">Email:</label> <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>
<div class="form-group"> <div class="form-group">
<label for="proton-password">Password:</label> <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>
<div class="form-group"> <div class="form-group">
<label for="proton-totp">2FA Code (optional):</label> <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> </div>
<button type="submit" class="btn-primary">Link</button> <button type="submit" class="btn-primary">Link</button>
<div id="proton-auth-status" class="auth-status"></div> <div id="proton-auth-status" class="auth-status"></div>

View file

@ -4,6 +4,8 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -17,37 +19,23 @@ const (
type Client struct { type Client struct {
ConfigPath string ConfigPath string
enabled bool
} }
func NewClient() *Client { func NewClient() *Client {
configPath := filepath.Join(os.Getenv("HOME"), DefaultConfigPath) if _, err := exec.LookPath("signal-cli"); err != nil {
return nil
enabled := false
if _, err := exec.LookPath("signal-cli"); err == nil {
enabled = true
} }
return &Client{ return &Client{
ConfigPath: configPath, ConfigPath: filepath.Join(os.Getenv("HOME"), DefaultConfigPath),
enabled: enabled,
} }
} }
func (c *Client) IsEnabled() bool {
return c.enabled
}
type Account struct { type Account struct {
Number string Number string
UUID string UUID string
} }
func (c *Client) exec(args ...string) ([]byte, error) { 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"} baseArgs := []string{"--config", c.ConfigPath, "--output=json"}
cmd := exec.Command("signal-cli", append(baseArgs, args...)...) 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) { func (c *Client) GetLinkedAccount() (*Account, error) {
if c == nil || !c.enabled { if c == nil {
return nil, nil return nil, nil
} }
@ -113,7 +101,7 @@ func (c *Client) GetLinkedAccount() (*Account, error) {
} }
func (c *Client) CreateGroup(name string) (string, string, 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") 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 { func (c *Client) SendGroupMessage(groupID, message string) error {
if c == nil || !c.enabled { if c == nil {
return fmt.Errorf("signal client not initialized") 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) _, err = c.exec("-a", account.Number, "send", "-g", groupID, "--notify-self", "-m", message)
return err 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
}

View 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
}

View file

@ -1,16 +1,21 @@
package signal package signal
import ( import (
"bytes"
"encoding/base64"
"encoding/json" "encoding/json"
"html/template" "html/template"
"log/slog" "log/slog"
"net/http" "net/http"
qrcode "github.com/yeqown/go-qrcode/v2"
"github.com/yeqown/go-qrcode/writer/standard"
"prism/service/util" "prism/service/util"
) )
type Handlers struct { type Handlers struct {
client *Client Client *Client
tmpl *util.TemplateRenderer tmpl *util.TemplateRenderer
logger *slog.Logger logger *slog.Logger
} }
@ -33,7 +38,7 @@ type IntegrationData struct {
func NewHandlers(client *Client, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers { func NewHandlers(client *Client, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers {
return &Handlers{ return &Handlers{
client: client, Client: client,
tmpl: tmpl, tmpl: tmpl,
logger: logger, logger: logger,
} }
@ -46,14 +51,14 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
var integData IntegrationData var integData IntegrationData
integData.Name = "Signal" integData.Name = "Signal"
if h.client == nil || !h.client.IsEnabled() { if h.Client == nil {
integData.StatusClass = "disconnected" integData.StatusClass = "disconnected"
integData.StatusText = "Not Available" integData.StatusText = "Not Available"
integData.StatusTooltip = "signal-cli not found in PATH" integData.StatusTooltip = "signal-cli not found in PATH"
integData.Open = true integData.Open = true
contentData.Error = "signal-cli not found. Download from: https://github.com/AsamK/signal-cli/releases" contentData.Error = "signal-cli not found. Download from: https://github.com/AsamK/signal-cli/releases"
} else { } else {
account, err := h.client.GetLinkedAccount() account, err := h.Client.GetLinkedAccount()
if err != nil { if err != nil {
integData.StatusClass = "disconnected" integData.StatusClass = "disconnected"
integData.StatusText = "Error" integData.StatusText = "Error"
@ -61,7 +66,7 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
integData.Open = true integData.Open = true
contentData.Error = err.Error() contentData.Error = err.Error()
} else if account == nil { } else if account == nil {
integData.StatusClass = "disconnected" integData.StatusClass = "unlinked"
integData.StatusText = "Unlinked" integData.StatusText = "Unlinked"
integData.StatusTooltip = "Click Link button below to link" integData.StatusTooltip = "Click Link button below to link"
integData.Open = true integData.Open = true
@ -91,16 +96,8 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(html)) 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) { 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) util.JSONError(w, "signal-cli not available", http.StatusServiceUnavailable)
return return
} }
@ -112,27 +109,45 @@ func (h *Handlers) HandleLinkDevice(w http.ResponseWriter, r *http.Request) {
req.DeviceName = DefaultDeviceName req.DeviceName = DefaultDeviceName
} }
qrCode, err := h.client.LinkDevice(req.DeviceName) qrCode, err := h.Client.LinkDevice(req.DeviceName)
if err != nil { if err != nil {
h.logger.Error("Failed to generate link code", "error", err) h.logger.Error("Failed to generate link code", "error", err)
util.JSONError(w, err.Error(), http.StatusInternalServerError) util.JSONError(w, err.Error(), http.StatusInternalServerError)
return 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{ json.NewEncoder(w).Encode(map[string]string{
"qr_code": qrCode, "qr_code": dataURL,
"status": "linking", "status": "linking",
}) })
} }
func (h *Handlers) HandleLinkStatus(w http.ResponseWriter, r *http.Request) { 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) util.JSONError(w, "signal-cli not available", http.StatusServiceUnavailable)
return return
} }
account, err := h.client.GetLinkedAccount() account, err := h.Client.GetLinkedAccount()
if err != nil { if err != nil {
h.logger.Error("Failed to get linked account", "error", err) h.logger.Error("Failed to get linked account", "error", err)
util.JSONError(w, err.Error(), http.StatusInternalServerError) 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 }

View file

@ -2,12 +2,13 @@ package signal
import ( import (
"context" "context"
"database/sql" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"prism/service/config" "prism/service/config"
"prism/service/notification" "prism/service/delivery"
"prism/service/subscription"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -16,38 +17,42 @@ import (
type Integration struct { type Integration struct {
cfg *config.Config cfg *config.Config
client *Client client *Client
handlers *Handlers Handlers *Handlers
sender *Sender Sender *Sender
tmpl *util.TemplateRenderer Groups *GroupCache
store *subscription.Store
logger *slog.Logger 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() client := NewClient()
groups, err := NewGroupCache(store.DB)
if err != nil {
logger.Error("Failed to initialize Signal group cache", "error", err)
}
var sender *Sender var sender *Sender
if client.IsEnabled() { if client != nil {
sender = NewSender(client, store, logger) sender = NewSender(client, groups, logger)
} }
return &Integration{ return &Integration{
cfg: cfg, cfg: cfg,
client: client, client: client,
sender: sender, Sender: sender,
tmpl: tmpl, Groups: groups,
store: store,
logger: logger, logger: logger,
tmpl: tmpl,
} }
} }
func (s *Integration) GetSender() *Sender { func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
return s.sender s.Handlers = RegisterRoutes(router, s.cfg, auth, s.tmpl, logger, s.client)
}
func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
s.handlers = RegisterRoutes(router, s.cfg, auth, s.tmpl, s.logger, s.client)
} }
func (s *Integration) Start(ctx context.Context, logger *slog.Logger) { func (s *Integration) Start(ctx context.Context, logger *slog.Logger) {
if s.handlers != nil && s.handlers.IsEnabled() { if s.Handlers != nil && s.Handlers.Client != nil {
client := s.handlers.GetClient() client := s.Handlers.Client
account, _ := client.GetLinkedAccount() account, _ := client.GetLinkedAccount()
if account != nil { if account != nil {
logger.Info("Signal enabled", "status", "linked", "number", FormatPhoneNumber(account.Number)) logger.Info("Signal enabled", "status", "linked", "number", FormatPhoneNumber(account.Number))
@ -60,12 +65,58 @@ func (s *Integration) Start(ctx context.Context, logger *slog.Logger) {
} }
func (s *Integration) IsEnabled() bool { func (s *Integration) IsEnabled() bool {
if s.handlers == nil { return s.Handlers != nil && s.Handlers.Client != nil
return false
}
return s.handlers.IsEnabled()
} }
func (s *Integration) GetHandlers() *Handlers { func (s *Integration) Health() (bool, string) {
return s.handlers 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
} }

View file

@ -12,7 +12,7 @@ import (
) )
func (c *Client) LinkDevice(deviceName string) (string, error) { 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") return "", fmt.Errorf("signal-cli not found in PATH")
} }

View file

@ -12,11 +12,7 @@ import (
) )
//go:embed templates/*.html //go:embed templates/*.html
var templates embed.FS var Templates embed.FS
func GetTemplates() embed.FS {
return templates
}
func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler, tmpl *util.TemplateRenderer, logger *slog.Logger, client *Client) *Handlers { 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) handlers := NewHandlers(client, tmpl, logger)

View file

@ -4,20 +4,21 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"prism/service/notification" "prism/service/delivery"
"prism/service/subscription"
"prism/service/util" "prism/service/util"
) )
type Sender struct { type Sender struct {
client *Client client *Client
store *notification.Store groups *GroupCache
logger *slog.Logger 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{ return &Sender{
client: client, client: client,
store: store, groups: groups,
logger: logger, logger: logger,
} }
} }
@ -35,7 +36,7 @@ func (s *Sender) IsLinked() (bool, error) {
return account != nil, nil return account != nil, nil
} }
func (s *Sender) CreateDefaultSignalSubscription(appName string) (*notification.SignalSubscription, error) { func (s *Sender) CreateDefaultSignalSubscription(appName string) (*subscription.SignalSubscription, error) {
if s.client == nil { if s.client == nil {
return nil, fmt.Errorf("signal integration not enabled") return nil, fmt.Errorf("signal integration not enabled")
} }
@ -45,25 +46,25 @@ func (s *Sender) CreateDefaultSignalSubscription(appName string) (*notification.
return nil, err return nil, err
} }
signalSub := &notification.SignalSubscription{ signalSub := &subscription.SignalSubscription{
GroupID: groupID, GroupID: groupID,
Account: account, Account: account,
} }
if err := s.store.SaveSignalGroup(appName, signalSub); err != nil { if err := s.groups.Save(appName, signalSub); err != nil {
s.logger.Warn("Failed to cache Signal group", "error", err) s.logger.Warn("Failed to cache Signal group", "error", err)
} }
return signalSub, nil return signalSub, nil
} }
func (s *Sender) Send(sub *notification.Subscription, notif notification.Notification) error { func (s *Sender) Send(sub *subscription.Subscription, notif delivery.Notification) error {
if s.client == nil { if s.client == nil {
return notification.NewPermanentError(fmt.Errorf("signal integration not enabled")) return delivery.NewPermanentError(fmt.Errorf("signal integration not enabled"))
} }
if sub.Signal == nil { if sub.Signal == nil {
return notification.NewPermanentError(fmt.Errorf("no signal subscription data")) return delivery.NewPermanentError(fmt.Errorf("no signal subscription data"))
} }
account, err := s.client.GetLinkedAccount() account, err := s.client.GetLinkedAccount()
@ -72,7 +73,7 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
} }
if account == nil { if account == nil {
s.logger.Error("No linked Signal account found") 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 var signalGroupID string
@ -87,7 +88,7 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
} }
if needsNewGroup { if needsNewGroup {
return notification.NewPermanentError(fmt.Errorf("signal group not configured for subscription %s", sub.ID)) return delivery.NewPermanentError(fmt.Errorf("signal group not configured for subscription %s", sub.ID))
} }
signalGroupID = sub.Signal.GroupID signalGroupID = sub.Signal.GroupID
@ -97,6 +98,14 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
message = fmt.Sprintf("%s\n\n%s", notif.Title, notif.Message) 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 { if err := s.client.SendGroupMessage(signalGroupID, message); err != nil {
s.logger.Error("Failed to send group message", "groupID", signalGroupID, "error", err) s.logger.Error("Failed to send group message", "groupID", signalGroupID, "error", err)
return err return err

View file

@ -1,6 +1,6 @@
{{if .Linked}} {{if .Linked}}
<p><strong>To unlink:</strong></p> <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>Open Signal on your phone</li>
<li>Go to Settings → Linked Devices</li> <li>Go to Settings → Linked Devices</li>
<li>Find and remove this device</li> <li>Find and remove this device</li>
@ -12,14 +12,14 @@
<button class="btn-primary" data-action="link-signal">Link</button> <button class="btn-primary" data-action="link-signal">Link</button>
<div id="signal-qr-container" class="qr-container" style="display:none;"> <div id="signal-qr-container" class="qr-container" style="display:none;">
<p><strong>Scan this QR code with Signal:</strong></p> <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>Open Signal on your phone</li>
<li>Go to Settings → Linked Devices</li> <li>Go to Settings → Linked Devices</li>
<li>Tap "+" or "Link New Device"</li> <li>Tap "+" or "Link New Device"</li>
<li>Scan the QR code below</li> <li>Scan the QR code below</li>
</ol> </ol>
<div class="qr-code-wrapper"> <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>
</div> </div>
{{end}} {{end}}

View file

@ -3,6 +3,9 @@ package telegram
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"net/http"
"os"
"github.com/mymmrac/telego" "github.com/mymmrac/telego"
"github.com/mymmrac/telego/telegoutil" "github.com/mymmrac/telego/telegoutil"
@ -46,6 +49,48 @@ func (c *Client) SendMessage(chatID int64, text string) error {
return err 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 { func (c *Client) IsAvailable() bool {
if c == nil || c.bot == nil { if c == nil || c.bot == nil {
return false return false

View file

@ -16,8 +16,8 @@ type Handlers struct {
chatID int64 chatID int64
tmpl *util.TemplateRenderer tmpl *util.TemplateRenderer
logger *slog.Logger logger *slog.Logger
db *sql.DB DB *sql.DB
apiKey string APIKey string
} }
type TelegramContentData struct { type TelegramContentData struct {
@ -41,22 +41,17 @@ func NewHandlers(client *Client, chatID int64, tmpl *util.TemplateRenderer, logg
chatID: chatID, chatID: chatID,
tmpl: tmpl, tmpl: tmpl,
logger: logger, logger: logger,
db: nil, DB: nil,
apiKey: "", APIKey: "",
} }
} }
func (h *Handlers) SetDB(db *sql.DB, apiKey string) {
h.db = db
h.apiKey = apiKey
}
func (h *Handlers) loadFreshCredentials() (*Client, int64, bool) { func (h *Handlers) loadFreshCredentials() (*Client, int64, bool) {
if h.db == nil || h.apiKey == "" { if h.DB == nil || h.APIKey == "" {
return nil, 0, false return nil, 0, false
} }
credStore, err := credentials.NewStore(h.db, h.apiKey) credStore, err := credentials.NewStore(h.DB, h.APIKey)
if err != nil { if err != nil {
return nil, 0, false return nil, 0, false
} }
@ -94,7 +89,7 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
integData.Name = "Telegram" integData.Name = "Telegram"
if client == nil { if client == nil {
integData.StatusClass = "disconnected" integData.StatusClass = "unlinked"
integData.StatusText = "Unlinked" integData.StatusText = "Unlinked"
integData.StatusTooltip = "Enter bot token to link" integData.StatusTooltip = "Enter bot token to link"
integData.Open = true integData.Open = true
@ -108,7 +103,7 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
integData.Open = true integData.Open = true
contentData.Error = err.Error() contentData.Error = err.Error()
} else if chatID == 0 { } else if chatID == 0 {
integData.StatusClass = "disconnected" integData.StatusClass = "unlinked"
integData.StatusText = "Needs Chat ID" integData.StatusText = "Needs Chat ID"
integData.StatusTooltip = "@" + bot.Username integData.StatusTooltip = "@" + bot.Username
integData.Open = true integData.Open = true
@ -137,10 +132,6 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(html)) w.Write([]byte(html))
} }
func (h *Handlers) IsEnabled() bool {
return true
}
func (h *Handlers) GetClient() *Client { func (h *Handlers) GetClient() *Client {
client, _, _ := h.loadFreshCredentials() client, _, _ := h.loadFreshCredentials()
return client return client

View file

@ -3,31 +3,35 @@ package telegram
import ( import (
"context" "context"
"database/sql" "database/sql"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
"prism/service/config"
"prism/service/credentials" "prism/service/credentials"
"prism/service/notification" "prism/service/delivery"
"prism/service/subscription"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type Integration struct { type Integration struct {
cfg *config.Config Handlers *Handlers
handlers *Handlers Sender *Sender
sender *Sender OnUnlink func()
OnLink func()
db *sql.DB
apiKey string
logger *slog.Logger 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 client *Client
var chatID int64 var chatID int64
var sender *Sender var sender *Sender
credStore, err := credentials.NewStoreWithLogger(store.GetDB(), cfg.APIKey, logger) credStore, err := credentials.NewStoreWithLogger(store.DB, apiKey, logger)
if err != nil { if err != nil {
logger.Warn("Failed to initialize credentials store for Telegram", "error", err) logger.Warn("Failed to initialize credentials store for Telegram", "error", err)
} else { } 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) sender = NewSender(client, store, logger, chatID)
} }
handlers := NewHandlers(client, chatID, tmpl, logger) handlers := NewHandlers(client, chatID, tmpl, logger)
return &Integration{ return &Integration{
cfg: cfg, Handlers: handlers,
handlers: handlers, Sender: sender,
sender: sender, db: store.DB,
apiKey: apiKey,
logger: logger, logger: logger,
} }
} }
func (t *Integration) GetSender() *Sender { func (t *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
return t.sender RegisterRoutes(router, t.Handlers, auth, t.db, t.apiKey, logger, t.OnLink, t.OnUnlink)
}
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) Start(ctx context.Context, logger *slog.Logger) { func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
client := t.handlers.GetClient() client := t.Handlers.GetClient()
if client == nil { if client == nil {
logger.Info("Telegram not configured", "action", "visit admin UI to configure") logger.Info("Telegram not configured", "action", "visit admin UI to configure")
return return
@ -84,7 +81,7 @@ func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
return return
} }
chatID := t.handlers.chatID chatID := t.Handlers.chatID
if chatID == 0 { if chatID == 0 {
logger.Info("Telegram bot connected", "bot", bot.Username, "status", "needs_chat_id") logger.Info("Telegram bot connected", "bot", bot.Username, "status", "needs_chat_id")
} else { } else {
@ -93,5 +90,60 @@ func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
} }
func (t *Integration) IsEnabled() bool { 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
} }

View file

@ -14,16 +14,14 @@ import (
) )
//go:embed templates/*.html //go:embed templates/*.html
var templates embed.FS var Templates embed.FS
func GetTemplates() embed.FS {
return templates
}
type linkHandler struct { type linkHandler struct {
db *sql.DB db *sql.DB
apiKey string apiKey string
logger *slog.Logger logger *slog.Logger
onLink func()
onUnlink func()
} }
type telegramLinkRequest struct { type telegramLinkRequest struct {
@ -59,6 +57,10 @@ func (h *linkHandler) handleLink(w http.ResponseWriter, r *http.Request) {
return return
} }
if h.onLink != nil {
h.onLink()
}
util.SetToast(w, "Telegram linked", "success") util.SetToast(w, "Telegram linked", "success")
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"}) json.NewEncoder(w).Encode(map[string]string{"status": "success"})
@ -76,19 +78,21 @@ func (h *linkHandler) handleUnlink(w http.ResponseWriter, r *http.Request) {
return return
} }
h.onUnlink()
util.SetToast(w, "Telegram unlinked", "success") util.SetToast(w, "Telegram unlinked", "success")
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) 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 { if handlers == nil {
return return
} }
handlers.SetDB(db, apiKey) handlers.DB = db
handlers.APIKey = apiKey
linkH := &linkHandler{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).Get("/fragment/telegram", handlers.HandleFragment)
router.With(auth).Post("/api/v1/telegram/link", linkH.handleLink) router.With(auth).Post("/api/v1/telegram/link", linkH.handleLink)

View file

@ -5,7 +5,8 @@ import (
"log/slog" "log/slog"
"strconv" "strconv"
"prism/service/notification" "prism/service/delivery"
"prism/service/subscription"
) )
func parseInt64(s string) (int64, error) { func parseInt64(s string) (int64, error) {
@ -14,12 +15,12 @@ func parseInt64(s string) (int64, error) {
type Sender struct { type Sender struct {
client *Client client *Client
store *notification.Store store *subscription.Store
logger *slog.Logger logger *slog.Logger
DefaultChatID int64 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{ return &Sender{
client: client, client: client,
store: store, store: store,
@ -48,13 +49,13 @@ func (s *Sender) IsLinked() (bool, error) {
return true, nil return true, nil
} }
func (s *Sender) Send(sub *notification.Subscription, notif notification.Notification) error { func (s *Sender) Send(sub *subscription.Subscription, notif delivery.Notification) error {
if s.client == nil { if s.client == nil {
return notification.NewPermanentError(fmt.Errorf("telegram integration not enabled")) return delivery.NewPermanentError(fmt.Errorf("telegram integration not enabled"))
} }
if sub.Telegram == nil || sub.Telegram.ChatID == "" { if sub.Telegram == nil || sub.Telegram.ChatID == "" {
return notification.NewPermanentError(fmt.Errorf("no telegram chat configured for subscription")) return delivery.NewPermanentError(fmt.Errorf("no telegram chat configured for subscription"))
} }
message := notif.Message message := notif.Message
@ -66,7 +67,15 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
chatID, err := parseInt64(sub.Telegram.ChatID) chatID, err := parseInt64(sub.Telegram.ChatID)
if err != nil { if err != nil {
return notification.NewPermanentError(fmt.Errorf("invalid chat ID: %w", err)) 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 { if err := s.client.SendMessage(chatID, fullMessage); err != nil {

View file

@ -1,16 +1,22 @@
{{if .NotConfigured}} {{if .NotConfigured}}
<p><strong>Setup Telegram Bot:</strong></p> <p><strong>Setup Telegram Bot:</strong></p>
<ol class="link-instructions"> <ol class="link-instructions" aria-label="Steps to set up Telegram bot">
<li>Message <a href="https://t.me/BotFather" target="_blank">@BotFather</a> on Telegram</li> <li>Message <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer">@BotFather</a> on Telegram</li>
<li>Send <code>/newbot</code> and follow the prompts</li> <li>Send <code>/newbot</code> and follow the prompts</li>
<li>Copy the bot token from BotFather</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> <li>Enter both below:</li>
</ol> </ol>
<form class="auth-form" data-handler="telegram"> <form class="auth-form" data-handler="telegram">
<div class="form-group"> <div class="form-group">
<label for="telegram-bot-token">Bot Token:</label> <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>
<div class="form-group"> <div class="form-group">
<label for="telegram-chat-id">Chat ID:</label> <label for="telegram-chat-id">Chat ID:</label>

View file

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

View file

@ -1,32 +1,22 @@
package webpush package webpush
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"prism/service/notification" "prism/service/subscription"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
func generateSubscriptionID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
type Handlers struct { type Handlers struct {
store *notification.Store store *subscription.Store
logger *slog.Logger logger *slog.Logger
} }
func NewHandlers(store *notification.Store, logger *slog.Logger) *Handlers { func NewHandlers(store *subscription.Store, logger *slog.Logger) *Handlers {
return &Handlers{ return &Handlers{
store: store, store: store,
logger: logger, logger: logger,
@ -44,7 +34,7 @@ type registerRequest struct {
func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
var req registerRequest var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 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 return
} }
@ -73,13 +63,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
return return
} }
subID, err := generateSubscriptionID() var webPush *subscription.WebPushSubscription
if err != nil {
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
return
}
var webPush *notification.WebPushSubscription
if req.P256dh != nil && req.Auth != nil && req.VapidPrivateKey != nil { if req.P256dh != nil && req.Auth != nil && req.VapidPrivateKey != nil {
normalizedP256dh, err := normalizeP256DH(*req.P256dh) normalizedP256dh, err := normalizeP256DH(*req.P256dh)
if err != nil { if err != nil {
@ -99,26 +83,26 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
return return
} }
webPush = &notification.WebPushSubscription{ webPush = &subscription.WebPushSubscription{
Endpoint: req.PushEndpoint, Endpoint: req.PushEndpoint,
P256dh: normalizedP256dh, P256dh: normalizedP256dh,
Auth: normalizedAuth, Auth: normalizedAuth,
VapidPrivateKey: normalizedKey, VapidPrivateKey: normalizedKey,
} }
} else { } else {
webPush = &notification.WebPushSubscription{ webPush = &subscription.WebPushSubscription{
Endpoint: req.PushEndpoint, Endpoint: req.PushEndpoint,
} }
} }
sub := notification.Subscription{ sub := subscription.Subscription{
ID: subID,
AppName: req.AppName, AppName: req.AppName,
Channel: notification.ChannelWebPush, Channel: subscription.ChannelWebPush,
WebPush: webPush, WebPush: webPush,
} }
if err := h.store.AddSubscription(sub); err != nil { subID, err := h.store.AddSubscription(sub)
if err != nil {
h.logger.Warn("Failed to add webpush subscription", "app", req.AppName, "error", err) 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) util.LogAndError(w, h.logger, "Failed to add subscription", http.StatusInternalServerError, err)
return return
@ -129,7 +113,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
response := map[string]string{ response := map[string]string{
"appName": req.AppName, "appName": req.AppName,
"channel": notification.ChannelWebPush.String(), "channel": subscription.ChannelWebPush.String(),
"subscriptionId": subID, "subscriptionId": subID,
} }
_ = json.NewEncoder(w).Encode(response) _ = json.NewEncoder(w).Encode(response)
@ -138,7 +122,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) {
subscriptionID := chi.URLParam(r, "subscriptionId") subscriptionID := chi.URLParam(r, "subscriptionId")
if subscriptionID == "" { if subscriptionID == "" {
http.Error(w, "subscriptionId is required", http.StatusBadRequest) util.JSONError(w, "subscriptionId is required", http.StatusBadRequest)
return return
} }

View file

@ -2,28 +2,27 @@ package webpush
import ( import (
"context" "context"
"database/sql"
"log/slog" "log/slog"
"net/http" "net/http"
"prism/service/notification" "prism/service/subscription"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type Integration struct { type Integration struct {
store *notification.Store store *subscription.Store
logger *slog.Logger logger *slog.Logger
} }
func NewIntegration(store *notification.Store, logger *slog.Logger) *Integration { func NewIntegration(store *subscription.Store, logger *slog.Logger) *Integration {
return &Integration{ return &Integration{
store: store, store: store,
logger: logger, 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) 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 { func (w *Integration) IsEnabled() bool {
return true return true
} }
func (w *Integration) Health() (bool, string) { return false, "" }

View file

@ -4,12 +4,12 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"prism/service/notification" "prism/service/subscription"
"github.com/go-chi/chi/v5" "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) handlers := NewHandlers(store, logger)
router.Route("/api/v1/webpush/subscriptions", func(r chi.Router) { router.Route("/api/v1/webpush/subscriptions", func(r chi.Router) {

View file

@ -7,7 +7,8 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"prism/service/notification" "prism/service/delivery"
"prism/service/subscription"
webpush "github.com/SherClockHolmes/webpush-go" webpush "github.com/SherClockHolmes/webpush-go"
) )
@ -17,14 +18,12 @@ type Sender struct {
} }
func NewSender(logger *slog.Logger) *Sender { func NewSender(logger *slog.Logger) *Sender {
return &Sender{ return &Sender{logger: logger}
logger: logger,
}
} }
func (s *Sender) Send(sub *notification.Subscription, notif notification.Notification) error { func (s *Sender) Send(sub *subscription.Subscription, notif delivery.Notification) error {
if sub.WebPush == nil { if sub.WebPush == nil {
return notification.NewPermanentError(fmt.Errorf("no push endpoint configured for subscription %s", sub.ID)) return delivery.NewPermanentError(fmt.Errorf("no push endpoint configured for subscription %s", sub.ID))
} }
payload, err := json.Marshal(notif) payload, err := json.Marshal(notif)
@ -35,7 +34,7 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
if sub.WebPush.HasEncryption() { if sub.WebPush.HasEncryption() {
vapidPublicKey, err := deriveVAPIDPublicKey(sub.WebPush.VapidPrivateKey) vapidPublicKey, err := deriveVAPIDPublicKey(sub.WebPush.VapidPrivateKey)
if err != nil { if err != nil {
return notification.NewPermanentError(fmt.Errorf("invalid webpush VAPID key for subscription %s: %w", sub.ID, err)) return delivery.NewPermanentError(fmt.Errorf("invalid webpush VAPID key for subscription %s: %w", sub.ID, err))
} }
subscription := &webpush.Subscription{ subscription := &webpush.Subscription{
@ -47,7 +46,7 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
} }
resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{ resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{
Subscriber: "mailto:lonecloud604@proton.me", Subscriber: "https://github.com/lone-cloud/prism",
VAPIDPublicKey: vapidPublicKey, VAPIDPublicKey: vapidPublicKey,
VAPIDPrivateKey: sub.WebPush.VapidPrivateKey, VAPIDPrivateKey: sub.WebPush.VapidPrivateKey,
TTL: 86400, TTL: 86400,

View file

@ -1,291 +0,0 @@
package notification
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log/slog"
"time"
"prism/service/util"
)
type NotificationSender interface {
Send(sub *Subscription, notif Notification) error
}
type linkedChecker interface {
IsLinked() (bool, error)
}
type signalAutoConfigurer interface {
CreateDefaultSignalSubscription(appName string) (*SignalSubscription, error)
}
type telegramSender interface {
GetChatID() int64
}
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) Send(appName string, notif Notification) error {
app, err := d.store.GetApp(appName)
if err != nil {
return util.LogError(d.logger, "Failed to get app", err, "app", appName)
}
if app == nil {
d.logger.Info("Registering new app and auto-configuring subscription", "app", appName)
app, err = d.CreateAppWithDefaultSubscription(appName)
if err != nil {
d.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err)
return nil
}
}
if len(app.Subscriptions) == 0 {
d.logger.Info("App has no subscriptions, attempting to auto-configure", "app", appName)
app, err = d.CreateAppWithDefaultSubscription(appName)
if err != nil {
d.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err)
return nil
}
}
var lastErr error
successCount := 0
for _, sub := range app.Subscriptions {
sender, ok := d.senders[sub.Channel]
if !ok {
d.logger.Debug("Skipping subscription for disabled channel", "channel", sub.Channel, "subscriptionID", sub.ID)
continue
}
if err := d.sendWithRetry(sender, &sub, notif, appName, sub.ID); err != nil {
lastErr = err
} else {
successCount++
}
}
if successCount == 0 && lastErr != nil {
return lastErr
}
return nil
}
func (d *Dispatcher) sendWithRetry(sender NotificationSender, sub *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 {
d.logger.Info("Notification sent after retry", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1)
}
return nil
}
lastErr = err
if IsPermanent(err) {
d.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))
d.logger.Warn("Failed to send notification, retrying", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1, "error", err, "retryIn", delay)
time.Sleep(delay)
}
}
d.logger.Error("Failed to send notification after retries", "app", appName, "subscriptionID", subscriptionID, "attempts", maxRetries, "error", lastErr)
return lastErr
}
func (d *Dispatcher) CreateAppWithDefaultSubscription(appName string) (*App, error) {
app, signalErr := d.trySignalAutoConfig(appName)
if signalErr == nil {
return app, nil
}
d.logger.Warn("Signal auto-config failed, trying Telegram", "app", appName, "error", signalErr)
app, telegramErr := d.tryTelegramAutoConfig(appName)
if telegramErr == nil {
return app, nil
}
return nil, fmt.Errorf("signal auto-config failed: %v; telegram unavailable: %w", signalErr, telegramErr)
}
func (d *Dispatcher) trySignalAutoConfig(appName string) (*App, error) {
sender, ok := d.senders[ChannelSignal]
if !ok {
return nil, fmt.Errorf("signal sender not found")
}
linkChecker, ok := sender.(linkedChecker)
if !ok {
return nil, fmt.Errorf("sender does not implement signal linked check")
}
linked, err := linkChecker.IsLinked()
if err != nil {
return nil, fmt.Errorf("failed to check Signal link status: %w", err)
}
if !linked {
return nil, fmt.Errorf("no linked Signal account")
}
autoConfigurer, ok := sender.(signalAutoConfigurer)
if !ok {
return nil, fmt.Errorf("sender does not implement signal auto-configuration")
}
var signalSub *SignalSubscription
cachedGroup, err := d.store.GetSignalGroup(appName)
if err != nil {
d.logger.Warn("Failed to check for cached Signal group", "error", err)
}
if cachedGroup != nil {
d.logger.Debug("Reusing cached Signal group", "app", appName)
signalSub = cachedGroup
} else {
signalSub, err = autoConfigurer.CreateDefaultSignalSubscription(appName)
if err != nil {
return nil, err
}
}
subID, err := GenerateSubscriptionID()
if err != nil {
return nil, err
}
sub := Subscription{ID: subID, AppName: appName, Channel: ChannelSignal, Signal: signalSub}
if err := d.store.AddSubscription(sub); err != nil {
return nil, err
}
d.logger.Info("Auto-configured Signal subscription", "app", appName, "subscriptionID", subID)
return d.store.GetApp(appName)
}
func (d *Dispatcher) tryTelegramAutoConfig(appName string) (*App, error) {
chatID, err := d.getTelegramChatID()
if err != nil || chatID == "" {
return nil, fmt.Errorf("telegram not linked or no chat ID configured: %w", err)
}
subID, err := GenerateSubscriptionID()
if err != nil {
return nil, err
}
sub := Subscription{
ID: subID,
AppName: appName,
Channel: ChannelTelegram,
Telegram: &TelegramSubscription{ChatID: chatID},
}
if err := d.store.AddSubscription(sub); err != nil {
return nil, err
}
d.logger.Info("Auto-configured Telegram subscription", "app", appName, "subscriptionID", subID, "chatID", chatID)
return d.store.GetApp(appName)
}
func (d *Dispatcher) getTelegramChatID() (string, error) {
sender, ok := d.senders[ChannelTelegram]
if !ok {
return "", fmt.Errorf("telegram sender not found")
}
linkChecker, ok := sender.(linkedChecker)
if !ok {
return "", fmt.Errorf("sender does not implement telegram linked check")
}
linked, err := linkChecker.IsLinked()
if err != nil {
return "", err
}
if !linked {
return "", fmt.Errorf("telegram not linked")
}
tgSender, ok := sender.(telegramSender)
if !ok {
return "", fmt.Errorf("sender does not implement telegram interface")
}
chatID := tgSender.GetChatID()
if chatID == 0 {
return "", fmt.Errorf("no chat ID configured")
}
return fmt.Sprintf("%d", chatID), nil
}
func GenerateSubscriptionID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

View file

@ -1,111 +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 `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"`
}

View file

@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"prism/service/notification" "prism/service/subscription"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -38,17 +38,17 @@ func (s *Server) handleGetApps(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(sanitizeApps(apps)) json.NewEncoder(w).Encode(sanitizeApps(apps))
} }
func sanitizeApps(apps []notification.App) []notification.App { func sanitizeApps(apps []subscription.App) []subscription.App {
sanitized := make([]notification.App, len(apps)) sanitized := make([]subscription.App, len(apps))
for i, app := range apps { for i, app := range apps {
sanitized[i] = notification.App{ sanitized[i] = subscription.App{
AppName: app.AppName, AppName: app.AppName,
Subscriptions: make([]notification.Subscription, len(app.Subscriptions)), Subscriptions: make([]subscription.Subscription, len(app.Subscriptions)),
} }
for j, sub := range app.Subscriptions { for j, sub := range app.Subscriptions {
sanitized[i].Subscriptions[j] = sub sanitized[i].Subscriptions[j] = sub
if sub.WebPush != nil { if sub.WebPush != nil {
sanitized[i].Subscriptions[j].WebPush = &notification.WebPushSubscription{ sanitized[i].Subscriptions[j].WebPush = &subscription.WebPushSubscription{
Endpoint: sub.WebPush.Endpoint, Endpoint: sub.WebPush.Endpoint,
} }
} }

View file

@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"prism/service/integration/signal" "prism/service/integration/signal"
"prism/service/notification" "prism/service/subscription"
"prism/service/util" "prism/service/util"
) )
@ -48,16 +48,16 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(buf.Bytes()) _, _ = w.Write(buf.Bytes())
} }
func (s *Server) buildAppListData(apps []notification.App) []AppListItem { func (s *Server) buildAppListData(apps []subscription.App) []AppListItem {
if len(apps) == 0 { if len(apps) == 0 {
return nil return nil
} }
signalEnabled := s.integrations.Signal != nil && s.integrations.Signal.IsEnabled() signalEnabled := s.integrations.IsSignalLinked()
telegramEnabled := s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled() telegramEnabled := s.integrations.IsTelegramLinked()
telegramBotName := "" telegramBotName := ""
if telegramEnabled { if telegramEnabled {
handlers := s.integrations.Telegram.GetHandlers() handlers := s.integrations.Telegram.Handlers
if handlers != nil { if handlers != nil {
client := handlers.GetClient() client := handlers.GetClient()
if client != nil { if client != nil {
@ -73,17 +73,17 @@ func (s *Server) buildAppListData(apps []notification.App) []AppListItem {
channels := []ChannelState{} channels := []ChannelState{}
if signalEnabled { if signalEnabled {
var signalSub *notification.Subscription var signalSub *subscription.Subscription
for i := range app.Subscriptions { for i := range app.Subscriptions {
if app.Subscriptions[i].Channel == notification.ChannelSignal { if app.Subscriptions[i].Channel == subscription.ChannelSignal {
signalSub = &app.Subscriptions[i] signalSub = &app.Subscriptions[i]
break break
} }
} }
state := ChannelState{ state := ChannelState{
Channel: string(notification.ChannelSignal), Channel: string(subscription.ChannelSignal),
Label: notification.ChannelSignal.Label(), Label: subscription.ChannelSignal.Label(),
Active: signalSub != nil, Active: signalSub != nil,
Toggleable: true, Toggleable: true,
} }
@ -99,17 +99,17 @@ func (s *Server) buildAppListData(apps []notification.App) []AppListItem {
} }
if telegramEnabled { if telegramEnabled {
var telegramSub *notification.Subscription var telegramSub *subscription.Subscription
for i := range app.Subscriptions { for i := range app.Subscriptions {
if app.Subscriptions[i].Channel == notification.ChannelTelegram { if app.Subscriptions[i].Channel == subscription.ChannelTelegram {
telegramSub = &app.Subscriptions[i] telegramSub = &app.Subscriptions[i]
break break
} }
} }
state := ChannelState{ state := ChannelState{
Channel: string(notification.ChannelTelegram), Channel: string(subscription.ChannelTelegram),
Label: notification.ChannelTelegram.Label(), Label: subscription.ChannelTelegram.Label(),
Active: telegramSub != nil, Active: telegramSub != nil,
Toggleable: true, Toggleable: true,
} }
@ -126,7 +126,7 @@ func (s *Server) buildAppListData(apps []notification.App) []AppListItem {
webPushSubs := []SubscriptionItem{} webPushSubs := []SubscriptionItem{}
for _, sub := range app.Subscriptions { for _, sub := range app.Subscriptions {
if sub.Channel == notification.ChannelWebPush && sub.WebPush != nil { if sub.Channel == subscription.ChannelWebPush && sub.WebPush != nil {
webPushSubs = append(webPushSubs, SubscriptionItem{ webPushSubs = append(webPushSubs, SubscriptionItem{
ID: sub.ID, ID: sub.ID,
}) })
@ -135,8 +135,8 @@ func (s *Server) buildAppListData(apps []notification.App) []AppListItem {
if len(webPushSubs) > 0 { if len(webPushSubs) > 0 {
channels = append(channels, ChannelState{ channels = append(channels, ChannelState{
Channel: string(notification.ChannelWebPush), Channel: string(subscription.ChannelWebPush),
Label: notification.ChannelWebPush.Label(), Label: subscription.ChannelWebPush.Label(),
Active: true, Active: true,
Toggleable: false, Toggleable: false,
Subscriptions: webPushSubs, Subscriptions: webPushSubs,

View file

@ -2,10 +2,10 @@ package server
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strings"
"time" "time"
"prism/service/util"
) )
type healthResponse struct { type healthResponse struct {
@ -30,47 +30,23 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := healthResponse{ resp := healthResponse{
Version: s.version, Version: s.version,
Uptime: util.FormatUptime(uptime), Uptime: formatUptime(uptime),
} }
if s.integrations.Signal != nil && s.integrations.Signal.IsEnabled() { if s.integrations.Signal != nil && s.integrations.Signal.IsEnabled() {
signalClient := s.integrations.Signal.GetHandlers().GetClient() linked, account := s.integrations.Signal.Health()
account, _ := signalClient.GetLinkedAccount() resp.Signal = &integrationHealth{Linked: linked, Account: account}
if account != nil {
resp.Signal = &integrationHealth{
Linked: true,
Account: account.Number,
}
} else {
resp.Signal = &integrationHealth{
Linked: false,
}
}
} }
if s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled() { if s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled() {
telegramClient := s.integrations.Telegram.GetHandlers().GetClient() if linked, account := s.integrations.Telegram.Health(); linked {
if telegramClient != nil { resp.Telegram = &integrationHealth{Linked: true, Account: account}
bot, err := telegramClient.GetMe()
if err == nil {
resp.Telegram = &integrationHealth{
Linked: true,
Account: "@" + bot.Username,
}
}
} }
} }
if s.integrations.Proton != nil && s.integrations.Proton.IsEnabled() { if s.integrations.Proton != nil && s.integrations.Proton.IsEnabled() {
protonHandlers := s.integrations.Proton.GetHandlers() if linked, account := s.integrations.Proton.Health(); linked {
if protonHandlers != nil && protonHandlers.IsEnabled() { resp.Proton = &integrationHealth{Linked: true, Account: account}
email, hasCredentials := protonHandlers.LoadFreshCredentials()
if hasCredentials {
resp.Proton = &integrationHealth{
Linked: true,
Account: email,
}
}
} }
} }
@ -79,3 +55,26 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
s.logger.Error("Failed to encode health response", "error", err) 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, " ")
}

View file

@ -10,7 +10,7 @@ import (
"strings" "strings"
"time" "time"
"prism/service/notification" "prism/service/delivery"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -20,25 +20,25 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
appName := chi.URLParam(r, "appName") appName := chi.URLParam(r, "appName")
decodedAppName, err := url.PathUnescape(appName) decodedAppName, err := url.PathUnescape(appName)
if err != nil { if err != nil {
http.Error(w, "Invalid app name", http.StatusBadRequest) util.JSONError(w, "Invalid app name", http.StatusBadRequest)
return return
} }
appName = decodedAppName appName = decodedAppName
if appName == "" || strings.Contains(appName, "/") { if appName == "" || strings.Contains(appName, "/") {
http.Error(w, "Invalid app name", http.StatusBadRequest) util.JSONError(w, "Invalid app name", http.StatusBadRequest)
return return
} }
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest) util.JSONError(w, "Failed to read body", http.StatusBadRequest)
return return
} }
message, title := parseNtfyPayload(r, body) message, title, imageURL := parseNtfyPayload(r, body)
if message == "" { if message == "" {
http.Error(w, "Message required", http.StatusBadRequest) util.JSONError(w, "Message required", http.StatusBadRequest)
return return
} }
@ -46,17 +46,18 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
title = "" title = ""
} }
notif := notification.Notification{ notif := delivery.Notification{
Title: title, Title: title,
Message: message, 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) util.LogAndError(w, s.logger, "Failed to send notification", http.StatusInternalServerError, err, "app", appName)
return 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() now := time.Now()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -72,8 +73,8 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
} }
} }
func parseNtfyPayload(r *http.Request, body []byte) (string, string) { func parseNtfyPayload(r *http.Request, body []byte) (string, string, string) {
var message, title string var message, title, imageURL string
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil { if err != nil {
@ -85,10 +86,13 @@ func parseNtfyPayload(r *http.Request, body []byte) (string, string) {
var payload struct { var payload struct {
Title string `json:"title"` Title string `json:"title"`
Message string `json:"message"` Message string `json:"message"`
Attach string `json:"attach"`
Image string `json:"image"`
} }
if err := json.Unmarshal(body, &payload); err == nil { if err := json.Unmarshal(body, &payload); err == nil {
message = payload.Message message = payload.Message
title = payload.Title title = payload.Title
imageURL = firstNonEmpty(payload.Attach, payload.Image)
} else { } else {
message = string(body) message = string(body)
} }
@ -111,7 +115,11 @@ func parseNtfyPayload(r *http.Request, body []byte) (string, string) {
title = firstNonEmpty(r.Header.Get("X-Title"), r.Header.Get("Title"), r.Header.Get("t")) title = firstNonEmpty(r.Header.Get("X-Title"), r.Header.Get("Title"), r.Header.Get("t"))
} }
return message, title if imageURL == "" {
imageURL = firstNonEmpty(r.Header.Get("X-Attach"), r.Header.Get("Attach"))
}
return message, title, imageURL
} }
func firstNonEmpty(values ...string) string { func firstNonEmpty(values ...string) string {
@ -122,10 +130,3 @@ func firstNonEmpty(values ...string) string {
} }
return "" return ""
} }
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}

View file

@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"prism/service/notification" "prism/service/subscription"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -34,8 +34,8 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
ChatID: r.FormValue("chat_id"), ChatID: r.FormValue("chat_id"),
} }
channel := notification.Channel(form.Channel) channel := subscription.Channel(form.Channel)
if !s.dispatcher.IsValidChannel(channel) { if !s.publisher.IsValidChannel(channel) {
util.SetToast(w, fmt.Sprintf("Invalid or unavailable channel: %s", form.Channel), "error") util.SetToast(w, fmt.Sprintf("Invalid or unavailable channel: %s", form.Channel), "error")
s.handleFragmentApps(w, r) s.handleFragmentApps(w, r)
return return
@ -56,40 +56,33 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
} }
} }
subID, err := notification.GenerateSubscriptionID() sub := subscription.Subscription{
if err != nil {
util.LogAndError(w, s.logger, "Failed to generate subscription ID", http.StatusInternalServerError, err)
return
}
sub := notification.Subscription{
ID: subID,
AppName: appName, AppName: appName,
Channel: channel, Channel: channel,
} }
switch channel { switch channel {
case notification.ChannelSignal: case subscription.ChannelSignal:
if s.integrations.Signal == nil || !s.integrations.Signal.IsEnabled() { if s.integrations.Signal == nil || !s.integrations.Signal.IsEnabled() {
util.SetToast(w, "Signal not configured", "error") util.SetToast(w, "Signal not configured", "error")
s.handleFragmentApps(w, r) s.handleFragmentApps(w, r)
return return
} }
client := s.integrations.Signal.GetHandlers().GetClient() client := s.integrations.Signal.Handlers.Client
account, err := client.GetLinkedAccount() account, err := client.GetLinkedAccount()
if err != nil || account == nil { if err != nil || account == nil {
util.SetToast(w, "Signal not linked - configure in Integrations below", "error") util.SetToast(w, "Signal not linked - configure its integration below", "error")
s.handleFragmentApps(w, r) s.handleFragmentApps(w, r)
return return
} }
if form.GroupID != "" { if form.GroupID != "" {
sub.Signal = &notification.SignalSubscription{ sub.Signal = &subscription.SignalSubscription{
GroupID: form.GroupID, GroupID: form.GroupID,
Account: account.Number, Account: account.Number,
} }
} else { } else {
cachedGroup, err := s.store.GetSignalGroup(appName) cachedGroup, err := s.integrations.Signal.Groups.Get(appName)
if err != nil { if err != nil {
util.LogAndError(w, s.logger, "Failed to check for cached Signal group", http.StatusInternalServerError, err) util.LogAndError(w, s.logger, "Failed to check for cached Signal group", http.StatusInternalServerError, err)
return return
@ -98,7 +91,7 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
if cachedGroup != nil && cachedGroup.Account == account.Number { if cachedGroup != nil && cachedGroup.Account == account.Number {
sub.Signal = cachedGroup sub.Signal = cachedGroup
} else { } else {
signalSub, err := s.integrations.Signal.GetSender().CreateDefaultSignalSubscription(appName) signalSub, err := s.integrations.Signal.Sender.CreateDefaultSignalSubscription(appName)
if err != nil { if err != nil {
util.LogAndError(w, s.logger, "Failed to create Signal subscription", http.StatusInternalServerError, err) util.LogAndError(w, s.logger, "Failed to create Signal subscription", http.StatusInternalServerError, err)
return return
@ -107,15 +100,15 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
} }
} }
case notification.ChannelTelegram: case subscription.ChannelTelegram:
if s.integrations.Telegram == nil || !s.integrations.Telegram.IsEnabled() { if s.integrations.Telegram == nil || !s.integrations.Telegram.IsEnabled() {
util.SetToast(w, "Telegram not configured", "error") util.SetToast(w, "Telegram not configured", "error")
s.handleFragmentApps(w, r) s.handleFragmentApps(w, r)
return return
} }
chatID := s.integrations.Telegram.GetHandlers().GetChatID() chatID := s.integrations.Telegram.Handlers.GetChatID()
if chatID == 0 { if chatID == 0 {
util.SetToast(w, "Telegram not linked - configure in Integrations below", "error") util.SetToast(w, "Telegram not linked - configure its integration below", "error")
s.handleFragmentApps(w, r) s.handleFragmentApps(w, r)
return return
} }
@ -123,7 +116,7 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
chatID = 0 chatID = 0
fmt.Sscanf(form.ChatID, "%d", &chatID) fmt.Sscanf(form.ChatID, "%d", &chatID)
} }
sub.Telegram = &notification.TelegramSubscription{ sub.Telegram = &subscription.TelegramSubscription{
ChatID: fmt.Sprintf("%d", chatID), ChatID: fmt.Sprintf("%d", chatID),
} }
@ -132,7 +125,7 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
return return
} }
if err := s.store.AddSubscription(sub); err != nil { if _, err := s.store.AddSubscription(sub); err != nil {
util.LogAndError(w, s.logger, "Failed to create subscription", http.StatusInternalServerError, err) util.LogAndError(w, s.logger, "Failed to create subscription", http.StatusInternalServerError, err)
return return
} }
@ -160,7 +153,7 @@ func (s *Server) handleDeleteSubscription(w http.ResponseWriter, r *http.Request
} }
message := fmt.Sprintf("%s channel disabled", sub.Channel.Label()) message := fmt.Sprintf("%s channel disabled", sub.Channel.Label())
if sub.Channel == notification.ChannelWebPush { if sub.Channel == subscription.ChannelWebPush {
message = fmt.Sprintf("%s channel deleted", sub.Channel.Label()) message = fmt.Sprintf("%s channel deleted", sub.Channel.Label())
} }
util.SetToast(w, message, "success") util.SetToast(w, message, "success")

View file

@ -3,13 +3,12 @@ package server
import ( import (
"log/slog" "log/slog"
"net/http" "net/http"
"sync"
"time" "time"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"golang.org/x/time/rate" "github.com/go-chi/httprate"
) )
var noisyPaths = map[string]bool{ 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 func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !util.VerifyAPIKey(r, apiKey) { 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) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return 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 { 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 { return func(next http.Handler) http.Handler {
limited := rl(next)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := util.GetClientIP(r) if util.IsLocalhost(util.GetClientIP(r)) {
if util.IsLocalhost(ip) {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
limited.ServeHTTP(w, r)
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)
}) })
} }
} }
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 { func maxBodySizeMiddleware(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View file

@ -2,21 +2,27 @@ package server
import ( import (
"context" "context"
"database/sql"
"embed" "embed"
"fmt" "fmt"
"html/template" "html/template"
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"path/filepath"
"time" "time"
"prism/service/config" "prism/service/config"
"prism/service/delivery"
"prism/service/integration" "prism/service/integration"
"prism/service/notification" "prism/service/subscription"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
_ "modernc.org/sqlite"
) )
//go:embed templates/*.html //go:embed templates/*.html
@ -26,8 +32,8 @@ type Server struct {
startTime time.Time startTime time.Time
publicAssets embed.FS publicAssets embed.FS
cfg *config.Config cfg *config.Config
store *notification.Store store *subscription.Store
dispatcher *notification.Dispatcher publisher *delivery.Publisher
integrations *integration.Integrations integrations *integration.Integrations
logger *slog.Logger logger *slog.Logger
router *chi.Mux router *chi.Mux
@ -37,10 +43,26 @@ type Server struct {
version string version string
} }
func New(cfg *config.Config, publicAssets embed.FS, version string) (*Server, error) { func openDB(dbPath string) (*sql.DB, error) {
logger := util.NewLogger(cfg.VerboseLogging) 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 { if err != nil {
return nil, fmt.Errorf("failed to create store: %w", err) return nil, fmt.Errorf("failed to create store: %w", err)
} }
@ -64,7 +86,7 @@ func New(cfg *config.Config, publicAssets embed.FS, version string) (*Server, er
s := &Server{ s := &Server{
cfg: cfg, cfg: cfg,
store: store, store: store,
dispatcher: integrations.Dispatcher, publisher: integrations.Publisher,
integrations: integrations, integrations: integrations,
logger: logger, logger: logger,
startTime: time.Now(), startTime: time.Now(),
@ -82,7 +104,6 @@ func (s *Server) setupRoutes() {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.RequestID) r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(loggingMiddleware(s.logger)) r.Use(loggingMiddleware(s.logger))
r.Use(securityHeadersMiddleware()) r.Use(securityHeadersMiddleware())
@ -100,7 +121,7 @@ func (s *Server) setupRoutes() {
} }
}) })
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/apps", s.handleFragmentApps)
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/integrations", s.handleFragmentIntegrations) r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/integrations", s.handleFragmentIntegrations)
@ -128,7 +149,7 @@ func (s *Server) setupRoutes() {
} }
func (s *Server) Start(ctx context.Context) error { 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) addr := fmt.Sprintf(":%d", s.cfg.Port)
s.httpServer = &http.Server{ s.httpServer = &http.Server{
@ -170,7 +191,7 @@ func (s *Server) Shutdown() error {
return fmt.Errorf("failed to shutdown http server: %w", err) 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) return fmt.Errorf("failed to close store: %w", err)
} }

View file

@ -11,33 +11,37 @@
{{$channel := .}} {{$channel := .}}
{{if .Toggleable}} {{if .Toggleable}}
{{if .Active}} {{if .Active}}
<button type="button" class="channel-badge channel-{{.Channel}} badge-active" <button type="button"
hx-delete="/apps/{{$app.AppName}}/subscriptions/{{(index .Subscriptions 0).ID}}" class="channel-badge channel-{{.Channel}} badge-active"
hx-target="#apps-list" hx-delete="/apps/{{$app.AppName}}/subscriptions/{{(index .Subscriptions 0).ID}}"
hx-swap="innerHTML"> hx-target="#apps-list"
{{.Label}} hx-swap="innerHTML"
<span class="tooltip">Channel ID: {{(index .Subscriptions 0).ID}}</span> aria-label="Disable {{.Label}} for {{$app.AppName}}">
</button> {{.Label}}
<span class="tooltip">Channel ID: {{(index .Subscriptions 0).ID}}</span>
</button>
{{else}} {{else}}
<button type="button" class="channel-badge channel-{{.Channel}} badge-inactive" <button type="button"
hx-post="/apps/{{$app.AppName}}/subscriptions" class="channel-badge channel-{{.Channel}} badge-inactive"
hx-target="#apps-list" hx-post="/apps/{{$app.AppName}}/subscriptions"
hx-swap="innerHTML" hx-target="#apps-list"
hx-vals='{"channel":"{{.Channel}}"}'> hx-swap="innerHTML"
+ {{.Label}} hx-vals='{"channel":"{{.Channel}}"}'
<span class="tooltip">Click to enable</span> aria-label="Enable {{.Label}} for {{$app.AppName}}">
</button> + {{.Label}}
<span class="tooltip">Click to enable</span>
</button>
{{end}} {{end}}
{{else}} {{else}}
{{range .Subscriptions}} {{range .Subscriptions}}
<span class="channel-badge channel-{{$channel.Channel}} badge-subscribed"> <span class="channel-badge channel-{{$channel.Channel}} badge-subscribed">
{{$channel.Label}} {{$channel.Label}}
<span class="tooltip">Channel ID: {{.ID}}</span> <span class="tooltip">Channel ID: {{.ID}}</span>
<button type="button" class="btn-delete-sub" <button type="button"
hx-delete="/apps/{{$app.AppName}}/subscriptions/{{.ID}}" class="btn-delete-sub"
hx-target="#apps-list" data-action="delete-subscription"
hx-swap="innerHTML" data-url="/apps/{{$app.AppName}}/subscriptions/{{.ID}}"
hx-confirm="Delete this channel?">×</button> aria-label="Remove {{$channel.Channel}} channel from {{$app.AppName}}">×</button>
</span> </span>
{{end}} {{end}}
{{end}} {{end}}
@ -45,12 +49,12 @@
</div> </div>
{{else}} {{else}}
<div class="app-no-subscriptions"> <div class="app-no-subscriptions">
<span class="channel-badge channel-not-configured">No channels enabled</span> <span class="channel-badge channel-not-configured">No integrations linked</span>
</div> </div>
{{end}} {{end}}
</div> </div>
<div class="app-actions"> <div class="app-actions">
<button type="button" class="btn-delete" hx-delete="/apps/{{.AppName}}" hx-target="#apps-list" hx-swap="innerHTML" hx-confirm="Delete {{.AppName}} and all subscriptions?">Delete</button> <button type="button" class="btn-danger" data-action="delete-app" data-app-name="{{.AppName}}">Delete</button>
</div> </div>
</li> </li>
{{end}} {{end}}

View file

@ -1,6 +1,6 @@
<details class="integration-card"{{if .Open}} open{{end}}> <details class="integration-card"{{if .Open}} open{{end}}>
<summary class="integration-header"> <summary class="integration-header">
<span class="integration-name">{{.Name}}</span> <h3>{{.Name}}</h3>
<span class="integration-status {{.StatusClass}}"> <span class="integration-status {{.StatusClass}}">
{{.StatusText}} {{.StatusText}}
{{if .StatusTooltip}}<span class="tooltip">{{.StatusTooltip}}</span>{{end}} {{if .StatusTooltip}}<span class="tooltip">{{.StatusTooltip}}</span>{{end}}

View 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
}
}

View file

@ -1,47 +1,19 @@
package notification package subscription
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
) )
type Store struct { type Store struct {
db *sql.DB DB *sql.DB
} }
func NewStore(dbPath string) (*Store, error) { func NewStore(db *sql.DB) (*Store, error) {
dir := filepath.Dir(dbPath) store := &Store{DB: db}
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
// foreign_keys(1): enable FK constraints
// busy_timeout(5000): wait up to 5s for locks instead of failing immediately
// journal_mode(WAL): improves concurrent read/write behavior
// synchronous(FULL): durability-first mode; safest on sudden power loss
db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(FULL)")
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(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 { if err := store.createTables(); err != nil {
return nil, err return nil, err
} }
return store, nil return store, nil
} }
@ -51,7 +23,7 @@ func (s *Store) createTables() error {
appName TEXT PRIMARY KEY appName TEXT PRIMARY KEY
)`, )`,
`CREATE TABLE IF NOT EXISTS subscriptions ( `CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY DEFAULT (hex(randomblob(16))),
appName TEXT NOT NULL, appName TEXT NOT NULL,
channel TEXT NOT NULL, channel TEXT NOT NULL,
signalGroupId TEXT, signalGroupId TEXT,
@ -63,17 +35,11 @@ func (s *Store) createTables() error {
vapidPrivateKey TEXT, vapidPrivateKey TEXT,
FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE
)`, )`,
`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
)`,
`CREATE INDEX IF NOT EXISTS idx_subscriptions_appName ON subscriptions(appName)`, `CREATE INDEX IF NOT EXISTS idx_subscriptions_appName ON subscriptions(appName)`,
} }
for _, query := range queries { for _, query := range queries {
if _, err := s.db.Exec(query); err != nil { if _, err := s.DB.Exec(query); err != nil {
return fmt.Errorf("failed to create tables: %w", err) return fmt.Errorf("failed to create tables: %w", err)
} }
} }
@ -81,30 +47,16 @@ func (s *Store) createTables() error {
return nil return nil
} }
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) GetDB() *sql.DB {
return s.db
}
func (s *Store) RegisterApp(appName string) error { func (s *Store) RegisterApp(appName string) error {
query := `INSERT INTO apps (appName) VALUES (?) ON CONFLICT(appName) DO NOTHING` _, err := s.DB.Exec(`INSERT INTO apps (appName) VALUES (?) ON CONFLICT(appName) DO NOTHING`, appName)
_, err := s.execWrite(query, appName)
return err return err
} }
func (s *Store) AddSubscription(sub Subscription) error { func (s *Store) AddSubscription(sub Subscription) (string, error) {
if err := s.RegisterApp(sub.AppName); err != nil { if err := s.RegisterApp(sub.AppName); err != nil {
return err return "", err
} }
query := `
INSERT INTO subscriptions (id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey *string var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey *string
if sub.Signal != nil { if sub.Signal != nil {
@ -121,13 +73,17 @@ func (s *Store) AddSubscription(sub Subscription) error {
vapidPrivateKey = &sub.WebPush.VapidPrivateKey vapidPrivateKey = &sub.WebPush.VapidPrivateKey
} }
_, err := s.execWrite(query, sub.ID, sub.AppName, sub.Channel, signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey) var id string
return err 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) { func (s *Store) GetApp(appName string) (*App, error) {
query := `SELECT appName FROM apps WHERE appName = ?` row := s.DB.QueryRow(`SELECT appName FROM apps WHERE appName = ?`, appName)
row := s.db.QueryRow(query, appName)
var app App var app App
if err := row.Scan(&app.AppName); err == sql.ErrNoRows { if err := row.Scan(&app.AppName); err == sql.ErrNoRows {
@ -146,12 +102,11 @@ func (s *Store) GetApp(appName string) (*App, error) {
} }
func (s *Store) GetSubscriptions(appName string) ([]Subscription, error) { func (s *Store) GetSubscriptions(appName string) ([]Subscription, error) {
query := ` rows, err := s.DB.Query(`
SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey
FROM subscriptions FROM subscriptions
WHERE appName = ? WHERE appName = ?
` `, appName)
rows, err := s.db.Query(query, appName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -199,8 +154,7 @@ func (s *Store) GetSubscriptions(appName string) ([]Subscription, error) {
} }
func (s *Store) GetAllApps() ([]App, error) { func (s *Store) GetAllApps() ([]App, error) {
query := `SELECT appName FROM apps ORDER BY appName` rows, err := s.DB.Query(`SELECT appName FROM apps ORDER BY appName`)
rows, err := s.db.Query(query)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -238,48 +192,22 @@ func (s *Store) GetAllApps() ([]App, error) {
return apps, nil return apps, nil
} }
func (s *Store) GetSignalGroup(appName string) (*SignalSubscription, error) { func (s *Store) DeleteSubscription(subscriptionID string) error {
query := `SELECT groupId, account FROM signal_groups WHERE appName = ?` _, err := s.DB.Exec(`DELETE FROM subscriptions WHERE id = ?`, subscriptionID)
row := s.db.QueryRow(query, appName)
var groupID, account string
if err := row.Scan(&groupID, &account); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &SignalSubscription{
GroupID: groupID,
Account: account,
}, nil
}
func (s *Store) SaveSignalGroup(appName string, sub *SignalSubscription) error {
if err := s.RegisterApp(appName); err != nil {
return err
}
query := `INSERT INTO signal_groups (appName, groupId, account) VALUES (?, ?, ?)
ON CONFLICT(appName) DO UPDATE SET groupId=excluded.groupId, account=excluded.account`
_, err := s.execWrite(query, appName, sub.GroupID, sub.Account)
return err return err
} }
func (s *Store) DeleteSubscription(subscriptionID string) error { func (s *Store) DeleteSubscriptionsByChannel(channel Channel) error {
query := `DELETE FROM subscriptions WHERE id = ?` _, err := s.DB.Exec(`DELETE FROM subscriptions WHERE channel = ?`, channel)
_, err := s.execWrite(query, subscriptionID)
return err return err
} }
func (s *Store) GetSubscription(subscriptionID string) (*Subscription, error) { func (s *Store) GetSubscription(subscriptionID string) (*Subscription, error) {
query := ` row := s.DB.QueryRow(`
SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey
FROM subscriptions FROM subscriptions
WHERE id = ? WHERE id = ?
` `, subscriptionID)
row := s.db.QueryRow(query, subscriptionID)
var sub Subscription var sub Subscription
var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString
@ -322,35 +250,6 @@ func (s *Store) GetSubscription(subscriptionID string) (*Subscription, error) {
} }
func (s *Store) RemoveApp(appName string) error { func (s *Store) RemoveApp(appName string) error {
_, err := s.execWrite(`DELETE FROM apps WHERE appName = ?`, appName) _, err := s.DB.Exec(`DELETE FROM apps WHERE appName = ?`, appName)
return err return err
} }
func (s *Store) execWrite(query string, args ...any) (sql.Result, error) {
const maxAttempts = 4
delay := 50 * time.Millisecond
for attempt := 0; attempt < maxAttempts; attempt++ {
result, err := s.db.Exec(query, args...)
if err == nil {
return result, nil
}
if !isSQLiteBusyError(err) || attempt == maxAttempts-1 {
return nil, err
}
time.Sleep(delay)
delay *= 2
}
return nil, fmt.Errorf("write failed")
}
func isSQLiteBusyError(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
return strings.Contains(message, "sqlite_busy") || strings.Contains(message, "database is locked")
}

View 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"`
}

View file

@ -34,48 +34,9 @@ func VerifyAPIKey(r *http.Request, apiKey string) bool {
return false 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 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 { func GetClientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr) host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil || host == "" { if err != nil || host == "" {

View file

@ -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
View 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))
}
}

View file

@ -6,18 +6,6 @@ import (
"net/http" "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))
}
}
func LogAndError(w http.ResponseWriter, logger *slog.Logger, message string, code int, err error, attrs ...any) { func LogAndError(w http.ResponseWriter, logger *slog.Logger, message string, code int, err error, attrs ...any) {
logAttrs := append([]any{"error", err}, attrs...) logAttrs := append([]any{"error", err}, attrs...)
logger.Error(message, logAttrs...) logger.Error(message, logAttrs...)

View file

@ -18,8 +18,9 @@ const (
) )
type ColorHandler struct { type ColorHandler struct {
w io.Writer w io.Writer
level slog.Level level slog.Level
preAttrs []slog.Attr
} }
func NewColorHandler(w io.Writer, opts *slog.HandlerOptions) *ColorHandler { 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 { 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 { 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 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 _, _ = fmt.Fprintf(h.w, "%s%s%s [%s%s%s] %s", //nolint:errcheck
colorGray, timestamp, colorReset, colorGray, timestamp, colorReset,
color, level, colorReset, color, level, colorReset,
r.Message) 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 { r.Attrs(func(a slog.Attr) bool {
_, _ = fmt.Fprintf(h.w, " %s=%v", a.Key, a.Value) //nolint:errcheck _, _ = fmt.Fprintf(h.w, " %s=%v", a.Key, a.Value) //nolint:errcheck
return true return true

3
signal-cli/README.md Normal file
View file

@ -0,0 +1,3 @@
# signal-cli binaries
GraalVM native builds from https://media.projektzentrisch.de/temp/signal-cli/

Binary file not shown.

Binary file not shown.