Compare commits
123 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f35129bc3 | |||
| 9b5ce8ad17 | |||
| 848b4ee2da | |||
| 850105181a | |||
| 37d745703c | |||
| 8dd23cc9b2 | |||
| fab4ca72de | |||
| e64423d1f3 | |||
| 397710c2d5 | |||
| 8c06caadf3 | |||
| 54afd9656d | |||
| 165c53339d | |||
| 960b5c2874 | |||
| 3f8fc957c5 | |||
| ca367baf35 | |||
| 4f259ccfea | |||
| 814c6fa258 | |||
| fe75679700 | |||
| 510a2d6f7a | |||
| ef98c7eab3 | |||
| d39a8e2b43 | |||
| 0c5306cbe7 | |||
| 6d8995eae9 | |||
| 93457a50a5 | |||
| fa6e967696 | |||
| 18c13e564f | |||
| c9cb3a7289 | |||
| 88aeff8539 | |||
| f136a17a4d | |||
| 2cfee68536 | |||
| 024421fe50 | |||
| 6ba21bad53 | |||
| eb762fd05b | |||
| e5778f68aa | |||
| c9d52e6b43 | |||
|
|
61d833d864 | ||
|
|
cc6b3ab198 | ||
| 7ccfe8e585 | |||
| 623cb9a998 | |||
| dff6addb35 | |||
| 6c3c5e1f6d | |||
| 4dac3dbba0 | |||
| f7f161221c | |||
| bb8ce0456a | |||
| 004c06a165 | |||
| b3bb864fac | |||
| ce18b03394 | |||
| bb1ee31308 | |||
|
|
621c3f7334 | ||
| c3204f7fc4 | |||
| d1972eaffc | |||
| 29949d90bc | |||
| 5c3e98c674 | |||
| 429ce5d239 | |||
| a96a814661 | |||
| 061fcb305f | |||
| 9976faaf27 | |||
| 45b55dc1c5 | |||
| 147534286f | |||
| 7f1de091e0 | |||
| df0834cc0f | |||
| 649b3d33c0 | |||
| f29f79e04c | |||
| d69651ecda | |||
| 667c8f77ac | |||
| 923e7110c4 | |||
| 36f6939b2f | |||
| a66ddc7363 | |||
| 98b18e28ca | |||
| 96f413c9f9 | |||
|
|
ece2fb2462 | ||
| 6143a45010 | |||
| 23a9725d13 | |||
| e205e84428 | |||
| c77a1e164d | |||
| 2fde90e650 | |||
| ea3345825a | |||
| 2a7b1e91c6 | |||
| c82b5381fc | |||
| dae17336f5 | |||
| af045a3736 | |||
| 7fe6d9652f | |||
| 74233957d3 | |||
| 48c420d14b | |||
| 372295be45 | |||
| 3978f476c7 | |||
| d79e8ab5bc | |||
| 113f09c2ab | |||
| 244ab02651 | |||
| f1fddabaf7 | |||
| 577db5251c | |||
| cc21c41e73 | |||
| 2aa86c6757 | |||
| c41d7ecfef | |||
| 7fd57101a3 | |||
| 7fdc62e91b | |||
| 23e905ee99 | |||
| 6d46ef0977 | |||
| b08fd5356d | |||
| f5c6166f57 | |||
| 25230521a3 | |||
| b5b927880b | |||
| f170211d6a | |||
| dc18625d26 | |||
| 53e46102eb | |||
| 27401f8841 | |||
| c58c6e6882 | |||
| fe11ed82af | |||
| ac40783aa7 | |||
| 4dd14a2833 | |||
| 2d4160f583 | |||
| e31ccc76c5 | |||
| 051a13cb7a | |||
| a8d8325971 | |||
| 73356a9fed | |||
| e9e6e06138 | |||
| c8a3e7dd4c | |||
| 63f2ce1a3e | |||
| 1d25469687 | |||
| 34c1406e84 | |||
| 8e4998ea77 | |||
| 13c068a8cf | |||
| c3dce75eea |
15
.air.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -ldflags=\"-X main.version=$(cat VERSION 2>/dev/null || echo 'dev')\" -o ./tmp/main ."
|
||||
include_ext = ["go", "html", "css", "js"]
|
||||
exclude_dir = ["tmp", "data"]
|
||||
delay = 1000
|
||||
|
||||
[log]
|
||||
time = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
server/node_modules
|
||||
proton-bridge/node_modules
|
||||
node_modules
|
||||
data/
|
||||
*.db
|
||||
prism
|
||||
prism-*
|
||||
|
|
|
|||
40
.env.example
|
|
@ -1,31 +1,21 @@
|
|||
# Required: API key for authentication
|
||||
# Required: Password for web UI login and credential encryption (use a strong unique password)
|
||||
# API_KEY=your-secret-key-here
|
||||
|
||||
# Optional: Server port
|
||||
# Default: 8080
|
||||
# Optional Configuration
|
||||
|
||||
# Optional: Disable integrations (default: all enabled)
|
||||
# ENABLE_SIGNAL=false
|
||||
# ENABLE_TELEGRAM=false
|
||||
# ENABLE_PROTON=false
|
||||
|
||||
# Optional: Server port (default: 8080)
|
||||
# PORT=8080
|
||||
|
||||
# Optional: Allow HTTP connections without HTTPS
|
||||
# Default: false
|
||||
# ALLOW_INSECURE_HTTP=true
|
||||
# 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: Rate limit for requests (per 15 minute window)
|
||||
# Default: 100 (requests per 15 minutes)
|
||||
# RATE_LIMIT=100
|
||||
# Optional: Enable verbose logging (default: false)
|
||||
# VERBOSE_LOGGING=false
|
||||
|
||||
# Optional: Device name shown in Signal app's linked devices list
|
||||
# Default: SUP
|
||||
# DEVICE_NAME=What SUP
|
||||
|
||||
# Optional: Enable verbose logging
|
||||
# Default: false
|
||||
# VERBOSE_LOGGING=true
|
||||
|
||||
# Optional: Proton Mail integration - receive email notifications via Signal
|
||||
# Get credentials by running: docker compose run --rm protonmail-bridge init
|
||||
# Then use 'login' and 'info' commands to get IMAP username/password
|
||||
# PROTON_IMAP_USERNAME=your-email@proton.me
|
||||
# PROTON_IMAP_PASSWORD=bridge-generated-password
|
||||
# PROTON_BRIDGE_HOST=protonmail-bridge
|
||||
# PROTON_BRIDGE_PORT=143
|
||||
# PROTON_SUP_TOPIC=Proton Mail
|
||||
# Optional: Database storage path (default: ./data/prism.db)
|
||||
# STORAGE_PATH=./data/prism.db
|
||||
|
|
|
|||
16
.github/FUNDING.yml
vendored
|
|
@ -1,15 +1 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: lone-cloud
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
github: lone-cloud
|
||||
57
.github/copilot-instructions.md
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Copilot Instructions
|
||||
|
||||
## Code Style
|
||||
|
||||
- **NO DOCUMENTATION FILES**: Never create README, GUIDE, HOWTO, or any other documentation markdown files unless explicitly requested. Code changes only.
|
||||
- **NO USELESS COMMENTS**: Don't add comments that just restate what the code does. If the code needs a comment to be understood, refactor it to be clearer instead.
|
||||
- **Self-documenting code**: Use clear variable and function names. The code should explain itself.
|
||||
- **Only comment WHY, not WHAT**: If you must add a comment, explain WHY something is done, not WHAT is being done.
|
||||
- all terminal commands must work for zsh
|
||||
|
||||
### Bad Comments (Don't Do This)
|
||||
|
||||
```go
|
||||
// Check what channels are actually available
|
||||
signalLinked := false
|
||||
|
||||
// Build channel selector with only available options
|
||||
var channelOptions string
|
||||
```
|
||||
|
||||
### Good Comments (Rare, Only When Necessary)
|
||||
|
||||
```go
|
||||
// HACK: QR service has 60s timeout, cache to avoid repeated calls
|
||||
if time.Since(l.generatedAt) < l.ttl {
|
||||
return l.qrCode, nil
|
||||
}
|
||||
```
|
||||
|
||||
## General Rules
|
||||
|
||||
- Code must be idiomatic and follow Go best practices
|
||||
- Keep functions focused and small
|
||||
- Use clear, descriptive names
|
||||
- Prefer composition over inheritance
|
||||
- Handle errors properly, don't ignore them
|
||||
|
||||
## 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.
|
||||
|
||||
38
.github/workflows/ci.yml
vendored
|
|
@ -6,23 +6,31 @@ on:
|
|||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
GOLANGCI_LINT_VERSION: v2.8.0
|
||||
|
||||
jobs:
|
||||
check:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.26'
|
||||
cache: true
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.6
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install && cd server && bun install
|
||||
|
||||
- name: Lint and type check
|
||||
run: cd server && bun run check
|
||||
|
||||
- name: Build server
|
||||
run: cd server && bun run build
|
||||
- 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: |
|
||||
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: Lint & Build
|
||||
run: make all
|
||||
|
|
|
|||
53
.github/workflows/docker.yml
vendored
|
|
@ -1,53 +0,0 @@
|
|||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/sup-server
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./server/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
35
.github/workflows/release-dev.yml
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
name: Dev Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-dev:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
build-args: |
|
||||
VERSION=dev
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ghcr.io/lone-cloud/prism:dev
|
||||
81
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.26'
|
||||
cache: true
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: |
|
||||
echo "tag=$(cat VERSION)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build binaries
|
||||
run: |
|
||||
VERSION=$(cat VERSION)
|
||||
|
||||
# Linux AMD64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOTOOLCHAIN=local go build \
|
||||
-trimpath \
|
||||
-buildvcs=false \
|
||||
-ldflags="-s -w -X main.version=$VERSION" \
|
||||
-o prism-linux-amd64 .
|
||||
|
||||
# Linux ARM64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 GOTOOLCHAIN=local go build \
|
||||
-trimpath \
|
||||
-buildvcs=false \
|
||||
-ldflags="-s -w -X main.version=$VERSION" \
|
||||
-o prism-linux-arm64 .
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.tag }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: |
|
||||
ghcr.io/lone-cloud/prism:${{ steps.version.outputs.tag }}
|
||||
ghcr.io/lone-cloud/prism:latest
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ steps.version.outputs.tag }}
|
||||
name: Release ${{ steps.version.outputs.tag }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
prism-linux-amd64
|
||||
prism-linux-arm64
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
12
.gitignore
vendored
|
|
@ -1,4 +1,10 @@
|
|||
node_modules/
|
||||
signal-cli
|
||||
data/
|
||||
*.db
|
||||
.env
|
||||
sup-server
|
||||
/prism
|
||||
prism-*
|
||||
tmp
|
||||
.VSCodeCounter/
|
||||
.image-digests
|
||||
.github/skills/
|
||||
.impeccable.md
|
||||
|
|
|
|||
9
.golangci.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: false
|
||||
|
||||
linters:
|
||||
disable:
|
||||
- errcheck
|
||||
2
.vscode/extensions.json
vendored
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"recommendations": ["biomejs.biome", "mathiasfrohlich.kotlin", "vscjava.vscode-gradle"]
|
||||
"recommendations": ["golang.go"]
|
||||
}
|
||||
|
|
|
|||
49
Dockerfile
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
FROM golang:1.26-alpine3.23 AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/go \
|
||||
VERSION=$(cat VERSION 2>/dev/null | tr -d '\n' || echo "dev") && \
|
||||
CGO_ENABLED=0 GOOS=linux GOTOOLCHAIN=local go build \
|
||||
-trimpath \
|
||||
-buildvcs=false \
|
||||
-ldflags="-w -s -X main.version=${VERSION}" \
|
||||
-o prism .
|
||||
|
||||
FROM debian:trixie-slim
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
COPY signal-cli/signal-cli-${TARGETARCH}.gz /tmp/signal-cli.gz
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ca-certificates wget && \
|
||||
gunzip /tmp/signal-cli.gz && \
|
||||
mv /tmp/signal-cli /usr/local/bin/signal-cli && \
|
||||
chmod +x /usr/local/bin/signal-cli && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/prism .
|
||||
|
||||
RUN useradd -m -u 1000 prism && \
|
||||
mkdir -p /app/data && \
|
||||
mkdir -p /home/prism/.local/share/signal-cli && \
|
||||
chown -R prism:prism /app /home/prism
|
||||
|
||||
USER prism
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT:-8080}/health || exit 1
|
||||
|
||||
CMD ["./prism"]
|
||||
85
Makefile
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
.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")
|
||||
GOBIN?=$(shell command -v go >/dev/null 2>&1 && go env GOPATH || echo "${HOME}/go")/bin
|
||||
export PATH := $(GOBIN):$(PATH)
|
||||
|
||||
all: fix build
|
||||
|
||||
build:
|
||||
go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY_NAME) .
|
||||
|
||||
start: build
|
||||
./$(BINARY_NAME)
|
||||
|
||||
dev:
|
||||
@which air > /dev/null || (echo "Installing air..." && go install github.com/air-verse/air@latest)
|
||||
air
|
||||
|
||||
fix:
|
||||
go mod download
|
||||
go mod tidy
|
||||
gofmt -s -w .
|
||||
goimports -w .
|
||||
golangci-lint run --fix
|
||||
npx @biomejs/biome@latest check --write --unsafe .
|
||||
|
||||
install-tools:
|
||||
@echo "Installing Go tools to $(GOBIN)..."
|
||||
go install golang.org/x/tools/cmd/goimports@latest
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.8.0
|
||||
@echo "Installing signal-cli native binary..."
|
||||
@ARCH=$$(uname -m); \
|
||||
case $$ARCH in \
|
||||
x86_64) SIGNAL_ARCH=amd64 ;; \
|
||||
aarch64) SIGNAL_ARCH=arm64 ;; \
|
||||
*) echo "Unsupported architecture: $$ARCH"; exit 1 ;; \
|
||||
esac; \
|
||||
gunzip -c signal-cli/signal-cli-$${SIGNAL_ARCH}.gz > /tmp/signal-cli && \
|
||||
sudo mv /tmp/signal-cli /usr/local/bin/signal-cli && \
|
||||
sudo chmod +x /usr/local/bin/signal-cli && \
|
||||
signal-cli --version && \
|
||||
echo "signal-cli installed successfully to /usr/local/bin/signal-cli"
|
||||
|
||||
check-updates:
|
||||
@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 \
|
||||
echo "Error: VERSION file not found"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@VERSION=$$(cat VERSION); \
|
||||
echo "Releasing v$$VERSION..."; \
|
||||
git tag -a "v$$VERSION" -m "Release v$$VERSION"; \
|
||||
git push origin "v$$VERSION"; \
|
||||
gh workflow run release.yml
|
||||
|
||||
release-dev:
|
||||
@echo "Triggering dev Docker image build..."
|
||||
gh workflow run release-dev.yml
|
||||
386
README.md
|
|
@ -1,194 +1,308 @@
|
|||
<div align="center">
|
||||
|
||||
<img src="assets/sup.webp" alt="SUP Icon" width="120" height="120" />
|
||||
<img src="assets/prism.webp" alt="Prism Icon" width="80" height="80" />
|
||||
|
||||
# SUP
|
||||
# Prism
|
||||
|
||||
**Push notification system using Signal as transport**
|
||||
**Private notification gateway**
|
||||
|
||||
[Setup](#setup) • [Real-World Examples](#real-world-examples) • [Architecture](#architecture)
|
||||
[Setup](#setup) • [Integrations](#integrations) • [API](#api) • [Examples](#real-world-examples) • [Monitoring](#monitoring)
|
||||
|
||||
</div>
|
||||
|
||||
<!-- markdownlint-enable MD033 -->
|
||||
|
||||
SUP is a self-hosted server that routes push notifications through Signal, allowing you to receive app notifications without exposing unique network fingerprints to any network observers. All notification traffic appears as regular Signal messages.
|
||||
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.
|
||||
|
||||
## How?
|
||||
Android companion app: [prism-android](https://github.com/lone-cloud/prism-android)
|
||||
|
||||
SUP functions as a UnifiedPush server to proxy http-based requests to Signal groups via [signal-cli](https://github.com/AsamK/signal-cli).
|
||||
|
||||
For the optional Proton Mail integration, SUP requires a server that runs Proton's official [proton-bridge](https://github.com/ProtonMail/proton-bridge). SUP's docker compose process will run an image from [protonmail-bridge-docker](https://github.com/shenxn/protonmail-bridge-docker). Once authenticated, the communication between SUP and proton-bridge will be over IMAP.
|
||||
<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
|
||||
|
||||
### 1. Proton Mail Integration
|
||||
|
||||
A Proton Mail Bridge is optionally available if you want to receive push notifications for incoming emails.
|
||||
|
||||
> **Note:** The default Proton Mail Bridge image uses `shenxn/protonmail-bridge:build` which compiles from source and supports multiple architectures. For x86_64 systems, you can use `shenxn/protonmail-bridge:latest` (pre-built binary, smaller and faster). For ARM devices (Raspberry Pi), stick with `:build`.
|
||||
|
||||
To receive Proton Mail notifications via Signal:
|
||||
|
||||
1. **Initialize Proton Mail Bridge** (one-time setup):
|
||||
### Docker (Recommended)
|
||||
|
||||
```bash
|
||||
# Download docker-compose.yml
|
||||
curl -L -O https://raw.githubusercontent.com/lone-cloud/sup/master/docker-compose.yml
|
||||
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
|
||||
|
||||
docker compose run --rm protonmail-bridge init
|
||||
```
|
||||
|
||||
2.**Login to Proton Mail Bridge**:
|
||||
|
||||
- At the `>>>` prompt, run: `login`
|
||||
- Enter your email
|
||||
- Enter your password
|
||||
- Enter your 2FA code
|
||||
|
||||
3.**Get IMAP credentials**:
|
||||
|
||||
- Run: `info`
|
||||
- Copy the Username and Password shown
|
||||
- Run: `exit` to quit
|
||||
|
||||
4.**Add credentials to .env**:
|
||||
|
||||
```bash
|
||||
# Add these to your .env file
|
||||
PROTON_IMAP_USERNAME=bridge-username-from-info-command
|
||||
PROTON_IMAP_PASSWORD=bridge-generated-password-from-info-command
|
||||
```
|
||||
|
||||
5.**Start all services with Proton Mail**:
|
||||
|
||||
```bash
|
||||
docker compose --profile protonmail up -d
|
||||
```
|
||||
|
||||
Your phone will now receive Signal notifications when Proton Mail receives new emails.
|
||||
|
||||
Note that the bridge will first need to sync all of your old emails before you can start getting new email notifications which may take a while, but this is a one-time setup.
|
||||
|
||||
### 2. Install SUP Server
|
||||
|
||||
```bash
|
||||
# Download docker-compose.yml
|
||||
curl -L -O https://raw.githubusercontent.com/lone-cloud/sup/master/docker-compose.yml
|
||||
|
||||
# Download .env.example (optional)
|
||||
curl -L -O https://raw.githubusercontent.com/lone-cloud/sup/master/server/.env.example
|
||||
|
||||
# Configure SUP server through environment variables (optional)
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
|
||||
# Start SUP server
|
||||
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/docker-compose.yml
|
||||
docker compose up -d
|
||||
|
||||
```
|
||||
|
||||
### 3. Link Your Signal Account
|
||||
|
||||
Visit <http://localhost:8080> and link your Signal account (one-time setup):
|
||||
|
||||
#### 1. Authenticate with your API_KEY
|
||||
|
||||

|
||||
|
||||
#### 2. Scan the QR code from your Signal app
|
||||
|
||||
Go to **Settings → Linked Devices → Link New Device** in Signal.
|
||||
|
||||

|
||||
|
||||
#### 3. Verify the setup
|
||||
|
||||
Once linked, you'll see the status dashboard:
|
||||
|
||||

|
||||
|
||||
With optional Proton Mail integration:
|
||||
|
||||

|
||||
|
||||
### Development
|
||||
|
||||
For local development, install Bun and signal-cli:
|
||||
### Binary (Alternative)
|
||||
|
||||
```bash
|
||||
# Install Bun (use your package manager and this is a backup)
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
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
|
||||
|
||||
git clone https://github.com/lone-cloud/sup.git
|
||||
cd sup
|
||||
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
|
||||
|
||||
bun install
|
||||
cd server
|
||||
bun start
|
||||
./prism
|
||||
```
|
||||
|
||||
Then build and run with docker-compose.dev.yml:
|
||||
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. Authenticate with your `API_KEY` as the password (username can be anything).
|
||||
|
||||
### Signal
|
||||
|
||||
Send notifications through Signal Messenger.
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Visit <http://localhost:8080> and authenticate with your API_KEY
|
||||
2. Expand the Signal integration card
|
||||
3. Click "Link Device"
|
||||
4. Scan the QR code with Signal on your phone:
|
||||
- Open Signal → Settings → Linked Devices → Link New Device
|
||||
- Scan the displayed QR code
|
||||
5. Your device will link automatically
|
||||
|
||||
> **Note:** Binary installs require [signal-cli](https://github.com/AsamK/signal-cli/releases) in your PATH. Docker includes it automatically.
|
||||
|
||||
### Telegram
|
||||
|
||||
Send notifications through a Telegram bot.
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Create a bot:
|
||||
- Message [@BotFather](https://t.me/BotFather) on Telegram
|
||||
- Send `/newbot` and follow the prompts
|
||||
- Copy the bot token
|
||||
|
||||
2. Get your Chat ID:
|
||||
- Message [@userinfobot](https://t.me/userinfobot) on Telegram
|
||||
- Copy your Chat ID from the response
|
||||
|
||||
3. Configure in Prism:
|
||||
- Visit <http://localhost:8080> and authenticate with your API_KEY
|
||||
- Expand the Telegram integration card
|
||||
- Enter your bot token and chat ID
|
||||
- Click "Configure"
|
||||
|
||||
### Proton Mail
|
||||
|
||||
Monitor a Proton Mail account and forward new emails as notifications through Signal or Telegram.
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Visit <http://localhost:8080> and authenticate with your API_KEY
|
||||
2. Configure Signal or Telegram first (required for routing)
|
||||
3. Expand the Proton Mail integration card
|
||||
4. Enter your Proton Mail credentials:
|
||||
- Email address
|
||||
- Password
|
||||
- 2FA code (if enabled)
|
||||
5. Click "Link"
|
||||
|
||||
New emails appear as notifications from the "Proton Mail" app. Credentials are encrypted (AES-256-GCM) and tokens refresh automatically.
|
||||
|
||||
### WebPush
|
||||
|
||||
Send notifications directly to your browser.
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Visit <http://localhost:8080> and authenticate with your API_KEY
|
||||
2. Allow browser notifications when prompted
|
||||
3. Apps without Signal or Telegram configured will automatically use WebPush
|
||||
|
||||
## API
|
||||
|
||||
All API endpoints require authentication with your API key:
|
||||
|
||||
```bash
|
||||
docker compose --profile protonmail -f docker-compose.dev.yml up -d
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
```
|
||||
|
||||
or just the proton-bridge:
|
||||
### ntfy-compatible publish API
|
||||
|
||||
Publish notifications to `POST /{appName}`.
|
||||
|
||||
`{appName}` is the target app/topic name. Messages are routed to all subscriptions configured for that app (Signal, Telegram, WebPush).
|
||||
|
||||
**JSON payload:**
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up protonmail-bridge
|
||||
curl -X POST http://localhost:8080/my-app \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-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
|
||||
curl -X POST http://localhost:8080/my-app \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-d "Simple message text"
|
||||
```
|
||||
|
||||
### WebPush subscription API
|
||||
|
||||
Register and remove WebPush subscriptions for an app.
|
||||
|
||||
#### POST /api/v1/webpush/subscriptions
|
||||
|
||||
Creates a WebPush subscription.
|
||||
|
||||
Required fields:
|
||||
|
||||
- `appName`
|
||||
- `pushEndpoint`
|
||||
|
||||
Optional encrypted payload fields (must be provided together):
|
||||
|
||||
- `p256dh`
|
||||
- `auth`
|
||||
- `vapidPrivateKey`
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/webpush/subscriptions \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"appName": "my-app",
|
||||
"pushEndpoint": "https://example.push.service/send/abc123",
|
||||
"p256dh": "BASE64URL_P256DH",
|
||||
"auth": "BASE64URL_AUTH",
|
||||
"vapidPrivateKey": "BASE64URL_VAPID_PRIVATE_KEY"
|
||||
}'
|
||||
```
|
||||
|
||||
Minimal registration (without encrypted payload fields):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/webpush/subscriptions \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"appName": "my-app",
|
||||
"pushEndpoint": "http://localhost:9001/mock-push"
|
||||
}'
|
||||
```
|
||||
|
||||
#### DELETE /api/v1/webpush/subscriptions/{subscriptionId}
|
||||
|
||||
Removes a WebPush subscription by ID.
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8080/api/v1/webpush/subscriptions/SUBSCRIPTION_ID \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Proton Mail Notifications
|
||||
### Home Assistant
|
||||
|
||||
Receive Signal notifications when new emails arrive in your Proton Mail inbox.
|
||||
Add to `configuration.yaml`:
|
||||
|
||||
SUP monitors a Proton Mail account via the local bridge and forwards email alerts through Signal. This relies on the same technology that a third-party email client like Thunderbird would be using to integrate with Proton Mail.
|
||||
|
||||
### Home Assistant Alerts
|
||||
|
||||
Add a rest notification configuration (eg. add to configuration.yaml) to Home Assistant like:
|
||||
|
||||
```bash
|
||||
```yaml
|
||||
notify:
|
||||
- platform: rest
|
||||
name: SUP
|
||||
resource: "http://<Your SUP server network IP>/Home Assistant"
|
||||
method: POST
|
||||
name: Prism
|
||||
resource: "http://<Your Prism server network IP>/Home Assistant"
|
||||
method: POST_JSON
|
||||
headers:
|
||||
Authorization: !secret sup_basic_auth
|
||||
Authorization: !secret prism_api_key
|
||||
data_template:
|
||||
title: "{{ title }}"
|
||||
message: "{{ message }}"
|
||||
image: "{{ data.image | default('') }}"
|
||||
```
|
||||
|
||||
Note how Home Assistant is also a self-hosted server. As such, it is advisable to turn on `ALLOW_INSECURE_HTTP` environment variable for SUP and to refer to it by its LAN IP address.
|
||||
|
||||
Add the Base64 version of your API_KEY environment variable secret to your secrets.yaml. This secret must be prepended by a colon and the simplest way to get this value is to run `btoa(':<API_KEY>')` in your browser's console.
|
||||
Add to `secrets.yaml`:
|
||||
|
||||
```bash
|
||||
sup_basic_auth: "Basic <Base64 Hash value>"
|
||||
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 sup action.
|
||||
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
|
||||
|
||||
The health of the system can be viewed in the same admin UI used for linking Signal. SUP uses [basic access authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) - provide your `API_KEY` as the password (username can be anything).
|
||||
### Health Endpoints
|
||||
|
||||
For API-based monitoring, call `/api/health` which returns JSON:
|
||||
#### GET /health
|
||||
|
||||
```json
|
||||
{"uptime":"3s","signal":{"daemon":"running","linked":true},"protonMail":"connected"}
|
||||
Public. Returns `200 OK` when running.
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
## Architecture
|
||||
#### GET /api/v1/health
|
||||
|
||||

|
||||
Authenticated. Returns uptime and integration status:
|
||||
|
||||
SUP consists of two services that **MUST run together on the same machine**:
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/health \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
|
||||
- **sup-server** (Bun): Receives webhooks, sends Signal messages via signal-cli. Optional: monitors Proton Mail IMAP
|
||||
- **protonmail-bridge** (Official Proton, optional): Decrypts Proton Mail emails, runs local IMAP server
|
||||
|
||||
All services communicate over a private Docker network with no external exposure except Signal protocol. **Separating these services across multiple machines would expose plaintext IMAP traffic and compromise security.**
|
||||
```json
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"uptime": "2h15m",
|
||||
"signal": {"linked": true, "account": "+1234567890"},
|
||||
"telegram": {"linked": true, "account": "123456789"},
|
||||
"proton": {"linked": true, "account": "user@proton.me"}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
1.4.2
|
||||
|
Before Width: | Height: | Size: 31 KiB |
BIN
assets/prism.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
BIN
assets/screenshots/dark.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
assets/screenshots/light.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
assets/sup.webp
|
Before Width: | Height: | Size: 24 KiB |
77
biome.json
|
|
@ -1,63 +1,18 @@
|
|||
{
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"useArrowFunction": "warn"
|
||||
},
|
||||
"style": {
|
||||
"useShorthandFunctionType": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"html": {
|
||||
"parser": {
|
||||
"interpolation": true
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["server/public/htmx.min.js"],
|
||||
"linter": {
|
||||
"enabled": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["scripts/**/*"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noConsole": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**/public/**/*.{js,css,html}",
|
||||
"!**/public/htmx.min.js",
|
||||
"!**/service/**/templates/**/*.html"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
74
bun.lock
|
|
@ -1,74 +0,0 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "sup",
|
||||
"dependencies": {
|
||||
"chalk": "5.6.2",
|
||||
"hono": "4.11.5",
|
||||
"hono-rate-limiter": "0.5.3",
|
||||
"imap": "0.8.19",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.12",
|
||||
"@types/bun": "1.3.6",
|
||||
"@types/imap": "0.8.43",
|
||||
"typescript": "5.9.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@biomejs/biome": ["@biomejs/biome@2.3.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.12", "@biomejs/cli-darwin-x64": "2.3.12", "@biomejs/cli-linux-arm64": "2.3.12", "@biomejs/cli-linux-arm64-musl": "2.3.12", "@biomejs/cli-linux-x64": "2.3.12", "@biomejs/cli-linux-x64-musl": "2.3.12", "@biomejs/cli-win32-arm64": "2.3.12", "@biomejs/cli-win32-x64": "2.3.12" }, "bin": { "biome": "bin/biome" } }, "sha512-AR7h4aSlAvXj7TAajW/V12BOw2EiS0AqZWV5dGozf4nlLoUF/ifvD0+YgKSskT0ylA6dY1A8AwgP8kZ6yaCQnA=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cO6fn+KiMBemva6EARDLQBxeyvLzgidaFRJi8G7OeRqz54kWK0E+uSjgFaiHlc3DZYoa0+1UFE8mDxozpc9ieg=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-/fiF/qmudKwSdvmSrSe/gOTkW77mHHkH8Iy7YC2rmpLuk27kbaUOPa7kPiH5l+3lJzTUfU/t6x1OuIq/7SGtxg=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-nbOsuQROa3DLla5vvsTZg+T5WVPGi9/vYxETm9BOuLHBJN3oWQIg3MIkE2OfL18df1ZtNkqXkH6Yg9mdTPem7A=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-aqkeSf7IH+wkzFpKeDVPSXy9uDjxtLpYA6yzkYsY+tVjwFFirSuajHDI3ul8en90XNs1NA0n8kgBrjwRi5JeyA=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.12", "", { "os": "linux", "cpu": "x64" }, "sha512-CQtqrJ+qEEI8tgRSTjjzk6wJAwfH3wQlkIGsM5dlecfRZaoT+XCms/mf7G4kWNexrke6mnkRzNy6w8ebV177ow=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.12", "", { "os": "linux", "cpu": "x64" }, "sha512-kVGWtupRRsOjvw47YFkk5mLiAdpCPMWBo1jOwAzh+juDpUb2sWarIp+iq+CPL1Wt0LLZnYtP7hH5kD6fskcxmg=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-Re4I7UnOoyE4kHMqpgtG6UvSBGBbbtvsOvBROgCCoH7EgANN6plSQhvo2W7OCITvTp7gD6oZOyZy72lUdXjqZg=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.12", "", { "os": "win32", "cpu": "x64" }, "sha512-qqGVWqNNek0KikwPZlOIoxtXgsNGsX+rgdEzgw82Re8nF02W+E2WokaQhpF5TdBh/D/RQ3TLppH+otp6ztN0lw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
||||
|
||||
"@types/imap": ["@types/imap@0.8.43", "", { "dependencies": { "@types/node": "*" } }, "sha512-POPoqrDax9mxM2N4ITZYCWaFtg1ORVfzJe4S7xwSh9aHawdEb7FwWTJYiAhzIvWp7DM+6BajnzYOwZ1BUrqtow=="],
|
||||
|
||||
"@types/node": ["@types/node@25.0.7", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"hono": ["hono@4.11.5", "", {}, "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g=="],
|
||||
|
||||
"hono-rate-limiter": ["hono-rate-limiter@0.5.3", "", { "peerDependencies": { "hono": "^4.10.8", "unstorage": "^1.17.3" }, "optionalPeers": ["unstorage"] }, "sha512-M0DxbVMpPELEzLi0AJg1XyBHLGJXz7GySjsPoK+gc5YeeBsdGDGe+2RvVuCAv8ydINiwlbxqYMNxUEyYfRji/A=="],
|
||||
|
||||
"imap": ["imap@0.8.19", "", { "dependencies": { "readable-stream": "1.1.x", "utf7": ">=1.0.2" } }, "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"isarray": ["isarray@0.0.1", "", {}, "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="],
|
||||
|
||||
"readable-stream": ["readable-stream@1.1.14", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", "isarray": "0.0.1", "string_decoder": "~0.10.x" } }, "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ=="],
|
||||
|
||||
"semver": ["semver@5.3.0", "", { "bin": { "semver": "./bin/semver" } }, "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw=="],
|
||||
|
||||
"string_decoder": ["string_decoder@0.10.31", "", {}, "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"utf7": ["utf7@1.0.2", "", { "dependencies": { "semver": "~5.3.0" } }, "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
services:
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: server/Dockerfile
|
||||
ports:
|
||||
- '8080:8080'
|
||||
environment:
|
||||
- PORT=8080
|
||||
- API_KEY=${API_KEY:-}
|
||||
- VERBOSE_LOGGING=${VERBOSE_LOGGING:-false}
|
||||
- ALLOW_INSECURE_HTTP=${ALLOW_INSECURE_HTTP:-false}
|
||||
- PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-}
|
||||
- PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-}
|
||||
- PROTON_BRIDGE_HOST=protonmail-bridge
|
||||
- PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143}
|
||||
- PROTON_SUP_TOPIC=${PROTON_SUP_TOPIC:-Proton Mail}
|
||||
volumes:
|
||||
- signal-data:/root/.local/share/signal-cli
|
||||
- sup-data:/root/.local/share/sup
|
||||
restart: unless-stopped
|
||||
|
||||
protonmail-bridge:
|
||||
image: shenxn/protonmail-bridge:build
|
||||
profiles: ['protonmail']
|
||||
ports:
|
||||
- '127.0.0.1:143:143'
|
||||
volumes:
|
||||
- proton-bridge-data:/root
|
||||
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
signal-data:
|
||||
sup-data:
|
||||
proton-bridge-data:
|
||||
|
|
@ -1,33 +1,16 @@
|
|||
services:
|
||||
server:
|
||||
image: ghcr.io/lone-cloud/sup-server:latest
|
||||
prism:
|
||||
container_name: prism
|
||||
image: ghcr.io/lone-cloud/prism:latest
|
||||
ports:
|
||||
- '8080:8080'
|
||||
environment:
|
||||
- PORT=8080
|
||||
- API_KEY=${API_KEY:-}
|
||||
- VERBOSE_LOGGING=${VERBOSE_LOGGING:-false}
|
||||
- ALLOW_INSECURE_HTTP=${ALLOW_INSECURE_HTTP:-false}
|
||||
- RATE_LIMIT=${RATE_LIMIT:-100}
|
||||
- PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-}
|
||||
- PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-}
|
||||
- PROTON_BRIDGE_HOST=protonmail-bridge
|
||||
- PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143}
|
||||
- PROTON_SUP_TOPIC=${PROTON_SUP_TOPIC:-Proton Mail}
|
||||
- "${PORT:-8080}:${PORT:-8080}"
|
||||
volumes:
|
||||
- signal-data:/root/.local/share/signal-cli
|
||||
- sup-data:/root/.local/share/sup
|
||||
restart: unless-stopped
|
||||
|
||||
protonmail-bridge:
|
||||
image: shenxn/protonmail-bridge:build
|
||||
profiles: ['protonmail']
|
||||
volumes:
|
||||
- proton-bridge-data:/root
|
||||
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro
|
||||
- prism-data:/app/data
|
||||
- signal-data:/home/prism/.local/share/signal-cli
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
signal-data:
|
||||
sup-data:
|
||||
proton-bridge-data:
|
||||
prism-data:
|
||||
|
|
|
|||
51
go.mod
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
module prism
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
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.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.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.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // 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.71.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/yeqown/reedsolomon v1.0.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
golang.org/x/arch v0.26.0 // indirect
|
||||
golang.org/x/image v0.39.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
204
go.sum
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
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.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=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/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.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=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
||||
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
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.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.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=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
|
||||
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk=
|
||||
github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw=
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34=
|
||||
github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ=
|
||||
github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0=
|
||||
github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.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.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=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
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.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.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.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=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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.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.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.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=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
72
main.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"prism/service/config"
|
||||
"prism/service/server"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
//go:embed public/*
|
||||
var publicAssets embed.FS
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_ = godotenv.Load() //nolint:errcheck
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "version" {
|
||||
fmt.Println("Prism v", version)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger := util.NewLogger(cfg.VerboseLogging)
|
||||
logger.Info("Starting Prism", "version", version)
|
||||
|
||||
if err := runServer(cfg, logger); err != nil {
|
||||
logger.Error("Fatal error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runServer(cfg *config.Config, logger *slog.Logger) error {
|
||||
srv, err := server.New(cfg, publicAssets, version, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create server: %w", err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- srv.Start(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Info("Received shutdown signal")
|
||||
return srv.Shutdown()
|
||||
case err := <-serverErr:
|
||||
return err
|
||||
}
|
||||
}
|
||||
36
package.json
|
|
@ -1,36 +0,0 @@
|
|||
{
|
||||
"name": "sup",
|
||||
"version": "0.1.2",
|
||||
"description": "Privacy-preserving push notifications using Signal as transport",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"author": {
|
||||
"name": "lone-cloud",
|
||||
"email": "lonecloud604@proton.me"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/lone-cloud/sup"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "bun run scripts/install-signal-cli.ts || true",
|
||||
"start": "bun run server/index.ts",
|
||||
"build": "bun build --compile server/index.ts --outfile server/sup-server",
|
||||
"check": "tsc --noEmit && biome check .",
|
||||
"fix": "biome check --write --unsafe .",
|
||||
"release": "bun run scripts/release.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "5.6.2",
|
||||
"hono": "4.11.5",
|
||||
"hono-rate-limiter": "0.5.3",
|
||||
"imap": "0.8.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.12",
|
||||
"@types/bun": "1.3.6",
|
||||
"@types/imap": "0.8.43",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/favicon.webp
Normal file
|
After Width: | Height: | Size: 820 B |
1
public/htmx.min.js
vendored
Normal file
BIN
public/icon-192.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
847
public/index.css
Normal file
|
|
@ -0,0 +1,847 @@
|
|||
/* Variables */
|
||||
:root {
|
||||
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));
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Reset */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img,
|
||||
picture,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Base */
|
||||
body {
|
||||
font-family:
|
||||
ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
max-width: 50rem;
|
||||
margin: 1rem auto;
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Components */
|
||||
|
||||
/* Cards */
|
||||
.card,
|
||||
.integration-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0;
|
||||
margin-bottom: 1.25rem;
|
||||
box-shadow: 0 0.125rem 0.25rem var(--border-color);
|
||||
}
|
||||
|
||||
.card-header,
|
||||
.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;
|
||||
}
|
||||
|
||||
.card-header::-webkit-details-marker,
|
||||
.integration-header::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card-header::before,
|
||||
.integration-header::before {
|
||||
content: "▶";
|
||||
font-size: 0.7em;
|
||||
margin-right: 0.5rem;
|
||||
transition: transform 0.2s;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
details[open] > .card-header::before,
|
||||
details[open] > .integration-header::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 0.5rem;
|
||||
background: #1e2124;
|
||||
color: #fafbfd;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
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 var(--shadow-tooltip);
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 0.3125rem solid transparent;
|
||||
border-top-color: #1e2124;
|
||||
}
|
||||
|
||||
:hover > .tooltip,
|
||||
:focus-within > .tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
padding: 0;
|
||||
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 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* App List */
|
||||
.app-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.app-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.app-subscriptions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.channel-badge {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.875em;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
border: none;
|
||||
background: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.badge-active {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.badge-active:hover {
|
||||
background-color: var(--channel-signal-hover);
|
||||
}
|
||||
|
||||
.channel-webpush.badge-active:hover {
|
||||
background-color: var(--channel-webpush-hover);
|
||||
}
|
||||
|
||||
.badge-active.htmx-request {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.badge-inactive {
|
||||
cursor: pointer;
|
||||
border: 0.0625rem dashed currentColor;
|
||||
transition: filter 0.2s ease;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.badge-inactive:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.badge-inactive.htmx-request {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.badge-subscribed {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.channel-signal.badge-active {
|
||||
background: var(--channel-signal-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-signal.badge-inactive {
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.channel-signal.badge-subscribed {
|
||||
background: var(--channel-signal-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-webpush.badge-active {
|
||||
background: var(--channel-webpush-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.channel-webpush.badge-subscribed {
|
||||
background: var(--channel-signal-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-telegram.badge-active {
|
||||
background: var(--channel-signal-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-telegram.badge-inactive {
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.channel-telegram.badge-subscribed {
|
||||
background: var(--channel-signal-bg);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-delete-sub {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
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 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.loading {
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 0.125rem solid var(--spinner-track);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Integrations */
|
||||
.integration-container {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.integration-status {
|
||||
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 {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.integration-status.disconnected::before {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.integration-status.disconnected {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.integration-status.unlinked::before {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
.integration-status.unlinked {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.link-instructions {
|
||||
margin: 0.25rem 0;
|
||||
padding-left: 1.25rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.channel-not-configured {
|
||||
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: 25rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="email"],
|
||||
.form-group input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 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,
|
||||
.btn-danger {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: var(--error-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled,
|
||||
.btn-secondary:disabled,
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 0.0625rem solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error-bg);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-status.success {
|
||||
display: block;
|
||||
background: var(--success-bg);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.auth-status.error {
|
||||
display: block;
|
||||
background: var(--error-bg);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.auth-status.info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
/* QR Code */
|
||||
.qr-container {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.qr-code-wrapper {
|
||||
background: var(--text-on-color);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.qr-code-wrapper img {
|
||||
width: 100%;
|
||||
max-width: 25rem;
|
||||
height: auto;
|
||||
display: block;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
#toast-container {
|
||||
position: fixed;
|
||||
bottom: 4rem;
|
||||
right: 1.5rem;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow:
|
||||
0 0.25rem 0.5rem var(--shadow-tooltip),
|
||||
0 0 0 0.0625rem var(--border-color);
|
||||
min-width: 15rem;
|
||||
max-width: 25rem;
|
||||
pointer-events: auto;
|
||||
animation: toastSlideIn 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: var(--success-bg);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: var(--error-bg);
|
||||
color: var(--text-on-color);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.toast.hiding {
|
||||
animation: toastSlideOut 0.3s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes toastSlideIn {
|
||||
from {
|
||||
transform: translateX(calc(100% + 1.5rem));
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toastSlideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(100% + 1.5rem));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline confirm widget */
|
||||
.confirm-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.confirm-message {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.confirm-inline .confirm-message {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
min-height: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.noscript-msg {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.skip-nav {
|
||||
position: absolute;
|
||||
top: -100%;
|
||||
left: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--accent);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 600;
|
||||
z-index: 10000;
|
||||
transition: top 0.1s;
|
||||
}
|
||||
|
||||
.skip-nav:focus {
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
#signal-qr-code:not([src]),
|
||||
#signal-qr-code[src=""] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.app-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.app-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
.theme-toggle {
|
||||
bottom: 0.75rem;
|
||||
right: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
#toast-container {
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
bottom: 3rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms;
|
||||
animation-iteration-count: 1;
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
47
public/index.html
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Prism</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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="/theme-init.js?v={{.Version}}"></script>
|
||||
<script src="/htmx.min.js" defer></script>
|
||||
<script src="/theme.js?v={{.Version}}" defer></script>
|
||||
<script src="/toast.js?v={{.Version}}" defer></script>
|
||||
<script src="/integration.js?v={{.Version}}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<a href="#main-content" class="skip-nav">Skip to content</a>
|
||||
<noscript><p class="noscript-msg">JavaScript is required to use Prism.</p></noscript>
|
||||
<main id="main-content">
|
||||
<h1 class="sr-only">Prism</h1>
|
||||
<details class="card" open>
|
||||
<summary class="card-header">
|
||||
<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>
|
||||
</main>
|
||||
|
||||
<div id="toast-container" role="status" aria-live="polite" aria-atomic="false"></div>
|
||||
|
||||
<button type="button" id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">🌓</button>
|
||||
</body>
|
||||
</html>
|
||||
314
public/integration.js
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
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;
|
||||
if (form.classList.contains('auth-form')) {
|
||||
e.preventDefault();
|
||||
const handler = form.dataset.handler;
|
||||
if (handler === 'telegram') submitTelegramAuth(e);
|
||||
else if (handler === 'telegram-chatid') submitTelegramChatId(e);
|
||||
else if (handler === 'proton') submitProtonAuth(e);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
if (action === 'link-signal') linkSignal(btn);
|
||||
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) {
|
||||
htmx.trigger(integrations, 'reload');
|
||||
}
|
||||
const appsList = document.getElementById('apps-list');
|
||||
if (appsList) {
|
||||
htmx.trigger(appsList, 'reload');
|
||||
}
|
||||
}
|
||||
|
||||
function deleteApp(btn) {
|
||||
const appName = btn.dataset.appName;
|
||||
showConfirm(btn, `Delete ${appName}?`, () => {
|
||||
htmx.ajax('DELETE', `/apps/${appName}`, {
|
||||
target: '#apps-list',
|
||||
swap: 'innerHTML',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deleteSubscription(btn) {
|
||||
const url = btn.dataset.url;
|
||||
const anchor = btn.closest('.channel-badge') ?? btn;
|
||||
showConfirm(anchor, 'Delete this channel?', () => {
|
||||
htmx.ajax('DELETE', url, { target: '#apps-list', swap: 'innerHTML' });
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAuthForm(form, endpoint, statusId, getPayload) {
|
||||
const status = document.getElementById(statusId);
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
const showError = (msg) => {
|
||||
status.textContent = `Error: ${msg}`;
|
||||
status.className = 'auth-status error';
|
||||
btn.disabled = false;
|
||||
};
|
||||
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(getPayload(formData, form)),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
checkToast(response);
|
||||
reloadIntegrations();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showError(error.error || 'Failed to save');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function checkToast(response) {
|
||||
const trigger = response.headers.get('HX-Trigger');
|
||||
if (trigger) {
|
||||
try {
|
||||
const data = JSON.parse(trigger);
|
||||
if (data.showToast && window.showToast) {
|
||||
showToast(data.showToast.message, data.showToast.type);
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTelegramAuth(e) {
|
||||
await handleAuthForm(
|
||||
e.target,
|
||||
'/api/v1/telegram/link',
|
||||
'telegram-auth-status',
|
||||
(fd) => ({
|
||||
bot_token: fd.get('bot_token'),
|
||||
chat_id: fd.get('chat_id'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function submitTelegramChatId(e) {
|
||||
await handleAuthForm(
|
||||
e.target,
|
||||
'/api/v1/telegram/link',
|
||||
'telegram-chatid-status',
|
||||
(fd, form) => ({
|
||||
bot_token: form.dataset.botToken,
|
||||
chat_id: fd.get('chat_id'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function submitProtonAuth(e) {
|
||||
await handleAuthForm(
|
||||
e.target,
|
||||
'/api/v1/proton/auth',
|
||||
'proton-auth-status',
|
||||
(fd) => ({
|
||||
email: fd.get('email'),
|
||||
password: fd.get('password'),
|
||||
totp: fd.get('totp'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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) => {
|
||||
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';
|
||||
};
|
||||
|
||||
btn.style.display = 'none';
|
||||
qrContainer.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/signal/link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device_name: 'Prism' }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
showQrError(error.error || 'Failed to generate link');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
qrCode.src = data.qr_code;
|
||||
qrContainer.style.display = 'block';
|
||||
|
||||
let statusCheckInProgress = false;
|
||||
signalLinkingPoll = setInterval(async () => {
|
||||
if (statusCheckInProgress) return;
|
||||
|
||||
statusCheckInProgress = true;
|
||||
try {
|
||||
const statusResp = await fetch('/api/v1/signal/status');
|
||||
const statusData = await statusResp.json();
|
||||
|
||||
if (statusData.linked) {
|
||||
clearInterval(signalLinkingPoll);
|
||||
const linked = document.createElement('p');
|
||||
linked.className = 'auth-status success';
|
||||
linked.textContent = 'Linked! Refreshing...';
|
||||
qrContainer.replaceChildren(linked);
|
||||
showToast('Signal linked', 'success');
|
||||
setTimeout(() => reloadIntegrations(), 1000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Status check failed:', err);
|
||||
} finally {
|
||||
statusCheckInProgress = false;
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
setTimeout(() => {
|
||||
if (signalLinkingPoll) {
|
||||
clearInterval(signalLinkingPoll);
|
||||
signalLinkingPoll = null;
|
||||
showQrError('QR code expired. Try again.');
|
||||
}
|
||||
}, 300000);
|
||||
} catch (err) {
|
||||
showQrError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTelegram(btn) {
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteProton(btn) {
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
28
public/manifest.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "Prism",
|
||||
"short_name": "Prism",
|
||||
"description": "Notification gateway admin panel",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#141618",
|
||||
"theme_color": "#0b74b5",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.webp",
|
||||
"sizes": "32x32",
|
||||
"type": "image/webp"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
5
public/theme-init.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
(() => {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t && t !== 'system')
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
49
public/theme.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
const savedTheme = localStorage.getItem('theme') || 'system';
|
||||
const themes = ['system', 'light', 'dark'];
|
||||
let currentIndex = themes.indexOf(savedTheme);
|
||||
|
||||
if (currentIndex === -1) currentIndex = 0;
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'system') {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
|
||||
window.cycleTheme = () => {
|
||||
currentIndex = (currentIndex + 1) % themes.length;
|
||||
const newTheme = themes[currentIndex];
|
||||
applyTheme(newTheme);
|
||||
updateButtonText(newTheme);
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
applyTheme(savedTheme);
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updateButtonText(themes[currentIndex]);
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (btn) btn.addEventListener('click', window.cycleTheme);
|
||||
});
|
||||
} else {
|
||||
updateButtonText(themes[currentIndex]);
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (btn) btn.addEventListener('click', window.cycleTheme);
|
||||
}
|
||||
59
public/toast.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
toast.setAttribute('tabindex', '0');
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
let timeoutId;
|
||||
const dismiss = () => {
|
||||
toast.classList.add('hiding');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
};
|
||||
|
||||
const startTimer = () => {
|
||||
timeoutId = setTimeout(dismiss, 3000);
|
||||
};
|
||||
const pauseTimer = () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
toast.addEventListener('mouseenter', pauseTimer);
|
||||
toast.addEventListener('mouseleave', startTimer);
|
||||
toast.addEventListener('focusin', pauseTimer);
|
||||
toast.addEventListener('focusout', startTimer);
|
||||
|
||||
startTimer();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const toasts = document.querySelectorAll('.toast:not(.hiding)');
|
||||
toasts.forEach((t) => {
|
||||
t.classList.add('hiding');
|
||||
setTimeout(() => t.remove(), 300);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterRequest', (event) => {
|
||||
const xhr = event.detail.xhr;
|
||||
const triggerHeader = xhr.getResponseHeader('HX-Trigger');
|
||||
|
||||
if (triggerHeader) {
|
||||
try {
|
||||
const trigger = JSON.parse(triggerHeader);
|
||||
if (trigger.showToast) {
|
||||
showToast(trigger.showToast.message, trigger.showToast.type);
|
||||
}
|
||||
} catch (_e) {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { chmod, rename } from 'node:fs/promises';
|
||||
|
||||
const SIGNAL_CLI_VERSION = '0.13.22';
|
||||
const SIGNAL_CLI_URL = `https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}.tar.gz`;
|
||||
const SIGNAL_CLI_DIR = `${import.meta.dir}/../signal-cli`;
|
||||
|
||||
async function installSignalCli() {
|
||||
if (await Bun.file(`${SIGNAL_CLI_DIR}/bin/signal-cli`).exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Downloading signal-cli...');
|
||||
|
||||
const response = await fetch(SIGNAL_CLI_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download: ${response.statusText}`);
|
||||
}
|
||||
|
||||
console.log('Extracting signal-cli...');
|
||||
|
||||
const archive = new Bun.Archive(await response.blob());
|
||||
await archive.extract(`${import.meta.dir}/..`);
|
||||
|
||||
const extractedDir = `${import.meta.dir}/../signal-cli-${SIGNAL_CLI_VERSION}`;
|
||||
await rename(extractedDir, SIGNAL_CLI_DIR);
|
||||
|
||||
await chmod(`${SIGNAL_CLI_DIR}/bin/signal-cli`, 0o755);
|
||||
|
||||
console.log('signal-cli installed successfully');
|
||||
}
|
||||
|
||||
await installSignalCli();
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import { $ } from 'bun';
|
||||
|
||||
try {
|
||||
const packageJson = await Bun.file(`package.json`).json();
|
||||
const version = `v${packageJson.version}`;
|
||||
|
||||
console.log(`Triggering release ${version} via CI...`);
|
||||
|
||||
const existingTags = await $`git tag -l ${version}`.text();
|
||||
if (existingTags.trim()) {
|
||||
console.error(`\n❌ Tag ${version} already exists!`);
|
||||
console.log(`\nTo re-release, delete the tag first:`);
|
||||
console.log(` git tag -d ${version}`);
|
||||
console.log(` git push origin :refs/tags/${version}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nCreating git tag ${version}...`);
|
||||
await $`git tag -a ${version} -m "Release ${version}"`;
|
||||
|
||||
console.log(`Pushing tag to trigger CI build...`);
|
||||
await $`git push origin ${version}`;
|
||||
|
||||
console.log(`
|
||||
✨ Release ${version} triggered!
|
||||
|
||||
GitHub Actions will now:
|
||||
1. Build Docker images
|
||||
2. Push to ghcr.io/lone-cloud/sup-server:${version}
|
||||
3. Push to ghcr.io/lone-cloud/sup-server:latest
|
||||
|
||||
Watch the build:
|
||||
https://github.com/lone-cloud/sup/actions
|
||||
|
||||
Once complete, images will be available:
|
||||
docker pull ghcr.io/lone-cloud/sup-server:${version}
|
||||
`);
|
||||
} catch (error) {
|
||||
console.error('Release failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
FROM oven/bun:1.3.6-debian AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY server ./server
|
||||
|
||||
RUN bun build --compile server/index.ts --outfile sup-server
|
||||
|
||||
FROM debian:13.3-slim
|
||||
|
||||
ARG SIGNAL_CLI_VERSION=0.13.22
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openjdk-21-jre-headless \
|
||||
curl \
|
||||
ca-certificates \
|
||||
zip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -L https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}.tar.gz | tar xz -C /tmp \
|
||||
&& mv /tmp/signal-cli-${SIGNAL_CLI_VERSION} /usr/local/signal-cli \
|
||||
&& chmod +x /usr/local/signal-cli/bin/signal-cli
|
||||
|
||||
RUN ARCH=$(uname -m) && \
|
||||
if [ "$ARCH" = "aarch64" ]; then \
|
||||
echo "Installing ARM64 native library for signal-cli..." && \
|
||||
LIBSIGNAL_VERSION=$(ls /usr/local/signal-cli/lib/libsignal-client-*.jar | sed 's/.*libsignal-client-\(.*\)\.jar/\1/') && \
|
||||
curl -L https://github.com/exquo/signal-libs-build/releases/download/libsignal_v${LIBSIGNAL_VERSION}/libsignal_jni.so-v${LIBSIGNAL_VERSION}-aarch64-unknown-linux-gnu.tar.gz | tar xz -C /tmp && \
|
||||
mv /tmp/libsignal_jni.so /tmp/libsignal_jni_aarch64.so && \
|
||||
cd /tmp && zip -u /usr/local/signal-cli/lib/libsignal-client-*.jar libsignal_jni_aarch64.so && \
|
||||
rm /tmp/libsignal_jni_aarch64.so; \
|
||||
fi
|
||||
|
||||
COPY --from=builder /app/sup-server /usr/local/bin/sup-server
|
||||
COPY --from=builder /app/server/public /public
|
||||
|
||||
ENV PATH="/usr/local/signal-cli/bin:${PATH}"
|
||||
ENV LD_LIBRARY_PATH="/usr/local/signal-cli/lib:${LD_LIBRARY_PATH}"
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["sup-server"]
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
export const PORT = Bun.env.PORT || 8080;
|
||||
export const API_KEY = Bun.env.API_KEY;
|
||||
export const VERBOSE_LOGGING = Bun.env.VERBOSE_LOGGING === 'true';
|
||||
export const ALLOW_INSECURE_HTTP = Bun.env.ALLOW_INSECURE_HTTP === 'true';
|
||||
export const RATE_LIMIT = Number.parseInt(Bun.env.RATE_LIMIT || '100', 10);
|
||||
|
||||
export const DEVICE_NAME = Bun.env.DEVICE_NAME || 'SUP';
|
||||
|
||||
export const SUP_ENDPOINT_PREFIX = `[${DEVICE_NAME}:`;
|
||||
|
||||
export const PROTON_IMAP_USERNAME = Bun.env.PROTON_IMAP_USERNAME;
|
||||
export const PROTON_IMAP_PASSWORD = Bun.env.PROTON_IMAP_PASSWORD;
|
||||
export const PROTON_BRIDGE_HOST = Bun.env.PROTON_BRIDGE_HOST || 'protonmail-bridge';
|
||||
export const PROTON_BRIDGE_PORT = Number.parseInt(Bun.env.PROTON_BRIDGE_PORT || '143', 10);
|
||||
export const PROTON_SUP_TOPIC = Bun.env.PROTON_SUP_TOPIC || 'Proton Mail';
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
const HOME = process.env.HOME || '/root';
|
||||
|
||||
const SIGNAL_CLI_BIN = `${import.meta.dir}/../../signal-cli/bin/signal-cli`;
|
||||
export const SIGNAL_CLI = (await Bun.file(SIGNAL_CLI_BIN).exists()) ? SIGNAL_CLI_BIN : 'signal-cli';
|
||||
|
||||
export const SIGNAL_CLI_SOCKET = '/tmp/signal-cli.sock';
|
||||
export const SIGNAL_CLI_DATA_DIR = `${HOME}/.local/share/signal-cli`;
|
||||
export const SIGNAL_CLI_DATA = `${HOME}/.local/share/signal-cli/data`;
|
||||
|
||||
export const SUP_DB = `${HOME}/.local/share/sup/store.db`;
|
||||
|
||||
const PUBLIC_DIR_LOCAL = `${import.meta.dir}/../public`;
|
||||
export const PUBLIC_DIR = (await Bun.file(`${PUBLIC_DIR_LOCAL}/favicon.webp`).exists())
|
||||
? PUBLIC_DIR_LOCAL
|
||||
: '/public';
|
||||
109
server/index.ts
|
|
@ -1,109 +0,0 @@
|
|||
import { Hono } from 'hono';
|
||||
import { getConnInfo, serveStatic } from 'hono/bun';
|
||||
import { compress } from 'hono/compress';
|
||||
import { secureHeaders } from 'hono/secure-headers';
|
||||
import { timeout } from 'hono/timeout';
|
||||
import { rateLimiter } from 'hono-rate-limiter';
|
||||
import {
|
||||
ALLOW_INSECURE_HTTP,
|
||||
API_KEY,
|
||||
PORT,
|
||||
PROTON_IMAP_PASSWORD,
|
||||
PROTON_IMAP_USERNAME,
|
||||
RATE_LIMIT,
|
||||
} from '@/constants/config';
|
||||
import { PUBLIC_DIR } from '@/constants/paths';
|
||||
import { cleanupDaemon, initSignal } from '@/modules/signal';
|
||||
import { admin } from '@/routes/admin';
|
||||
import { ntfy } from '@/routes/ntfy';
|
||||
import { getLanIP, isLocalIP } from '@/utils/auth';
|
||||
import { formatToCspString } from '@/utils/format';
|
||||
import { logError, logInfo, logVerbose, logWarn } from '@/utils/log';
|
||||
|
||||
const cspConfig = {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:'],
|
||||
formAction: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
objectSrc: ["'none'"],
|
||||
};
|
||||
|
||||
initSignal();
|
||||
|
||||
if (!API_KEY) {
|
||||
logWarn('Server running without API_KEY');
|
||||
}
|
||||
|
||||
if (PROTON_IMAP_USERNAME && PROTON_IMAP_PASSWORD) {
|
||||
try {
|
||||
const { startProtonMonitor } = await import('./modules/proton-mail');
|
||||
await startProtonMonitor();
|
||||
} catch (err) {
|
||||
logError('Failed to start Proton Mail monitor:', err);
|
||||
logWarn('Continuing without Proton Mail integration');
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.use('*', (c, next) => {
|
||||
const proto = c.req.header('x-forwarded-proto') || 'http';
|
||||
const addr = getConnInfo(c).remote.address;
|
||||
|
||||
if (proto === 'https' || isLocalIP(addr)) {
|
||||
return secureHeaders({
|
||||
contentSecurityPolicy: cspConfig,
|
||||
})(c, next);
|
||||
}
|
||||
|
||||
c.header('Content-Security-Policy', formatToCspString(cspConfig));
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
app.use('*', timeout(5000));
|
||||
|
||||
app.use('*', compress());
|
||||
|
||||
app.use(
|
||||
'*',
|
||||
rateLimiter({
|
||||
limit: RATE_LIMIT,
|
||||
keyGenerator: (c) => getConnInfo(c).remote.address || 'unknown',
|
||||
skip: (c) => ALLOW_INSECURE_HTTP || isLocalIP(getConnInfo(c).remote.address),
|
||||
}),
|
||||
);
|
||||
|
||||
app.use('*', serveStatic({ root: PUBLIC_DIR }));
|
||||
|
||||
app.route('/', ntfy);
|
||||
app.route('/', admin);
|
||||
|
||||
app.notFound((c) => c.text('Not Found', 404));
|
||||
|
||||
const server = Bun.serve({
|
||||
port: PORT,
|
||||
fetch: app.fetch,
|
||||
maxRequestBodySize: 1024 * 1024,
|
||||
idleTimeout: 30,
|
||||
});
|
||||
|
||||
logInfo(`\nSUP running on:`);
|
||||
logInfo(` Local: http://localhost:${server.port}`);
|
||||
|
||||
const lanIP = getLanIP();
|
||||
if (lanIP) {
|
||||
logVerbose(` Network: http://${lanIP}:${server.port}\n`);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
cleanupDaemon();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
cleanupDaemon();
|
||||
process.exit(0);
|
||||
});
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
import Imap from 'imap';
|
||||
import {
|
||||
PROTON_BRIDGE_HOST,
|
||||
PROTON_BRIDGE_PORT,
|
||||
PROTON_IMAP_PASSWORD,
|
||||
PROTON_IMAP_USERNAME,
|
||||
PROTON_SUP_TOPIC,
|
||||
} from '@/constants/config';
|
||||
import { hasValidAccount, sendGroupMessage } from '@/modules/signal';
|
||||
import { getOrCreateGroup } from '@/modules/store';
|
||||
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
|
||||
|
||||
let imapConnected = false;
|
||||
let monitorStartTime = 0;
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT_DELAY = 300000; // 5 minutes
|
||||
|
||||
export const isImapConnected = () => imapConnected;
|
||||
|
||||
const getReconnectDelay = () => {
|
||||
const baseDelay = 10000; // 10 seconds
|
||||
const delay = Math.min(baseDelay * 2 ** reconnectAttempts, MAX_RECONNECT_DELAY);
|
||||
reconnectAttempts++;
|
||||
return delay;
|
||||
};
|
||||
|
||||
async function sendNotification(from: string, subject: string) {
|
||||
if (!(await hasValidAccount())) {
|
||||
logVerbose('Skipping notification (Signal not linked)');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const groupId = await getOrCreateGroup(`proton-${PROTON_SUP_TOPIC}`, PROTON_SUP_TOPIC);
|
||||
|
||||
await sendGroupMessage(groupId, subject, {
|
||||
title: from,
|
||||
});
|
||||
|
||||
logVerbose(`Email from ${from}: ${subject}`);
|
||||
} catch (error) {
|
||||
logError('Failed to send notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startProtonMonitor() {
|
||||
if (!PROTON_IMAP_USERNAME || !PROTON_IMAP_PASSWORD) {
|
||||
logError('Missing required env vars: PROTON_IMAP_USERNAME and PROTON_IMAP_PASSWORD');
|
||||
logWarn('Run: docker compose run --rm protonmail-bridge init');
|
||||
logWarn('Then use `login` and `info` commands to get IMAP credentials');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logInfo(`Connecting to Proton Bridge at ${PROTON_BRIDGE_HOST}:${PROTON_BRIDGE_PORT}`);
|
||||
logInfo(`Monitoring mailbox: ${PROTON_IMAP_USERNAME}`);
|
||||
|
||||
const imap = new Imap({
|
||||
user: PROTON_IMAP_USERNAME,
|
||||
password: PROTON_IMAP_PASSWORD,
|
||||
host: PROTON_BRIDGE_HOST,
|
||||
port: PROTON_BRIDGE_PORT,
|
||||
keepalive: true,
|
||||
});
|
||||
|
||||
const openInbox = () =>
|
||||
imap.openBox('INBOX', false, (err, box) => {
|
||||
if (err) {
|
||||
logError('Failed to open inbox:', err);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!monitorStartTime) {
|
||||
monitorStartTime = Date.now();
|
||||
} else {
|
||||
logVerbose('Inbox reopened (reconnection)');
|
||||
}
|
||||
|
||||
imap.on('mail', async (numNewMsgs: number) => {
|
||||
logVerbose(`${numNewMsgs} new message(s) in mailbox`);
|
||||
|
||||
const fetch = imap.seq.fetch(`${box.messages.total - numNewMsgs + 1}:*`, {
|
||||
bodies: 'HEADER.FIELDS (FROM SUBJECT DATE)',
|
||||
struct: true,
|
||||
});
|
||||
|
||||
fetch.on('message', (msg) => {
|
||||
msg.on('body', (stream) => {
|
||||
let buffer = '';
|
||||
|
||||
stream.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
});
|
||||
|
||||
stream.once('end', () => {
|
||||
const header = Imap.parseHeader(buffer);
|
||||
const rawFrom = header.from?.[0] || 'Unknown sender';
|
||||
const subject = header.subject?.[0] || 'No subject';
|
||||
const dateStr = header.date?.[0];
|
||||
|
||||
if (dateStr) {
|
||||
const messageDate = new Date(dateStr).getTime();
|
||||
|
||||
if (messageDate < monitorStartTime) {
|
||||
const formattedDate = new Date(dateStr).toLocaleString('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
|
||||
logVerbose(`Skipping old email: ${subject} (${formattedDate})`);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const nameMatch = rawFrom.match(/^"?([^"<]+)"?\s*<?/);
|
||||
const from = nameMatch?.[1]?.trim() || rawFrom;
|
||||
|
||||
reconnectAttempts = 0;
|
||||
|
||||
sendNotification(from, subject);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
imap.on('ready', () => {
|
||||
imapConnected = true;
|
||||
reconnectAttempts = 0;
|
||||
logSuccess('IMAP is ready');
|
||||
openInbox();
|
||||
});
|
||||
|
||||
imap.on('error', (err: Error) => {
|
||||
imapConnected = false;
|
||||
logError('IMAP error:', err.message);
|
||||
});
|
||||
|
||||
const handleReconnect = (reason: string) => {
|
||||
imapConnected = false;
|
||||
logError(reason);
|
||||
|
||||
const delay = getReconnectDelay();
|
||||
logInfo(`Attempting to reconnect in ${delay / 1000}s (attempt ${reconnectAttempts})...`);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!imapConnected) {
|
||||
logInfo('Reconnecting to Proton Bridge...');
|
||||
imap.connect();
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
imap.on('close', (hadError: boolean) => {
|
||||
handleReconnect(`IMAP connection closed (hadError: ${hadError})`);
|
||||
});
|
||||
|
||||
imap.on('end', () => {
|
||||
handleReconnect('IMAP connection ended by server');
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
|
||||
process.on('SIGTERM', () => imap.end());
|
||||
|
||||
process.on('SIGINT', () => imap.end());
|
||||
}
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
import { rm } from 'node:fs/promises';
|
||||
import { DEVICE_NAME, VERBOSE_LOGGING } from '@/constants/config';
|
||||
import { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '@/constants/paths';
|
||||
import { formatPhoneNumber } from '@/utils/format';
|
||||
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
|
||||
import { call } from '@/utils/rpc';
|
||||
|
||||
interface StartLinkResult {
|
||||
deviceLinkUri: string;
|
||||
}
|
||||
|
||||
interface UpdateGroupResult {
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
type ListAccountsResult = {
|
||||
number: string;
|
||||
name?: string;
|
||||
uuid?: string;
|
||||
}[];
|
||||
|
||||
let account: string | null = null;
|
||||
let currentLinkUri: string | null = null;
|
||||
let daemon: ReturnType<typeof Bun.spawn> | null = null;
|
||||
|
||||
export async function initSignal({ accountOverride }: { accountOverride?: string } = {}) {
|
||||
await startDaemon();
|
||||
|
||||
const isLinked = await checkSignalCli();
|
||||
|
||||
if (!isLinked) {
|
||||
return { linked: false, account: null };
|
||||
}
|
||||
|
||||
if (accountOverride) {
|
||||
account = accountOverride;
|
||||
logVerbose(`Signal account set: ${account}`);
|
||||
|
||||
return { linked: true, account };
|
||||
}
|
||||
|
||||
const result = (await call('listAccounts', {}, null)) as ListAccountsResult;
|
||||
const [firstAccount] = result;
|
||||
|
||||
if (firstAccount) {
|
||||
account = firstAccount.number;
|
||||
logVerbose(`Signal account initialized: ${formatPhoneNumber(account)}`);
|
||||
return { linked: true, account };
|
||||
}
|
||||
|
||||
logVerbose('No Signal accounts found');
|
||||
|
||||
return { linked: false, account: null };
|
||||
}
|
||||
|
||||
export async function generateLinkQR() {
|
||||
try {
|
||||
const result = (await call(
|
||||
'startLink',
|
||||
{
|
||||
deviceName: DEVICE_NAME,
|
||||
},
|
||||
account,
|
||||
)) as StartLinkResult;
|
||||
const uri = result.deviceLinkUri;
|
||||
|
||||
if (!uri) {
|
||||
throw new Error('Failed to generate linking URI');
|
||||
}
|
||||
|
||||
logVerbose(`Generated link URI: ${uri.substring(0, 30)}...`);
|
||||
currentLinkUri = uri;
|
||||
|
||||
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(uri)}`;
|
||||
const response = await fetch(qrUrl);
|
||||
const buffer = await response.arrayBuffer();
|
||||
const base64 = Buffer.from(buffer).toString('base64');
|
||||
|
||||
return `data:image/png;base64,${base64}`;
|
||||
} catch (error) {
|
||||
logError('Failed to generate link QR:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function finishLink() {
|
||||
if (!currentLinkUri) {
|
||||
throw new Error('No link in progress');
|
||||
}
|
||||
|
||||
const uri = currentLinkUri;
|
||||
currentLinkUri = null;
|
||||
|
||||
const result = await call(
|
||||
'finishLink',
|
||||
{
|
||||
deviceLinkUri: uri,
|
||||
deviceName: DEVICE_NAME,
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
logSuccess('Device linked successfully');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function unlinkDevice() {
|
||||
account = null;
|
||||
currentLinkUri = null;
|
||||
|
||||
if (await Bun.file(SIGNAL_CLI_DATA).exists()) {
|
||||
logWarn('Removing local account data...');
|
||||
|
||||
try {
|
||||
await rm(SIGNAL_CLI_DATA, { recursive: true, force: true });
|
||||
|
||||
logSuccess('Account data removed');
|
||||
} catch (error) {
|
||||
logError('Failed to remove account data directory:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createGroup(name: string, members: string[] = []) {
|
||||
const result = (await call(
|
||||
'updateGroup',
|
||||
{
|
||||
name,
|
||||
member: members,
|
||||
},
|
||||
account,
|
||||
)) as UpdateGroupResult;
|
||||
|
||||
if (!result?.groupId) {
|
||||
throw new Error('Failed to create group');
|
||||
}
|
||||
|
||||
return result.groupId;
|
||||
}
|
||||
|
||||
export async function sendGroupMessage(
|
||||
groupId: string,
|
||||
message: string,
|
||||
options?: { title?: string },
|
||||
) {
|
||||
let formattedMessage = message;
|
||||
|
||||
if (options?.title) {
|
||||
formattedMessage = `${options.title}\n${message}`;
|
||||
}
|
||||
|
||||
await call(
|
||||
'send',
|
||||
{
|
||||
groupId,
|
||||
message: formattedMessage,
|
||||
'notify-self': true,
|
||||
},
|
||||
account,
|
||||
);
|
||||
}
|
||||
|
||||
export async function checkSignalCli() {
|
||||
try {
|
||||
await call('listAccounts', {}, account);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasValidAccount() {
|
||||
try {
|
||||
const result = (await call('listAccounts', {}, account)) as ListAccountsResult;
|
||||
|
||||
return result.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDaemon() {
|
||||
let authError = false;
|
||||
let cleaned = false;
|
||||
|
||||
if (daemon && !daemon.killed) {
|
||||
daemon.kill();
|
||||
await Bun.sleep(2000);
|
||||
}
|
||||
|
||||
try {
|
||||
await Bun.file(SIGNAL_CLI_SOCKET).delete();
|
||||
logVerbose('Removed stale socket file');
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
|
||||
logError('Failed to remove stale socket file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const proc = Bun.spawn([SIGNAL_CLI, 'daemon', '--socket', SIGNAL_CLI_SOCKET], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
(async () => {
|
||||
for await (const chunk of proc.stderr) {
|
||||
const text = new TextDecoder().decode(chunk);
|
||||
const trimmed = text.trim();
|
||||
|
||||
if (!trimmed) continue;
|
||||
|
||||
if (trimmed.includes('Authorization failed') || trimmed.includes('AccountCheckException')) {
|
||||
authError = true;
|
||||
}
|
||||
|
||||
if (trimmed.includes('ConcurrentModificationException')) continue;
|
||||
|
||||
if (
|
||||
trimmed.includes('Accepted new client connection') ||
|
||||
(trimmed.includes('Connection') && trimmed.includes('closed'))
|
||||
)
|
||||
continue;
|
||||
|
||||
if (VERBOSE_LOGGING) {
|
||||
if (trimmed.includes('ERROR')) {
|
||||
logError('[signal-cli]', trimmed);
|
||||
} else if (trimmed.includes('WARN')) {
|
||||
logWarn('[signal-cli]', trimmed);
|
||||
} else {
|
||||
logInfo('[signal-cli]', trimmed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.includes('WARN')) {
|
||||
logWarn('[signal-cli]', trimmed);
|
||||
} else if (trimmed.includes('ERROR') || !trimmed.includes('INFO')) {
|
||||
logError('[signal-cli]', trimmed);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await Bun.sleep(500);
|
||||
|
||||
try {
|
||||
const socket = await Bun.connect({
|
||||
unix: SIGNAL_CLI_SOCKET,
|
||||
socket: {
|
||||
data() {},
|
||||
},
|
||||
});
|
||||
|
||||
socket.end();
|
||||
|
||||
logSuccess(`signal-cli daemon started (${(i + 1) * 0.5}s)`);
|
||||
|
||||
daemon = proc;
|
||||
|
||||
return proc;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (authError && !cleaned) {
|
||||
logWarn('Detected stale account data, cleaning up and retrying...');
|
||||
|
||||
proc.kill();
|
||||
|
||||
await unlinkDevice();
|
||||
|
||||
cleaned = true;
|
||||
|
||||
return startDaemon();
|
||||
}
|
||||
|
||||
logError('Failed to connect to signal-cli socket: daemon did not start within 30 seconds');
|
||||
|
||||
if (proc.exitCode !== null) {
|
||||
logError('signal-cli process exited with code:', proc.exitCode);
|
||||
}
|
||||
|
||||
if (authError) {
|
||||
logError('Account authorization failed. You may need to unlink and re-link your device.');
|
||||
}
|
||||
|
||||
throw new Error('Failed to start signal-cli daemon');
|
||||
}
|
||||
|
||||
export const cleanupDaemon = () => daemon?.kill();
|
||||
|
||||
export const getAccount = () => account;
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import { Database } from 'bun:sqlite';
|
||||
import { SUP_DB } from '@/constants/paths';
|
||||
import { createGroup } from '@/modules/signal';
|
||||
|
||||
interface EndpointMapping {
|
||||
endpoint: string;
|
||||
groupId: string;
|
||||
appName: string;
|
||||
}
|
||||
|
||||
const db = new Database(SUP_DB);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS mappings (
|
||||
endpoint TEXT PRIMARY KEY,
|
||||
groupId TEXT NOT NULL,
|
||||
appName TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
export const register = (endpoint: string, groupId: string, appName: string) =>
|
||||
db.run('INSERT OR REPLACE INTO mappings (endpoint, groupId, appName) VALUES (?, ?, ?)', [
|
||||
endpoint,
|
||||
groupId,
|
||||
appName,
|
||||
]);
|
||||
|
||||
export const getGroupId = (endpoint: string) => {
|
||||
const row = db.query('SELECT groupId FROM mappings WHERE endpoint = ?').get(endpoint) as
|
||||
| { groupId: string }
|
||||
| undefined;
|
||||
|
||||
return row?.groupId;
|
||||
};
|
||||
|
||||
export const getAppName = (endpoint: string) => {
|
||||
const row = db.query('SELECT appName FROM mappings WHERE endpoint = ?').get(endpoint) as
|
||||
| { appName: string }
|
||||
| undefined;
|
||||
|
||||
return row?.appName;
|
||||
};
|
||||
|
||||
export const getAllMappings = () =>
|
||||
db.query('SELECT endpoint, groupId, appName FROM mappings').all() as EndpointMapping[];
|
||||
|
||||
export const remove = (endpoint: string) =>
|
||||
db.run('DELETE FROM mappings WHERE endpoint = ?', [endpoint]);
|
||||
|
||||
export const getOrCreateGroup = async (key: string, name: string) => {
|
||||
const existingGroupId = getGroupId(key);
|
||||
if (existingGroupId) return existingGroupId;
|
||||
|
||||
const groupId = await createGroup(name);
|
||||
register(key, groupId, name);
|
||||
|
||||
return groupId;
|
||||
};
|
||||
|
Before Width: | Height: | Size: 950 B |
1
server/public/htmx.min.js
vendored
|
Before Width: | Height: | Size: 24 KiB |
|
|
@ -1,325 +0,0 @@
|
|||
:root {
|
||||
--bg-primary: #f5f5f5;
|
||||
--bg-secondary: #fdfdfd;
|
||||
--text-primary: #000;
|
||||
--text-secondary: #666;
|
||||
--border-color: rgba(0, 0, 0, 0.1);
|
||||
--accent: #8159b8;
|
||||
--success: #28a745;
|
||||
--error: #dc3545;
|
||||
--spinner-track: #e0e0e0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--success: #28a745;
|
||||
--error: #ef4444;
|
||||
--spinner-track: #4a4a4a;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f5f5f5;
|
||||
--bg-secondary: #fdfdfd;
|
||||
--text-primary: #000;
|
||||
--text-secondary: #666;
|
||||
--border-color: rgba(0, 0, 0, 0.1);
|
||||
--success: #28a745;
|
||||
--error: #dc3545;
|
||||
--spinner-track: #e0e0e0;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--border-color: rgba(255, 255, 255, 0.1);
|
||||
--success: #28a745;
|
||||
--error: #ef4444;
|
||||
--spinner-track: #4a4a4a;
|
||||
}
|
||||
|
||||
/* CSS Reset */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
img,
|
||||
picture,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Styles */
|
||||
body {
|
||||
font-family: system-ui;
|
||||
max-width: 50rem;
|
||||
margin: 1.25rem auto;
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
box-shadow: 0 0.125rem 0.25rem var(--border-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.status-item:has(.tooltip) {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.status-item .tooltip {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.status-item .tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 0.3125rem solid transparent;
|
||||
border-top-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.status-item:hover .tooltip {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.endpoint-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
max-height: 18.75rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.endpoint-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.endpoint-name {
|
||||
font-size: 1.1em;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--error);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--text-secondary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.unlink-details {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.unlink-summary {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.unlink-instructions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.unlink-instructions ol {
|
||||
margin-left: 1.25rem;
|
||||
}
|
||||
|
||||
.qr-instructions {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
margin-top: 1rem;
|
||||
width: 18.75rem;
|
||||
height: 18.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
display: inline-block;
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 0.625rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 0.125rem solid var(--spinner-track);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>SUP Admin</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#8159b8">
|
||||
<link rel="icon" type="image/webp" href="/favicon.webp">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
<script src="/htmx.min.js"></script>
|
||||
<script src="/theme.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<button id="theme-toggle" class="theme-toggle"></button>
|
||||
|
||||
<div class="card">
|
||||
<div id="health-status"
|
||||
hx-get="/fragment/health"
|
||||
hx-trigger="load, every 10s"
|
||||
hx-indicator="#health-status">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Signal</h2>
|
||||
<div id="signal-info"
|
||||
hx-get="/fragment/signal-info"
|
||||
hx-trigger="load, accountLinked from:body"
|
||||
hx-indicator="#signal-info">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="qr-section"
|
||||
hx-get="/fragment/signal-info"
|
||||
hx-trigger="accountLinked from:body"
|
||||
hx-swap="none"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Endpoints</h2>
|
||||
<div id="endpoints-list"
|
||||
hx-get="/fragment/endpoints"
|
||||
hx-trigger="load, every 10s"
|
||||
hx-indicator="#endpoints-list">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"name": "SUP Admin",
|
||||
"short_name": "SUP",
|
||||
"description": "Signal Unified Push Admin Panel",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a1a",
|
||||
"theme_color": "#8159b8",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.webp",
|
||||
"sizes": "32x32",
|
||||
"type": "image/webp",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
const themes = ['system', 'light', 'dark'];
|
||||
let currentIndex = 0;
|
||||
|
||||
const savedTheme = localStorage.getItem('theme') || 'system';
|
||||
currentIndex = themes.indexOf(savedTheme);
|
||||
if (currentIndex === -1) currentIndex = 0;
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (theme === 'system') {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
|
||||
window.cycleTheme = () => {
|
||||
currentIndex = (currentIndex + 1) % themes.length;
|
||||
const newTheme = themes[currentIndex];
|
||||
applyTheme(newTheme);
|
||||
updateButtonText(newTheme);
|
||||
};
|
||||
|
||||
function updateButtonText(theme) {
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (btn) {
|
||||
btn.textContent = theme === 'system' ? '🌓' : theme === 'light' ? '☀️' : '🌙';
|
||||
}
|
||||
}
|
||||
|
||||
applyTheme(savedTheme);
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updateButtonText(themes[currentIndex]);
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (btn) btn.addEventListener('click', window.cycleTheme);
|
||||
});
|
||||
} else {
|
||||
updateButtonText(themes[currentIndex]);
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (btn) btn.addEventListener('click', window.cycleTheme);
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
import { Hono } from 'hono';
|
||||
import { basicAuth } from 'hono/basic-auth';
|
||||
import { DEVICE_NAME, PROTON_IMAP_PASSWORD, PROTON_IMAP_USERNAME } from '@/constants/config';
|
||||
import { isImapConnected } from '@/modules/proton-mail';
|
||||
import {
|
||||
checkSignalCli,
|
||||
finishLink,
|
||||
generateLinkQR,
|
||||
getAccount,
|
||||
hasValidAccount,
|
||||
} from '@/modules/signal';
|
||||
import { getAllMappings, remove } from '@/modules/store';
|
||||
import { verifyApiKey } from '@/utils/auth';
|
||||
import { formatPhoneNumber, formatUptime } from '@/utils/format';
|
||||
|
||||
let cachedQR: string | null = null;
|
||||
let qrCacheTime = 0;
|
||||
let generatingPromise: Promise<string> | null = null;
|
||||
const QR_CACHE_TTL = 10 * 60 * 1000;
|
||||
|
||||
export const admin = new Hono();
|
||||
|
||||
admin.use(
|
||||
'*',
|
||||
basicAuth({
|
||||
verifyUser: (_, password, c) => verifyApiKey(password, c),
|
||||
realm: 'SUP Admin - Username: any, Password: API_KEY',
|
||||
}),
|
||||
);
|
||||
|
||||
admin.get('/api/health', async (c) => {
|
||||
const signalOk = await checkSignalCli();
|
||||
const linked = signalOk && (await hasValidAccount());
|
||||
const hasProtonConfig = PROTON_IMAP_USERNAME && PROTON_IMAP_PASSWORD;
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
uptime: formatUptime(process.uptime()),
|
||||
signal: {
|
||||
daemon: signalOk ? 'running' : 'stopped',
|
||||
linked,
|
||||
},
|
||||
};
|
||||
|
||||
if (hasProtonConfig) {
|
||||
result.protonMail = isImapConnected() ? 'connected' : 'disconnected';
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
admin.get('/fragment/health', async (c) => {
|
||||
const { html } = await handleHealthFragment();
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
admin.get('/fragment/signal-info', async (c) => c.html(await handleSignalInfoFragment()));
|
||||
|
||||
admin.get('/fragment/endpoints', async (c) => c.html(await handleEndpointsFragment()));
|
||||
|
||||
admin.get('/fragment/link-qr', async (c) => c.html(await handleQRSection()));
|
||||
|
||||
admin.delete('/action/delete-endpoint/:endpoint', async (c) => {
|
||||
const endpoint = decodeURIComponent(c.req.param('endpoint'));
|
||||
|
||||
if (!endpoint) {
|
||||
return c.text('Invalid endpoint', 400);
|
||||
}
|
||||
|
||||
remove(endpoint);
|
||||
return c.html(await handleEndpointsFragment());
|
||||
});
|
||||
|
||||
const handleHealthFragment = async () => {
|
||||
const signalOk = await checkSignalCli();
|
||||
const linked = signalOk && (await hasValidAccount());
|
||||
const imap = isImapConnected();
|
||||
const hasProtonConfig = PROTON_IMAP_USERNAME && PROTON_IMAP_PASSWORD;
|
||||
const accountNumber = getAccount();
|
||||
|
||||
const html = `
|
||||
<div class="status">
|
||||
<div class="status-item ${signalOk ? 'status-ok' : 'status-error'}">
|
||||
Signal: ${signalOk ? 'Connected' : 'Disconnected'} and ${linked ? 'Linked' : 'Unlinked'}
|
||||
${linked && accountNumber ? `<span class="tooltip">${formatPhoneNumber(accountNumber)}</span>` : ''}
|
||||
</div>
|
||||
${
|
||||
hasProtonConfig
|
||||
? `<div class="status-item ${imap ? 'status-ok' : 'status-error'}">
|
||||
Proton Mail: ${imap ? 'Connected' : 'Disconnected'}
|
||||
${imap ? `<span class="tooltip">${PROTON_IMAP_USERNAME}</span>` : ''}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
<div id="signal-info" hx-swap-oob="true">
|
||||
${await handleSignalInfoFragment()}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return { html, linked };
|
||||
};
|
||||
|
||||
const handleSignalInfoFragment = async () => {
|
||||
if (await hasValidAccount()) {
|
||||
cachedQR = null;
|
||||
return `<details class="unlink-details">
|
||||
<summary class="unlink-summary">Unlink and remove device</summary>
|
||||
<div class="unlink-instructions">
|
||||
<ol>
|
||||
<li>Open Signal app → <strong>Settings → Linked Devices</strong></li>
|
||||
<li>Find <strong>"${DEVICE_NAME}"</strong> and tap it</li>
|
||||
<li>Tap <strong>"Unlink Device"</strong></li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
return handleQRSection();
|
||||
};
|
||||
|
||||
const handleEndpointsFragment = async () => {
|
||||
const endpoints = getAllMappings();
|
||||
|
||||
if (endpoints.length === 0) {
|
||||
return '<p>No endpoints registered</p>';
|
||||
}
|
||||
|
||||
return `
|
||||
<ul class="endpoint-list">
|
||||
${endpoints
|
||||
.map(
|
||||
(e: { appName: string; endpoint: string }) => `
|
||||
<li class="endpoint-item">
|
||||
<div class="endpoint-name">
|
||||
<strong>${e.appName}</strong>
|
||||
</div>
|
||||
<button
|
||||
class="btn-delete"
|
||||
hx-delete="/action/delete-endpoint/${encodeURIComponent(e.endpoint)}"
|
||||
hx-target="#endpoints-list"
|
||||
hx-swap="innerHTML"
|
||||
>Delete</button>
|
||||
</li>
|
||||
`,
|
||||
)
|
||||
.join('')}
|
||||
</ul>
|
||||
`;
|
||||
};
|
||||
|
||||
const handleQRSection = async () => {
|
||||
if (await hasValidAccount()) {
|
||||
return '<p>Account already linked</p>';
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if ((!cachedQR || now - qrCacheTime > QR_CACHE_TTL) && !generatingPromise) {
|
||||
generatingPromise = (async () => {
|
||||
try {
|
||||
const qr = await generateLinkQR();
|
||||
cachedQR = qr;
|
||||
qrCacheTime = Date.now();
|
||||
|
||||
finishLink().finally(() => {
|
||||
generatingPromise = null;
|
||||
cachedQR = null;
|
||||
qrCacheTime = 0;
|
||||
});
|
||||
|
||||
return qr;
|
||||
} catch (error) {
|
||||
generatingPromise = null;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (generatingPromise && !cachedQR) {
|
||||
try {
|
||||
await generatingPromise;
|
||||
} catch {
|
||||
return '<p>Signal daemon is starting up, please refresh in a few seconds...</p>';
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<p>Scan this QR code with your Signal app:</p>
|
||||
<p class="qr-instructions"><strong>Settings → Linked Devices → Link New Device</strong></p>
|
||||
<div class="qr-container">
|
||||
<img src="${cachedQR}" class="qr-image" alt="QR Code" />
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { Hono } from 'hono';
|
||||
import { basicAuth } from 'hono/basic-auth';
|
||||
import { sendGroupMessage } from '@/modules/signal';
|
||||
import { getOrCreateGroup } from '@/modules/store';
|
||||
import { verifyApiKey } from '@/utils/auth';
|
||||
import { logError, logVerbose } from '@/utils/log';
|
||||
|
||||
export const ntfy = new Hono();
|
||||
|
||||
ntfy.use(
|
||||
'*',
|
||||
basicAuth({
|
||||
verifyUser: (_, password) => verifyApiKey(password),
|
||||
realm: 'SUP ntfy - Username: any, Password: API_KEY',
|
||||
}),
|
||||
);
|
||||
|
||||
ntfy.post('/:topic', async (c) => {
|
||||
try {
|
||||
const topic = decodeURIComponent(c.req.param('topic'));
|
||||
|
||||
if (!topic || topic.includes('/')) {
|
||||
return c.text('Invalid topic', 400);
|
||||
}
|
||||
|
||||
let message = await c.req.text();
|
||||
if (!message) {
|
||||
return c.text('Message required', 400);
|
||||
}
|
||||
|
||||
const contentType = c.req.header('content-type') || '';
|
||||
let title: string | undefined =
|
||||
c.req.header('X-Title') || c.req.header('Title') || c.req.header('t') || undefined;
|
||||
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const params = new URLSearchParams(message);
|
||||
message = params.get('message') || message;
|
||||
title = title || params.get('title') || params.get('t') || undefined;
|
||||
}
|
||||
|
||||
if (title === topic) title = undefined;
|
||||
|
||||
const groupId = await getOrCreateGroup(`ntfy-${topic}`, topic);
|
||||
|
||||
await sendGroupMessage(groupId, message, {
|
||||
title: title || undefined,
|
||||
});
|
||||
|
||||
logVerbose(`Sent ntfy message to topic ${topic}: ${title || message.substring(0, 50)}`);
|
||||
|
||||
return c.json({
|
||||
id: Date.now().toString(),
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
event: 'message',
|
||||
topic,
|
||||
message,
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Failed to handle ntfy publish:', error);
|
||||
return c.text('Internal server error', 500);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { networkInterfaces } from 'node:os';
|
||||
import type { Context } from 'hono';
|
||||
import { getConnInfo } from 'hono/bun';
|
||||
import { ALLOW_INSECURE_HTTP, API_KEY } from '@/constants/config';
|
||||
|
||||
export const isLocalIP = (addr: string | undefined) => {
|
||||
if (!addr || addr === '::1' || addr === 'localhost') return true;
|
||||
|
||||
const octets = addr.split('.').map(Number);
|
||||
if (octets.length !== 4 || octets.some((n) => Number.isNaN(n))) return false;
|
||||
|
||||
const [a, b] = octets;
|
||||
|
||||
return (
|
||||
a === 127 ||
|
||||
a === 10 ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 172 && b !== undefined && b >= 16 && b <= 31)
|
||||
);
|
||||
};
|
||||
|
||||
export const verifyApiKey = (password: string, c?: Context) => {
|
||||
if (!API_KEY || password.length !== API_KEY.length) return false;
|
||||
|
||||
if (c && !ALLOW_INSECURE_HTTP) {
|
||||
const addr = getConnInfo(c).remote.address;
|
||||
const proto = c.req.header('x-forwarded-proto') || 'http';
|
||||
|
||||
if (proto !== 'https' && !isLocalIP(addr)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const providedBuffer = Buffer.from(password);
|
||||
const keyBuffer = Buffer.from(API_KEY);
|
||||
|
||||
return timingSafeEqual(providedBuffer, keyBuffer);
|
||||
};
|
||||
|
||||
export const getLanIP = () => {
|
||||
const nets = networkInterfaces();
|
||||
|
||||
for (const name of Object.keys(nets)) {
|
||||
const interfaces = nets[name];
|
||||
|
||||
if (!interfaces) continue;
|
||||
|
||||
for (const iface of interfaces) {
|
||||
if (iface.family === 'IPv4' && !iface.internal) {
|
||||
return iface.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
export const formatUptime = (seconds: number): string => {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
const parts = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
export const formatPhoneNumber = (phone: string): string => {
|
||||
if (!phone) return phone;
|
||||
|
||||
// US/Canada: +1 (234) 567-8901
|
||||
if (phone.startsWith('+1') && phone.length === 12) {
|
||||
return `+1 (${phone.slice(2, 5)}) ${phone.slice(5, 8)}-${phone.slice(8)}`;
|
||||
}
|
||||
|
||||
// International: +XX XXXX XXXX
|
||||
if (phone.startsWith('+')) {
|
||||
const match = phone.match(/^\+(\d{1,3})(\d{3,4})(\d+)$/);
|
||||
if (match) {
|
||||
const [, code, first, rest] = match;
|
||||
const parts = rest?.match(/.{1,4}/g) || [];
|
||||
return `+${code} ${first} ${parts.join(' ')}`;
|
||||
}
|
||||
}
|
||||
|
||||
return phone;
|
||||
};
|
||||
|
||||
export const formatToCspString = (cspConfig: Record<string, string[]>) =>
|
||||
Object.entries(cspConfig)
|
||||
.map(([key, values]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()} ${values.join(' ')}`)
|
||||
.join('; ');
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import chalk from 'chalk';
|
||||
import { VERBOSE_LOGGING } from '@/constants/config';
|
||||
|
||||
export const logVerbose = (...args: unknown[]) => VERBOSE_LOGGING && console.log(...args);
|
||||
|
||||
export const logError = (...args: unknown[]) => console.error(chalk.red(...args));
|
||||
export const logWarn = (...args: unknown[]) => console.warn(chalk.yellow(...args));
|
||||
export const logInfo = (...args: unknown[]) => console.log(chalk.blue(...args));
|
||||
export const logSuccess = (...args: unknown[]) => console.log(chalk.green(...args));
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { SIGNAL_CLI_SOCKET } from '@/constants/paths';
|
||||
|
||||
const MESSAGE_DELIMITER = '\n';
|
||||
let rpcId = 1;
|
||||
|
||||
export const call = (method: string, params: Record<string, unknown>, account: string | null) =>
|
||||
new Promise((resolve, reject) => {
|
||||
let response = '';
|
||||
|
||||
Bun.connect({
|
||||
unix: SIGNAL_CLI_SOCKET,
|
||||
socket: {
|
||||
data(socket, data) {
|
||||
response += new TextDecoder().decode(data);
|
||||
|
||||
const isComplete = response.includes(MESSAGE_DELIMITER);
|
||||
if (isComplete) {
|
||||
socket.end();
|
||||
|
||||
const parsed = JSON.parse(response.trim());
|
||||
|
||||
if (parsed.error) {
|
||||
reject(new Error(`signal-cli RPC error: ${parsed.error.message}`));
|
||||
} else {
|
||||
resolve(parsed.result);
|
||||
}
|
||||
}
|
||||
},
|
||||
error(_, error) {
|
||||
reject(error);
|
||||
},
|
||||
connectError(_, error) {
|
||||
reject(error);
|
||||
},
|
||||
close() {
|
||||
const isComplete = response.includes(MESSAGE_DELIMITER);
|
||||
|
||||
if (!isComplete) {
|
||||
reject(new Error('Connection closed before response received'));
|
||||
}
|
||||
},
|
||||
open(socket) {
|
||||
const request = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: rpcId++,
|
||||
method,
|
||||
params: account ? { account, ...params } : params,
|
||||
});
|
||||
|
||||
socket.write(`${request}${MESSAGE_DELIMITER}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
75
service/config/config.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
RateLimit int
|
||||
APIKey string
|
||||
StoragePath string
|
||||
VerboseLogging bool
|
||||
EnableSignal bool
|
||||
EnableTelegram bool
|
||||
EnableProton bool
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
APIKey: os.Getenv("API_KEY"),
|
||||
Port: getEnvInt("PORT", 8080),
|
||||
VerboseLogging: getEnvBool("VERBOSE_LOGGING", false),
|
||||
RateLimit: getEnvInt("RATE_LIMIT", 20),
|
||||
StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"),
|
||||
EnableSignal: getEnvBool("ENABLE_SIGNAL", true),
|
||||
EnableTelegram: getEnvBool("ENABLE_TELEGRAM", true),
|
||||
EnableProton: getEnvBool("ENABLE_PROTON", true),
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func getEnvString(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if i, err := strconv.Atoi(value); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvBool(key string, defaultValue bool) bool {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if b, err := strconv.ParseBool(value); err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
253
service/credentials/credentials.go
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
package credentials
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
type IntegrationType string
|
||||
|
||||
const (
|
||||
IntegrationSignal IntegrationType = "signal"
|
||||
IntegrationProton IntegrationType = "proton"
|
||||
IntegrationTelegram IntegrationType = "telegram"
|
||||
)
|
||||
|
||||
type ProtonCredentials struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password,omitempty"`
|
||||
UID string `json:"uid,omitempty"`
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
KeySalts map[string][]byte `json:"key_salts,omitempty"`
|
||||
State *ProtonState `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
type ProtonState struct {
|
||||
LastEventID string `json:"last_event_id"`
|
||||
}
|
||||
|
||||
type TelegramCredentials struct {
|
||||
BotToken string `json:"bot_token"`
|
||||
ChatID string `json:"chat_id"`
|
||||
}
|
||||
|
||||
type SignalCredentials struct {
|
||||
Linked bool `json:"linked"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
encryptionKey []byte
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB, masterPassword string) (*Store, error) {
|
||||
return NewStoreWithLogger(db, masterPassword, slog.Default())
|
||||
}
|
||||
|
||||
func NewStoreWithLogger(db *sql.DB, masterPassword string, logger *slog.Logger) (*Store, error) {
|
||||
key, err := deriveKey(masterPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive encryption key: %w", err)
|
||||
}
|
||||
|
||||
store := &Store{
|
||||
db: db,
|
||||
encryptionKey: key,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
if err := store.createTable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := store.checkIntegrity(); err != nil {
|
||||
if strings.Contains(err.Error(), "corrupted") {
|
||||
logger.Warn("Credentials corrupted (API_KEY likely changed), clearing all integration credentials", "error", err)
|
||||
if clearErr := store.clearAll(); clearErr != nil {
|
||||
logger.Error("Failed to clear corrupted credentials", "error", clearErr)
|
||||
} else {
|
||||
logger.Info("Cleared all integration credentials - please reconfigure integrations")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) createTable() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS integration_credentials (
|
||||
integration_type TEXT PRIMARY KEY,
|
||||
credentials_encrypted BLOB NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func deriveKey(password string) ([]byte, error) {
|
||||
salt := []byte("prism-integration-salt-v1")
|
||||
return scrypt.Key([]byte(password), salt, 32768, 8, 1, 32)
|
||||
}
|
||||
|
||||
func (s *Store) encrypt(plaintext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(s.encryptionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
func (s *Store) decrypt(ciphertext []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(s.encryptionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(ciphertext) < gcm.NonceSize() {
|
||||
return nil, fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveProton(creds *ProtonCredentials) error {
|
||||
return s.save(IntegrationProton, creds)
|
||||
}
|
||||
|
||||
func (s *Store) GetProton() (*ProtonCredentials, error) {
|
||||
var creds ProtonCredentials
|
||||
return &creds, s.load(IntegrationProton, &creds)
|
||||
}
|
||||
|
||||
func (s *Store) SaveTelegram(creds *TelegramCredentials) error {
|
||||
return s.save(IntegrationTelegram, creds)
|
||||
}
|
||||
|
||||
func (s *Store) GetTelegram() (*TelegramCredentials, error) {
|
||||
var creds TelegramCredentials
|
||||
return &creds, s.load(IntegrationTelegram, &creds)
|
||||
}
|
||||
|
||||
func (s *Store) save(integrationType IntegrationType, credentials interface{}) error {
|
||||
jsonData, err := json.Marshal(credentials)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal credentials: %w", err)
|
||||
}
|
||||
encrypted, err := s.encrypt(jsonData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt credentials: %w", err)
|
||||
}
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO integration_credentials (integration_type, credentials_encrypted, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(integration_type) DO UPDATE SET
|
||||
credentials_encrypted = excluded.credentials_encrypted,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, string(integrationType), encrypted)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) load(integrationType IntegrationType, dest interface{}) error {
|
||||
var encrypted []byte
|
||||
err := s.db.QueryRow(`
|
||||
SELECT credentials_encrypted FROM integration_credentials
|
||||
WHERE integration_type = ? AND enabled = 1
|
||||
`, string(integrationType)).Scan(&encrypted)
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("integration %s not configured", integrationType)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decrypted, err := s.decrypt(encrypted)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt credentials: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(decrypted, dest); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal credentials: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteIntegration(integrationType IntegrationType) error {
|
||||
_, err := s.db.Exec(`DELETE FROM integration_credentials WHERE integration_type = ?`, string(integrationType))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) IsEnabled(integrationType IntegrationType) (bool, error) {
|
||||
var enabled bool
|
||||
err := s.db.QueryRow(`SELECT enabled FROM integration_credentials WHERE integration_type = ?`, string(integrationType)).Scan(&enabled)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
func (s *Store) clearAll() error {
|
||||
_, err := s.db.Exec(`DELETE FROM integration_credentials`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) checkIntegrity() error {
|
||||
rows, err := s.db.Query(`SELECT integration_type, credentials_encrypted FROM integration_credentials`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var integrationType string
|
||||
var encrypted []byte
|
||||
if err := rows.Scan(&integrationType, &encrypted); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.decrypt(encrypted); err != nil {
|
||||
return fmt.Errorf("credentials corrupted for %s (likely API_KEY changed): %w", integrationType, err)
|
||||
}
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
}
|
||||
48
service/delivery/enrich.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package delivery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var phoneRegex = regexp.MustCompile(`(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}`)
|
||||
var emailRegex = regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`)
|
||||
|
||||
func enrichActions(notif Notification) Notification {
|
||||
if len(notif.Actions) > 0 {
|
||||
return notif
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, num := range phoneRegex.FindAllString(notif.Message, -1) {
|
||||
if seen[num] {
|
||||
continue
|
||||
}
|
||||
seen[num] = true
|
||||
notif.Actions = append(notif.Actions, Action{
|
||||
ID: fmt.Sprintf("call-%s", num),
|
||||
Label: fmt.Sprintf("Call %s", num),
|
||||
Endpoint: fmt.Sprintf("tel:%s", num),
|
||||
})
|
||||
}
|
||||
|
||||
for _, addr := range emailRegex.FindAllString(notif.Message, -1) {
|
||||
if seen[addr] {
|
||||
continue
|
||||
}
|
||||
seen[addr] = true
|
||||
local := addr
|
||||
if i := strings.Index(addr, "@"); i != -1 {
|
||||
local = addr[:i]
|
||||
}
|
||||
notif.Actions = append(notif.Actions, Action{
|
||||
ID: fmt.Sprintf("email-%s", addr),
|
||||
Label: fmt.Sprintf("Email %s", local),
|
||||
Endpoint: fmt.Sprintf("mailto:%s", addr),
|
||||
})
|
||||
}
|
||||
|
||||
return notif
|
||||
}
|
||||
24
service/delivery/errors.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package delivery
|
||||
|
||||
import "errors"
|
||||
|
||||
type PermanentError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *PermanentError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *PermanentError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func NewPermanentError(err error) error {
|
||||
return &PermanentError{Err: err}
|
||||
}
|
||||
|
||||
func IsPermanent(err error) bool {
|
||||
var permErr *PermanentError
|
||||
return errors.As(err, &permErr)
|
||||
}
|
||||
17
service/delivery/notification.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package delivery
|
||||
|
||||
type Action struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Actions []Action `json:"actions,omitempty"`
|
||||
ImageURL string `json:"image,omitempty"`
|
||||
}
|
||||
127
service/delivery/publisher.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package delivery
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
)
|
||||
|
||||
type NotificationSender interface {
|
||||
Send(sub *subscription.Subscription, notif Notification) error
|
||||
}
|
||||
|
||||
type Publisher struct {
|
||||
Store *subscription.Store
|
||||
senders map[subscription.Channel]NotificationSender
|
||||
autoSubscribeFn func(string) error
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewPublisher(store *subscription.Store, logger *slog.Logger, autoSubscribeFn func(string) error) *Publisher {
|
||||
return &Publisher{
|
||||
Store: store,
|
||||
senders: make(map[subscription.Channel]NotificationSender),
|
||||
autoSubscribeFn: autoSubscribeFn,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Publisher) RegisterSender(channel subscription.Channel, sender NotificationSender) {
|
||||
p.senders[channel] = sender
|
||||
}
|
||||
|
||||
func (p *Publisher) DeregisterSender(channel subscription.Channel) {
|
||||
delete(p.senders, channel)
|
||||
}
|
||||
|
||||
func (p *Publisher) HasChannel(channel subscription.Channel) bool {
|
||||
_, ok := p.senders[channel]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (p *Publisher) IsValidChannel(channel subscription.Channel) bool {
|
||||
return p.HasChannel(channel)
|
||||
}
|
||||
|
||||
func (p *Publisher) Publish(appName string, notif Notification) error {
|
||||
notif = enrichActions(notif)
|
||||
|
||||
app, err := p.Store.GetApp(appName)
|
||||
if err != nil {
|
||||
return util.LogError(p.logger, "Failed to get app", err, "app", appName)
|
||||
}
|
||||
|
||||
if app == nil || len(app.Subscriptions) == 0 {
|
||||
if p.autoSubscribeFn != nil {
|
||||
if err := p.autoSubscribeFn(appName); err != nil {
|
||||
p.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err)
|
||||
}
|
||||
app, err = p.Store.GetApp(appName)
|
||||
if err != nil {
|
||||
return util.LogError(p.logger, "Failed to get app", err, "app", appName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if app == nil || len(app.Subscriptions) == 0 {
|
||||
p.logger.Warn("No subscriptions found for app, dropping notification", "app", appName)
|
||||
return nil
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
successCount := 0
|
||||
|
||||
for _, sub := range app.Subscriptions {
|
||||
sender, ok := p.senders[sub.Channel]
|
||||
if !ok {
|
||||
p.logger.Debug("Skipping subscription for disabled channel", "channel", sub.Channel, "subscriptionID", sub.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := p.sendWithRetry(sender, &sub, notif, appName, sub.ID); err != nil {
|
||||
lastErr = err
|
||||
} else {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
if successCount == 0 && lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Publisher) sendWithRetry(sender NotificationSender, sub *subscription.Subscription, notif Notification, appName, subscriptionID string) error {
|
||||
maxRetries := 10
|
||||
baseDelay := 500 * time.Millisecond
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
err := sender.Send(sub, notif)
|
||||
if err == nil {
|
||||
if attempt > 0 {
|
||||
p.logger.Info("Notification sent after retry", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
if IsPermanent(err) {
|
||||
p.logger.Error("Permanent error, not retrying", "app", appName, "subscriptionID", subscriptionID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if attempt < maxRetries-1 {
|
||||
delay := baseDelay * time.Duration(1<<uint(attempt))
|
||||
p.logger.Warn("Failed to send notification, retrying", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1, "error", err, "retryIn", delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
p.logger.Error("Failed to send notification after retries", "app", appName, "subscriptionID", subscriptionID, "attempts", maxRetries, "error", lastErr)
|
||||
return lastErr
|
||||
}
|
||||
205
service/integration/integration.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"prism/service/config"
|
||||
"prism/service/delivery"
|
||||
"prism/service/integration/proton"
|
||||
"prism/service/integration/signal"
|
||||
"prism/service/integration/telegram"
|
||||
"prism/service/integration/webpush"
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templates embed.FS
|
||||
|
||||
type Integration interface {
|
||||
RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger)
|
||||
Start(ctx context.Context, logger *slog.Logger)
|
||||
IsEnabled() bool
|
||||
Health() (linked bool, account string)
|
||||
}
|
||||
|
||||
type AutoSubscriber interface {
|
||||
AutoSubscribe(appName string, store *subscription.Store, publisher *delivery.Publisher) error
|
||||
}
|
||||
|
||||
type Integrations struct {
|
||||
Publisher *delivery.Publisher
|
||||
Signal *signal.Integration
|
||||
Telegram *telegram.Integration
|
||||
Proton *proton.Integration
|
||||
store *subscription.Store
|
||||
logger *slog.Logger
|
||||
integrations []Integration
|
||||
}
|
||||
|
||||
func Initialize(cfg *config.Config, store *subscription.Store, logger *slog.Logger, baseTmpl *template.Template) (*Integrations, *template.Template, error) {
|
||||
var err error
|
||||
baseTmpl, err = baseTmpl.ParseFS(templates, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse integration templates: %w", err)
|
||||
}
|
||||
|
||||
var signalIntegration *signal.Integration
|
||||
var telegramIntegration *telegram.Integration
|
||||
var protonIntegration *proton.Integration
|
||||
|
||||
integrations := []Integration{webpush.NewIntegration(store, logger)}
|
||||
|
||||
if cfg.EnableSignal {
|
||||
tmplRenderer, err := newIntegrationRenderer(baseTmpl, "signal", signal.Templates)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
signalIntegration = signal.NewIntegration(cfg, store, logger, tmplRenderer)
|
||||
integrations = append(integrations, signalIntegration)
|
||||
}
|
||||
|
||||
if cfg.EnableTelegram {
|
||||
tmplRenderer, err := newIntegrationRenderer(baseTmpl, "telegram", telegram.Templates)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
telegramIntegration = telegram.NewIntegration(store, logger, tmplRenderer, cfg.APIKey)
|
||||
integrations = append(integrations, telegramIntegration)
|
||||
}
|
||||
|
||||
if cfg.EnableProton {
|
||||
tmplRenderer, err := newIntegrationRenderer(baseTmpl, "proton", proton.Templates)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
protonIntegration = proton.NewIntegration(cfg, logger, tmplRenderer, store.DB, cfg.APIKey)
|
||||
integrations = append(integrations, protonIntegration)
|
||||
}
|
||||
|
||||
i := &Integrations{
|
||||
Signal: signalIntegration,
|
||||
Telegram: telegramIntegration,
|
||||
Proton: protonIntegration,
|
||||
store: store,
|
||||
logger: logger,
|
||||
integrations: integrations,
|
||||
}
|
||||
|
||||
publisher := delivery.NewPublisher(store, logger, i.autoSubscribeApp)
|
||||
publisher.RegisterSender(subscription.ChannelWebPush, webpush.NewSender(logger))
|
||||
if signalIntegration != nil && signalIntegration.Sender != nil {
|
||||
if linked, err := signalIntegration.Sender.IsLinked(); err == nil && linked {
|
||||
publisher.RegisterSender(subscription.ChannelSignal, signalIntegration.Sender)
|
||||
}
|
||||
}
|
||||
if telegramIntegration != nil && telegramIntegration.Sender != nil {
|
||||
publisher.RegisterSender(subscription.ChannelTelegram, telegramIntegration.Sender)
|
||||
}
|
||||
i.Publisher = publisher
|
||||
|
||||
if telegramIntegration != nil {
|
||||
telegramIntegration.OnLink = func() {
|
||||
client := telegramIntegration.Handlers.GetClient()
|
||||
chatID := telegramIntegration.Handlers.GetChatID()
|
||||
if client != nil && chatID != 0 {
|
||||
sender := telegram.NewSender(client, store, logger, chatID)
|
||||
telegramIntegration.Sender = sender
|
||||
i.Publisher.RegisterSender(subscription.ChannelTelegram, sender)
|
||||
}
|
||||
}
|
||||
telegramIntegration.OnUnlink = func() {
|
||||
i.Publisher.DeregisterSender(subscription.ChannelTelegram)
|
||||
if err := i.store.DeleteSubscriptionsByChannel(subscription.ChannelTelegram); err != nil {
|
||||
i.logger.Error("Failed to delete Telegram subscriptions on unlink", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if protonIntegration != nil {
|
||||
protonIntegration.Publisher = publisher
|
||||
}
|
||||
|
||||
return i, baseTmpl, nil
|
||||
}
|
||||
|
||||
func newIntegrationRenderer(base *template.Template, name string, templates fs.FS) (*util.TemplateRenderer, error) {
|
||||
integrationTmpl, err := base.Clone()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to clone base templates for %s: %w", name, err)
|
||||
}
|
||||
integrationTmpl, err = integrationTmpl.ParseFS(templates, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse %s templates: %w", name, err)
|
||||
}
|
||||
|
||||
return util.NewTemplateRenderer(integrationTmpl), nil
|
||||
}
|
||||
|
||||
func (i *Integrations) Start(ctx context.Context, logger *slog.Logger) {
|
||||
for _, integration := range i.integrations {
|
||||
if integration.IsEnabled() {
|
||||
integration.Start(ctx, logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterAll(integrations *Integrations, router *chi.Mux, cfg *config.Config, logger *slog.Logger, authMiddleware func(string) func(http.Handler) http.Handler) {
|
||||
auth := authMiddleware(cfg.APIKey)
|
||||
for _, integration := range integrations.integrations {
|
||||
integration.RegisterRoutes(router, auth, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Integrations) autoSubscribeApp(appName string) error {
|
||||
app, err := i.store.GetApp(appName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if app != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := i.store.RegisterApp(appName); err != nil {
|
||||
return fmt.Errorf("failed to register app %q: %w", appName, err)
|
||||
}
|
||||
i.logger.Info("Registering new app and auto-configuring subscription", "app", appName)
|
||||
|
||||
for _, integration := range i.integrations {
|
||||
sub, ok := integration.(AutoSubscriber)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := sub.AutoSubscribe(appName, i.store, i.Publisher); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
i.logger.Warn("Auto-config failed", "integration", fmt.Sprintf("%T", integration), "app", appName, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("no integration available to auto-configure app %q", appName)
|
||||
}
|
||||
|
||||
func (i *Integrations) IsSignalLinked() bool {
|
||||
if i.Signal == nil {
|
||||
return false
|
||||
}
|
||||
linked, _ := i.Signal.Health()
|
||||
return linked
|
||||
}
|
||||
|
||||
func (i *Integrations) IsTelegramLinked() bool {
|
||||
if i.Telegram == nil {
|
||||
return false
|
||||
}
|
||||
linked, _ := i.Telegram.Health()
|
||||
return linked
|
||||
}
|
||||
147
service/integration/proton/auth.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package proton
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"prism/service/credentials"
|
||||
|
||||
"github.com/emersion/hydroxide/protonmail"
|
||||
)
|
||||
|
||||
func (m *Monitor) authenticateAndSetup(credStore *credentials.Store) error {
|
||||
creds, err := credStore.GetProton()
|
||||
if err != nil {
|
||||
m.logger.Info("Proton credentials not configured", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
m.credStore = credStore
|
||||
m.logger.Info("Starting Proton Mail monitor", "email", creds.Email)
|
||||
|
||||
c := &protonmail.Client{
|
||||
RootURL: protonAPIURL,
|
||||
AppVersion: protonAppVersion,
|
||||
Debug: false,
|
||||
}
|
||||
|
||||
var auth *protonmail.Auth
|
||||
|
||||
if creds.UID != "" && creds.AccessToken != "" && creds.RefreshToken != "" {
|
||||
auth = &protonmail.Auth{
|
||||
UID: creds.UID,
|
||||
AccessToken: creds.AccessToken,
|
||||
RefreshToken: creds.RefreshToken,
|
||||
Scope: creds.Scope,
|
||||
}
|
||||
|
||||
_, err = c.Unlock(auth, creds.KeySalts, creds.Password)
|
||||
if err != nil {
|
||||
m.logger.Warn("Stored access token expired, attempting refresh", "error", err)
|
||||
|
||||
auth, err = m.refreshTokens(c, auth, creds)
|
||||
if err != nil {
|
||||
if deleteErr := credStore.DeleteIntegration(credentials.IntegrationProton); deleteErr != nil {
|
||||
m.logger.Error("Failed to clear invalid credentials", "error", deleteErr)
|
||||
}
|
||||
return fmt.Errorf("failed to refresh expired tokens: %v", err)
|
||||
}
|
||||
m.logger.Info("Token refreshed successfully on startup")
|
||||
} else {
|
||||
m.logger.Info("Restored Proton session from stored tokens")
|
||||
}
|
||||
} else if creds.Password != "" {
|
||||
authInfo, err := c.AuthInfo(creds.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authResult, err := c.Auth(creds.Email, creds.Password, authInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auth = authResult
|
||||
|
||||
keySalts, err := c.ListKeySalts()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get key salts: %v", err)
|
||||
}
|
||||
|
||||
_, err = c.Unlock(auth, keySalts, creds.Password)
|
||||
if err != nil {
|
||||
m.logger.Error("Failed to unlock keys", "error", err)
|
||||
if deleteErr := credStore.DeleteIntegration(credentials.IntegrationProton); deleteErr != nil {
|
||||
m.logger.Error("Failed to clear invalid credentials", "error", deleteErr)
|
||||
}
|
||||
return fmt.Errorf("failed to unlock keys: %v", err)
|
||||
}
|
||||
|
||||
creds.KeySalts = keySalts
|
||||
if err := credStore.SaveProton(creds); err != nil {
|
||||
m.logger.Warn("Failed to cache key salts", "error", err)
|
||||
}
|
||||
|
||||
m.logger.Info("Authenticated and unlocked Proton session")
|
||||
} else {
|
||||
return fmt.Errorf("no valid credentials found - need password or tokens")
|
||||
}
|
||||
|
||||
m.setupTokenRefresh(c, auth, creds)
|
||||
m.client = c
|
||||
|
||||
if creds.State != nil {
|
||||
m.eventID = creds.State.LastEventID
|
||||
} else {
|
||||
m.eventID = auth.EventID
|
||||
if err := m.saveState(creds); err != nil {
|
||||
m.logger.Warn("Failed to save initial state", "error", err)
|
||||
}
|
||||
m.logger.Info("Initialized Proton state")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Monitor) refreshTokens(c *protonmail.Client, auth *protonmail.Auth, creds *credentials.ProtonCredentials) (*protonmail.Auth, error) {
|
||||
m.logger.Debug("Refreshing Proton session tokens")
|
||||
newAuth, err := c.AuthRefresh(auth)
|
||||
if err != nil {
|
||||
m.logger.Error("Token refresh failed", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = c.Unlock(newAuth, creds.KeySalts, creds.Password)
|
||||
if err != nil {
|
||||
m.logger.Error("Token refresh failed - cannot unlock keys", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedCreds, err := m.credStore.GetProton()
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to get credentials for token update", "error", err)
|
||||
return newAuth, nil
|
||||
}
|
||||
|
||||
updatedCreds.UID = newAuth.UID
|
||||
updatedCreds.AccessToken = newAuth.AccessToken
|
||||
updatedCreds.RefreshToken = newAuth.RefreshToken
|
||||
updatedCreds.Scope = newAuth.Scope
|
||||
|
||||
if err := m.credStore.SaveProton(updatedCreds); err != nil {
|
||||
m.logger.Warn("Failed to save refreshed tokens", "error", err)
|
||||
} else {
|
||||
m.logger.Debug("Proton tokens refreshed and saved")
|
||||
}
|
||||
|
||||
return newAuth, nil
|
||||
}
|
||||
|
||||
func (m *Monitor) setupTokenRefresh(c *protonmail.Client, auth *protonmail.Auth, creds *credentials.ProtonCredentials) {
|
||||
c.ReAuth = func() error {
|
||||
newAuth, err := m.refreshTokens(c, auth, creds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auth = newAuth
|
||||
return nil
|
||||
}
|
||||
}
|
||||
216
service/integration/proton/handlers.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
package proton
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"prism/service/credentials"
|
||||
"prism/service/util"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
monitor *Monitor
|
||||
username string
|
||||
logger *slog.Logger
|
||||
tmpl *util.TemplateRenderer
|
||||
DB *sql.DB
|
||||
APIKey string
|
||||
}
|
||||
|
||||
type ProtonContentData struct {
|
||||
Connected bool
|
||||
}
|
||||
|
||||
type IntegrationData struct {
|
||||
Name string
|
||||
StatusClass string
|
||||
StatusText string
|
||||
StatusTooltip string
|
||||
Content template.HTML
|
||||
Open bool
|
||||
}
|
||||
|
||||
func NewHandlers(monitor *Monitor, username string, logger *slog.Logger, tmpl *util.TemplateRenderer) *Handlers {
|
||||
return &Handlers{
|
||||
monitor: monitor,
|
||||
username: username,
|
||||
logger: logger,
|
||||
tmpl: tmpl,
|
||||
DB: nil,
|
||||
APIKey: "",
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) LoadFreshCredentials() (string, bool) {
|
||||
if h.DB == nil || h.APIKey == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
credStore, err := credentials.NewStore(h.DB, h.APIKey)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
creds, err := credStore.GetProton()
|
||||
if err != nil || creds == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return creds.Email, true
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
||||
email, hasCredentials := h.LoadFreshCredentials()
|
||||
|
||||
var contentData ProtonContentData
|
||||
var integData IntegrationData
|
||||
integData.Name = "Proton Mail"
|
||||
|
||||
if !hasCredentials {
|
||||
integData.StatusClass = "unlinked"
|
||||
integData.StatusText = "Unlinked"
|
||||
integData.StatusTooltip = "Enter credentials to link"
|
||||
integData.Open = true
|
||||
} else if h.monitor == nil || !h.monitor.IsConnected() {
|
||||
integData.StatusClass = "disconnected"
|
||||
integData.StatusText = "Connecting…"
|
||||
integData.StatusTooltip = email
|
||||
integData.Open = false
|
||||
contentData.Connected = true
|
||||
} else {
|
||||
integData.StatusClass = "connected"
|
||||
integData.StatusText = "Linked"
|
||||
integData.StatusTooltip = email
|
||||
integData.Open = false
|
||||
contentData.Connected = true
|
||||
}
|
||||
|
||||
content, err := h.tmpl.RenderHTML("proton-content.html", contentData)
|
||||
if err != nil {
|
||||
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
integData.Content = content
|
||||
|
||||
html, err := h.tmpl.Render("integration.html", integData)
|
||||
if err != nil {
|
||||
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
func (h *Handlers) IsEnabled() bool {
|
||||
return h.monitor != nil
|
||||
}
|
||||
|
||||
type markReadRequest struct {
|
||||
UID string `json:"uid"`
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
var req markReadRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.JSONError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.UID == "" {
|
||||
util.JSONError(w, "uid (number) is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if h.monitor == nil {
|
||||
util.JSONError(w, "Proton integration not enabled", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.monitor.MarkAsRead(req.UID); err != nil {
|
||||
h.logger.Error("failed to mark email as read", "uid", req.UID, "error", err)
|
||||
util.JSONError(w, "failed to mark as read", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("marked email as read", "uid", req.UID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil {
|
||||
h.logger.Error("failed to encode response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
type archiveRequest struct {
|
||||
UID string `json:"uid"`
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleArchive(w http.ResponseWriter, r *http.Request) {
|
||||
var req archiveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.JSONError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.UID == "" {
|
||||
util.JSONError(w, "uid is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if h.monitor == nil {
|
||||
util.JSONError(w, "Proton integration not enabled", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.monitor.Archive(req.UID); err != nil {
|
||||
h.logger.Error("failed to archive email", "uid", req.UID, "error", err)
|
||||
util.JSONError(w, "failed to archive", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("archived email", "uid", req.UID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil {
|
||||
h.logger.Error("failed to encode response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
type trashRequest struct {
|
||||
UID string `json:"uid"`
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleTrash(w http.ResponseWriter, r *http.Request) {
|
||||
var req trashRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.JSONError(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.UID == "" {
|
||||
util.JSONError(w, "uid is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if h.monitor == nil {
|
||||
util.JSONError(w, "Proton integration not enabled", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.monitor.Trash(req.UID); err != nil {
|
||||
h.logger.Error("failed to trash email", "uid", req.UID, "error", err)
|
||||
util.JSONError(w, "failed to trash", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("trashed email", "uid", req.UID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil {
|
||||
h.logger.Error("failed to encode response", "error", err)
|
||||
}
|
||||
}
|
||||
84
service/integration/proton/integration.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package proton
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"prism/service/config"
|
||||
"prism/service/credentials"
|
||||
"prism/service/delivery"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Integration struct {
|
||||
cfg *config.Config
|
||||
Publisher *delivery.Publisher
|
||||
Handlers *Handlers
|
||||
monitor *Monitor
|
||||
db *sql.DB
|
||||
apiKey string
|
||||
}
|
||||
|
||||
func NewIntegration(cfg *config.Config, logger *slog.Logger, tmpl *util.TemplateRenderer, db *sql.DB, apiKey string) *Integration {
|
||||
monitor := NewMonitor(cfg, logger)
|
||||
handlers := NewHandlers(monitor, "", logger, tmpl)
|
||||
return &Integration{
|
||||
cfg: cfg,
|
||||
Handlers: handlers,
|
||||
monitor: monitor,
|
||||
db: db,
|
||||
apiKey: apiKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
|
||||
credStore, err := credentials.NewStore(p.db, p.apiKey)
|
||||
if err == nil {
|
||||
if creds, err := credStore.GetProton(); err == nil {
|
||||
p.Handlers.username = creds.Email
|
||||
}
|
||||
}
|
||||
|
||||
RegisterRoutes(router, p.Handlers, auth, p.db, p.apiKey, logger, p, p.cfg)
|
||||
}
|
||||
|
||||
func (p *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
||||
credStore, err := credentials.NewStore(p.db, p.apiKey)
|
||||
if err != nil {
|
||||
logger.Error("Failed to initialize credentials store for Proton", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
creds, err := credStore.GetProton()
|
||||
if err != nil {
|
||||
logger.Info("Proton credentials not configured", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := p.monitor.Start(ctx, credStore, p.Publisher); err != nil {
|
||||
logger.Error("Failed to start Proton monitor", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if p.Handlers != nil {
|
||||
p.Handlers.username = creds.Email
|
||||
}
|
||||
|
||||
logger.Info("Proton Mail enabled", "email", creds.Email)
|
||||
}
|
||||
|
||||
func (p *Integration) IsEnabled() bool {
|
||||
return p.cfg.EnableProton
|
||||
}
|
||||
|
||||
func (p *Integration) Health() (bool, string) {
|
||||
if p.Handlers == nil {
|
||||
return false, ""
|
||||
}
|
||||
email, ok := p.Handlers.LoadFreshCredentials()
|
||||
return ok, email
|
||||
}
|
||||
150
service/integration/proton/messages.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package proton
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"prism/service/delivery"
|
||||
"prism/service/subscription"
|
||||
|
||||
"github.com/emersion/hydroxide/protonmail"
|
||||
)
|
||||
|
||||
func (m *Monitor) processMessageEvents(events []*protonmail.EventMessage) {
|
||||
for _, evt := range events {
|
||||
switch evt.Action {
|
||||
case protonmail.EventCreate:
|
||||
if evt.Created != nil {
|
||||
msg := evt.Created
|
||||
if msg.Unread == 1 && hasLabel(msg, protonmail.LabelInbox) && msg.Time.Time().After(m.startTime) {
|
||||
if _, seen := m.unseenMessageIDs[msg.ID]; !seen {
|
||||
m.unseenMessageIDs[msg.ID] = time.Now()
|
||||
m.sendNotification(msg)
|
||||
}
|
||||
} else if msg.Unread == 0 && hasLabel(msg, protonmail.LabelInbox) && msg.Time.Time().After(m.startTime) {
|
||||
m.logger.Debug("Skipping already-read message", "msgID", msg.ID, "subject", msg.Subject)
|
||||
}
|
||||
}
|
||||
case protonmail.EventUpdate, protonmail.EventUpdateFlags:
|
||||
if evt.Updated != nil && evt.Updated.Unread != nil && *evt.Updated.Unread == 0 {
|
||||
if _, wasSent := m.unseenMessageIDs[evt.ID]; wasSent {
|
||||
m.clearNotification(evt.ID)
|
||||
}
|
||||
delete(m.unseenMessageIDs, evt.ID)
|
||||
}
|
||||
case protonmail.EventDelete:
|
||||
if _, wasSent := m.unseenMessageIDs[evt.ID]; wasSent {
|
||||
m.clearNotification(evt.ID)
|
||||
}
|
||||
delete(m.unseenMessageIDs, evt.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) cleanupOldMessages() {
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
for msgID, notifiedAt := range m.unseenMessageIDs {
|
||||
if notifiedAt.Before(cutoff) {
|
||||
delete(m.unseenMessageIDs, msgID)
|
||||
}
|
||||
}
|
||||
if len(m.unseenMessageIDs) > 0 {
|
||||
m.logger.Debug("Cleaned up old message IDs", "remaining", len(m.unseenMessageIDs))
|
||||
}
|
||||
}
|
||||
|
||||
func hasLabel(msg *protonmail.Message, labelID string) bool {
|
||||
for _, id := range msg.LabelIDs {
|
||||
if id == labelID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Monitor) sendNotification(msg *protonmail.Message) {
|
||||
from := "Unknown"
|
||||
if msg.Sender != nil {
|
||||
if msg.Sender.Name != "" {
|
||||
from = msg.Sender.Name
|
||||
} else {
|
||||
from = msg.Sender.Address
|
||||
}
|
||||
}
|
||||
|
||||
subject := msg.Subject
|
||||
if subject == "" {
|
||||
subject = "(No subject)"
|
||||
}
|
||||
|
||||
notif := delivery.Notification{
|
||||
Title: from,
|
||||
Message: subject,
|
||||
Tag: "proton-" + msg.ID,
|
||||
Actions: []delivery.Action{
|
||||
{
|
||||
ID: "archive",
|
||||
Label: "Archive",
|
||||
Endpoint: "/api/v1/proton/archive",
|
||||
Method: "POST",
|
||||
Data: map[string]any{
|
||||
"uid": msg.ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "trash",
|
||||
Label: "Trash",
|
||||
Endpoint: "/api/v1/proton/trash",
|
||||
Method: "POST",
|
||||
Data: map[string]any{
|
||||
"uid": msg.ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "mark-read",
|
||||
Label: "Mark read",
|
||||
Endpoint: "/api/v1/proton/mark-read",
|
||||
Method: "POST",
|
||||
Data: map[string]any{
|
||||
"uid": msg.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := m.dispatcher.Publish(prismTopic, notif); err != nil {
|
||||
m.logger.Error("Failed to send notification", "error", err)
|
||||
} else {
|
||||
m.logger.Info("Sent notification", "from", from, "subject", subject, "msgID", msg.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) clearNotification(msgID string) {
|
||||
app, err := m.dispatcher.Store.GetApp(prismTopic)
|
||||
if err != nil || app == nil {
|
||||
return
|
||||
}
|
||||
|
||||
hasWebPush := false
|
||||
for _, sub := range app.Subscriptions {
|
||||
if sub.Channel == subscription.ChannelWebPush {
|
||||
hasWebPush = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasWebPush {
|
||||
return
|
||||
}
|
||||
|
||||
notif := delivery.Notification{
|
||||
Tag: "proton-" + msgID,
|
||||
Title: "",
|
||||
Message: "",
|
||||
}
|
||||
|
||||
if err := m.dispatcher.Publish(prismTopic, notif); err != nil {
|
||||
m.logger.Error("Failed to clear notification", "error", err, "msgID", msgID)
|
||||
} else {
|
||||
m.logger.Debug("Cleared notification", "msgID", msgID)
|
||||
}
|
||||
}
|
||||
189
service/integration/proton/monitor.go
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
package proton
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"prism/service/config"
|
||||
"prism/service/credentials"
|
||||
"prism/service/delivery"
|
||||
|
||||
"github.com/emersion/hydroxide/protonmail"
|
||||
)
|
||||
|
||||
const (
|
||||
pollInterval = 30 * time.Second
|
||||
prismTopic = "Proton Mail"
|
||||
protonAPIURL = "https://mail.proton.me/api"
|
||||
protonAppVersion = "Other"
|
||||
)
|
||||
|
||||
type Monitor struct {
|
||||
cfg *config.Config
|
||||
dispatcher *delivery.Publisher
|
||||
logger *slog.Logger
|
||||
credStore *credentials.Store
|
||||
client *protonmail.Client
|
||||
eventID string
|
||||
unseenMessageIDs map[string]time.Time
|
||||
startTime time.Time
|
||||
consecutiveErrs int
|
||||
lastConnected time.Time
|
||||
cancelPoll context.CancelFunc
|
||||
}
|
||||
|
||||
func NewMonitor(cfg *config.Config, logger *slog.Logger) *Monitor {
|
||||
return &Monitor{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if m.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.startTime = time.Now()
|
||||
m.lastConnected = time.Now()
|
||||
m.unseenMessageIDs = make(map[string]time.Time)
|
||||
|
||||
pollCtx, cancel := context.WithCancel(ctx)
|
||||
m.cancelPoll = cancel
|
||||
go m.pollEvents(pollCtx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Monitor) pollEvents(ctx context.Context) {
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
cleanupTicker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
defer cleanupTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := m.checkEventsWithRetry(ctx); err != nil {
|
||||
m.logger.Error("Failed to check events after retries", "error", err, "consecutive_errors", m.consecutiveErrs)
|
||||
m.consecutiveErrs++
|
||||
} else {
|
||||
if m.consecutiveErrs > 0 {
|
||||
m.logger.Info("Proton connection recovered", "downtime", time.Since(m.lastConnected).String())
|
||||
m.consecutiveErrs = 0
|
||||
}
|
||||
m.lastConnected = time.Now()
|
||||
}
|
||||
case <-cleanupTicker.C:
|
||||
m.cleanupOldMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) checkEventsWithRetry(ctx context.Context) error {
|
||||
maxRetries := 3
|
||||
baseDelay := 1 * time.Second
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := baseDelay * time.Duration(1<<uint(attempt-1))
|
||||
m.logger.Debug("Retrying event check", "attempt", attempt+1, "delay", delay)
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err := m.checkEvents()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if attempt < maxRetries-1 {
|
||||
m.logger.Warn("Event check failed, will retry", "error", err, "attempt", attempt+1)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("event check failed after %d attempts", maxRetries)
|
||||
}
|
||||
|
||||
func (m *Monitor) checkEvents() error {
|
||||
if m.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
event, err := m.client.GetEvent(m.eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if event.ID != m.eventID {
|
||||
m.processMessageEvents(event.Messages)
|
||||
m.eventID = event.ID
|
||||
|
||||
creds, err := m.credStore.GetProton()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.saveState(creds); err != nil {
|
||||
m.logger.Warn("Failed to save state", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Monitor) saveState(creds *credentials.ProtonCredentials) error {
|
||||
creds.State = &credentials.ProtonState{
|
||||
LastEventID: m.eventID,
|
||||
}
|
||||
return m.credStore.SaveProton(creds)
|
||||
}
|
||||
|
||||
func (m *Monitor) IsConnected() bool {
|
||||
if m.client == nil {
|
||||
return false
|
||||
}
|
||||
return m.consecutiveErrs < 5
|
||||
}
|
||||
|
||||
func (m *Monitor) MarkAsRead(msgID string) error {
|
||||
if m.client == nil {
|
||||
return nil
|
||||
}
|
||||
return m.client.MarkMessagesRead([]string{msgID})
|
||||
}
|
||||
|
||||
func (m *Monitor) Archive(msgID string) error {
|
||||
if m.client == nil {
|
||||
return nil
|
||||
}
|
||||
return m.client.UnlabelMessages(protonmail.LabelInbox, []string{msgID})
|
||||
}
|
||||
|
||||
func (m *Monitor) Trash(msgID string) error {
|
||||
if m.client == nil {
|
||||
return nil
|
||||
}
|
||||
return m.client.DeleteMessages([]string{msgID})
|
||||
}
|
||||
242
service/integration/proton/routes.go
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
package proton
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"prism/service/config"
|
||||
"prism/service/credentials"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/emersion/hydroxide/protonmail"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var Templates embed.FS
|
||||
|
||||
type authHandler struct {
|
||||
db *sql.DB
|
||||
apiKey string
|
||||
logger *slog.Logger
|
||||
integration *Integration
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
type protonAuthRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
TOTP string `json:"totp"`
|
||||
}
|
||||
|
||||
func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||
var req protonAuthRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.JSONError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Email == "" || req.Password == "" {
|
||||
util.JSONError(w, "email and password are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
c := &protonmail.Client{
|
||||
RootURL: protonAPIURL,
|
||||
AppVersion: protonAppVersion,
|
||||
Debug: false,
|
||||
}
|
||||
authInfo, err := c.AuthInfo(req.Email)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get auth info", "error", err)
|
||||
util.JSONError(w, "Failed to get auth info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
auth, err := c.Auth(req.Email, req.Password, authInfo)
|
||||
if err != nil {
|
||||
h.logger.Error("Authentication failed", "error", err)
|
||||
util.JSONError(w, "Incorrect login credentials. Please try again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if auth.TwoFactor.Enabled != 0 {
|
||||
if req.TOTP == "" {
|
||||
util.JSONError(w, "2FA enabled: TOTP code required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if auth.TwoFactor.TOTP != 1 {
|
||||
util.JSONError(w, "Only TOTP is supported as a 2FA method", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
scope, err := c.AuthTOTP(req.TOTP)
|
||||
if err != nil {
|
||||
h.logger.Error("TOTP verification failed", "error", err)
|
||||
util.JSONError(w, "Invalid 2FA code. Please try again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// 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)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to initialize credentials store", "error", err)
|
||||
util.JSONError(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
creds := &credentials.ProtonCredentials{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
UID: auth.UID,
|
||||
AccessToken: auth.AccessToken,
|
||||
RefreshToken: auth.RefreshToken,
|
||||
Scope: auth.Scope,
|
||||
KeySalts: keySalts,
|
||||
}
|
||||
|
||||
if err := credStore.SaveProton(creds); err != nil {
|
||||
h.logger.Error("Failed to save Proton credentials", "error", err)
|
||||
util.JSONError(w, "Failed to save credentials", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if h.integration != nil {
|
||||
ctx := context.Background()
|
||||
if err := h.integration.monitor.Start(ctx, credStore, h.integration.Publisher); err != nil {
|
||||
h.logger.Error("Failed to start Proton monitor after auth", "error", err)
|
||||
util.JSONError(w, "Authentication failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.logger.Info("Proton monitor started")
|
||||
if h.integration.Handlers != nil {
|
||||
h.integration.Handlers.username = req.Email
|
||||
}
|
||||
}
|
||||
|
||||
util.SetToast(w, "Proton Mail linked", "success")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
credStore, err := credentials.NewStore(h.db, h.apiKey)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to initialize credentials store", "error", err)
|
||||
util.JSONError(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := credStore.DeleteIntegration(credentials.IntegrationProton); err != nil {
|
||||
h.logger.Error("Failed to delete integration", "error", err)
|
||||
util.JSONError(w, "Failed to delete integration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
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 listKeySaltsWithAuth(rootURL, appVersion string, auth *protonmail.Auth) (map[string][]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, rootURL+"/keys/salts", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Pm-Appversion", appVersion)
|
||||
req.Header.Set("X-Pm-Apiversion", strconv.Itoa(protonmail.Version))
|
||||
req.Header.Set("X-Pm-Uid", auth.UID)
|
||||
req.Header.Set("Authorization", "Bearer "+auth.AccessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:101.0) Gecko/20100101 Firefox/101.0")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result struct {
|
||||
Code int `json:"Code"`
|
||||
Error string `json:"Error"`
|
||||
KeySalts []struct {
|
||||
ID string `json:"ID"`
|
||||
KeySalt string `json:"KeySalt"`
|
||||
} `json:"KeySalts"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Code != 1000 {
|
||||
return nil, fmt.Errorf("[%d] %s", result.Code, result.Error)
|
||||
}
|
||||
|
||||
salts := make(map[string][]byte, len(result.KeySalts))
|
||||
for _, ks := range result.KeySalts {
|
||||
if ks.KeySalt == "" {
|
||||
salts[ks.ID] = nil
|
||||
continue
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(ks.KeySalt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode key salt for %s: %w", ks.ID, err)
|
||||
}
|
||||
salts[ks.ID] = decoded
|
||||
}
|
||||
return salts, nil
|
||||
}
|
||||
|
||||
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger, integration *Integration, cfg *config.Config) {
|
||||
if handlers == nil {
|
||||
return
|
||||
}
|
||||
|
||||
handlers.DB = db
|
||||
handlers.APIKey = apiKey
|
||||
|
||||
authH := &authHandler{
|
||||
db: db,
|
||||
apiKey: apiKey,
|
||||
logger: logger,
|
||||
integration: integration,
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
router.With(auth).Get("/fragment/proton", handlers.HandleFragment)
|
||||
|
||||
router.Route("/api/v1/proton", func(r chi.Router) {
|
||||
r.Use(auth)
|
||||
r.Post("/mark-read", handlers.HandleMarkRead)
|
||||
r.Post("/archive", handlers.HandleArchive)
|
||||
r.Post("/trash", handlers.HandleTrash)
|
||||
r.Post("/auth", authH.handleAuth)
|
||||
r.Delete("/auth", authH.handleDelete)
|
||||
})
|
||||
}
|
||||
28
service/integration/proton/templates/proton-content.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{{if .Connected}}
|
||||
<button class="btn-danger" data-action="delete-proton">Unlink</button>
|
||||
{{else}}
|
||||
<p><strong>Link Proton Account:</strong></p>
|
||||
<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 autocomplete="email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="proton-password">Password:</label>
|
||||
<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}" inputmode="numeric" autocomplete="one-time-code">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Link</button>
|
||||
<div id="proton-auth-status" class="auth-status"></div>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
188
service/integration/signal/client.go
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultDeviceName = "Prism"
|
||||
DefaultConfigPath = ".local/share/signal-cli"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
func NewClient() *Client {
|
||||
if _, err := exec.LookPath("signal-cli"); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &Client{
|
||||
ConfigPath: filepath.Join(os.Getenv("HOME"), DefaultConfigPath),
|
||||
}
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
Number string
|
||||
UUID string
|
||||
}
|
||||
|
||||
func (c *Client) exec(args ...string) ([]byte, error) {
|
||||
baseArgs := []string{"--config", c.ConfigPath, "--output=json"}
|
||||
cmd := exec.Command("signal-cli", append(baseArgs, args...)...)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if stderr.Len() > 0 {
|
||||
return nil, fmt.Errorf("signal-cli: %s", strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
func (c *Client) GetLinkedAccount() (*Account, error) {
|
||||
if c == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
accountsFile := filepath.Join(c.ConfigPath, "data", "accounts.json")
|
||||
if _, err := os.Stat(accountsFile); os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(accountsFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var accountsData struct {
|
||||
Accounts []struct {
|
||||
Number string `json:"number"`
|
||||
UUID string `json:"uuid"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &accountsData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(accountsData.Accounts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
account := &Account{
|
||||
Number: accountsData.Accounts[0].Number,
|
||||
UUID: accountsData.Accounts[0].UUID,
|
||||
}
|
||||
|
||||
cmd := exec.Command("signal-cli", "-a", account.Number, "receive", "--timeout", "0")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
errStr := strings.ToLower(string(output))
|
||||
if strings.Contains(errStr, "not registered") || strings.Contains(errStr, "authorization failed") {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateGroup(name string) (string, string, error) {
|
||||
if c == nil {
|
||||
return "", "", fmt.Errorf("signal client not initialized")
|
||||
}
|
||||
|
||||
account, err := c.GetLinkedAccount()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get linked account: %w", err)
|
||||
}
|
||||
if account == nil {
|
||||
return "", "", fmt.Errorf("no linked Signal account")
|
||||
}
|
||||
|
||||
output, err := c.exec("-o", "json", "-a", account.Number, "updateGroup", "-n", name)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to create group: %w", err)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
GroupID string `json:"groupId"`
|
||||
}
|
||||
|
||||
lines := bytes.Split(output, []byte("\n"))
|
||||
for _, line := range lines {
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(line, &response); err == nil && response.GroupID != "" {
|
||||
return response.GroupID, account.Number, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("failed to parse group creation response")
|
||||
}
|
||||
|
||||
func (c *Client) SendGroupMessage(groupID, message string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("signal client not initialized")
|
||||
}
|
||||
|
||||
account, err := c.GetLinkedAccount()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get 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)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) SendGroupMessageWithAttachment(groupID, message, imageURL string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("signal client not initialized")
|
||||
}
|
||||
|
||||
resp, err := http.Get(imageURL) //nolint:noctx
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
tmp, err := os.CreateTemp("", "prism-signal-*.jpg")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
|
||||
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("failed to write image: %w", err)
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
account, err := c.GetLinkedAccount()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get linked account: %w", err)
|
||||
}
|
||||
if account == nil {
|
||||
return fmt.Errorf("no linked Signal account")
|
||||
}
|
||||
|
||||
_, err = c.exec("-a", account.Number, "send", "-g", groupID, "--notify-self", "-m", message, "--attachment", tmp.Name())
|
||||
return err
|
||||
}
|
||||
47
service/integration/signal/format.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func FormatPhoneNumber(number string) string {
|
||||
if number == "" {
|
||||
return number
|
||||
}
|
||||
|
||||
if strings.HasPrefix(number, "+1") && len(number) == 12 {
|
||||
return fmt.Sprintf("+1 (%s) %s-%s", number[2:5], number[5:8], number[8:])
|
||||
}
|
||||
|
||||
if strings.HasPrefix(number, "+") && len(number) > 4 {
|
||||
digits := number[1:]
|
||||
var countryCode, rest string
|
||||
|
||||
for i := 1; i <= 3 && i < len(digits); i++ {
|
||||
if digits[i] < '0' || digits[i] > '9' {
|
||||
break
|
||||
}
|
||||
countryCode = digits[:i+1]
|
||||
rest = digits[i+1:]
|
||||
}
|
||||
|
||||
if len(rest) >= 3 {
|
||||
var parts []string
|
||||
for len(rest) > 0 {
|
||||
size := 3
|
||||
if len(rest) == 4 || len(rest) == 8 {
|
||||
size = 4
|
||||
}
|
||||
if size > len(rest) {
|
||||
size = len(rest)
|
||||
}
|
||||
parts = append(parts, rest[:size])
|
||||
rest = rest[size:]
|
||||
}
|
||||
return "+" + countryCode + " " + strings.Join(parts, " ")
|
||||
}
|
||||
}
|
||||
|
||||
return number
|
||||
}
|
||||
47
service/integration/signal/groups.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"prism/service/subscription"
|
||||
)
|
||||
|
||||
type GroupCache struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewGroupCache(db *sql.DB) (*GroupCache, error) {
|
||||
c := &GroupCache{db: db}
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS signal_groups (
|
||||
appName TEXT PRIMARY KEY,
|
||||
groupId TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE
|
||||
)`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create signal_groups table: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *GroupCache) Get(appName string) (*subscription.SignalSubscription, error) {
|
||||
var groupID, account string
|
||||
err := c.db.QueryRow(`SELECT groupId, account FROM signal_groups WHERE appName = ?`, appName).Scan(&groupID, &account)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &subscription.SignalSubscription{GroupID: groupID, Account: account}, nil
|
||||
}
|
||||
|
||||
func (c *GroupCache) Save(appName string, sub *subscription.SignalSubscription) error {
|
||||
_, err := c.db.Exec(
|
||||
`INSERT INTO signal_groups (appName, groupId, account) VALUES (?, ?, ?)
|
||||
ON CONFLICT(appName) DO UPDATE SET groupId=excluded.groupId, account=excluded.account`,
|
||||
appName, sub.GroupID, sub.Account,
|
||||
)
|
||||
return err
|
||||
}
|
||||
174
service/integration/signal/handlers.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
qrcode "github.com/yeqown/go-qrcode/v2"
|
||||
"github.com/yeqown/go-qrcode/writer/standard"
|
||||
|
||||
"prism/service/util"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
Client *Client
|
||||
tmpl *util.TemplateRenderer
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type SignalContentData struct {
|
||||
Linked bool
|
||||
DeviceName string
|
||||
Error string
|
||||
QRCode string
|
||||
}
|
||||
|
||||
type IntegrationData struct {
|
||||
Name string
|
||||
StatusClass string
|
||||
StatusText string
|
||||
StatusTooltip string
|
||||
Content template.HTML
|
||||
Open bool
|
||||
}
|
||||
|
||||
func NewHandlers(client *Client, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers {
|
||||
return &Handlers{
|
||||
Client: client,
|
||||
tmpl: tmpl,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
||||
var contentData SignalContentData
|
||||
var integData IntegrationData
|
||||
integData.Name = "Signal"
|
||||
|
||||
if h.Client == nil {
|
||||
integData.StatusClass = "disconnected"
|
||||
integData.StatusText = "Not Available"
|
||||
integData.StatusTooltip = "signal-cli not found in PATH"
|
||||
integData.Open = true
|
||||
contentData.Error = "signal-cli not found. Download from: https://github.com/AsamK/signal-cli/releases"
|
||||
} else {
|
||||
account, err := h.Client.GetLinkedAccount()
|
||||
if err != nil {
|
||||
integData.StatusClass = "disconnected"
|
||||
integData.StatusText = "Error"
|
||||
integData.StatusTooltip = err.Error()
|
||||
integData.Open = true
|
||||
contentData.Error = err.Error()
|
||||
} else if account == nil {
|
||||
integData.StatusClass = "unlinked"
|
||||
integData.StatusText = "Unlinked"
|
||||
integData.StatusTooltip = "Click Link button below to link"
|
||||
integData.Open = true
|
||||
} else {
|
||||
integData.StatusClass = "connected"
|
||||
integData.StatusText = "Linked"
|
||||
integData.StatusTooltip = FormatPhoneNumber(account.Number)
|
||||
integData.Open = false
|
||||
contentData.Linked = true
|
||||
contentData.DeviceName = FormatPhoneNumber(account.Number)
|
||||
}
|
||||
}
|
||||
|
||||
content, err := h.tmpl.RenderHTML("signal-content.html", contentData)
|
||||
if err != nil {
|
||||
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
integData.Content = content
|
||||
|
||||
html, err := h.tmpl.Render("integration.html", integData)
|
||||
if err != nil {
|
||||
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleLinkDevice(w http.ResponseWriter, r *http.Request) {
|
||||
if h.Client == nil {
|
||||
util.JSONError(w, "signal-cli not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DeviceName string `json:"device_name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
req.DeviceName = DefaultDeviceName
|
||||
}
|
||||
|
||||
qrCode, err := h.Client.LinkDevice(req.DeviceName)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to generate link code", "error", err)
|
||||
util.JSONError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
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": dataURL,
|
||||
"status": "linking",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if h.Client == nil {
|
||||
util.JSONError(w, "signal-cli not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
account, err := h.Client.GetLinkedAccount()
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get linked account", "error", err)
|
||||
util.JSONError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("Link status check", "linked", account != nil, "account", account)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if account != nil {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"linked": true,
|
||||
"phone_number": account.Number,
|
||||
})
|
||||
} else {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"linked": false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type nopWriteCloser struct{ *bytes.Buffer }
|
||||
|
||||
func (nopWriteCloser) Close() error { return nil }
|
||||
122
service/integration/signal/integration.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"prism/service/config"
|
||||
"prism/service/delivery"
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Integration struct {
|
||||
cfg *config.Config
|
||||
client *Client
|
||||
Handlers *Handlers
|
||||
Sender *Sender
|
||||
Groups *GroupCache
|
||||
store *subscription.Store
|
||||
logger *slog.Logger
|
||||
tmpl *util.TemplateRenderer
|
||||
}
|
||||
|
||||
func NewIntegration(cfg *config.Config, store *subscription.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
|
||||
client := NewClient()
|
||||
groups, err := NewGroupCache(store.DB)
|
||||
if err != nil {
|
||||
logger.Error("Failed to initialize Signal group cache", "error", err)
|
||||
}
|
||||
var sender *Sender
|
||||
if client != nil {
|
||||
sender = NewSender(client, groups, logger)
|
||||
}
|
||||
return &Integration{
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
Sender: sender,
|
||||
Groups: groups,
|
||||
store: store,
|
||||
logger: logger,
|
||||
tmpl: tmpl,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
|
||||
s.Handlers = RegisterRoutes(router, s.cfg, auth, s.tmpl, logger, s.client)
|
||||
}
|
||||
|
||||
func (s *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
||||
if s.Handlers != nil && s.Handlers.Client != nil {
|
||||
client := s.Handlers.Client
|
||||
account, _ := client.GetLinkedAccount()
|
||||
if account != nil {
|
||||
logger.Info("Signal enabled", "status", "linked", "number", FormatPhoneNumber(account.Number))
|
||||
} else {
|
||||
logger.Info("Signal enabled", "status", "unlinked", "action", "visit admin UI to link")
|
||||
}
|
||||
} else {
|
||||
logger.Info("Signal disabled", "reason", "signal-cli not found in PATH")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Integration) IsEnabled() bool {
|
||||
return s.Handlers != nil && s.Handlers.Client != nil
|
||||
}
|
||||
|
||||
func (s *Integration) Health() (bool, string) {
|
||||
if s.Handlers == nil || s.Handlers.Client == nil {
|
||||
return false, ""
|
||||
}
|
||||
account, _ := s.Handlers.Client.GetLinkedAccount()
|
||||
if account == nil {
|
||||
return false, ""
|
||||
}
|
||||
return true, account.Number
|
||||
}
|
||||
|
||||
func (s *Integration) AutoSubscribe(appName string, store *subscription.Store, publisher *delivery.Publisher) error {
|
||||
if s.Sender == nil {
|
||||
return fmt.Errorf("signal-cli not available")
|
||||
}
|
||||
linked, err := s.Sender.IsLinked()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check Signal link status: %w", err)
|
||||
}
|
||||
if !linked {
|
||||
return fmt.Errorf("no linked Signal account")
|
||||
}
|
||||
if !publisher.HasChannel(subscription.ChannelSignal) {
|
||||
publisher.RegisterSender(subscription.ChannelSignal, s.Sender)
|
||||
}
|
||||
|
||||
var signalSub *subscription.SignalSubscription
|
||||
if cachedGroup, err := s.Groups.Get(appName); err != nil {
|
||||
s.logger.Warn("Failed to check for cached Signal group", "error", err)
|
||||
} else if cachedGroup != nil {
|
||||
s.logger.Debug("Reusing cached Signal group", "app", appName)
|
||||
signalSub = cachedGroup
|
||||
}
|
||||
|
||||
if signalSub == nil {
|
||||
if signalSub, err = s.Sender.CreateDefaultSignalSubscription(appName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
subID, err := store.AddSubscription(subscription.Subscription{
|
||||
AppName: appName,
|
||||
Channel: subscription.ChannelSignal,
|
||||
Signal: signalSub,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("Auto-configured Signal subscription", "app", appName, "subscriptionID", subID)
|
||||
return nil
|
||||
}
|
||||
128
service/integration/signal/link.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (c *Client) LinkDevice(deviceName string) (string, error) {
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("signal-cli not found in PATH")
|
||||
}
|
||||
|
||||
if deviceName == "" {
|
||||
deviceName = DefaultDeviceName
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(c.ConfigPath, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
dataDir := filepath.Join(c.ConfigPath, "data")
|
||||
if entries, err := os.ReadDir(c.ConfigPath); err == nil {
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() && strings.HasPrefix(entry.Name(), "+") {
|
||||
accountDir := filepath.Join(c.ConfigPath, entry.Name())
|
||||
os.RemoveAll(accountDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
os.RemoveAll(dataDir)
|
||||
|
||||
cmd := exec.Command("signal-cli", "--config", c.ConfigPath, "link", "-n", deviceName)
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("failed to start link command: %w", err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
buf := make([]byte, 8192)
|
||||
|
||||
done := make(chan string, 1)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
n, err := stdout.Read(buf)
|
||||
if n > 0 {
|
||||
output.Write(buf[:n])
|
||||
text := output.String()
|
||||
|
||||
if idx := strings.Index(text, "sgnl://linkdevice"); idx != -1 {
|
||||
end := strings.IndexAny(text[idx:], " \n\r\t")
|
||||
var url string
|
||||
if end == -1 {
|
||||
url = text[idx:]
|
||||
} else {
|
||||
url = text[idx : idx+end]
|
||||
}
|
||||
done <- strings.TrimSpace(url)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
errChan <- err
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
n, err := stderr.Read(buf)
|
||||
if n > 0 {
|
||||
output.Write(buf[:n])
|
||||
text := output.String()
|
||||
|
||||
if idx := strings.Index(text, "sgnl://linkdevice"); idx != -1 {
|
||||
end := strings.IndexAny(text[idx:], " \n\r\t")
|
||||
var url string
|
||||
if end == -1 {
|
||||
url = text[idx:]
|
||||
} else {
|
||||
url = text[idx : idx+end]
|
||||
}
|
||||
done <- strings.TrimSpace(url)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case url := <-done:
|
||||
go func() {
|
||||
io.Copy(io.Discard, stdout)
|
||||
io.Copy(io.Discard, stderr)
|
||||
cmd.Wait()
|
||||
}()
|
||||
return url, nil
|
||||
case err := <-errChan:
|
||||
cmd.Process.Kill()
|
||||
return "", err
|
||||
case <-time.After(5 * time.Minute):
|
||||
cmd.Process.Kill()
|
||||
return "", fmt.Errorf("timeout waiting for QR code URL")
|
||||
}
|
||||
}
|
||||
25
service/integration/signal/routes.go
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"prism/service/config"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var Templates embed.FS
|
||||
|
||||
func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler, tmpl *util.TemplateRenderer, logger *slog.Logger, client *Client) *Handlers {
|
||||
handlers := NewHandlers(client, tmpl, logger)
|
||||
|
||||
router.With(authMiddleware).Get("/fragment/signal", handlers.HandleFragment)
|
||||
router.With(authMiddleware).Post("/api/v1/signal/link", handlers.HandleLinkDevice)
|
||||
router.With(authMiddleware).Get("/api/v1/signal/status", handlers.HandleLinkStatus)
|
||||
|
||||
return handlers
|
||||
}
|
||||
115
service/integration/signal/sender.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"prism/service/delivery"
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
)
|
||||
|
||||
type Sender struct {
|
||||
client *Client
|
||||
groups *GroupCache
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewSender(client *Client, groups *GroupCache, logger *slog.Logger) *Sender {
|
||||
return &Sender{
|
||||
client: client,
|
||||
groups: groups,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) IsLinked() (bool, error) {
|
||||
if s.client == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
account, err := s.client.GetLinkedAccount()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return account != nil, nil
|
||||
}
|
||||
|
||||
func (s *Sender) CreateDefaultSignalSubscription(appName string) (*subscription.SignalSubscription, error) {
|
||||
if s.client == nil {
|
||||
return nil, fmt.Errorf("signal integration not enabled")
|
||||
}
|
||||
|
||||
groupID, account, err := s.client.CreateGroup(appName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signalSub := &subscription.SignalSubscription{
|
||||
GroupID: groupID,
|
||||
Account: account,
|
||||
}
|
||||
|
||||
if err := s.groups.Save(appName, signalSub); err != nil {
|
||||
s.logger.Warn("Failed to cache Signal group", "error", err)
|
||||
}
|
||||
|
||||
return signalSub, nil
|
||||
}
|
||||
|
||||
func (s *Sender) Send(sub *subscription.Subscription, notif delivery.Notification) error {
|
||||
if s.client == nil {
|
||||
return delivery.NewPermanentError(fmt.Errorf("signal integration not enabled"))
|
||||
}
|
||||
|
||||
if sub.Signal == nil {
|
||||
return delivery.NewPermanentError(fmt.Errorf("no signal subscription data"))
|
||||
}
|
||||
|
||||
account, err := s.client.GetLinkedAccount()
|
||||
if err != nil {
|
||||
return util.LogError(s.logger, "Failed to get linked account", err)
|
||||
}
|
||||
if account == nil {
|
||||
s.logger.Error("No linked Signal account found")
|
||||
return delivery.NewPermanentError(fmt.Errorf("no linked Signal account"))
|
||||
}
|
||||
|
||||
var signalGroupID string
|
||||
needsNewGroup := sub.Signal.GroupID == ""
|
||||
|
||||
if !needsNewGroup && sub.Signal.Account != account.Number {
|
||||
s.logger.Info("Signal account changed, recreating group",
|
||||
"app", sub.AppName,
|
||||
"oldAccount", sub.Signal.Account,
|
||||
"newAccount", account.Number)
|
||||
needsNewGroup = true
|
||||
}
|
||||
|
||||
if needsNewGroup {
|
||||
return delivery.NewPermanentError(fmt.Errorf("signal group not configured for subscription %s", sub.ID))
|
||||
}
|
||||
|
||||
signalGroupID = sub.Signal.GroupID
|
||||
|
||||
message := notif.Message
|
||||
if notif.Title != "" {
|
||||
message = fmt.Sprintf("%s\n\n%s", notif.Title, notif.Message)
|
||||
}
|
||||
|
||||
if notif.ImageURL != "" {
|
||||
if err := s.client.SendGroupMessageWithAttachment(signalGroupID, message, notif.ImageURL); err != nil {
|
||||
s.logger.Error("Failed to send group message with attachment", "groupID", signalGroupID, "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.client.SendGroupMessage(signalGroupID, message); err != nil {
|
||||
s.logger.Error("Failed to send group message", "groupID", signalGroupID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
26
service/integration/signal/templates/signal-content.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{{if .Linked}}
|
||||
<p><strong>To unlink:</strong></p>
|
||||
<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>
|
||||
</ol>
|
||||
{{else}}
|
||||
{{if .Error}}
|
||||
<p class="channel-not-configured">{{.Error}}</p>
|
||||
{{else}}
|
||||
<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" 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="QR code to scan with Signal and link this device" width="400" height="400">
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
100
service/integration/telegram/client.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
"github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
bot *telego.Bot
|
||||
}
|
||||
|
||||
func NewClient(token string) (*Client, error) {
|
||||
if token == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
bot, err := telego.NewBot(token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
|
||||
}
|
||||
|
||||
return &Client{bot: bot}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMe() (*telego.User, error) {
|
||||
if c == nil || c.bot == nil {
|
||||
return nil, fmt.Errorf("telegram client not initialized")
|
||||
}
|
||||
return c.bot.GetMe(context.Background())
|
||||
}
|
||||
|
||||
func (c *Client) SendMessage(chatID int64, text string) error {
|
||||
if c == nil || c.bot == nil {
|
||||
return fmt.Errorf("telegram client not initialized")
|
||||
}
|
||||
|
||||
msg := telegoutil.Message(
|
||||
telegoutil.ID(chatID),
|
||||
text,
|
||||
).WithParseMode(telego.ModeHTML)
|
||||
|
||||
_, err := c.bot.SendMessage(context.Background(), msg)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) 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
|
||||
}
|
||||
_, err := c.bot.GetMe(context.Background())
|
||||
return err == nil
|
||||
}
|
||||
143
service/integration/telegram/handlers.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package telegram
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"prism/service/credentials"
|
||||
"prism/service/util"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
client *Client
|
||||
chatID int64
|
||||
tmpl *util.TemplateRenderer
|
||||
logger *slog.Logger
|
||||
DB *sql.DB
|
||||
APIKey string
|
||||
}
|
||||
|
||||
type TelegramContentData struct {
|
||||
NotConfigured bool
|
||||
Error string
|
||||
NeedsChatID bool
|
||||
}
|
||||
|
||||
type IntegrationData struct {
|
||||
Name string
|
||||
StatusClass string
|
||||
StatusText string
|
||||
StatusTooltip string
|
||||
Content template.HTML
|
||||
Open bool
|
||||
}
|
||||
|
||||
func NewHandlers(client *Client, chatID int64, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers {
|
||||
return &Handlers{
|
||||
client: client,
|
||||
chatID: chatID,
|
||||
tmpl: tmpl,
|
||||
logger: logger,
|
||||
DB: nil,
|
||||
APIKey: "",
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) loadFreshCredentials() (*Client, int64, bool) {
|
||||
if h.DB == nil || h.APIKey == "" {
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
credStore, err := credentials.NewStore(h.DB, h.APIKey)
|
||||
if err != nil {
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
creds, err := credStore.GetTelegram()
|
||||
if err != nil || creds == nil {
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
client, err := NewClient(creds.BotToken)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create Telegram client", "error", err)
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
var chatID int64
|
||||
if creds.ChatID != "" {
|
||||
chatID, err = strconv.ParseInt(creds.ChatID, 10, 64)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to parse chat ID", "error", err)
|
||||
return client, 0, true
|
||||
}
|
||||
}
|
||||
|
||||
return client, chatID, true
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
||||
client, chatID, _ := h.loadFreshCredentials()
|
||||
|
||||
var contentData TelegramContentData
|
||||
var integData IntegrationData
|
||||
integData.Name = "Telegram"
|
||||
|
||||
if client == nil {
|
||||
integData.StatusClass = "unlinked"
|
||||
integData.StatusText = "Unlinked"
|
||||
integData.StatusTooltip = "Enter bot token to link"
|
||||
integData.Open = true
|
||||
contentData.NotConfigured = true
|
||||
} else {
|
||||
bot, err := client.GetMe()
|
||||
if err != nil {
|
||||
integData.StatusClass = "disconnected"
|
||||
integData.StatusText = "Error"
|
||||
integData.StatusTooltip = err.Error()
|
||||
integData.Open = true
|
||||
contentData.Error = err.Error()
|
||||
} else if chatID == 0 {
|
||||
integData.StatusClass = "unlinked"
|
||||
integData.StatusText = "Needs Chat ID"
|
||||
integData.StatusTooltip = "@" + bot.Username
|
||||
integData.Open = true
|
||||
contentData.NeedsChatID = true
|
||||
} else {
|
||||
integData.StatusClass = "connected"
|
||||
integData.StatusText = "Linked"
|
||||
integData.StatusTooltip = "@" + bot.Username
|
||||
integData.Open = false
|
||||
}
|
||||
}
|
||||
|
||||
content, err := h.tmpl.RenderHTML("telegram-content.html", contentData)
|
||||
if err != nil {
|
||||
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
integData.Content = content
|
||||
|
||||
html, err := h.tmpl.Render("integration.html", integData)
|
||||
if err != nil {
|
||||
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
func (h *Handlers) GetClient() *Client {
|
||||
client, _, _ := h.loadFreshCredentials()
|
||||
return client
|
||||
}
|
||||
|
||||
func (h *Handlers) GetChatID() int64 {
|
||||
_, chatID, _ := h.loadFreshCredentials()
|
||||
return chatID
|
||||
}
|
||||
149
service/integration/telegram/integration.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"prism/service/credentials"
|
||||
"prism/service/delivery"
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Integration struct {
|
||||
Handlers *Handlers
|
||||
Sender *Sender
|
||||
OnUnlink func()
|
||||
OnLink func()
|
||||
db *sql.DB
|
||||
apiKey string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewIntegration(store *subscription.Store, logger *slog.Logger, tmpl *util.TemplateRenderer, apiKey string) *Integration {
|
||||
var client *Client
|
||||
var chatID int64
|
||||
var sender *Sender
|
||||
|
||||
credStore, err := credentials.NewStoreWithLogger(store.DB, apiKey, logger)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to initialize credentials store for Telegram", "error", err)
|
||||
} else {
|
||||
creds, err := credStore.GetTelegram()
|
||||
if err == nil && creds != nil {
|
||||
logger.Info("Loading Telegram credentials from database")
|
||||
client, err = NewClient(creds.BotToken)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create Telegram client", "error", err)
|
||||
client = nil
|
||||
}
|
||||
if creds.ChatID != "" {
|
||||
chatID, _ = strconv.ParseInt(creds.ChatID, 10, 64)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if client != nil && chatID != 0 {
|
||||
sender = NewSender(client, store, logger, chatID)
|
||||
}
|
||||
|
||||
handlers := NewHandlers(client, chatID, tmpl, logger)
|
||||
|
||||
return &Integration{
|
||||
Handlers: handlers,
|
||||
Sender: sender,
|
||||
db: store.DB,
|
||||
apiKey: apiKey,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
|
||||
RegisterRoutes(router, t.Handlers, auth, t.db, t.apiKey, logger, t.OnLink, t.OnUnlink)
|
||||
}
|
||||
|
||||
func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
|
||||
client := t.Handlers.GetClient()
|
||||
if client == nil {
|
||||
logger.Info("Telegram not configured", "action", "visit admin UI to configure")
|
||||
return
|
||||
}
|
||||
|
||||
bot, err := client.GetMe()
|
||||
if err != nil {
|
||||
logger.Error("Telegram bot error", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
chatID := t.Handlers.chatID
|
||||
if chatID == 0 {
|
||||
logger.Info("Telegram bot connected", "bot", bot.Username, "status", "needs_chat_id")
|
||||
} else {
|
||||
logger.Info("Telegram enabled", "bot", bot.Username, "chat_id", chatID)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Integration) IsEnabled() bool {
|
||||
return t.Handlers != nil && t.Handlers.GetClient() != nil
|
||||
}
|
||||
|
||||
func (t *Integration) Health() (bool, string) {
|
||||
if t.Handlers == nil {
|
||||
return false, ""
|
||||
}
|
||||
client := t.Handlers.GetClient()
|
||||
if client == nil {
|
||||
return false, ""
|
||||
}
|
||||
bot, err := client.GetMe()
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
return true, "@" + bot.Username
|
||||
}
|
||||
|
||||
func (t *Integration) AutoSubscribe(appName string, store *subscription.Store, publisher *delivery.Publisher) error {
|
||||
credStore, err := credentials.NewStore(t.db, t.apiKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load credentials: %w", err)
|
||||
}
|
||||
creds, err := credStore.GetTelegram()
|
||||
if err != nil || creds == nil {
|
||||
return fmt.Errorf("telegram not configured")
|
||||
}
|
||||
if creds.ChatID == "" {
|
||||
return fmt.Errorf("no chat ID configured")
|
||||
}
|
||||
chatID, err := strconv.ParseInt(creds.ChatID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid chat ID: %w", err)
|
||||
}
|
||||
|
||||
if !publisher.HasChannel(subscription.ChannelTelegram) {
|
||||
client, err := NewClient(creds.BotToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create telegram client: %w", err)
|
||||
}
|
||||
sender := NewSender(client, store, t.logger, chatID)
|
||||
t.Sender = sender
|
||||
publisher.RegisterSender(subscription.ChannelTelegram, sender)
|
||||
}
|
||||
|
||||
subID, err := store.AddSubscription(subscription.Subscription{
|
||||
AppName: appName,
|
||||
Channel: subscription.ChannelTelegram,
|
||||
Telegram: &subscription.TelegramSubscription{ChatID: fmt.Sprintf("%d", chatID)},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.logger.Info("Auto-configured Telegram subscription", "app", appName, "subscriptionID", subID, "chatID", chatID)
|
||||
return nil
|
||||
}
|
||||
100
service/integration/telegram/routes.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package telegram
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"prism/service/credentials"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var Templates embed.FS
|
||||
|
||||
type linkHandler struct {
|
||||
db *sql.DB
|
||||
apiKey string
|
||||
logger *slog.Logger
|
||||
onLink func()
|
||||
onUnlink func()
|
||||
}
|
||||
|
||||
type telegramLinkRequest struct {
|
||||
BotToken string `json:"bot_token"`
|
||||
ChatID string `json:"chat_id"`
|
||||
}
|
||||
|
||||
func (h *linkHandler) handleLink(w http.ResponseWriter, r *http.Request) {
|
||||
var req telegramLinkRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.JSONError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.BotToken == "" || req.ChatID == "" {
|
||||
util.JSONError(w, "bot_token and chat_id are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
credStore, err := credentials.NewStore(h.db, h.apiKey)
|
||||
if err != nil {
|
||||
util.LogAndError(w, h.logger, "Failed to initialize credentials store", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
creds := &credentials.TelegramCredentials{
|
||||
BotToken: req.BotToken,
|
||||
ChatID: req.ChatID,
|
||||
}
|
||||
|
||||
if err := credStore.SaveTelegram(creds); err != nil {
|
||||
util.LogAndError(w, h.logger, "Failed to save Telegram credentials", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if h.onLink != nil {
|
||||
h.onLink()
|
||||
}
|
||||
|
||||
util.SetToast(w, "Telegram linked", "success")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
func (h *linkHandler) handleUnlink(w http.ResponseWriter, r *http.Request) {
|
||||
credStore, err := credentials.NewStore(h.db, h.apiKey)
|
||||
if err != nil {
|
||||
util.LogAndError(w, h.logger, "Failed to initialize credentials store", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := credStore.DeleteIntegration(credentials.IntegrationTelegram); err != nil {
|
||||
util.LogAndError(w, h.logger, "Failed to delete integration", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.onUnlink()
|
||||
util.SetToast(w, "Telegram unlinked", "success")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
|
||||
}
|
||||
|
||||
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger, onLink func(), onUnlink func()) {
|
||||
if handlers == nil {
|
||||
return
|
||||
}
|
||||
|
||||
handlers.DB = db
|
||||
handlers.APIKey = apiKey
|
||||
|
||||
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)
|
||||
router.With(auth).Delete("/api/v1/telegram/link", linkH.handleUnlink)
|
||||
}
|
||||
87
service/integration/telegram/sender.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package telegram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"prism/service/delivery"
|
||||
"prism/service/subscription"
|
||||
)
|
||||
|
||||
func parseInt64(s string) (int64, error) {
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
|
||||
type Sender struct {
|
||||
client *Client
|
||||
store *subscription.Store
|
||||
logger *slog.Logger
|
||||
DefaultChatID int64
|
||||
}
|
||||
|
||||
func NewSender(client *Client, store *subscription.Store, logger *slog.Logger, defaultChatID int64) *Sender {
|
||||
return &Sender{
|
||||
client: client,
|
||||
store: store,
|
||||
logger: logger,
|
||||
DefaultChatID: defaultChatID,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sender) GetChatID() int64 {
|
||||
return s.DefaultChatID
|
||||
}
|
||||
|
||||
func (s *Sender) IsLinked() (bool, error) {
|
||||
if s.client == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if !s.client.IsAvailable() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if s.DefaultChatID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Sender) Send(sub *subscription.Subscription, notif delivery.Notification) error {
|
||||
if s.client == nil {
|
||||
return delivery.NewPermanentError(fmt.Errorf("telegram integration not enabled"))
|
||||
}
|
||||
|
||||
if sub.Telegram == nil || sub.Telegram.ChatID == "" {
|
||||
return delivery.NewPermanentError(fmt.Errorf("no telegram chat configured for subscription"))
|
||||
}
|
||||
|
||||
message := notif.Message
|
||||
if notif.Title != "" {
|
||||
message = fmt.Sprintf("<b>%s</b>\n%s", notif.Title, notif.Message)
|
||||
}
|
||||
|
||||
fullMessage := fmt.Sprintf("<b>%s</b>\n\n%s", sub.AppName, message)
|
||||
|
||||
chatID, err := parseInt64(sub.Telegram.ChatID)
|
||||
if err != nil {
|
||||
return delivery.NewPermanentError(fmt.Errorf("invalid chat ID: %w", err))
|
||||
}
|
||||
|
||||
if notif.ImageURL != "" {
|
||||
if err := s.client.SendPhoto(chatID, notif.ImageURL, fullMessage); err != nil {
|
||||
s.logger.Error("Failed to send telegram photo", "chatID", chatID, "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.client.SendMessage(chatID, fullMessage); err != nil {
|
||||
s.logger.Error("Failed to send telegram message", "chatID", chatID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
45
service/integration/telegram/templates/telegram-content.html
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{{if .NotConfigured}}
|
||||
<p><strong>Setup Telegram Bot:</strong></p>
|
||||
<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" 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>
|
||||
<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>
|
||||
<input type="text" id="telegram-chat-id" name="chat_id" placeholder="123456789" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Link</button>
|
||||
<div id="telegram-auth-status" class="auth-status"></div>
|
||||
</form>
|
||||
{{else if .Error}}
|
||||
<p>Error: {{.Error}}</p>
|
||||
<button class="btn-secondary" data-action="reload">Retry</button>
|
||||
{{else if .NeedsChatID}}
|
||||
<p><strong>Complete Setup:</strong></p>
|
||||
<p>Bot is configured, but Chat ID is missing.</p>
|
||||
<form class="auth-form" data-handler="telegram-chatid" data-bot-token="{{.BotToken}}">
|
||||
<div class="form-group">
|
||||
<label for="telegram-chat-id-only">Chat ID:</label>
|
||||
<input type="text" id="telegram-chat-id-only" name="chat_id" placeholder="Get from @userinfobot" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Save Chat ID</button>
|
||||
<div id="telegram-chatid-status" class="auth-status"></div>
|
||||
</form>
|
||||
{{else}}
|
||||
<button class="btn-danger" data-action="delete-telegram">Unlink</button>
|
||||
{{end}}
|
||||
|
||||
17
service/integration/templates/integrations.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{{if .EnableSignal}}
|
||||
<div id="signal-integration" class="integration-container" hx-get="/fragment/signal" hx-trigger="load">
|
||||
<div class="loading" 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" 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" role="status"><div class="spinner"></div><span class="sr-only">Loading…</span></div>
|
||||
</div>
|
||||
{{end}}
|
||||
142
service/integration/webpush/handlers.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package webpush
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
store *subscription.Store
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHandlers(store *subscription.Store, logger *slog.Logger) *Handlers {
|
||||
return &Handlers{
|
||||
store: store,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
AppName string `json:"appName"`
|
||||
PushEndpoint string `json:"pushEndpoint"`
|
||||
P256dh *string `json:"p256dh,omitempty"`
|
||||
Auth *string `json:"auth,omitempty"`
|
||||
VapidPrivateKey *string `json:"vapidPrivateKey,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.JSONError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.AppName == "" || req.PushEndpoint == "" {
|
||||
util.JSONError(w, "appName and pushEndpoint are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
encryptedFieldCount := 0
|
||||
if req.P256dh != nil {
|
||||
encryptedFieldCount++
|
||||
}
|
||||
if req.Auth != nil {
|
||||
encryptedFieldCount++
|
||||
}
|
||||
if req.VapidPrivateKey != nil {
|
||||
encryptedFieldCount++
|
||||
}
|
||||
if encryptedFieldCount > 0 && encryptedFieldCount < 3 {
|
||||
util.JSONError(w, "p256dh, auth, and vapidPrivateKey must all be provided together", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validatePushEndpoint(req.PushEndpoint, encryptedFieldCount == 3); err != nil {
|
||||
util.JSONError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var webPush *subscription.WebPushSubscription
|
||||
if req.P256dh != nil && req.Auth != nil && req.VapidPrivateKey != nil {
|
||||
normalizedP256dh, err := normalizeP256DH(*req.P256dh)
|
||||
if err != nil {
|
||||
util.JSONError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
normalizedAuth, err := normalizeAuthSecret(*req.Auth)
|
||||
if err != nil {
|
||||
util.JSONError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
normalizedKey, err := normalizeVAPIDPrivateKey(*req.VapidPrivateKey)
|
||||
if err != nil {
|
||||
util.JSONError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
webPush = &subscription.WebPushSubscription{
|
||||
Endpoint: req.PushEndpoint,
|
||||
P256dh: normalizedP256dh,
|
||||
Auth: normalizedAuth,
|
||||
VapidPrivateKey: normalizedKey,
|
||||
}
|
||||
} else {
|
||||
webPush = &subscription.WebPushSubscription{
|
||||
Endpoint: req.PushEndpoint,
|
||||
}
|
||||
}
|
||||
|
||||
sub := subscription.Subscription{
|
||||
AppName: req.AppName,
|
||||
Channel: subscription.ChannelWebPush,
|
||||
WebPush: webPush,
|
||||
}
|
||||
|
||||
subID, err := h.store.AddSubscription(sub)
|
||||
if err != nil {
|
||||
h.logger.Warn("Failed to add webpush subscription", "app", req.AppName, "error", err)
|
||||
util.LogAndError(w, h.logger, "Failed to add subscription", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("Added webpush subscription", "app", req.AppName, "subscriptionID", subID, "pushEndpoint", req.PushEndpoint)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
response := map[string]string{
|
||||
"appName": req.AppName,
|
||||
"channel": subscription.ChannelWebPush.String(),
|
||||
"subscriptionId": subID,
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) {
|
||||
subscriptionID := chi.URLParam(r, "subscriptionId")
|
||||
if subscriptionID == "" {
|
||||
util.JSONError(w, "subscriptionId is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteSubscription(subscriptionID); err != nil {
|
||||
util.LogAndError(w, h.logger, "Failed to delete subscription", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("Deleted webpush subscription", "subscriptionID", subscriptionID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
response := map[string]string{
|
||||
"status": "deleted",
|
||||
"subscriptionId": subscriptionID,
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||