mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
Compare commits
No commits in common. "v1.1.0" and "master" have entirely different histories.
52 changed files with 1166 additions and 461 deletions
|
|
@ -11,8 +11,8 @@
|
|||
# Optional: Server port (default: 8080)
|
||||
# PORT=8080
|
||||
|
||||
# Optional: Rate limit for requests (default: 100 per 15 minute window)
|
||||
# RATE_LIMIT=100
|
||||
# Optional: Rate limit for requests (default: 20 req/s per IP). Set to 0 to disable (e.g. behind a remote reverse proxy with its own rate limiting)
|
||||
# RATE_LIMIT=20
|
||||
|
||||
# Optional: Enable verbose logging (default: false)
|
||||
# VERBOSE_LOGGING=false
|
||||
|
|
|
|||
20
.github/copilot-instructions.md
vendored
20
.github/copilot-instructions.md
vendored
|
|
@ -35,3 +35,23 @@ if time.Since(l.generatedAt) < l.ttl {
|
|||
- Prefer composition over inheritance
|
||||
- Handle errors properly, don't ignore them
|
||||
|
||||
## Design Context
|
||||
|
||||
### Users
|
||||
Self-hosters — technically capable people who run their own infrastructure. They visit this UI occasionally to configure integrations (Signal, Telegram, WebPush, Proton Mail), register apps, and verify things are wired up. Could be on any device, but desktop is most common. Setup is a one-time or rare task; the UI is not used daily.
|
||||
|
||||
### Brand Personality
|
||||
Sharp. Minimal. No-nonsense. This is a tool for people who run servers, not a product trying to sell them something. The interface should feel like it respects their time — no filler, no decorative chrome, no marketing energy.
|
||||
|
||||
### Aesthetic Direction
|
||||
Refined utilitarian. Light theme by default, dark by system preference (keep existing both-modes behavior). The palette should feel clean and precise, not sterile. The cyan accent (`#56c3de`) is a good starting point for the brand hue — it reads as calm and technical without being loud. Neutrals should be very subtly tinted toward it.
|
||||
|
||||
Anti-reference: PHPMyAdmin / boring CRUD admin panel. The goal is something that wouldn't look out of place as a polished open-source project UI — functional, cohesive, with quiet confidence.
|
||||
|
||||
### Design Principles
|
||||
1. **Earn every element** — if it doesn't serve a function, remove it. No decorative chrome.
|
||||
2. **Precision over personality** — tight spacing, clear hierarchy, no ambiguity about what's interactive.
|
||||
3. **Trust through clarity** — statuses should be instantly readable; nothing should make the user wonder "did that work?"
|
||||
4. **Quiet confidence** — not boring, not flashy. Typography and spacing do the work, not color.
|
||||
5. **Polish the structure, don't replace it** — the existing `<details>`-card pattern and HTMX architecture should be respected. Improve the CSS layer, don't overhaul templates.
|
||||
|
||||
|
|
|
|||
13
.github/dependabot.yml
vendored
13
.github/dependabot.yml
vendored
|
|
@ -1,13 +0,0 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 3
|
||||
15
.github/workflows/ci.yml
vendored
15
.github/workflows/ci.yml
vendored
|
|
@ -6,6 +6,9 @@ on:
|
|||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
GOLANGCI_LINT_VERSION: v2.8.0
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -18,10 +21,16 @@ jobs:
|
|||
go-version: '1.26'
|
||||
cache: true
|
||||
|
||||
- name: Cache Go tools
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/go/bin
|
||||
key: go-tools-${{ runner.os }}-goimports-latest-golangci-lint-${{ env.GOLANGCI_LINT_VERSION }}
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
go install golang.org/x/tools/cmd/goimports@latest
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0
|
||||
which goimports || go install golang.org/x/tools/cmd/goimports@latest
|
||||
which golangci-lint || go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${{ env.GOLANGCI_LINT_VERSION }}
|
||||
|
||||
- name: Build
|
||||
- name: Lint & Build
|
||||
run: make all
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -5,3 +5,6 @@ data/
|
|||
prism-*
|
||||
tmp
|
||||
.VSCodeCounter/
|
||||
.image-digests
|
||||
.github/skills/
|
||||
.impeccable.md
|
||||
|
|
|
|||
32
Makefile
32
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: all build build-linux run dev lint fix vet clean install-tools deps check-updates update update-all docker-build docker-run docker-down release
|
||||
.PHONY: all build start dev fix install-tools check-updates release release-dev
|
||||
|
||||
BINARY_NAME=prism
|
||||
VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev")
|
||||
|
|
@ -25,10 +25,6 @@ fix:
|
|||
golangci-lint run --fix
|
||||
npx @biomejs/biome@latest check --write --unsafe .
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY_NAME) $(BINARY_NAME)-*
|
||||
rm -rf data/
|
||||
|
||||
install-tools:
|
||||
@echo "Installing Go tools to $(GOBIN)..."
|
||||
go install golang.org/x/tools/cmd/goimports@latest
|
||||
|
|
@ -47,7 +43,31 @@ install-tools:
|
|||
echo "signal-cli installed successfully to /usr/local/bin/signal-cli"
|
||||
|
||||
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"
|
||||
@echo ""
|
||||
@echo "=== Dockerfile base image updates ==="
|
||||
@for image in $$(grep -E '^FROM ' Dockerfile | awk '{print $$2}' | grep -v 'AS'); do \
|
||||
echo "Checking $$image..."; \
|
||||
name=$$(echo $$image | cut -d: -f1); \
|
||||
tag=$$(echo $$image | cut -d: -f2); \
|
||||
digest=$$(curl -sf "https://hub.docker.com/v2/repositories/library/$$name/tags/$$tag" | jq -r '.digest // empty'); \
|
||||
if [ -z "$$digest" ]; then \
|
||||
echo " $$image: could not fetch digest (offline or image not found)"; \
|
||||
continue; \
|
||||
fi; \
|
||||
cache_key=$$(echo $$image | tr ':/' '--'); \
|
||||
stored=$$(grep "^$$cache_key=" .image-digests 2>/dev/null | cut -d= -f2); \
|
||||
if [ -z "$$stored" ]; then \
|
||||
echo "$$cache_key=$$digest" >> .image-digests; \
|
||||
echo " $$image: digest saved for future comparisons"; \
|
||||
elif [ "$$stored" = "$$digest" ]; then \
|
||||
echo " $$image: up to date"; \
|
||||
else \
|
||||
sed -i "s|^$$cache_key=.*|$$cache_key=$$digest|" .image-digests; \
|
||||
echo " $$image: UPDATE AVAILABLE (image content has changed, consider rebuilding)"; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
release:
|
||||
@if [ ! -f VERSION ]; then \
|
||||
|
|
|
|||
167
README.md
167
README.md
|
|
@ -4,64 +4,61 @@
|
|||
|
||||
# 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>
|
||||
|
||||
<!-- 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
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
```bash
|
||||
# Create .env file
|
||||
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example
|
||||
mv .env.example .env
|
||||
nano .env # Set API_KEY=your-secret-key-here
|
||||
|
||||
# Run Prism
|
||||
docker run -d \
|
||||
--name prism \
|
||||
-p 8080:8080 \
|
||||
-v prism-data:/app/data \
|
||||
-v signal-data:/home/prism/.local/share/signal-cli \
|
||||
--env-file .env \
|
||||
ghcr.io/lone-cloud/prism:latest
|
||||
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/docker-compose.yml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Binary (Alternative)
|
||||
|
||||
```bash
|
||||
# Download latest release
|
||||
curl -L -O https://github.com/lone-cloud/prism/releases/latest/download/prism-linux-amd64
|
||||
chmod +x prism-linux-amd64
|
||||
mv prism-linux-amd64 prism
|
||||
|
||||
# Create .env file
|
||||
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example
|
||||
mv .env.example .env
|
||||
nano .env # Set API_KEY=your-secret-key-here
|
||||
|
||||
# Run Prism
|
||||
./prism
|
||||
```
|
||||
|
||||
Prism is now running at <http://localhost:8080>.
|
||||
|
||||

|
||||
## Security
|
||||
|
||||
**Deploy behind HTTPS.** Every API request sends your `API_KEY` in the `Authorization` header. Over plain HTTP that header is transmitted in cleartext — anyone who can observe the traffic between your callers and the server can read the key and make authenticated requests. Use a reverse proxy with TLS termination (Caddy, nginx, Traefik) or a tunnel service like Cloudflare Tunnel in front of Prism.
|
||||
|
||||
Only use http URLs when callers run on the same host and traffic never leaves the machine.
|
||||
|
||||
## Integrations
|
||||
|
||||
All integrations are configured through the web UI - no environment variables or command-line setup needed!
|
||||
|
||||
Authenticate using your `API_KEY` as the password (username can be anything).
|
||||
All integrations are configured through the web UI. Authenticate with your `API_KEY` as the password (username can be anything).
|
||||
|
||||
### Signal
|
||||
|
||||
|
|
@ -77,9 +74,7 @@ Send notifications through Signal Messenger.
|
|||
- Scan the displayed QR code
|
||||
5. Your device will link automatically
|
||||
|
||||
All notifications will be sent via Signal.
|
||||
|
||||
**Note:** If running the binary directly (not Docker), you'll need [signal-cli](https://github.com/AsamK/signal-cli/releases) installed and in your PATH. Docker images include signal-cli automatically.
|
||||
> **Note:** Binary installs require [signal-cli](https://github.com/AsamK/signal-cli/releases) in your PATH. Docker includes it automatically.
|
||||
|
||||
### Telegram
|
||||
|
||||
|
|
@ -102,20 +97,10 @@ Send notifications through a Telegram bot.
|
|||
- Enter your bot token and chat ID
|
||||
- Click "Configure"
|
||||
|
||||
All notifications will be sent to your Telegram chat.
|
||||
|
||||
### Proton Mail
|
||||
|
||||
Monitor a Proton Mail account and forward new emails as notifications through Signal or Telegram.
|
||||
|
||||
**Features:**
|
||||
|
||||
- Monitors inbox for new emails in real-time
|
||||
- Supports 2FA-enabled accounts
|
||||
- Auto-creates "Proton Mail" app for received emails
|
||||
- Secure credential storage with AES-256-GCM encryption
|
||||
- Automatic token refresh - no re-authentication needed
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Visit <http://localhost:8080> and authenticate with your API_KEY
|
||||
|
|
@ -127,9 +112,7 @@ Monitor a Proton Mail account and forward new emails as notifications through Si
|
|||
- 2FA code (if enabled)
|
||||
5. Click "Link"
|
||||
|
||||
Proton Mail will connect and begin monitoring. New emails will appear as notifications from the "Proton Mail" app.
|
||||
|
||||
**Note:** Prism uses the official Proton Mail API (same as the Proton Bridge). Credentials are encrypted and stored locally. Tokens refresh automatically in the background.
|
||||
New emails appear as notifications from the "Proton Mail" app. Credentials are encrypted (AES-256-GCM) and tokens refresh automatically.
|
||||
|
||||
### WebPush
|
||||
|
||||
|
|
@ -141,40 +124,6 @@ Send notifications directly to your browser.
|
|||
2. Allow browser notifications when prompted
|
||||
3. Apps without Signal or Telegram configured will automatically use WebPush
|
||||
|
||||
You'll receive browser notifications when messages arrive.
|
||||
|
||||
## 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
|
||||
|
||||
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"}'
|
||||
```
|
||||
|
||||
**JSON payload with image:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/my-app \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title": "Motion Detected", "message": "Front door", "attach": "https://example.com/snapshot.jpg"}'
|
||||
```
|
||||
|
||||
**Plain text payload:**
|
||||
|
||||
```bash
|
||||
|
|
@ -259,13 +217,72 @@ curl -X DELETE http://localhost:8080/api/v1/webpush/subscriptions/SUBSCRIPTION_I
|
|||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Home Assistant
|
||||
|
||||
Add to `configuration.yaml`:
|
||||
|
||||
```yaml
|
||||
notify:
|
||||
- platform: rest
|
||||
name: Prism
|
||||
resource: "http://<Your Prism server network IP>/Home Assistant"
|
||||
method: POST_JSON
|
||||
headers:
|
||||
Authorization: !secret prism_api_key
|
||||
data_template:
|
||||
title: "{{ title }}"
|
||||
message: "{{ message }}"
|
||||
image: "{{ data.image | default('') }}"
|
||||
```
|
||||
|
||||
Add to `secrets.yaml`:
|
||||
|
||||
```bash
|
||||
prism_api_key: "Bearer YOUR_API_KEY_HERE"
|
||||
```
|
||||
|
||||
Then use the `notify.prism` action in automations.
|
||||
|
||||
**Sending an image from a camera snapshot:**
|
||||
|
||||
Take a snapshot first, save it to HA's local static file server, then send the URL:
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- action: camera.snapshot
|
||||
target:
|
||||
entity_id: camera.front_door
|
||||
data:
|
||||
filename: /config/www/snapshot_front.jpg
|
||||
- action: notify.prism
|
||||
data:
|
||||
title: "Motion Detected"
|
||||
message: "Front door camera triggered"
|
||||
data:
|
||||
image: https://<your-ha-domain>/local/snapshot_front.jpg
|
||||
```
|
||||
|
||||
The `/config/www/` directory is served as `/local/` by Home Assistant's built-in HTTP server. Use your HA's external HTTPS URL so Prism can fetch the image when delivering the notification.
|
||||
|
||||
### Beszel
|
||||
|
||||
In [Beszel](https://beszel.dev)'s **Settings → Notifications**, add:
|
||||
|
||||
```
|
||||
ntfy://:YOUR_API_KEY@<prism-host>:<port>/Beszel?disableTLS=yes
|
||||
```
|
||||
|
||||
`disableTLS=yes` is only needed for local HTTP. The app name (`Beszel`) can be anything.
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Endpoints
|
||||
|
||||
#### GET /health
|
||||
|
||||
Public health check endpoint (no authentication required). Returns `200 OK` when the service is running. Used for Docker health checks and load balancer health probes.
|
||||
Public. Returns `200 OK` when running.
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
|
|
@ -273,7 +290,7 @@ curl http://localhost:8080/health
|
|||
|
||||
#### GET /api/v1/health
|
||||
|
||||
Detailed health endpoint (requires authentication). Returns JSON with uptime and integration status:
|
||||
Authenticated. Returns uptime and integration status:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/health \
|
||||
|
|
@ -282,14 +299,10 @@ curl http://localhost:8080/api/v1/health \
|
|||
|
||||
```json
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"version": "1.2.0",
|
||||
"uptime": "2h15m",
|
||||
"signal": {"linked": true, "account": "+1234567890"},
|
||||
"telegram": {"linked": true, "account": "123456789"},
|
||||
"proton": {"linked": true, "account": "user@proton.me"}
|
||||
}
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1.1.0
|
||||
1.4.2
|
||||
|
|
|
|||
BIN
assets/screenshots/dark.webp
Normal file
BIN
assets/screenshots/dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 28 KiB |
BIN
assets/screenshots/light.webp
Normal file
BIN
assets/screenshots/light.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
40
go.mod
40
go.mod
|
|
@ -4,42 +4,48 @@ go 1.26
|
|||
|
||||
require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/emersion/hydroxide v0.2.31
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/emersion/hydroxide v0.2.32
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/go-chi/httprate v0.15.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mymmrac/telego v1.7.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
modernc.org/sqlite v1.46.1
|
||||
github.com/mymmrac/telego v1.9.0
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 // indirect
|
||||
github.com/fogleman/gg v1.3.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grbit/go-json v0.11.0 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
github.com/valyala/fasthttp v1.71.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
modernc.org/libc v1.68.0 // indirect
|
||||
golang.org/x/arch v0.26.0 // indirect
|
||||
golang.org/x/image v0.39.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
|
|
|||
101
go.sum
101
go.sum
|
|
@ -1,15 +1,15 @@
|
|||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
|
||||
github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
|
||||
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
|
|
@ -21,15 +21,19 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63 h1:7aCSuwTBzg7BCPRRaBJD0weKZYdeAykOrY6ktpx8Vvc=
|
||||
github.com/emersion/go-bcrypt v0.0.0-20170822072041-6e724a1baa63/go.mod h1:eRwwJnuLVFtYTC+AI2JDJTMcuQUTYhBIK4I6bC5tpqw=
|
||||
github.com/emersion/hydroxide v0.2.31 h1:ofPKtEpD+AtE2oKJhcQKhUjubQS8+AHIjCuO3aeLBsM=
|
||||
github.com/emersion/hydroxide v0.2.31/go.mod h1:jhoMVyP0z2GACrmFkL0ppcWKt2LbC02auADSSsB4vH4=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/emersion/hydroxide v0.2.32 h1:Lg6GtUDgG+pGoKgKwlTxgTr4jL/Of9iqsqFhhN5RLDA=
|
||||
github.com/emersion/hydroxide v0.2.32/go.mod h1:ENJLlgG+CrTEhAuBARo7LXNsNkhsm+8FvKh1EJqosMg=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
|
|
@ -41,16 +45,18 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
|||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo=
|
||||
github.com/mymmrac/telego v1.7.0/go.mod h1:pdLV346EgVuq7Xrh3kMggeBiazeHhsdEoK0RTEOPXRM=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mymmrac/telego v1.9.0 h1:ZUJxZaPx/1IgRvVb5lXnUB8FgW5rNYfRe6Q2EJ4OJ+Y=
|
||||
github.com/mymmrac/telego v1.9.0/go.mod h1:tVEB7OqiOPx8elRk9+ETkwiDQrUhWSB2XmAKIY9KmWY=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
|
|
@ -69,12 +75,18 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
|
||||
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34=
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ=
|
||||
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
||||
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
|
|
@ -82,18 +94,18 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
|||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4=
|
||||
golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
|
|
@ -116,22 +128,21 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
|
|
@ -163,30 +174,30 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
|
||||
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
|
|
|||
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
495
public/index.css
495
public/index.css
|
|
@ -1,39 +1,34 @@
|
|||
/* Variables */
|
||||
:root {
|
||||
--bg-primary: #f5f5f5;
|
||||
--bg-secondary: #fdfdfd;
|
||||
--text-primary: #000;
|
||||
--text-secondary: #666;
|
||||
--text-on-color: #fff;
|
||||
--border-color: rgba(0, 0, 0, 0.1);
|
||||
--accent: #56c3de;
|
||||
--success: #06d6a0;
|
||||
--error: #ef476f;
|
||||
--spinner-track: #e0e0e0;
|
||||
color-scheme: light dark;
|
||||
--bg-primary: light-dark(#f5f7fa, #141618);
|
||||
--bg-secondary: light-dark(#fafbfd, #1e2124);
|
||||
--text-primary: light-dark(#0f1115, #dde1e7);
|
||||
--text-secondary: light-dark(#566070, #8ea0b5);
|
||||
--text-on-color: #f8fafc;
|
||||
--border-color: light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1));
|
||||
--accent: light-dark(#0b74b5, #71c9f8);
|
||||
--accent-hover: light-dark(#0a6399, #4db8f5);
|
||||
--text-on-accent: light-dark(#f8fafc, #00243c);
|
||||
--success: light-dark(#077a5d, #34d399);
|
||||
--success-hover: light-dark(#065e48, #2ab886);
|
||||
--success-bg: light-dark(#077a5d, #065e48);
|
||||
--error: light-dark(#c01746, #f87171);
|
||||
--error-hover: light-dark(#9e1239, #7f1d3e);
|
||||
--error-bg: light-dark(#c01746, #9e1239);
|
||||
--channel-signal-bg: light-dark(#e0f0fa, #1a3044);
|
||||
--channel-signal-hover: light-dark(#cce4f3, #243d55);
|
||||
--channel-webpush-bg: light-dark(#e0f5ef, #1a3329);
|
||||
--channel-webpush-hover: light-dark(#cceade, #243d33);
|
||||
--spinner-track: light-dark(#d9dde5, #2e3440);
|
||||
--shadow-tooltip: light-dark(rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-on-color: #fff;
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--accent: #56c3de;
|
||||
--spinner-track: #4a4a4a;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-on-color: #fff;
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--accent: #56c3de;
|
||||
--spinner-track: #4a4a4a;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Reset */
|
||||
|
|
@ -81,9 +76,8 @@ h6 {
|
|||
|
||||
/* Base */
|
||||
body {
|
||||
font-family: system-ui;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-family:
|
||||
ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
max-width: 50rem;
|
||||
margin: 1rem auto;
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
|
|
@ -101,21 +95,23 @@ a {
|
|||
}
|
||||
|
||||
a:hover {
|
||||
opacity: 0.8;
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Components */
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
.card,
|
||||
.integration-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
box-shadow: 0 0.125rem 0.25rem var(--border-color);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
.card-header,
|
||||
.integration-header {
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
display: flex;
|
||||
|
|
@ -127,11 +123,13 @@ a:hover {
|
|||
user-select: none;
|
||||
}
|
||||
|
||||
.card-header::-webkit-details-marker {
|
||||
.card-header::-webkit-details-marker,
|
||||
.integration-header::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-header::before {
|
||||
.card-header::before,
|
||||
.integration-header::before {
|
||||
content: "▶";
|
||||
font-size: 0.7em;
|
||||
margin-right: 0.5rem;
|
||||
|
|
@ -139,14 +137,39 @@ a:hover {
|
|||
display: inline-block;
|
||||
}
|
||||
|
||||
details[open] > .card-header::before {
|
||||
details[open] > .card-header::before,
|
||||
details[open] > .integration-header::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
.card-content,
|
||||
.integration-content {
|
||||
padding: 0 1.25rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
/* Page identity */
|
||||
.site-header {
|
||||
padding-bottom: 1.25rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.site-wordmark {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.card-header h2,
|
||||
.integration-header h3 {
|
||||
margin-bottom: 0;
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
.integration-status:has(.tooltip) {
|
||||
cursor: help;
|
||||
|
|
@ -160,18 +183,18 @@ details[open] > .card-header::before {
|
|||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
background: #1e2124;
|
||||
color: #fafbfd;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 0.0625rem solid var(--border-color);
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 0.25rem 0.5rem var(--shadow-tooltip);
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
|
|
@ -181,10 +204,11 @@ details[open] > .card-header::before {
|
|||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 0.3125rem solid transparent;
|
||||
border-top-color: var(--bg-secondary);
|
||||
border-top-color: #1e2124;
|
||||
}
|
||||
|
||||
:hover > .tooltip {
|
||||
:hover > .tooltip,
|
||||
:focus-within > .tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
|
@ -202,6 +226,10 @@ details[open] > .card-header::before {
|
|||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
|
|
@ -212,8 +240,6 @@ details[open] > .card-header::before {
|
|||
.app-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
max-height: 18.75rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
|
|
@ -231,17 +257,23 @@ details[open] > .card-header::before {
|
|||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.app-subscriptions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.channel-badge {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.85em;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.875em;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
border: none;
|
||||
|
|
@ -250,16 +282,20 @@ details[open] > .card-header::before {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
cursor: pointer;
|
||||
transition: filter 0.2s ease;
|
||||
transition: background-color 0.2s ease;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
|
@ -272,10 +308,11 @@ details[open] > .card-header::before {
|
|||
cursor: pointer;
|
||||
border: 0.0625rem dashed currentColor;
|
||||
transition: filter 0.2s ease;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.badge-inactive:hover {
|
||||
filter: brightness(1.2);
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.badge-inactive.htmx-request {
|
||||
|
|
@ -289,8 +326,8 @@ details[open] > .card-header::before {
|
|||
}
|
||||
|
||||
.channel-signal.badge-active {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
background: var(--channel-signal-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-signal.badge-inactive {
|
||||
|
|
@ -299,23 +336,23 @@ details[open] > .card-header::before {
|
|||
}
|
||||
|
||||
.channel-signal.badge-subscribed {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
background: var(--channel-signal-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-webpush.badge-active {
|
||||
background: var(--success);
|
||||
color: var(--text-on-color);
|
||||
background: var(--channel-webpush-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.channel-webpush.badge-subscribed {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
background: var(--channel-signal-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-telegram.badge-active {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
background: var(--channel-signal-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-telegram.badge-inactive {
|
||||
|
|
@ -324,8 +361,8 @@ details[open] > .card-header::before {
|
|||
}
|
||||
|
||||
.channel-telegram.badge-subscribed {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
background: var(--channel-signal-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
|
|
@ -334,21 +371,6 @@ details[open] > .card-header::before {
|
|||
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 {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
@ -356,10 +378,15 @@ details[open] > .card-header::before {
|
|||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
padding: 0 0.25rem;
|
||||
margin-left: 0.25rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
margin-left: 0.125rem;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
line-height: 1;
|
||||
min-width: 1rem;
|
||||
min-height: 1.5rem;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.btn-delete-sub:hover {
|
||||
|
|
@ -390,77 +417,51 @@ details[open] > .card-header::before {
|
|||
}
|
||||
|
||||
/* Integrations */
|
||||
.integration-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
box-shadow: 0 0.125rem 0.25rem var(--border-color);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.integration-container {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.integration-header {
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.integration-name {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.integration-header::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.integration-header::before {
|
||||
content: "▶";
|
||||
font-size: 0.7em;
|
||||
margin-right: 0.5rem;
|
||||
transition: transform 0.2s;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
details[open] > .integration-header::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.integration-content {
|
||||
padding: 0 1.25rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.integration-status {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.integration-status::before {
|
||||
content: "";
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.integration-status.connected::before,
|
||||
.integration-status.available::before {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.integration-status.connected,
|
||||
.integration-status.available {
|
||||
background: var(--success);
|
||||
color: var(--text-on-color);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.integration-status.disconnected::before {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.integration-status.disconnected {
|
||||
background: var(--error);
|
||||
color: var(--text-on-color);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.integration-status.unlinked::before {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
.integration-status.unlinked {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 0.0625rem solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.link-instructions {
|
||||
|
|
@ -470,14 +471,23 @@ details[open] > .integration-header::before {
|
|||
}
|
||||
|
||||
.channel-not-configured {
|
||||
background: var(--error);
|
||||
background: var(--error-bg);
|
||||
color: var(--text-on-color);
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.integration-content code {
|
||||
background: var(--bg-primary);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.auth-form {
|
||||
margin-top: 1rem;
|
||||
max-width: 400px;
|
||||
max-width: 25rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
|
|
@ -496,13 +506,62 @@ details[open] > .integration-header::before {
|
|||
.form-group input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border: 0.0625rem solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus-visible {
|
||||
border-color: var(--accent);
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Password Toggle */
|
||||
.password-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.password-wrapper input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group .password-wrapper input[type="text"],
|
||||
.form-group .password-wrapper input[type="email"],
|
||||
.form-group .password-wrapper input[type="password"] {
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
color: var(--text-secondary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.eye-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.eye-hide {
|
||||
display: none; /* overridden by inline style after first toggle */
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
|
|
@ -513,13 +572,19 @@ details[open] > .integration-header::before {
|
|||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: filter 0.2s ease;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-secondary:hover,
|
||||
.btn-danger:hover {
|
||||
filter: brightness(0.85);
|
||||
background-color: var(--error-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled,
|
||||
|
|
@ -531,7 +596,7 @@ details[open] > .integration-header::before {
|
|||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
|
|
@ -541,7 +606,7 @@ details[open] > .integration-header::before {
|
|||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
background: var(--error-bg);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
|
|
@ -554,13 +619,13 @@ details[open] > .integration-header::before {
|
|||
|
||||
.auth-status.success {
|
||||
display: block;
|
||||
background: var(--success);
|
||||
background: var(--success-bg);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.auth-status.error {
|
||||
display: block;
|
||||
background: var(--error);
|
||||
background: var(--error-bg);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
|
|
@ -569,7 +634,7 @@ details[open] > .integration-header::before {
|
|||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
/* QR Code */
|
||||
|
|
@ -591,12 +656,13 @@ details[open] > .integration-header::before {
|
|||
max-width: 25rem;
|
||||
height: auto;
|
||||
display: block;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
bottom: 4rem;
|
||||
right: 1.5rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
|
|
@ -611,7 +677,7 @@ details[open] > .integration-header::before {
|
|||
padding: 0.875rem 1.25rem;
|
||||
border-radius: 0.375rem;
|
||||
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);
|
||||
min-width: 15rem;
|
||||
max-width: 25rem;
|
||||
|
|
@ -623,18 +689,18 @@ details[open] > .integration-header::before {
|
|||
}
|
||||
|
||||
.toast.success {
|
||||
background: var(--success);
|
||||
background: var(--success-bg);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: var(--error);
|
||||
background: var(--error-bg);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-color);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.toast.hiding {
|
||||
|
|
@ -662,3 +728,120 @@ details[open] > .integration-header::before {
|
|||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline confirm widget */
|
||||
.confirm-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.confirm-message {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.confirm-inline .confirm-message {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
min-height: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.noscript-msg {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.skip-nav {
|
||||
position: absolute;
|
||||
top: -100%;
|
||||
left: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--accent);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 600;
|
||||
z-index: 10000;
|
||||
transition: top 0.1s;
|
||||
}
|
||||
|
||||
.skip-nav:focus {
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
#signal-qr-code:not([src]),
|
||||
#signal-qr-code[src=""] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.app-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
.theme-toggle {
|
||||
bottom: 0.75rem;
|
||||
right: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
#toast-container {
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
bottom: 3rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms;
|
||||
animation-iteration-count: 1;
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,38 +2,46 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Prism Admin</title>
|
||||
<title>Prism</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#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="manifest" href="/manifest.json">
|
||||
<link rel="stylesheet" href="/index.css?v={{.Version}}">
|
||||
<script src="/htmx.min.js"></script>
|
||||
<script src="/theme.js?v={{.Version}}"></script>
|
||||
<script src="/toast.js?v={{.Version}}"></script>
|
||||
<script src="/integration.js?v={{.Version}}"></script>
|
||||
<script src="/theme-init.js?v={{.Version}}"></script>
|
||||
<script src="/htmx.min.js" defer></script>
|
||||
<script src="/theme.js?v={{.Version}}" defer></script>
|
||||
<script src="/toast.js?v={{.Version}}" defer></script>
|
||||
<script src="/integration.js?v={{.Version}}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<details class="card" open>
|
||||
<summary class="card-header">
|
||||
<span class="integration-name">Registered Apps</span>
|
||||
</summary>
|
||||
<div id="apps-list" class="card-content"
|
||||
hx-get="/fragment/apps"
|
||||
hx-trigger="load, reload">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<a href="#main-content" class="skip-nav">Skip to content</a>
|
||||
<noscript><p class="noscript-msg">JavaScript is required to use Prism.</p></noscript>
|
||||
<main id="main-content">
|
||||
<h1 class="sr-only">Prism</h1>
|
||||
<details class="card" open>
|
||||
<summary class="card-header">
|
||||
<h2>Registered Apps</h2>
|
||||
</summary>
|
||||
<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>
|
||||
</details>
|
||||
|
||||
<div id="integrations"
|
||||
hx-get="/fragment/integrations"
|
||||
hx-trigger="load, reload">
|
||||
</div>
|
||||
</details>
|
||||
</main>
|
||||
|
||||
<div id="integrations"
|
||||
hx-get="/fragment/integrations"
|
||||
hx-trigger="load, reload">
|
||||
</div>
|
||||
<div id="toast-container" role="status" aria-live="polite" aria-atomic="false"></div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<button type="button" id="theme-toggle" class="theme-toggle"></button>
|
||||
<button type="button" id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">🌓</button>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,41 @@
|
|||
function showConfirm(btn, message, onConfirm) {
|
||||
document.querySelectorAll('.confirm-inline').forEach((el) => {
|
||||
el.replaceWith(el._original);
|
||||
});
|
||||
const wrapper = document.createElement('span');
|
||||
wrapper.className = 'confirm-inline';
|
||||
wrapper.setAttribute('role', 'group');
|
||||
wrapper.setAttribute('aria-label', message);
|
||||
wrapper.setAttribute('aria-live', 'polite');
|
||||
|
||||
const msg = document.createElement('span');
|
||||
msg.className = 'confirm-message';
|
||||
msg.textContent = message;
|
||||
|
||||
const yes = document.createElement('button');
|
||||
yes.type = 'button';
|
||||
yes.className = 'btn-danger btn-sm';
|
||||
yes.textContent = 'Yes';
|
||||
|
||||
const cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.className = 'btn-secondary btn-sm';
|
||||
cancel.textContent = 'Cancel';
|
||||
|
||||
yes.addEventListener('click', () => {
|
||||
wrapper.replaceWith(btn);
|
||||
onConfirm();
|
||||
});
|
||||
cancel.addEventListener('click', () => {
|
||||
delete btn.dataset.confirming;
|
||||
wrapper.replaceWith(btn);
|
||||
});
|
||||
|
||||
wrapper._original = btn;
|
||||
wrapper.append(msg, '\u00a0', yes, '\u00a0', cancel);
|
||||
btn.replaceWith(wrapper);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('submit', (e) => {
|
||||
const form = e.target;
|
||||
|
|
@ -18,9 +56,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
else if (action === 'delete-telegram') deleteTelegram(btn);
|
||||
else if (action === 'delete-proton') deleteProton(btn);
|
||||
else if (action === 'reload') reloadIntegrations();
|
||||
else if (action === 'toggle-password') togglePassword(btn);
|
||||
else if (action === 'delete-app') deleteApp(btn);
|
||||
else if (action === 'delete-subscription') deleteSubscription(btn);
|
||||
});
|
||||
});
|
||||
|
||||
function togglePassword(btn) {
|
||||
const input = btn.closest('.password-wrapper').querySelector('input');
|
||||
const isHidden = input.type === 'password';
|
||||
input.type = isHidden ? 'text' : 'password';
|
||||
btn.querySelector('.eye-show').style.display = isHidden ? 'none' : 'block';
|
||||
btn.querySelector('.eye-hide').style.display = isHidden ? 'block' : 'none';
|
||||
btn.setAttribute('aria-label', isHidden ? 'Hide password' : 'Show password');
|
||||
}
|
||||
|
||||
function reloadIntegrations() {
|
||||
const integrations = document.getElementById('integrations');
|
||||
if (integrations) {
|
||||
|
|
@ -32,6 +82,24 @@ function reloadIntegrations() {
|
|||
}
|
||||
}
|
||||
|
||||
function deleteApp(btn) {
|
||||
const appName = btn.dataset.appName;
|
||||
showConfirm(btn, `Delete ${appName}?`, () => {
|
||||
htmx.ajax('DELETE', `/apps/${appName}`, {
|
||||
target: '#apps-list',
|
||||
swap: 'innerHTML',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deleteSubscription(btn) {
|
||||
const url = btn.dataset.url;
|
||||
const anchor = btn.closest('.channel-badge') ?? btn;
|
||||
showConfirm(anchor, 'Delete this channel?', () => {
|
||||
htmx.ajax('DELETE', url, { target: '#apps-list', swap: 'innerHTML' });
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAuthForm(form, endpoint, statusId, getPayload) {
|
||||
const status = document.getElementById(statusId);
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
|
|
@ -116,11 +184,38 @@ async function submitProtonAuth(e) {
|
|||
|
||||
let signalLinkingPoll = null;
|
||||
|
||||
document.addEventListener('htmx:beforeSwap', (e) => {
|
||||
if (!signalLinkingPoll) return;
|
||||
const t = e.detail.target;
|
||||
if (t && (t.id === 'integrations' || t.id === 'signal-integration')) {
|
||||
clearInterval(signalLinkingPoll);
|
||||
signalLinkingPoll = null;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('htmx:confirm', (e) => {
|
||||
if (!e.detail.question) return;
|
||||
if (e.detail.elt.dataset.confirming) return;
|
||||
e.preventDefault();
|
||||
e.detail.elt.dataset.confirming = '1';
|
||||
showConfirm(e.detail.elt, e.detail.question, () => {
|
||||
delete e.detail.elt.dataset.confirming;
|
||||
e.detail.issueRequest(true);
|
||||
});
|
||||
});
|
||||
|
||||
async function linkSignal(btn) {
|
||||
if (signalLinkingPoll) {
|
||||
clearInterval(signalLinkingPoll);
|
||||
signalLinkingPoll = null;
|
||||
}
|
||||
const qrContainer = document.getElementById('signal-qr-container');
|
||||
const qrCode = document.getElementById('signal-qr-code');
|
||||
const showQrError = (msg) => {
|
||||
qrContainer.innerHTML = `<p class="channel-not-configured">Error: ${msg}</p>`;
|
||||
const p = document.createElement('p');
|
||||
p.className = 'channel-not-configured';
|
||||
p.textContent = `Error: ${msg}`;
|
||||
qrContainer.replaceChildren(p);
|
||||
qrContainer.style.display = 'block';
|
||||
btn.style.display = 'inline-block';
|
||||
};
|
||||
|
|
@ -142,8 +237,7 @@ async function linkSignal(btn) {
|
|||
}
|
||||
|
||||
const data = await response.json();
|
||||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(data.qr_code)}`;
|
||||
qrCode.src = qrUrl;
|
||||
qrCode.src = data.qr_code;
|
||||
qrContainer.style.display = 'block';
|
||||
|
||||
let statusCheckInProgress = false;
|
||||
|
|
@ -157,8 +251,10 @@ async function linkSignal(btn) {
|
|||
|
||||
if (statusData.linked) {
|
||||
clearInterval(signalLinkingPoll);
|
||||
qrContainer.innerHTML =
|
||||
'<p class="auth-status success">Linked! Refreshing...</p>';
|
||||
const linked = document.createElement('p');
|
||||
linked.className = 'auth-status success';
|
||||
linked.textContent = 'Linked! Refreshing...';
|
||||
qrContainer.replaceChildren(linked);
|
||||
showToast('Signal linked', 'success');
|
||||
setTimeout(() => reloadIntegrations(), 1000);
|
||||
}
|
||||
|
|
@ -168,51 +264,51 @@ async function linkSignal(btn) {
|
|||
statusCheckInProgress = false;
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
setTimeout(() => {
|
||||
if (signalLinkingPoll) {
|
||||
clearInterval(signalLinkingPoll);
|
||||
signalLinkingPoll = null;
|
||||
showQrError('QR code expired. Try again.');
|
||||
}
|
||||
}, 300000);
|
||||
} catch (err) {
|
||||
showQrError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTelegram(btn) {
|
||||
if (!confirm('Unlink Telegram integration?')) return;
|
||||
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/telegram/link', { method: 'DELETE' });
|
||||
|
||||
if (response.ok) {
|
||||
checkToast(response);
|
||||
reloadIntegrations();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Error: ${error.error || 'Failed to unlink integration'}`);
|
||||
btn.disabled = false;
|
||||
showConfirm(btn, 'Unlink Telegram?', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/telegram/link', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
checkToast(response);
|
||||
reloadIntegrations();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(error.error || 'Failed to unlink Telegram', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteProton(btn) {
|
||||
if (!confirm('Unlink Proton Mail integration?')) return;
|
||||
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/proton/auth', { method: 'DELETE' });
|
||||
|
||||
if (response.ok) {
|
||||
checkToast(response);
|
||||
reloadIntegrations();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Error: ${error.error || 'Failed to unlink integration'}`);
|
||||
btn.disabled = false;
|
||||
showConfirm(btn, 'Unlink Proton Mail?', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/proton/auth', { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
checkToast(response);
|
||||
reloadIntegrations();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(error.error || 'Failed to unlink Proton', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,28 @@
|
|||
{
|
||||
"name": "Prism Admin",
|
||||
"name": "Prism",
|
||||
"short_name": "Prism",
|
||||
"description": "Notification gateway admin panel",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a1a",
|
||||
"theme_color": "#56C3DE",
|
||||
"background_color": "#141618",
|
||||
"theme_color": "#0b74b5",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.webp",
|
||||
"sizes": "32x32",
|
||||
"type": "image/webp",
|
||||
"purpose": "any maskable"
|
||||
"type": "image/webp"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
5
public/theme-init.js
Normal file
5
public/theme-init.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
(() => {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t && t !== 'system')
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
|
|
@ -23,8 +23,14 @@ window.cycleTheme = () => {
|
|||
function updateButtonText(theme) {
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (btn) {
|
||||
const labels = {
|
||||
system: 'Toggle theme (system)',
|
||||
light: 'Toggle theme (light)',
|
||||
dark: 'Toggle theme (dark)',
|
||||
};
|
||||
btn.textContent =
|
||||
theme === 'system' ? '🌓' : theme === 'light' ? '☀️' : '🌙';
|
||||
btn.setAttribute('aria-label', labels[theme] || 'Toggle theme');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,16 +5,42 @@ function showToast(message, type = 'info') {
|
|||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
toast.setAttribute('tabindex', '0');
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
let timeoutId;
|
||||
const dismiss = () => {
|
||||
toast.classList.add('hiding');
|
||||
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('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const toasts = document.querySelectorAll('.toast:not(.hiding)');
|
||||
toasts.forEach((t) => {
|
||||
t.classList.add('hiding');
|
||||
setTimeout(() => t.remove(), 300);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterRequest', (event) => {
|
||||
const xhr = event.detail.xhr;
|
||||
const triggerHeader = xhr.getResponseHeader('HX-Trigger');
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
|
|
@ -22,7 +23,7 @@ func Load() (*Config, error) {
|
|||
APIKey: os.Getenv("API_KEY"),
|
||||
Port: getEnvInt("PORT", 8080),
|
||||
VerboseLogging: getEnvBool("VERBOSE_LOGGING", false),
|
||||
RateLimit: getEnvInt("RATE_LIMIT", 100),
|
||||
RateLimit: getEnvInt("RATE_LIMIT", 20),
|
||||
StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"),
|
||||
EnableSignal: getEnvBool("ENABLE_SIGNAL", true),
|
||||
EnableTelegram: getEnvBool("ENABLE_TELEGRAM", true),
|
||||
|
|
@ -40,6 +41,11 @@ func (c *Config) Validate() error {
|
|||
if c.APIKey == "" {
|
||||
return fmt.Errorf("API_KEY environment variable is required")
|
||||
}
|
||||
for _, r := range c.APIKey {
|
||||
if r > unicode.MaxASCII {
|
||||
return fmt.Errorf("API_KEY must contain only ASCII characters")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
48
service/delivery/enrich.go
Normal file
48
service/delivery/enrich.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package delivery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var phoneRegex = regexp.MustCompile(`(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}`)
|
||||
var emailRegex = regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`)
|
||||
|
||||
func enrichActions(notif Notification) Notification {
|
||||
if len(notif.Actions) > 0 {
|
||||
return notif
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, num := range phoneRegex.FindAllString(notif.Message, -1) {
|
||||
if seen[num] {
|
||||
continue
|
||||
}
|
||||
seen[num] = true
|
||||
notif.Actions = append(notif.Actions, Action{
|
||||
ID: fmt.Sprintf("call-%s", num),
|
||||
Label: fmt.Sprintf("Call %s", num),
|
||||
Endpoint: fmt.Sprintf("tel:%s", num),
|
||||
})
|
||||
}
|
||||
|
||||
for _, addr := range emailRegex.FindAllString(notif.Message, -1) {
|
||||
if seen[addr] {
|
||||
continue
|
||||
}
|
||||
seen[addr] = true
|
||||
local := addr
|
||||
if i := strings.Index(addr, "@"); i != -1 {
|
||||
local = addr[:i]
|
||||
}
|
||||
notif.Actions = append(notif.Actions, Action{
|
||||
ID: fmt.Sprintf("email-%s", addr),
|
||||
Label: fmt.Sprintf("Email %s", local),
|
||||
Endpoint: fmt.Sprintf("mailto:%s", addr),
|
||||
})
|
||||
}
|
||||
|
||||
return notif
|
||||
}
|
||||
|
|
@ -4,13 +4,14 @@ type Action struct {
|
|||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Method string `json:"method"`
|
||||
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"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Actions []Action `json:"actions,omitempty"`
|
||||
ImageURL string `json:"image,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ func (p *Publisher) IsValidChannel(channel subscription.Channel) bool {
|
|||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -107,6 +107,15 @@ func Initialize(cfg *config.Config, store *subscription.Store, logger *slog.Logg
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ func (m *Monitor) authenticateAndSetup(credStore *credentials.Store) error {
|
|||
c := &protonmail.Client{
|
||||
RootURL: protonAPIURL,
|
||||
AppVersion: protonAppVersion,
|
||||
Debug: false,
|
||||
}
|
||||
|
||||
var auth *protonmail.Auth
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
|||
integData.Name = "Proton Mail"
|
||||
|
||||
if !hasCredentials {
|
||||
integData.StatusClass = "disconnected"
|
||||
integData.StatusClass = "unlinked"
|
||||
integData.StatusText = "Unlinked"
|
||||
integData.StatusTooltip = "Enter credentials to link"
|
||||
integData.Open = true
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) ht
|
|||
}
|
||||
}
|
||||
|
||||
RegisterRoutes(router, p.Handlers, auth, p.db, p.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) {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ type Monitor struct {
|
|||
startTime time.Time
|
||||
consecutiveErrs int
|
||||
lastConnected time.Time
|
||||
cancelPoll context.CancelFunc
|
||||
}
|
||||
|
||||
func NewMonitor(cfg *config.Config, logger *slog.Logger) *Monitor {
|
||||
|
|
@ -40,7 +41,17 @@ func NewMonitor(cfg *config.Config, logger *slog.Logger) *Monitor {
|
|||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) Stop() {
|
||||
if m.cancelPoll != nil {
|
||||
m.cancelPoll()
|
||||
m.cancelPoll = nil
|
||||
}
|
||||
m.client = nil
|
||||
}
|
||||
|
||||
func (m *Monitor) Start(ctx context.Context, credStore *credentials.Store, publisher *delivery.Publisher) error {
|
||||
m.Stop()
|
||||
|
||||
m.dispatcher = publisher
|
||||
if err := m.authenticateAndSetup(credStore); err != nil {
|
||||
return err
|
||||
|
|
@ -54,7 +65,9 @@ func (m *Monitor) Start(ctx context.Context, credStore *credentials.Store, publi
|
|||
m.lastConnected = time.Now()
|
||||
m.unseenMessageIDs = make(map[string]time.Time)
|
||||
|
||||
go m.pollEvents(ctx)
|
||||
pollCtx, cancel := context.WithCancel(ctx)
|
||||
m.cancelPoll = cancel
|
||||
go m.pollEvents(pollCtx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,14 @@ import (
|
|||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"prism/service/config"
|
||||
"prism/service/credentials"
|
||||
"prism/service/util"
|
||||
|
||||
|
|
@ -23,6 +27,7 @@ type authHandler struct {
|
|||
apiKey string
|
||||
logger *slog.Logger
|
||||
integration *Integration
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
type protonAuthRequest struct {
|
||||
|
|
@ -46,6 +51,7 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
|||
c := &protonmail.Client{
|
||||
RootURL: protonAPIURL,
|
||||
AppVersion: protonAppVersion,
|
||||
Debug: false,
|
||||
}
|
||||
authInfo, err := c.AuthInfo(req.Email)
|
||||
if err != nil {
|
||||
|
|
@ -76,7 +82,16 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
|||
util.JSONError(w, "Invalid 2FA code. Please try again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Pre-2FA bearer is invalid for /keys/salts; refresh returns post-2FA tokens (hydroxide
|
||||
// does not apply them to the Client, so we use auth below for the salts request).
|
||||
auth.Scope = scope
|
||||
refreshed, err := c.AuthRefresh(auth)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to refresh session after 2FA", "error", err)
|
||||
util.JSONError(w, "Failed to complete authentication", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
auth = refreshed
|
||||
}
|
||||
|
||||
credStore, err := credentials.NewStore(h.db, h.apiKey)
|
||||
|
|
@ -86,7 +101,7 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
keySalts, err := c.ListKeySalts()
|
||||
keySalts, err := listKeySaltsWithAuth(protonAPIURL, protonAppVersion, auth)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get key salts", "error", err)
|
||||
util.JSONError(w, "Failed to complete authentication", http.StatusInternalServerError)
|
||||
|
|
@ -141,12 +156,64 @@ func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if h.integration != nil {
|
||||
h.integration.monitor.Stop()
|
||||
}
|
||||
|
||||
util.SetToast(w, "Proton Mail unlinked", "success")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger, integration *Integration) {
|
||||
func listKeySaltsWithAuth(rootURL, appVersion string, auth *protonmail.Auth) (map[string][]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, rootURL+"/keys/salts", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Pm-Appversion", appVersion)
|
||||
req.Header.Set("X-Pm-Apiversion", strconv.Itoa(protonmail.Version))
|
||||
req.Header.Set("X-Pm-Uid", auth.UID)
|
||||
req.Header.Set("Authorization", "Bearer "+auth.AccessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:101.0) Gecko/20100101 Firefox/101.0")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Code int `json:"Code"`
|
||||
Error string `json:"Error"`
|
||||
KeySalts []struct {
|
||||
ID string `json:"ID"`
|
||||
KeySalt string `json:"KeySalt"`
|
||||
} `json:"KeySalts"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Code != 1000 {
|
||||
return nil, fmt.Errorf("[%d] %s", result.Code, result.Error)
|
||||
}
|
||||
|
||||
salts := make(map[string][]byte, len(result.KeySalts))
|
||||
for _, ks := range result.KeySalts {
|
||||
if ks.KeySalt == "" {
|
||||
salts[ks.ID] = nil
|
||||
continue
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(ks.KeySalt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode key salt for %s: %w", ks.ID, err)
|
||||
}
|
||||
salts[ks.ID] = decoded
|
||||
}
|
||||
return salts, nil
|
||||
}
|
||||
|
||||
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger, integration *Integration, cfg *config.Config) {
|
||||
if handlers == nil {
|
||||
return
|
||||
}
|
||||
|
|
@ -159,6 +226,7 @@ func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler)
|
|||
apiKey: apiKey,
|
||||
logger: logger,
|
||||
integration: integration,
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
router.With(auth).Get("/fragment/proton", handlers.HandleFragment)
|
||||
|
|
|
|||
|
|
@ -5,15 +5,21 @@
|
|||
<form class="auth-form" data-handler="proton">
|
||||
<div class="form-group">
|
||||
<label for="proton-email">Email:</label>
|
||||
<input type="email" id="proton-email" name="email" placeholder="your-email@proton.me" required>
|
||||
<input type="email" id="proton-email" name="email" placeholder="your-email@proton.me" required autocomplete="email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="proton-password">Password:</label>
|
||||
<input type="password" id="proton-password" name="password" placeholder="Your Proton password" required>
|
||||
<div class="password-wrapper">
|
||||
<input type="password" id="proton-password" name="password" placeholder="Your Proton password" required autocomplete="current-password">
|
||||
<button type="button" class="password-toggle" data-action="toggle-password" aria-label="Show password">
|
||||
<svg class="eye-icon eye-show" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<svg class="eye-icon eye-hide" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-10-7-10-7a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 10 7 10 7a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="proton-totp">2FA Code (optional):</label>
|
||||
<input type="text" id="proton-totp" name="totp" placeholder="123456" pattern="[0-9]{6}">
|
||||
<input type="text" id="proton-totp" name="totp" placeholder="123456" pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Link</button>
|
||||
<div id="proton-auth-status" class="auth-status"></div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -149,3 +151,38 @@ func (c *Client) SendGroupMessage(groupID, message string) error {
|
|||
_, err = c.exec("-a", account.Number, "send", "-g", groupID, "--notify-self", "-m", message)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) SendGroupMessageWithAttachment(groupID, message, imageURL string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("signal client not initialized")
|
||||
}
|
||||
|
||||
resp, err := http.Get(imageURL) //nolint:noctx
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
tmp, err := os.CreateTemp("", "prism-signal-*.jpg")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("failed to write image: %w", err)
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
account, err := c.GetLinkedAccount()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get linked account: %w", err)
|
||||
}
|
||||
if account == nil {
|
||||
return fmt.Errorf("no linked Signal account")
|
||||
}
|
||||
|
||||
_, err = c.exec("-a", account.Number, "send", "-g", groupID, "--notify-self", "-m", message, "--attachment", tmp.Name())
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
qrcode "github.com/yeqown/go-qrcode/v2"
|
||||
"github.com/yeqown/go-qrcode/writer/standard"
|
||||
|
||||
"prism/service/util"
|
||||
)
|
||||
|
||||
|
|
@ -61,7 +66,7 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
|||
integData.Open = true
|
||||
contentData.Error = err.Error()
|
||||
} else if account == nil {
|
||||
integData.StatusClass = "disconnected"
|
||||
integData.StatusClass = "unlinked"
|
||||
integData.StatusText = "Unlinked"
|
||||
integData.StatusTooltip = "Click Link button below to link"
|
||||
integData.Open = true
|
||||
|
|
@ -111,9 +116,27 @@ func (h *Handlers) HandleLinkDevice(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
qrc, err := qrcode.NewWith(qrCode, qrcode.WithErrorCorrectionLevel(qrcode.ErrorCorrectionMedium))
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to encode QR code", "error", err)
|
||||
util.JSONError(w, "Failed to generate QR code", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w2 := standard.NewWithWriter(nopWriteCloser{&buf},
|
||||
standard.WithBuiltinImageEncoder(standard.PNG_FORMAT),
|
||||
standard.WithQRWidth(10),
|
||||
)
|
||||
if err := qrc.Save(w2); err != nil {
|
||||
h.logger.Error("Failed to render QR code", "error", err)
|
||||
util.JSONError(w, "Failed to generate QR code", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"qr_code": qrCode,
|
||||
"qr_code": dataURL,
|
||||
"status": "linking",
|
||||
})
|
||||
}
|
||||
|
|
@ -145,3 +168,7 @@ func (h *Handlers) HandleLinkStatus(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
type nopWriteCloser struct{ *bytes.Buffer }
|
||||
|
||||
func (nopWriteCloser) Close() error { return nil }
|
||||
|
|
|
|||
|
|
@ -98,6 +98,14 @@ func (s *Sender) Send(sub *subscription.Subscription, notif delivery.Notificatio
|
|||
message = fmt.Sprintf("%s\n\n%s", notif.Title, notif.Message)
|
||||
}
|
||||
|
||||
if notif.ImageURL != "" {
|
||||
if err := s.client.SendGroupMessageWithAttachment(signalGroupID, message, notif.ImageURL); err != nil {
|
||||
s.logger.Error("Failed to send group message with attachment", "groupID", signalGroupID, "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.client.SendGroupMessage(signalGroupID, message); err != nil {
|
||||
s.logger.Error("Failed to send group message", "groupID", signalGroupID, "error", err)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{{if .Linked}}
|
||||
<p><strong>To unlink:</strong></p>
|
||||
<ol class="link-instructions">
|
||||
<ol class="link-instructions" aria-label="Steps to unlink Signal">
|
||||
<li>Open Signal on your phone</li>
|
||||
<li>Go to Settings → Linked Devices</li>
|
||||
<li>Find and remove this device</li>
|
||||
|
|
@ -12,14 +12,14 @@
|
|||
<button class="btn-primary" data-action="link-signal">Link</button>
|
||||
<div id="signal-qr-container" class="qr-container" style="display:none;">
|
||||
<p><strong>Scan this QR code with Signal:</strong></p>
|
||||
<ol class="link-instructions">
|
||||
<ol class="link-instructions" aria-label="Steps to link Signal">
|
||||
<li>Open Signal on your phone</li>
|
||||
<li>Go to Settings → Linked Devices</li>
|
||||
<li>Tap "+" or "Link New Device"</li>
|
||||
<li>Scan the QR code below</li>
|
||||
</ol>
|
||||
<div class="qr-code-wrapper">
|
||||
<img id="signal-qr-code" alt="Signal QR Code">
|
||||
<img id="signal-qr-code" alt="QR code to scan with Signal and link this device" width="400" height="400">
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ package telegram
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
"github.com/mymmrac/telego/telegoutil"
|
||||
|
|
@ -46,6 +49,48 @@ func (c *Client) SendMessage(chatID int64, text string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func (c *Client) SendPhoto(chatID int64, imageURL, caption string) error {
|
||||
if c == nil || c.bot == nil {
|
||||
return fmt.Errorf("telegram client not initialized")
|
||||
}
|
||||
|
||||
resp, err := http.Get(imageURL) //nolint:noctx
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
tmp, err := os.CreateTemp("", "prism-telegram-*.jpg")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("failed to write image: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close temp file: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Open(tmp.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open temp file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
params := &telego.SendPhotoParams{
|
||||
ChatID: telegoutil.ID(chatID),
|
||||
Photo: telego.InputFile{File: f},
|
||||
Caption: caption,
|
||||
ParseMode: telego.ModeHTML,
|
||||
}
|
||||
|
||||
_, err = c.bot.SendPhoto(context.Background(), params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) IsAvailable() bool {
|
||||
if c == nil || c.bot == nil {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
|||
integData.Name = "Telegram"
|
||||
|
||||
if client == nil {
|
||||
integData.StatusClass = "disconnected"
|
||||
integData.StatusClass = "unlinked"
|
||||
integData.StatusText = "Unlinked"
|
||||
integData.StatusTooltip = "Enter bot token to link"
|
||||
integData.Open = true
|
||||
|
|
@ -103,7 +103,7 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
|||
integData.Open = true
|
||||
contentData.Error = err.Error()
|
||||
} else if chatID == 0 {
|
||||
integData.StatusClass = "disconnected"
|
||||
integData.StatusClass = "unlinked"
|
||||
integData.StatusText = "Needs Chat ID"
|
||||
integData.StatusTooltip = "@" + bot.Username
|
||||
integData.Open = true
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ type Integration struct {
|
|||
Handlers *Handlers
|
||||
Sender *Sender
|
||||
OnUnlink func()
|
||||
OnLink func()
|
||||
db *sql.DB
|
||||
apiKey string
|
||||
logger *slog.Logger
|
||||
|
|
@ -64,7 +65,7 @@ func NewIntegration(store *subscription.Store, logger *slog.Logger, tmpl *util.T
|
|||
}
|
||||
|
||||
func (t *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
|
||||
RegisterRoutes(router, t.Handlers, auth, t.db, t.apiKey, logger, t.OnUnlink)
|
||||
RegisterRoutes(router, t.Handlers, auth, t.db, t.apiKey, logger, t.OnLink, t.OnUnlink)
|
||||
}
|
||||
|
||||
func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ type linkHandler struct {
|
|||
db *sql.DB
|
||||
apiKey string
|
||||
logger *slog.Logger
|
||||
onLink func()
|
||||
onUnlink func()
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +57,10 @@ func (h *linkHandler) handleLink(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if h.onLink != nil {
|
||||
h.onLink()
|
||||
}
|
||||
|
||||
util.SetToast(w, "Telegram linked", "success")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
|
|
@ -79,7 +84,7 @@ func (h *linkHandler) handleUnlink(w http.ResponseWriter, r *http.Request) {
|
|||
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, onUnlink func()) {
|
||||
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger, onLink func(), onUnlink func()) {
|
||||
if handlers == nil {
|
||||
return
|
||||
}
|
||||
|
|
@ -87,7 +92,7 @@ func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler)
|
|||
handlers.DB = db
|
||||
handlers.APIKey = apiKey
|
||||
|
||||
linkH := &linkHandler{db: db, apiKey: apiKey, logger: logger, onUnlink: onUnlink}
|
||||
linkH := &linkHandler{db: db, apiKey: apiKey, logger: logger, onLink: onLink, onUnlink: onUnlink}
|
||||
|
||||
router.With(auth).Get("/fragment/telegram", handlers.HandleFragment)
|
||||
router.With(auth).Post("/api/v1/telegram/link", linkH.handleLink)
|
||||
|
|
|
|||
|
|
@ -70,6 +70,14 @@ func (s *Sender) Send(sub *subscription.Subscription, notif delivery.Notificatio
|
|||
return delivery.NewPermanentError(fmt.Errorf("invalid chat ID: %w", err))
|
||||
}
|
||||
|
||||
if notif.ImageURL != "" {
|
||||
if err := s.client.SendPhoto(chatID, notif.ImageURL, fullMessage); err != nil {
|
||||
s.logger.Error("Failed to send telegram photo", "chatID", chatID, "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.client.SendMessage(chatID, fullMessage); err != nil {
|
||||
s.logger.Error("Failed to send telegram message", "chatID", chatID, "error", err)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
{{if .NotConfigured}}
|
||||
<p><strong>Setup Telegram Bot:</strong></p>
|
||||
<ol class="link-instructions">
|
||||
<li>Message <a href="https://t.me/BotFather" target="_blank">@BotFather</a> on Telegram</li>
|
||||
<ol class="link-instructions" aria-label="Steps to set up Telegram bot">
|
||||
<li>Message <a href="https://t.me/BotFather" target="_blank" rel="noopener noreferrer">@BotFather</a> on Telegram</li>
|
||||
<li>Send <code>/newbot</code> and follow the prompts</li>
|
||||
<li>Copy the bot token from BotFather</li>
|
||||
<li>Message <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> to get your Chat ID</li>
|
||||
<li>Message <a href="https://t.me/userinfobot" target="_blank" rel="noopener noreferrer">@userinfobot</a> to get your Chat ID</li>
|
||||
<li>Enter both below:</li>
|
||||
</ol>
|
||||
<form class="auth-form" data-handler="telegram">
|
||||
<div class="form-group">
|
||||
<label for="telegram-bot-token">Bot Token:</label>
|
||||
<input type="text" id="telegram-bot-token" name="bot_token" placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz" required>
|
||||
<div class="password-wrapper">
|
||||
<input type="password" id="telegram-bot-token" name="bot_token" placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz" required autocomplete="off">
|
||||
<button type="button" class="password-toggle" data-action="toggle-password" aria-label="Show password">
|
||||
<svg class="eye-icon eye-show" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<svg class="eye-icon eye-hide" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-10-7-10-7a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 10 7 10 7a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="telegram-chat-id">Chat ID:</label>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
{{if .EnableSignal}}
|
||||
<div id="signal-integration" class="integration-container" hx-get="/fragment/signal" hx-trigger="load">
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
<div class="loading" role="status"><div class="spinner"></div><span class="sr-only">Loading…</span></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .EnableTelegram}}
|
||||
<div id="telegram-integration" class="integration-container" hx-get="/fragment/telegram" hx-trigger="load">
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
<div class="loading" role="status"><div class="spinner"></div><span class="sr-only">Loading…</span></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .EnableProton}}
|
||||
<div id="proton-integration" class="integration-container" hx-get="/fragment/proton" hx-trigger="load">
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
<div class="loading" role="status"><div class="spinner"></div><span class="sr-only">Loading…</span></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
message, title := parseNtfyPayload(r, body)
|
||||
message, title, imageURL := parseNtfyPayload(r, body)
|
||||
|
||||
if message == "" {
|
||||
util.JSONError(w, "Message required", http.StatusBadRequest)
|
||||
|
|
@ -47,8 +47,9 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
notif := delivery.Notification{
|
||||
Title: title,
|
||||
Message: message,
|
||||
Title: title,
|
||||
Message: message,
|
||||
ImageURL: imageURL,
|
||||
}
|
||||
|
||||
if err := s.publisher.Publish(appName, notif); err != nil {
|
||||
|
|
@ -56,7 +57,7 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
s.logger.Debug("Sent ntfy message", "app", appName, "preview", truncate(message, 50))
|
||||
s.logger.Debug("Sent ntfy message", "app", appName, "title", title, "message", message, "image", imageURL)
|
||||
|
||||
now := time.Now()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -72,8 +73,8 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func parseNtfyPayload(r *http.Request, body []byte) (string, string) {
|
||||
var message, title string
|
||||
func parseNtfyPayload(r *http.Request, body []byte) (string, string, string) {
|
||||
var message, title, imageURL string
|
||||
|
||||
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
|
|
@ -85,10 +86,13 @@ func parseNtfyPayload(r *http.Request, body []byte) (string, string) {
|
|||
var payload struct {
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Attach string `json:"attach"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err == nil {
|
||||
message = payload.Message
|
||||
title = payload.Title
|
||||
imageURL = firstNonEmpty(payload.Attach, payload.Image)
|
||||
} else {
|
||||
message = string(body)
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -122,10 +130,3 @@ func firstNonEmpty(values ...string) string {
|
|||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ func authMiddleware(apiKey string) func(http.Handler) http.Handler {
|
|||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !util.VerifyAPIKey(r, apiKey) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Prism Admin - Username: any, Password: API_KEY"`)
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Prism - Username: any, Password: API_KEY"`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
|
@ -63,6 +63,9 @@ func securityHeadersMiddleware() func(http.Handler) http.Handler {
|
|||
}
|
||||
|
||||
func rateLimitMiddleware(rps int) func(http.Handler) http.Handler {
|
||||
if rps <= 0 {
|
||||
return func(next http.Handler) http.Handler { return next }
|
||||
}
|
||||
rl := httprate.LimitByIP(rps, time.Second)
|
||||
return func(next http.Handler) http.Handler {
|
||||
limited := rl(next)
|
||||
|
|
|
|||
|
|
@ -104,7 +104,6 @@ func (s *Server) setupRoutes() {
|
|||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(loggingMiddleware(s.logger))
|
||||
r.Use(securityHeadersMiddleware())
|
||||
|
|
|
|||
|
|
@ -11,33 +11,37 @@
|
|||
{{$channel := .}}
|
||||
{{if .Toggleable}}
|
||||
{{if .Active}}
|
||||
<button type="button" class="channel-badge channel-{{.Channel}} badge-active"
|
||||
<button type="button"
|
||||
class="channel-badge channel-{{.Channel}} badge-active"
|
||||
hx-delete="/apps/{{$app.AppName}}/subscriptions/{{(index .Subscriptions 0).ID}}"
|
||||
hx-target="#apps-list"
|
||||
hx-swap="innerHTML">
|
||||
{{.Label}}
|
||||
<span class="tooltip">Channel ID: {{(index .Subscriptions 0).ID}}</span>
|
||||
</button>
|
||||
hx-target="#apps-list"
|
||||
hx-swap="innerHTML"
|
||||
aria-label="Disable {{.Label}} for {{$app.AppName}}">
|
||||
{{.Label}}
|
||||
<span class="tooltip">Channel ID: {{(index .Subscriptions 0).ID}}</span>
|
||||
</button>
|
||||
{{else}}
|
||||
<button type="button" class="channel-badge channel-{{.Channel}} badge-inactive"
|
||||
<button type="button"
|
||||
class="channel-badge channel-{{.Channel}} badge-inactive"
|
||||
hx-post="/apps/{{$app.AppName}}/subscriptions"
|
||||
hx-target="#apps-list"
|
||||
hx-swap="innerHTML"
|
||||
hx-vals='{"channel":"{{.Channel}}"}'>
|
||||
+ {{.Label}}
|
||||
<span class="tooltip">Click to enable</span>
|
||||
</button>
|
||||
hx-target="#apps-list"
|
||||
hx-swap="innerHTML"
|
||||
hx-vals='{"channel":"{{.Channel}}"}'
|
||||
aria-label="Enable {{.Label}} for {{$app.AppName}}">
|
||||
+ {{.Label}}
|
||||
<span class="tooltip">Click to enable</span>
|
||||
</button>
|
||||
{{end}}
|
||||
{{else}}
|
||||
{{range .Subscriptions}}
|
||||
<span class="channel-badge channel-{{$channel.Channel}} badge-subscribed">
|
||||
{{$channel.Label}}
|
||||
<span class="tooltip">Channel ID: {{.ID}}</span>
|
||||
<button type="button" class="btn-delete-sub"
|
||||
hx-delete="/apps/{{$app.AppName}}/subscriptions/{{.ID}}"
|
||||
hx-target="#apps-list"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Delete this channel?">×</button>
|
||||
<button type="button"
|
||||
class="btn-delete-sub"
|
||||
data-action="delete-subscription"
|
||||
data-url="/apps/{{$app.AppName}}/subscriptions/{{.ID}}"
|
||||
aria-label="Remove {{$channel.Channel}} channel from {{$app.AppName}}">×</button>
|
||||
</span>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
@ -50,7 +54,7 @@
|
|||
{{end}}
|
||||
</div>
|
||||
<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>
|
||||
</li>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<details class="integration-card"{{if .Open}} open{{end}}>
|
||||
<summary class="integration-header">
|
||||
<span class="integration-name">{{.Name}}</span>
|
||||
<h3>{{.Name}}</h3>
|
||||
<span class="integration-status {{.StatusClass}}">
|
||||
{{.StatusText}}
|
||||
{{if .StatusTooltip}}<span class="tooltip">{{.StatusTooltip}}</span>{{end}}
|
||||
|
|
|
|||
3
signal-cli/README.md
Normal file
3
signal-cli/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# signal-cli binaries
|
||||
|
||||
GraalVM native builds from https://media.projektzentrisch.de/temp/signal-cli/
|
||||
Binary file not shown.
Binary file not shown.
Loading…
Add table
Reference in a new issue