Compare commits

...

125 commits

Author SHA1 Message Date
7f35129bc3
bump version to 1.4.2 2026-05-24 14:03:03 -07:00
9b5ce8ad17
revert signal-cli to v1.3.2 binary; fixes group message rate limiting (upstream bug in v0.14.4.x) 2026-05-24 13:31:37 -07:00
848b4ee2da
ci: cache Go tools, bump action versions, use golangci-lint env var 2026-05-23 23:15:41 -07:00
850105181a
update signal-cli to v0.14.4.1 (GraalVM native, amd64 + arm64) 2026-05-23 23:08:32 -07:00
37d745703c
security: remove RealIP middleware, tighten rate limiter defaults
- remove chi middleware.RealIP; deprecated in chi v5.3.0 due to IP spoofing
  vulnerabilities (GHSA-3fxj-6jh8-hvhx, GHSA-rjr7-jggh-pgcp, GHSA-9g5q-2w5x-hmxf)
- lower default RATE_LIMIT from 100 to 20 req/s per IP
- support RATE_LIMIT=0 to disable rate limiting entirely (for deployments behind
  a remote reverse proxy with its own rate limiting)
- fix incorrect .env.example comment (was 'per 15 minute window', is per second)
2026-05-23 18:37:55 -07:00
8dd23cc9b2
deps: upgrade chi, telego, hydroxide, sqlite, crypto and transitive deps 2026-05-23 18:37:43 -07:00
fab4ca72de
new release for go's latest security patch 2026-05-08 11:17:10 -07:00
e64423d1f3
docs: expand HA camera snapshot example, use HTTPS URL 2026-05-04 13:53:03 -07:00
397710c2d5
update README to highlight the importance of https for Prism 2026-05-01 15:07:32 -07:00
8c06caadf3
update sqlite dep, disallow non-ascii characters in API keys 2026-04-25 18:37:03 -07:00
54afd9656d
dep upgrade, disable dependabot PRs 2026-04-18 14:47:02 -07:00
165c53339d
readme update 2026-04-17 18:21:50 -07:00
960b5c2874
shorten email actions to include just the first @ part 2026-04-15 09:48:07 -07:00
3f8fc957c5
dont need to truncate for logs 2026-04-15 09:16:22 -07:00
ca367baf35
fix telegram linking, minor fixes for upcoming release 2026-04-15 07:58:38 -07:00
4f259ccfea
support images in notifications, enrich notifications with phone + email actions, UX/a11y improvements 2026-04-14 18:05:14 -07:00
814c6fa258
more minor UI nitpicks 2026-04-13 22:47:59 -07:00
fe75679700
improve readme, update screenshots for light/dark mode, 2026-04-13 22:23:30 -07:00
510a2d6f7a
chore: restore GHA cache for dev builds 2026-04-13 20:50:01 -07:00
ef98c7eab3
fix missed telegram unlinked status update 2026-04-13 20:27:17 -07:00
d39a8e2b43
more minor UI adjustments, back to matching Android theme 2026-04-13 20:13:09 -07:00
0c5306cbe7
fix: update golang.org/x/image to v0.39.0 (CVE fixes) 2026-04-13 14:52:36 -07:00
6d8995eae9
fix: remove min-height from delete-sub button 2026-04-13 14:44:15 -07:00
93457a50a5
chore: force no-cache dev build 2026-04-13 14:37:33 -07:00
fa6e967696
tighter badge padding 2026-04-13 13:07:08 -07:00
18c13e564f
note where the signal-cli come from 2026-04-13 12:32:01 -07:00
c9cb3a7289
update to latest signal-cli, UI audit improvements" 2026-04-13 12:17:59 -07:00
88aeff8539
update deps 2026-04-10 18:28:51 -07:00
f136a17a4d README update: title needs to be explicitly passed from HA automations 2026-04-04 13:07:09 -07:00
2cfee68536 reduce debug spam from hydroxide, better log title from ntfy alerts 2026-04-04 12:36:24 -07:00
024421fe50 update deps, check for docker image updates without local docker (caching version digest locally) 2026-04-03 14:41:16 -07:00
6ba21bad53 adding a new Beszel real world example, move examples below API 2026-03-29 14:15:27 -07:00
eb762fd05b another fix to ensure consistent relinking proton behaviour 2026-03-29 12:17:59 -07:00
e5778f68aa rename prism admin -> prism 2026-03-28 17:07:39 -07:00
c9d52e6b43 new eye icon to show/hide proton mail password during initial entry, fix proton re-linking, new release 2026-03-28 14:25:55 -07:00
dependabot[bot]
61d833d864 Bump modernc.org/sqlite from 1.47.0 to 1.48.0 (#4)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.47.0 to 1.48.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.47.0...v1.48.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-28 09:23:01 -07:00
dependabot[bot]
cc6b3ab198 Bump modernc.org/sqlite from 1.46.1 to 1.47.0 (#3)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.46.1 to 1.47.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.46.1...v1.47.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-22 20:42:14 -07:00
7ccfe8e585 default integration flags to true, update x/crypto to latest, v1.1.0 2026-03-11 19:19:10 -07:00
623cb9a998 update telego to latest 2026-03-04 15:58:59 -08:00
dff6addb35 make info logging more consistent 2026-02-27 18:26:01 -08:00
6c3c5e1f6d rename Delete to Trash 2026-02-27 13:43:47 -08:00
4dac3dbba0 better logging levels 2026-02-26 23:56:19 -08:00
f7f161221c upgrade transient deps 2026-02-26 18:42:26 -08:00
bb8ce0456a code cleanups and refactors 2026-02-26 18:35:11 -08:00
004c06a165 ensure we use go 1.26 everywhere, dockerfile optimizations, decreease all workflow times with better go caching 2026-02-24 22:42:13 -08:00
b3bb864fac better notification action ordering 2026-02-24 22:02:00 -08:00
ce18b03394 new delete action for proton mail notifications 2026-02-24 20:43:17 -08:00
bb1ee31308 code cleanups, switch to chi rate limiting middleware 2026-02-24 01:24:22 -08:00
dependabot[bot]
621c3f7334 Bump modernc.org/sqlite from 1.45.0 to 1.46.1 (#2)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.45.0 to 1.46.1.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.45.0...v1.46.1)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.46.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 19:14:53 -08:00
c3204f7fc4 readme touchups 2026-02-19 21:26:18 -08:00
d1972eaffc update dashboard screenshot 2026-02-19 17:44:08 -08:00
29949d90bc fix lint checks to work with newest version of go 2026-02-19 16:56:24 -08:00
5c3e98c674 add API examples to readme 2026-02-19 01:55:44 -08:00
429ce5d239 new favicon 2026-02-18 19:08:26 -08:00
a96a814661 correcting sqlite limitation on bursty requests which lock the DB without a busy timeout 2026-02-18 11:35:25 -08:00
061fcb305f display subscription id in tooltip 2026-02-18 00:29:47 -08:00
9976faaf27 correcting webpush data 2026-02-17 22:23:46 -08:00
45b55dc1c5 remove excessive logging 2026-02-17 21:54:07 -08:00
147534286f adding basic webpush request validation 2026-02-17 21:22:06 -08:00
7f1de091e0 minor UI nits 2026-02-17 19:05:41 -08:00
df0834cc0f more minor webpush UI adjustments 2026-02-16 23:13:47 -08:00
649b3d33c0 webpush improvements 2026-02-16 21:52:53 -08:00
f29f79e04c ignore favicon 404s 2026-02-16 18:25:20 -08:00
d69651ecda cache previously created signal groups better for potential re-use 2026-02-15 22:45:41 -08:00
667c8f77ac clean up unused admin routes, expose existing apps via new endpoint, update schema to allow an app to have many subscription channels 2026-02-15 21:19:40 -08:00
923e7110c4 fix version in api/health response 2026-02-14 14:40:47 -08:00
36f6939b2f highlight the importance of a strong API key password 2026-02-14 13:31:23 -08:00
a66ddc7363 version all APIs for v1 2026-02-14 12:53:08 -08:00
98b18e28ca fix: correctly inject the version from the VERION file into the code on build 2026-02-13 22:42:12 -08:00
96f413c9f9 fix corrupted arm64 binary, new release 2026-02-13 19:08:08 -08:00
dependabot[bot]
ece2fb2462 Bump golang from 1.25-alpine3.23 to 1.26-alpine3.23 (#1)
Bumps golang from 1.25-alpine3.23 to 1.26-alpine3.23.

---
updated-dependencies:
- dependency-name: golang
  dependency-version: 1.26-alpine3.23
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 19:04:39 -08:00
6143a45010 release v0.2.3 2026-02-13 18:57:00 -08:00
23a9725d13 need ca-certificates after all 2026-02-13 18:24:48 -08:00
e205e84428 fix ci 2026-02-13 17:57:23 -08:00
c77a1e164d vendor the native signal-cli for more consistent releases 2026-02-13 17:41:41 -08:00
2fde90e650 fix proton access token expiring after not being refreshed on service start, fix linking regression 2026-02-13 00:05:16 -08:00
ea3345825a code clean ups, minor improvements 2026-02-12 23:13:16 -08:00
2a7b1e91c6 fix signal directory permissions in docker, fix version-based cache busting 2026-02-11 01:44:29 -08:00
c82b5381fc persist signal-data when using docker compose, update docker command 2026-02-11 01:23:04 -08:00
dae17336f5 configurable ports for docker compose, dont build arm64 for test dev builds 2026-02-11 01:13:40 -08:00
af045a3736 minor logging improvements 2026-02-11 00:52:54 -08:00
7fe6d9652f update deps 2026-02-10 17:29:33 -08:00
74233957d3 split larger files into multiple, slim down biome config, dont retry for permanent errors, consistently use Link, new chi middleware to fix 401s hanging 2026-02-10 17:10:02 -08:00
48c420d14b reduce health spam, make dockerfile health checks respect the configured port 2026-02-09 18:10:57 -08:00
372295be45 fix /health but for real this time 2026-02-09 03:22:38 -08:00
3978f476c7 fix health endpoint, nits 2026-02-09 02:57:30 -08:00
d79e8ab5bc docker compose just for dev 2026-02-09 02:45:38 -08:00
113f09c2ab don't cache signal auth status, dont send empty messages to non-webpush channels 2026-02-09 02:07:32 -08:00
244ab02651 using biome for html/cs/js formatting and linting, simplify app to run with no services, re-implement proton and signal implementations to be much better, configure all integration in web UI instead of .env 2026-02-09 01:19:47 -08:00
f1fddabaf7 return linked account from /api/health 2026-02-07 12:58:36 -08:00
577db5251c fix missing env vars, check deps via dependabot 2026-02-07 11:44:57 -08:00
cc21c41e73 health monitoring for prism, expose server version in /api/health 2026-02-07 03:07:21 -08:00
2aa86c6757 update deps 2026-02-07 02:23:26 -08:00
c41d7ecfef fix sqlite open 2026-02-07 02:13:05 -08:00
7fd57101a3 improve README, code cleaning, use the other sqlite lib for less RAM usage, use any instead of interface, add 3 retries with exponential backoff for undelivered notifications 2026-02-07 01:54:06 -08:00
7fdc62e91b fix prism data not being persited on pulls 2026-02-06 23:47:50 -08:00
23e905ee99 different sqlite lib to avoid CGO for faster builds, 2026-02-06 23:23:57 -08:00
6d46ef0977 lets not UPX compress release binaries, dev release only for arm64 2026-02-06 22:53:31 -08:00
b08fd5356d better error logging, improvements for sqlite, prevent DB nil overwrites on conflict 2026-02-06 22:38:04 -08:00
f5c6166f57 fix notify self, fix superfluous WriteHeader warning 2026-02-06 19:30:03 -08:00
25230521a3 fix signal linking, docker dev is for prism dev image 2026-02-06 17:39:28 -08:00
b5b927880b allow prism dev releases 2026-02-06 03:29:51 -08:00
f170211d6a signal-cli socket has to be cleaned up as root 2026-02-06 02:47:55 -08:00
dc18625d26 clean up the previous (crashed?) signal-cli socket on start 2026-02-06 02:39:14 -08:00
53e46102eb better arm64 signal-cli support 2026-02-06 02:00:09 -08:00
27401f8841 minor makefile and readme adjustments 2026-02-06 01:39:43 -08:00
c58c6e6882 fix signal-cli release 2026-02-05 23:02:42 -08:00
fe11ed82af lock down alpine version, code clean ups, optimize release size with upx 2026-02-05 22:19:38 -08:00
ac40783aa7 re-architect to a new integration system, ensure that signal is optional, adding telegram support 2026-02-05 15:46:28 -08:00
4dd14a2833 nits 2026-02-03 21:18:09 -08:00
2d4160f583 support webpush and webhooks together, allow unregistered webpush, rename endpoints to apps 2026-02-03 19:31:47 -08:00
e31ccc76c5 make protonmail notifications work again, cleaner UI, update to v2 of IMAP lib, 2026-02-03 03:25:48 -08:00
051a13cb7a re-organize folder structure 2026-02-02 14:52:07 -08:00
a8d8325971 use air for fast reloads, cleap code and make it work again (TODO proton) 2026-02-01 23:38:29 -08:00
73356a9fed rename module, dont need prism CLI 2026-02-01 17:13:01 -08:00
e9e6e06138 fix linting issues 2026-02-01 00:48:01 -08:00
c8a3e7dd4c WIP: rewrite in golang 2026-02-01 00:18:22 -08:00
63f2ce1a3e back to bun 1.3.6 2026-01-31 03:14:29 -08:00
1d25469687 rename to prism 2026-01-31 02:00:29 -08:00
34c1406e84 update to latest bun, better docker container names, dont poll the UI fragments, unifiedpush -> webhook rename 2026-01-30 15:31:39 -08:00
8e4998ea77 update to latest signal-cli and bun, install-signal-cli should now handle updates on new versions, htmx upgrade, adding unifiedpush support 2026-01-28 20:59:06 -08:00
13c068a8cf new endpoint to mark emails as read 2026-01-27 10:21:11 -08:00
c3dce75eea release 0.1.2, renamed sup-server -> sup 2026-01-26 01:39:08 -08:00
6b1e69382b 0.1.2 release, support more architectures for docker 2026-01-26 00:35:05 -08:00
f869649622 rely on docker releases via CI 2026-01-25 22:47:49 -08:00
126 changed files with 7569 additions and 3733 deletions

15
.air.toml Normal file
View 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

View file

@ -1,3 +1,4 @@
server/node_modules
proton-bridge/node_modules
node_modules
data/
*.db
prism
prism-*

View file

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

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

View file

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

View file

@ -1,48 +0,0 @@
name: Docker
on:
push:
tags: ['v*']
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- 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=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

35
.github/workflows/release-dev.yml vendored Normal file
View 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
View 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
View file

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

@ -0,0 +1,9 @@
version: "2"
run:
timeout: 5m
tests: false
linters:
disable:
- errcheck

View file

@ -1,3 +1,3 @@
{
"recommendations": ["biomejs.biome", "mathiasfrohlich.kotlin", "vscjava.vscode-gradle"]
"recommendations": ["golang.go"]
}

49
Dockerfile Normal file
View 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
View 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

390
README.md
View file

@ -1,198 +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
> ⚠️ **Early Alpha**: Currently only `docker-compose.dev.yml` dev deployments are available.
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
> ⚠️ **Early Alpha**: Currently only `docker-compose.dev.yml` dev deployments are available.
```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
![Admin login screen](assets/screenshots/1.webp)
#### 2. Scan the QR code from your Signal app
Go to **Settings → Linked Devices → Link New Device** in Signal.
![QR code linking screen](assets/screenshots/2.webp)
#### 3. Verify the setup
Once linked, you'll see the status dashboard:
![Healthy setup with linked account](assets/screenshots/3.webp)
With optional Proton Mail integration:
![Healthy setup with Proton Mail](assets/screenshots/4.webp)
### 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 instant Signal notifications when new emails arrive in your Proton Mail inbox.
Add to `configuration.yaml`:
SUP monitors your Proton Mail account via the local Proton Mail 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
![SUP Architecture](assets/SUP%20Architecture.webp)
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
View file

@ -0,0 +1 @@
1.4.2

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

BIN
assets/prism.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

View file

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

View file

@ -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=="],
}
}

View file

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

View file

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

View file

@ -1,36 +0,0 @@
{
"name": "sup",
"version": "0.1.0",
"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 .",
"docker:release": "bun run scripts/release-docker.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/favicon.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 B

1
public/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

847
public/index.css Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}
}
});
});

View file

@ -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();

View file

@ -1,39 +0,0 @@
import { $ } from 'bun';
const registry = 'ghcr.io/lone-cloud';
const config = { name: 'sup-server', path: './server' };
try {
const packageJson = await Bun.file(`${config.path}/package.json`).json();
const version = `v${packageJson.version}`;
console.log(`Releasing ${config.name} ${version}...`);
const fullName = `${registry}/${config.name}`;
console.log(`\nBuilding ${config.name}...`);
await $`docker build -t ${fullName}:${version} -t ${fullName}:latest ${config.path}`;
console.log(`Built ${config.name}`);
console.log(`Pushing ${fullName}:${version}...`);
await $`docker push ${fullName}:${version}`;
console.log(`Pushed ${fullName}:${version}`);
console.log(`Pushing ${fullName}:latest...`);
await $`docker push ${fullName}:latest`;
console.log(`Pushed ${fullName}:latest`);
console.log(`
${config.name} ${version} released successfully!
Images pushed:
- ${fullName}:${version}
- ${fullName}:latest
Users can now pull with:
docker compose pull
`);
} catch (error) {
console.error('Release failed:', error);
process.exit(1);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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());
}

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 950 B

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

View file

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

View file

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

View file

@ -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"
}
]
}

View file

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

View file

@ -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>
`;
};

View file

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

View file

@ -1,9 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}

View file

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

View file

@ -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('; ');

View file

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

View file

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

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

View file

@ -0,0 +1,48 @@
package delivery
import (
"fmt"
"regexp"
"strings"
)
var phoneRegex = regexp.MustCompile(`(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}`)
var emailRegex = regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`)
func enrichActions(notif Notification) Notification {
if len(notif.Actions) > 0 {
return notif
}
seen := make(map[string]bool)
for _, num := range phoneRegex.FindAllString(notif.Message, -1) {
if seen[num] {
continue
}
seen[num] = true
notif.Actions = append(notif.Actions, Action{
ID: fmt.Sprintf("call-%s", num),
Label: fmt.Sprintf("Call %s", num),
Endpoint: fmt.Sprintf("tel:%s", num),
})
}
for _, addr := range emailRegex.FindAllString(notif.Message, -1) {
if seen[addr] {
continue
}
seen[addr] = true
local := addr
if i := strings.Index(addr, "@"); i != -1 {
local = addr[:i]
}
notif.Actions = append(notif.Actions, Action{
ID: fmt.Sprintf("email-%s", addr),
Label: fmt.Sprintf("Email %s", local),
Endpoint: fmt.Sprintf("mailto:%s", addr),
})
}
return notif
}

View file

@ -0,0 +1,24 @@
package delivery
import "errors"
type PermanentError struct {
Err error
}
func (e *PermanentError) Error() string {
return e.Err.Error()
}
func (e *PermanentError) Unwrap() error {
return e.Err
}
func NewPermanentError(err error) error {
return &PermanentError{Err: err}
}
func IsPermanent(err error) bool {
var permErr *PermanentError
return errors.As(err, &permErr)
}

View file

@ -0,0 +1,17 @@
package delivery
type Action struct {
ID string `json:"id"`
Label string `json:"label"`
Endpoint string `json:"endpoint"`
Method string `json:"method,omitempty"`
Data map[string]any `json:"data,omitempty"`
}
type Notification struct {
Title string `json:"title,omitempty"`
Message string `json:"message"`
Tag string `json:"tag,omitempty"`
Actions []Action `json:"actions,omitempty"`
ImageURL string `json:"image,omitempty"`
}

View file

@ -0,0 +1,127 @@
package delivery
import (
"log/slog"
"time"
"prism/service/subscription"
"prism/service/util"
)
type NotificationSender interface {
Send(sub *subscription.Subscription, notif Notification) error
}
type Publisher struct {
Store *subscription.Store
senders map[subscription.Channel]NotificationSender
autoSubscribeFn func(string) error
logger *slog.Logger
}
func NewPublisher(store *subscription.Store, logger *slog.Logger, autoSubscribeFn func(string) error) *Publisher {
return &Publisher{
Store: store,
senders: make(map[subscription.Channel]NotificationSender),
autoSubscribeFn: autoSubscribeFn,
logger: logger,
}
}
func (p *Publisher) RegisterSender(channel subscription.Channel, sender NotificationSender) {
p.senders[channel] = sender
}
func (p *Publisher) DeregisterSender(channel subscription.Channel) {
delete(p.senders, channel)
}
func (p *Publisher) HasChannel(channel subscription.Channel) bool {
_, ok := p.senders[channel]
return ok
}
func (p *Publisher) IsValidChannel(channel subscription.Channel) bool {
return p.HasChannel(channel)
}
func (p *Publisher) Publish(appName string, notif Notification) error {
notif = enrichActions(notif)
app, err := p.Store.GetApp(appName)
if err != nil {
return util.LogError(p.logger, "Failed to get app", err, "app", appName)
}
if app == nil || len(app.Subscriptions) == 0 {
if p.autoSubscribeFn != nil {
if err := p.autoSubscribeFn(appName); err != nil {
p.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err)
}
app, err = p.Store.GetApp(appName)
if err != nil {
return util.LogError(p.logger, "Failed to get app", err, "app", appName)
}
}
}
if app == nil || len(app.Subscriptions) == 0 {
p.logger.Warn("No subscriptions found for app, dropping notification", "app", appName)
return nil
}
var lastErr error
successCount := 0
for _, sub := range app.Subscriptions {
sender, ok := p.senders[sub.Channel]
if !ok {
p.logger.Debug("Skipping subscription for disabled channel", "channel", sub.Channel, "subscriptionID", sub.ID)
continue
}
if err := p.sendWithRetry(sender, &sub, notif, appName, sub.ID); err != nil {
lastErr = err
} else {
successCount++
}
}
if successCount == 0 && lastErr != nil {
return lastErr
}
return nil
}
func (p *Publisher) sendWithRetry(sender NotificationSender, sub *subscription.Subscription, notif Notification, appName, subscriptionID string) error {
maxRetries := 10
baseDelay := 500 * time.Millisecond
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
err := sender.Send(sub, notif)
if err == nil {
if attempt > 0 {
p.logger.Info("Notification sent after retry", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1)
}
return nil
}
lastErr = err
if IsPermanent(err) {
p.logger.Error("Permanent error, not retrying", "app", appName, "subscriptionID", subscriptionID, "error", err)
return err
}
if attempt < maxRetries-1 {
delay := baseDelay * time.Duration(1<<uint(attempt))
p.logger.Warn("Failed to send notification, retrying", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1, "error", err, "retryIn", delay)
time.Sleep(delay)
}
}
p.logger.Error("Failed to send notification after retries", "app", appName, "subscriptionID", subscriptionID, "attempts", maxRetries, "error", lastErr)
return lastErr
}

View file

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

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

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,47 @@
package signal
import (
"database/sql"
"fmt"
"prism/service/subscription"
)
type GroupCache struct {
db *sql.DB
}
func NewGroupCache(db *sql.DB) (*GroupCache, error) {
c := &GroupCache{db: db}
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS signal_groups (
appName TEXT PRIMARY KEY,
groupId TEXT NOT NULL,
account TEXT NOT NULL,
FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE
)`)
if err != nil {
return nil, fmt.Errorf("failed to create signal_groups table: %w", err)
}
return c, nil
}
func (c *GroupCache) Get(appName string) (*subscription.SignalSubscription, error) {
var groupID, account string
err := c.db.QueryRow(`SELECT groupId, account FROM signal_groups WHERE appName = ?`, appName).Scan(&groupID, &account)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &subscription.SignalSubscription{GroupID: groupID, Account: account}, nil
}
func (c *GroupCache) Save(appName string, sub *subscription.SignalSubscription) error {
_, err := c.db.Exec(
`INSERT INTO signal_groups (appName, groupId, account) VALUES (?, ?, ?)
ON CONFLICT(appName) DO UPDATE SET groupId=excluded.groupId, account=excluded.account`,
appName, sub.GroupID, sub.Account,
)
return err
}

View file

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Some files were not shown because too many files have changed in this diff Show more