mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
combine servers, fixing shit
This commit is contained in:
parent
ae9aa195fc
commit
28c23a0553
34 changed files with 3465 additions and 613 deletions
14
.env.example
14
.env.example
|
|
@ -5,3 +5,17 @@
|
|||
# Optional: Enable verbose signal-cli logging
|
||||
# Default: false
|
||||
# VERBOSE=true
|
||||
|
||||
# Optional: ProtonMail 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
|
||||
# BRIDGE_IMAP_USERNAME=your-email@proton.me
|
||||
# BRIDGE_IMAP_PASSWORD=bridge-generated-password
|
||||
# PROTON_BRIDGE_HOST=protonmail-bridge
|
||||
# PROTON_BRIDGE_PORT=143
|
||||
# SUP_TOPIC=Proton Mail
|
||||
# Optional: Enable Android app integration for ProtonMail notifications
|
||||
# When enabled, sends special format that SUP Android app intercepts to show custom notifications
|
||||
# that open ProtonMail app on click. When disabled, ProtonMail notifications appear as plain Signal messages.
|
||||
# Default: false
|
||||
# ENABLE_PROTON_ANDROID=true
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -2,16 +2,11 @@ node_modules/
|
|||
signal-cli
|
||||
.env
|
||||
sup-server
|
||||
sup-proton-bridge
|
||||
|
||||
android/.gradle
|
||||
android/.kotlin
|
||||
android/local.properties
|
||||
android/build/
|
||||
android/*/build/
|
||||
android/*.iml
|
||||
android/captures/
|
||||
android/.externalNativeBuild
|
||||
android/.cxx
|
||||
android/*.keystore
|
||||
android/*.jks
|
||||
android/.kotlin
|
||||
|
|
|
|||
64
README.md
64
README.md
|
|
@ -6,18 +6,6 @@ Privacy-preserving push notifications using Signal as transport.
|
|||
|
||||
SUP is a UnifiedPush distributor that routes push notifications through Signal, allowing you to receive app notifications without exposing unique network fingerprints to your ISP or network observers. All notification traffic appears as regular Signal messages.
|
||||
|
||||
## Architecture
|
||||
|
||||
SUP consists of three services that **MUST run together on the same machine**:
|
||||
|
||||
- **sup-server** (Bun/TypeScript): Receives webhooks, sends Signal messages via signal-cli
|
||||
- **protonmail-bridge** (Official Proton): Decrypts ProtonMail emails, runs local IMAP server
|
||||
- **sup-proton-bridge** (Custom): Monitors IMAP, forwards to sup-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.**
|
||||
|
||||
**Android App** (Kotlin): Monitors Signal notifications, extracts UnifiedPush payloads, delivers to apps
|
||||
|
||||
## Why?
|
||||
|
||||
Traditional push notification systems (ntfy, FCM) require persistent WebSocket connections or polling to specific servers, creating unique network fingerprints. SUP blends your notification traffic with regular Signal usage for better privacy.
|
||||
|
|
@ -26,24 +14,6 @@ Traditional push notification systems (ntfy, FCM) require persistent WebSocket c
|
|||
|
||||
**⚠️ DOCKER COMPOSE REQUIRED**: The services must be deployed together using `docker compose`. Running individual Dockerfiles separately is not supported and will compromise security.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
#### Installing Docker on Arch Linux
|
||||
|
||||
```bash
|
||||
# Install Docker and Compose plugin
|
||||
sudo pacman -S docker docker-buildx
|
||||
|
||||
# Start Docker service
|
||||
sudo systemctl start docker
|
||||
sudo systemctl enable docker
|
||||
|
||||
# Add your user to docker group (logout/login required)
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
After adding yourself to the docker group, **logout and login** for it to take effect.
|
||||
|
||||
### Quick Start with Docker Compose
|
||||
|
||||
**Without ProtonMail** (just UnifiedPush):
|
||||
|
|
@ -110,6 +80,17 @@ To receive ProtonMail notifications via Signal:
|
|||
|
||||
Your phone will now receive Signal notifications when ProtonMail receives new emails.
|
||||
|
||||
#### ProtonMail Android App Integration (Optional)
|
||||
|
||||
If you have the ProtonMail Android app installed, you can enable integration so that clicking on email notifications opens the ProtonMail app directly:
|
||||
|
||||
```bash
|
||||
# Add this to your .env file
|
||||
ENABLE_PROTON_ANDROID=true
|
||||
```
|
||||
|
||||
When enabled, the SUP Android app will intercept email notifications and show them as custom notifications that launch the ProtonMail app on click. When disabled, email notifications appear as regular Signal messages.
|
||||
|
||||
### Development
|
||||
|
||||
For local development, install Bun and signal-cli:
|
||||
|
|
@ -137,16 +118,6 @@ bun install
|
|||
bun --filter sup-server dev
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Android app registers with server via `/up/{app_id}`
|
||||
2. Server creates a Signal group for the app
|
||||
3. Server returns UnifiedPush endpoint URL
|
||||
4. App shares endpoint with notification provider
|
||||
5. Provider sends notifications to endpoint
|
||||
6. Server forwards to Signal group
|
||||
7. Android app monitors Signal, extracts payloads, wakes apps
|
||||
|
||||
## Android App
|
||||
|
||||
Download the latest APK from [GitHub Releases](https://github.com/lone-cloud/sup/releases).
|
||||
|
|
@ -158,3 +129,16 @@ Download the latest APK from [GitHub Releases](https://github.com/lone-cloud/sup
|
|||
```text
|
||||
0D:3C:99:15:0E:12:1A:DE:0D:AE:05:CB:16:46:5E:65:31:56:DC:D6:98:87:59:4E:79:B1:0D:AE:1E:56:F2:E8
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||
|
||||
SUP consists of two services that **MUST run together on the same machine**:
|
||||
|
||||
- **sup-server** (Bun): Receives webhooks, sends Signal messages via signal-cli. Optional: monitors ProtonMail IMAP
|
||||
- **protonmail-bridge** (Official Proton, optional): Decrypts ProtonMail 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.**
|
||||
|
||||
**Android App** (Kotlin): Monitors Signal notifications, extracts UnifiedPush payloads, delivers to apps
|
||||
|
|
|
|||
|
|
@ -28,11 +28,20 @@ class SignalNotificationListener : NotificationListenerService() {
|
|||
companion object {
|
||||
private const val CHANNEL_ID = "sup_notifications"
|
||||
private const val CHANNEL_NAME = "SUP Notifications"
|
||||
private const val CHANNEL_ID_PROTON = "sup_email"
|
||||
private const val CHANNEL_NAME_PROTON = "Email Notifications"
|
||||
private const val SUP_ENDPOINT_PREFIX = "[SUP:"
|
||||
private const val LAUNCH_PREFIX = "[LAUNCH:"
|
||||
|
||||
private val SIGNAL_PACKAGES = setOf(
|
||||
"org.thoughtcrime.securesms", // Signal
|
||||
"im.molly.app" // Molly
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
createNotificationChannels()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
|
@ -41,7 +50,7 @@ class SignalNotificationListener : NotificationListenerService() {
|
|||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||
if (sbn?.packageName != "org.thoughtcrime.securesms") return
|
||||
if (sbn?.packageName !in SIGNAL_PACKAGES) return
|
||||
|
||||
val notification = sbn.notification
|
||||
val extras = notification.extras
|
||||
|
|
@ -51,14 +60,21 @@ class SignalNotificationListener : NotificationListenerService() {
|
|||
|
||||
Log.d(TAG, "Signal notification: title=$title, text=$text")
|
||||
|
||||
if (text.startsWith("[UP:")) {
|
||||
when {
|
||||
text.startsWith(SUP_ENDPOINT_PREFIX) -> {
|
||||
parseAndDeliverUnifiedPush(text)
|
||||
cancelNotification(sbn.key)
|
||||
}
|
||||
text.startsWith(LAUNCH_PREFIX) -> {
|
||||
parseAndShowLaunchNotification(text)
|
||||
cancelNotification(sbn.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseAndDeliverUnifiedPush(message: String) {
|
||||
try {
|
||||
val endpointMatch = Regex("""\[UP:([^\]]+)\]""").find(message)
|
||||
val endpointMatch = Regex("""\[SUP:([^\]]+)\]""").find(message)
|
||||
val endpointId = endpointMatch?.groupValues?.get(1) ?: run {
|
||||
Log.w(TAG, "No endpoint ID found in message")
|
||||
return
|
||||
|
|
@ -74,9 +90,9 @@ class SignalNotificationListener : NotificationListenerService() {
|
|||
val payload = message.substringAfter("]").trim()
|
||||
|
||||
val intent = Intent("org.unifiedpush.android.connector.MESSAGE").apply {
|
||||
putExtra("token", subscription.upConnectorToken) // UnifiedPush connector token
|
||||
putExtra("token", subscription.upConnectorToken)
|
||||
putExtra("message", payload)
|
||||
`package` = subscription.upAppId // Target app package
|
||||
`package` = subscription.upAppId
|
||||
}
|
||||
sendBroadcast(intent)
|
||||
|
||||
|
|
@ -86,7 +102,70 @@ class SignalNotificationListener : NotificationListenerService() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
private fun parseAndShowLaunchNotification(message: String) {
|
||||
try {
|
||||
val packageMatch = Regex("""\[LAUNCH:([^\]]+)\]""").find(message)
|
||||
val packageName = packageMatch?.groupValues?.get(1) ?: run {
|
||||
Log.w(TAG, "No package name found in LAUNCH message")
|
||||
return
|
||||
}
|
||||
|
||||
val content = message.substringAfter("]").trim()
|
||||
|
||||
// Parse title and body (format: **Title**\nBody)
|
||||
val titleMatch = Regex("""\*\*([^*]+)\*\*""").find(content)
|
||||
val title = titleMatch?.groupValues?.get(1) ?: "Email"
|
||||
val body = content.substringAfter("**", "").substringAfter("**", "").trim()
|
||||
|
||||
// Check if target app is installed
|
||||
val isInstalled = try {
|
||||
packageManager.getPackageInfo(packageName, 0)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
val clickIntent = if (isInstalled) {
|
||||
packageManager.getLaunchIntentForPackage(packageName)?.let { intent ->
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
Random.nextInt(),
|
||||
intent.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID_PROTON)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_email)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
.apply {
|
||||
if (clickIntent != null) {
|
||||
setContentIntent(clickIntent)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.notify(Random.nextInt(), notification)
|
||||
|
||||
Log.d(TAG, "Showed app launch notification")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse/show app launch notification", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannels() {
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
|
|
@ -94,8 +173,15 @@ class SignalNotificationListener : NotificationListenerService() {
|
|||
).apply {
|
||||
description = "Notifications from SUP topics"
|
||||
}
|
||||
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
|
||||
val protonChannel = NotificationChannel(
|
||||
CHANNEL_ID_PROTON,
|
||||
CHANNEL_NAME_PROTON,
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = "Email notifications"
|
||||
}
|
||||
notificationManager.createNotificationChannel(protonChannel)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2901
assets/SUP Architecture.excalidraw
Normal file
2901
assets/SUP Architecture.excalidraw
Normal file
File diff suppressed because it is too large
Load diff
BIN
assets/SUP Architecture.webp
Normal file
BIN
assets/SUP Architecture.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
42
bun.lock
42
bun.lock
|
|
@ -10,24 +10,6 @@
|
|||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
"proton-bridge": {
|
||||
"name": "sup-proton-bridge",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"imap": "^0.8.19",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/imap": "^0.8.43",
|
||||
},
|
||||
},
|
||||
"server": {
|
||||
"name": "sup-server",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="],
|
||||
|
|
@ -50,36 +32,12 @@
|
|||
|
||||
"@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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"sup-proton-bridge": ["sup-proton-bridge@workspace:proton-bridge"],
|
||||
|
||||
"sup-server": ["sup-server@workspace:server"],
|
||||
|
||||
"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=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@ services:
|
|||
- PORT=8080
|
||||
- API_KEY=${API_KEY:-}
|
||||
- VERBOSE=${VERBOSE:-false}
|
||||
# ProtonMail integration (optional)
|
||||
- BRIDGE_IMAP_USERNAME=${BRIDGE_IMAP_USERNAME:-}
|
||||
- BRIDGE_IMAP_PASSWORD=${BRIDGE_IMAP_PASSWORD:-}
|
||||
- PROTON_BRIDGE_HOST=protonmail-bridge
|
||||
- SUP_TOPIC=${SUP_TOPIC:-Proton Mail}
|
||||
volumes:
|
||||
- signal-data:/root/.local/share/signal-cli
|
||||
- sup-data:/root/.local/share/sup
|
||||
restart: unless-stopped
|
||||
|
||||
protonmail-bridge:
|
||||
|
|
@ -19,24 +25,10 @@ services:
|
|||
profiles: ['protonmail']
|
||||
volumes:
|
||||
- proton-bridge-data:/root
|
||||
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro # Disable auto-updates
|
||||
restart: unless-stopped
|
||||
|
||||
sup-proton-bridge:
|
||||
build: ./proton-bridge
|
||||
container_name: sup-proton-bridge
|
||||
profiles: ['protonmail']
|
||||
depends_on:
|
||||
- sup-server
|
||||
- protonmail-bridge
|
||||
environment:
|
||||
- BRIDGE_IMAP_USERNAME=${BRIDGE_IMAP_USERNAME}
|
||||
- BRIDGE_IMAP_PASSWORD=${BRIDGE_IMAP_PASSWORD}
|
||||
- SUP_API_KEY=${API_KEY}
|
||||
- SUP_TOPIC=${SUP_TOPIC:-Proton Mail}
|
||||
- VERBOSE=${VERBOSE:-false}
|
||||
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
signal-data:
|
||||
sup-data:
|
||||
proton-bridge-data:
|
||||
|
|
|
|||
|
|
@ -7,8 +7,14 @@ services:
|
|||
- PORT=8080
|
||||
- API_KEY=${API_KEY:-}
|
||||
- VERBOSE=${VERBOSE:-false}
|
||||
# ProtonMail integration (optional)
|
||||
- BRIDGE_IMAP_USERNAME=${BRIDGE_IMAP_USERNAME:-}
|
||||
- BRIDGE_IMAP_PASSWORD=${BRIDGE_IMAP_PASSWORD:-}
|
||||
- PROTON_BRIDGE_HOST=protonmail-bridge
|
||||
- SUP_TOPIC=${SUP_TOPIC:-Proton Mail}
|
||||
volumes:
|
||||
- signal-data:/root/.local/share/signal-cli
|
||||
- sup-data:/root/.local/share/sup
|
||||
restart: unless-stopped
|
||||
|
||||
protonmail-bridge:
|
||||
|
|
@ -17,24 +23,10 @@ services:
|
|||
profiles: ['protonmail']
|
||||
volumes:
|
||||
- proton-bridge-data:/root
|
||||
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro # Disable auto-updates
|
||||
restart: unless-stopped
|
||||
|
||||
sup-proton-bridge:
|
||||
image: ghcr.io/lone-cloud/sup-proton-bridge:latest
|
||||
container_name: sup-proton-bridge
|
||||
profiles: ['protonmail']
|
||||
depends_on:
|
||||
- sup-server
|
||||
- protonmail-bridge
|
||||
environment:
|
||||
- BRIDGE_IMAP_USERNAME=${BRIDGE_IMAP_USERNAME}
|
||||
- BRIDGE_IMAP_PASSWORD=${BRIDGE_IMAP_PASSWORD}
|
||||
- SUP_API_KEY=${API_KEY}
|
||||
- SUP_TOPIC=${SUP_TOPIC:-Proton Mail}
|
||||
- VERBOSE=${VERBOSE:-false}
|
||||
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
signal-data:
|
||||
sup-data:
|
||||
proton-bridge-data:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "sup-monorepo",
|
||||
"name": "sup",
|
||||
"version": "0.1.0",
|
||||
"description": "Privacy-preserving push notifications using Signal as transport",
|
||||
"private": true,
|
||||
|
|
@ -13,10 +13,6 @@
|
|||
"type": "git",
|
||||
"url": "https://github.com/lone-cloud/sup"
|
||||
},
|
||||
"workspaces": [
|
||||
"server",
|
||||
"proton-bridge"
|
||||
],
|
||||
"scripts": {
|
||||
"check": "tsc --noEmit && biome check .",
|
||||
"fix": "biome check --write .",
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
# ProtonMail Bridge Configuration
|
||||
PROTON_BRIDGE_HOST=localhost
|
||||
PROTON_BRIDGE_PORT=1143
|
||||
PROTON_EMAIL=your-email@protonmail.com
|
||||
PROTON_PASSWORD=bridge-generated-password
|
||||
|
||||
# SUP Server Configuration
|
||||
SUP_SERVER_URL=http://localhost:8080
|
||||
SUP_API_KEY=your-api-key
|
||||
SUP_TOPIC=Proton Mail
|
||||
4
proton-bridge/.gitignore
vendored
4
proton-bridge/.gitignore
vendored
|
|
@ -1,4 +0,0 @@
|
|||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
dist/
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
FROM oven/bun:1.1.42-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN bun build --compile src/index.ts --outfile sup-proton-bridge
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
COPY --from=builder /app/sup-proton-bridge /usr/local/bin/sup-proton-bridge
|
||||
|
||||
CMD ["sup-proton-bridge"]
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
# SUP ProtonMail Bridge
|
||||
|
||||
IMAP bridge that monitors a ProtonMail account via Proton Bridge and sends notifications to a SUP server.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ProtonMail (E2EE) → Proton Bridge (IMAP) → This Bridge → SUP Server → Signal → Phone
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Proton Bridge** installed and running locally
|
||||
- Download from: https://proton.me/mail/bridge
|
||||
- Set up your ProtonMail account
|
||||
- Note the generated IMAP password (not your ProtonMail password!)
|
||||
|
||||
2. **SUP Server** running
|
||||
- See main README for SUP server setup
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Copy `.env.example` to `.env`:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Configure your settings:
|
||||
|
||||
```env
|
||||
PROTON_EMAIL=your-email@protonmail.com
|
||||
PROTON_PASSWORD=bridge-generated-password # From Proton Bridge settings
|
||||
SUP_SERVER_URL=http://localhost:8080
|
||||
SUP_API_KEY=your-api-key # Optional
|
||||
SUP_TOPIC=protonmail
|
||||
```
|
||||
|
||||
## Running Locally
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Run in development mode (auto-reload)
|
||||
bun dev
|
||||
|
||||
# Run in production mode
|
||||
bun start
|
||||
```
|
||||
|
||||
## Running with Docker
|
||||
|
||||
```bash
|
||||
# Build
|
||||
docker build -t sup-proton-bridge .
|
||||
|
||||
# Run
|
||||
docker run -d \
|
||||
--name sup-proton-bridge \
|
||||
--env-file .env \
|
||||
--network host \
|
||||
sup-proton-bridge
|
||||
```
|
||||
|
||||
## Running with Docker Compose
|
||||
|
||||
See the main `docker-compose.yml` in the repo root.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Connects to Proton Bridge via IMAP (localhost:1143)
|
||||
2. Opens INBOX and enters IDLE mode
|
||||
3. When new mail arrives, fetches sender and subject
|
||||
4. Sends notification to SUP server at `/notify/protonmail`
|
||||
5. SUP delivers notification via Signal to your phone
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Proton Bridge runs locally and handles E2EE decryption
|
||||
- This bridge only accesses the already-decrypted IMAP interface
|
||||
- No credentials are sent to SUP server
|
||||
- Only email metadata (sender, subject) is transmitted
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"name": "sup-proton-bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "ProtonMail IMAP bridge for SUP notifications via Signal",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/index.ts",
|
||||
"start": "bun src/index.ts",
|
||||
"build": "bun build --compile src/index.ts --outfile sup-proton-bridge"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"imap": "^0.8.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/imap": "^0.8.43"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import chalk from 'chalk';
|
||||
import Imap from 'imap';
|
||||
|
||||
const PROTON_BRIDGE_HOST = process.env.PROTON_BRIDGE_HOST || 'protonmail-bridge';
|
||||
const PROTON_BRIDGE_PORT = Number.parseInt(process.env.PROTON_BRIDGE_PORT || '143', 10);
|
||||
const BRIDGE_IMAP_USERNAME = process.env.BRIDGE_IMAP_USERNAME;
|
||||
const BRIDGE_IMAP_PASSWORD = process.env.BRIDGE_IMAP_PASSWORD; // Generated by Proton Bridge
|
||||
|
||||
const SUP_SERVER_URL = process.env.SUP_SERVER_URL || 'http://sup-server:8080';
|
||||
const SUP_API_KEY = process.env.SUP_API_KEY;
|
||||
const SUP_TOPIC = process.env.SUP_TOPIC || 'Proton Mail';
|
||||
const VERBOSE = process.env.VERBOSE === 'true';
|
||||
|
||||
const log = (...args: unknown[]) => VERBOSE && console.log(...args);
|
||||
|
||||
if (!BRIDGE_IMAP_USERNAME || !BRIDGE_IMAP_PASSWORD) {
|
||||
console.error(
|
||||
chalk.red('Missing required env vars: BRIDGE_IMAP_USERNAME and BRIDGE_IMAP_PASSWORD'),
|
||||
);
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
'Run: docker run --rm -it -v proton-bridge-data:/root shenxn/protonmail-bridge init',
|
||||
),
|
||||
);
|
||||
console.error(chalk.yellow('Then use `login` and `info` commands to get IMAP credentials'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.blue(`🔗 Connecting to Proton Bridge at ${PROTON_BRIDGE_HOST}:${PROTON_BRIDGE_PORT}`),
|
||||
);
|
||||
console.log(chalk.blue(`📨 Monitoring mailbox: ${BRIDGE_IMAP_USERNAME}`));
|
||||
console.log(chalk.blue(`🔔 Sending notifications to: ${SUP_SERVER_URL}/notify/${SUP_TOPIC}`));
|
||||
|
||||
const imap = new Imap({
|
||||
user: BRIDGE_IMAP_USERNAME,
|
||||
password: BRIDGE_IMAP_PASSWORD,
|
||||
host: PROTON_BRIDGE_HOST,
|
||||
port: PROTON_BRIDGE_PORT,
|
||||
tls: true,
|
||||
tlsOptions: { rejectUnauthorized: false }, // Proton Bridge uses self-signed cert
|
||||
keepalive: true,
|
||||
});
|
||||
|
||||
async function sendNotification(title: string, message: string) {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (SUP_API_KEY) {
|
||||
headers.Authorization = `Bearer ${SUP_API_KEY}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${SUP_SERVER_URL}/notify/${SUP_TOPIC}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ title, message }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
new Error(`SUP server responded with ${response.status}`);
|
||||
}
|
||||
|
||||
console.log(chalk.green(`✅ Notification sent: ${title}`));
|
||||
} catch (error) {
|
||||
console.error(chalk.red('❌ Failed to send notification:'), error);
|
||||
}
|
||||
}
|
||||
|
||||
function openInbox() {
|
||||
imap.openBox('INBOX', false, (err, box) => {
|
||||
if (err) {
|
||||
console.error(chalk.red('Failed to open inbox:'), err);
|
||||
}
|
||||
|
||||
log(`✅ Connected to inbox (${box.messages.total} messages)`);
|
||||
|
||||
imap.on('mail', async (numNewMsgs: number) => {
|
||||
log(`📬 ${numNewMsgs} new message(s) received`);
|
||||
|
||||
const fetch = imap.seq.fetch(`${box.messages.total}:*`, {
|
||||
bodies: 'HEADER.FIELDS (FROM SUBJECT)',
|
||||
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 from = header.from?.[0] || 'Unknown sender';
|
||||
const subject = header.subject?.[0] || 'No subject';
|
||||
|
||||
sendNotification('New ProtonMail', `From: ${from}\n${subject}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
imap.on('update', () => {
|
||||
log('📊 Mailbox updated');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
imap.once('ready', () => {
|
||||
log('✅ IMAP connection ready');
|
||||
openInbox();
|
||||
});
|
||||
|
||||
imap.once('error', (err: Error) => {
|
||||
console.error(chalk.red('❌ IMAP error:'), err);
|
||||
});
|
||||
|
||||
imap.once('end', () => {
|
||||
log('⚠️ IMAP connection ended, reconnecting...');
|
||||
setTimeout(() => imap.connect(), 5000);
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
log('Shutting down...');
|
||||
imap.end();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
log('Shutting down...');
|
||||
imap.end();
|
||||
process.exit(0);
|
||||
});
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
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`;
|
||||
|
|
@ -14,23 +16,15 @@ async function installSignalCli() {
|
|||
throw new Error(`Failed to download: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const tarball = await response.arrayBuffer();
|
||||
|
||||
console.log('Extracting signal-cli...');
|
||||
|
||||
const proc = Bun.spawn(['tar', 'xzf', '-'], {
|
||||
stdin: 'pipe',
|
||||
cwd: `${import.meta.dir}/..`,
|
||||
});
|
||||
|
||||
proc.stdin.write(new Uint8Array(tarball));
|
||||
proc.stdin.end();
|
||||
await proc.exited;
|
||||
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 Bun.spawn(['mv', extractedDir, SIGNAL_CLI_DIR]).exited;
|
||||
await rename(extractedDir, SIGNAL_CLI_DIR);
|
||||
|
||||
await Bun.spawn(['chmod', '+x', `${SIGNAL_CLI_DIR}/bin/signal-cli`]).exited;
|
||||
await chmod(`${SIGNAL_CLI_DIR}/bin/signal-cli`, 0o755);
|
||||
|
||||
console.log('✓ signal-cli installed successfully');
|
||||
}
|
||||
|
|
|
|||
41
server/bun.lock
Normal file
41
server/bun.lock
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "sup-server",
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"imap": "^0.8.19",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/imap": "^0.8.43",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/imap": ["@types/imap@0.8.43", "", { "dependencies": { "@types/node": "*" } }, "sha512-POPoqrDax9mxM2N4ITZYCWaFtg1ORVfzJe4S7xwSh9aHawdEb7FwWTJYiAhzIvWp7DM+6BajnzYOwZ1BUrqtow=="],
|
||||
|
||||
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"utf7": ["utf7@1.0.2", "", { "dependencies": { "semver": "~5.3.0" } }, "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw=="],
|
||||
}
|
||||
}
|
||||
17
server/constants/config.ts
Normal file
17
server/constants/config.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export const PORT = Bun.env.PORT || 8080;
|
||||
export const API_KEY = Bun.env.API_KEY;
|
||||
export const VERBOSE = Bun.env.VERBOSE === 'true';
|
||||
|
||||
export const DEVICE_NAME = 'SUP';
|
||||
export const DAEMON_START_MAX_ATTEMPTS = 10;
|
||||
|
||||
export const SUP_ENDPOINT_PREFIX = `[${DEVICE_NAME}:`;
|
||||
export const LAUNCH_ENDPOINT_PREFIX = '[LAUNCH:';
|
||||
|
||||
// ProtonMail Integration
|
||||
export const BRIDGE_IMAP_USERNAME = Bun.env.BRIDGE_IMAP_USERNAME;
|
||||
export const BRIDGE_IMAP_PASSWORD = Bun.env.BRIDGE_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 SUP_TOPIC = Bun.env.SUP_TOPIC || 'Proton Mail';
|
||||
export const ENABLE_PROTON_ANDROID = Bun.env.ENABLE_PROTON_ANDROID === 'true';
|
||||
9
server/constants/paths.ts
Normal file
9
server/constants/paths.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
const HOME = process.env.HOME || '/root';
|
||||
|
||||
export const SIGNAL_CLI_BIN = '../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_DATA_DIR = `${HOME}/.local/share/sup`;
|
||||
export const SUP_DB = `${HOME}/.local/share/sup/store.db`;
|
||||
|
|
@ -7,11 +7,10 @@ export const ROUTES = {
|
|||
FAVICON: '/favicon.png',
|
||||
MATRIX_NOTIFY: '/_matrix/push/v1/notify',
|
||||
UP: '/up',
|
||||
UP_PREFIX: '/up/',
|
||||
UP_INSTANCE: '/up/:instance',
|
||||
ENDPOINTS: '/endpoints',
|
||||
NOTIFY_PREFIX: '/notify/',
|
||||
NOTIFY_TOPIC: '/notify/:topic',
|
||||
TOPICS: '/topics',
|
||||
NOTIFICATIONS: '/notifications',
|
||||
} as const;
|
||||
|
||||
export const CONTENT_TYPE = {
|
||||
|
|
@ -21,7 +20,7 @@ export const CONTENT_TYPE = {
|
|||
} as const;
|
||||
|
||||
export const TEMPLATES = {
|
||||
LINKED: 'server/templates/linked.html',
|
||||
LINK: 'server/templates/link.html',
|
||||
SETUP: 'server/templates/setup.html',
|
||||
LINKED: 'templates/linked.html',
|
||||
LINK: 'templates/link.html',
|
||||
SETUP: 'templates/setup.html',
|
||||
} as const;
|
||||
|
|
|
|||
126
server/index.ts
126
server/index.ts
|
|
@ -1,8 +1,10 @@
|
|||
import chalk from 'chalk';
|
||||
import { API_KEY, BRIDGE_IMAP_PASSWORD, BRIDGE_IMAP_USERNAME, PORT } from './constants/config';
|
||||
import { CONTENT_TYPE, ROUTES, TEMPLATES } from './constants/server';
|
||||
import { checkSignalCli, hasValidAccount, initSignal, startDaemon } from './modules/signal';
|
||||
import { handleHealth } from './routes/health';
|
||||
import { handleLink, handleLinkQR, handleLinkStatus, handleUnlink } from './routes/link';
|
||||
import { handleGetNotifications, handleNotify, handleTopics } from './routes/notify';
|
||||
import { handleNotify, handleTopics } from './routes/notify';
|
||||
import {
|
||||
handleDiscovery,
|
||||
handleEndpoints,
|
||||
|
|
@ -10,10 +12,7 @@ import {
|
|||
handleRegister,
|
||||
handleUnregister,
|
||||
} from './routes/unifiedpush';
|
||||
import { checkSignalCli, hasValidAccount, initSignal, startDaemon } from './signal';
|
||||
|
||||
const PORT = Bun.env.PORT || 8080;
|
||||
const API_KEY = Bun.env.API_KEY;
|
||||
import { checkAuth } from './utils/auth';
|
||||
|
||||
let daemon: ReturnType<typeof Bun.spawn> | null = null;
|
||||
|
||||
|
|
@ -30,75 +29,96 @@ if (hasAccount) {
|
|||
}
|
||||
|
||||
if (!API_KEY) {
|
||||
console.warn(chalk.yellow('⚠️ Server running without API_KEY - anyone can register endpoints!'));
|
||||
console.warn(chalk.yellow('⚠️ Server running without API_KEY'));
|
||||
console.warn(chalk.dim(' Set API_KEY env var for production deployments.'));
|
||||
}
|
||||
|
||||
const requireHttps = (req: Request) => {
|
||||
const proto = req.headers.get('x-forwarded-proto') || 'http';
|
||||
const host = req.headers.get('host') || '';
|
||||
const isLocalhost = host.startsWith('localhost') || host.startsWith('127.0.0.1');
|
||||
|
||||
if (API_KEY && proto !== 'https' && !isLocalhost) {
|
||||
return new Response('HTTPS required when API_KEY is configured', { status: 403 });
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
if (BRIDGE_IMAP_USERNAME && BRIDGE_IMAP_PASSWORD) {
|
||||
const { startProtonMonitor } = await import('./modules/protonmail');
|
||||
await startProtonMonitor();
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port: PORT,
|
||||
idleTimeout: 60,
|
||||
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
routes: {
|
||||
[ROUTES.FAVICON]: Bun.file('assets/favicon.png'),
|
||||
|
||||
if (url.pathname === ROUTES.FAVICON) {
|
||||
const file = Bun.file('server/assets/favicon.png');
|
||||
return new Response(file, { headers: { 'content-type': 'image/png' } });
|
||||
}
|
||||
[ROUTES.HEALTH]: handleHealth,
|
||||
|
||||
if (url.pathname === ROUTES.HEALTH) return handleHealth();
|
||||
if (url.pathname === ROUTES.LINK) return handleLink();
|
||||
if (url.pathname === ROUTES.LINK_QR) return handleLinkQR();
|
||||
if (url.pathname === ROUTES.LINK_STATUS) return handleLinkStatus();
|
||||
[ROUTES.LINK]: {
|
||||
GET: handleLink,
|
||||
},
|
||||
|
||||
if (!(await hasValidAccount())) {
|
||||
const html = await Bun.file(TEMPLATES.SETUP).text();
|
||||
return new Response(html, { headers: { 'content-type': CONTENT_TYPE.HTML } });
|
||||
}
|
||||
[ROUTES.LINK_QR]: {
|
||||
GET: handleLinkQR,
|
||||
},
|
||||
|
||||
if (url.pathname === ROUTES.MATRIX_NOTIFY && req.method === 'POST') {
|
||||
return handleMatrixNotify(req);
|
||||
}
|
||||
[ROUTES.LINK_STATUS]: {
|
||||
GET: handleLinkStatus,
|
||||
},
|
||||
|
||||
const httpsCheck = requireHttps(req);
|
||||
if (httpsCheck) return httpsCheck;
|
||||
|
||||
if (url.pathname === ROUTES.LINK_UNLINK && req.method === 'POST') {
|
||||
[ROUTES.LINK_UNLINK]: {
|
||||
POST: async (req) => {
|
||||
const response = await handleUnlink(req, daemon);
|
||||
|
||||
if (response.status === 303) {
|
||||
daemon = await startDaemon();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
if (url.pathname.startsWith(ROUTES.UP_PREFIX)) {
|
||||
if (req.method === 'POST') return handleRegister(req, url);
|
||||
if (req.method === 'DELETE') return handleUnregister(url);
|
||||
}
|
||||
[ROUTES.UP]: {
|
||||
GET: handleDiscovery,
|
||||
},
|
||||
|
||||
if (url.pathname === ROUTES.UP && req.method === 'GET') return handleDiscovery();
|
||||
if (url.pathname === ROUTES.ENDPOINTS && req.method === 'GET') return handleEndpoints();
|
||||
if (url.pathname === ROUTES.TOPICS && req.method === 'GET') return handleTopics();
|
||||
if (url.pathname === ROUTES.NOTIFICATIONS && req.method === 'GET') {
|
||||
return handleGetNotifications(req, url);
|
||||
}
|
||||
[ROUTES.ENDPOINTS]: {
|
||||
GET: (req) => {
|
||||
const auth = checkAuth(req);
|
||||
if (auth) return auth;
|
||||
return handleEndpoints();
|
||||
},
|
||||
},
|
||||
|
||||
if (url.pathname.startsWith(ROUTES.NOTIFY_PREFIX) && req.method === 'POST') {
|
||||
return handleNotify(req, url);
|
||||
[ROUTES.TOPICS]: {
|
||||
GET: (req) => {
|
||||
const auth = checkAuth(req);
|
||||
if (auth) return auth;
|
||||
return handleTopics();
|
||||
},
|
||||
},
|
||||
|
||||
[ROUTES.MATRIX_NOTIFY]: {
|
||||
POST: handleMatrixNotify,
|
||||
},
|
||||
|
||||
[ROUTES.UP_INSTANCE]: {
|
||||
POST: (req) => {
|
||||
const auth = checkAuth(req);
|
||||
if (auth) return auth;
|
||||
return handleRegister(req, new URL(req.url));
|
||||
},
|
||||
DELETE: (req) => {
|
||||
const auth = checkAuth(req);
|
||||
if (auth) return auth;
|
||||
return handleUnregister(new URL(req.url));
|
||||
},
|
||||
},
|
||||
|
||||
[ROUTES.NOTIFY_TOPIC]: {
|
||||
POST: (req) => {
|
||||
const auth = checkAuth(req);
|
||||
if (auth) return auth;
|
||||
return handleNotify(req, new URL(req.url));
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async fetch(_req) {
|
||||
if (!(await hasValidAccount())) {
|
||||
const html = await Bun.file(TEMPLATES.SETUP).text();
|
||||
return new Response(html, { headers: { 'content-type': CONTENT_TYPE.HTML } });
|
||||
}
|
||||
|
||||
return new Response(null, { status: 404 });
|
||||
|
|
|
|||
126
server/modules/protonmail.ts
Normal file
126
server/modules/protonmail.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import chalk from 'chalk';
|
||||
import Imap from 'imap';
|
||||
import {
|
||||
BRIDGE_IMAP_PASSWORD,
|
||||
BRIDGE_IMAP_USERNAME,
|
||||
ENABLE_PROTON_ANDROID,
|
||||
LAUNCH_ENDPOINT_PREFIX,
|
||||
PROTON_BRIDGE_HOST,
|
||||
PROTON_BRIDGE_PORT,
|
||||
SUP_TOPIC,
|
||||
VERBOSE,
|
||||
} from '../constants/config';
|
||||
import { createGroup, sendGroupMessage } from './signal';
|
||||
import { getGroupId, register } from './store';
|
||||
|
||||
const log = (...args: unknown[]) => VERBOSE && console.log(...args);
|
||||
|
||||
export async function startProtonMonitor() {
|
||||
if (!BRIDGE_IMAP_USERNAME || !BRIDGE_IMAP_PASSWORD) {
|
||||
console.error(
|
||||
chalk.red('Missing required env vars: BRIDGE_IMAP_USERNAME and BRIDGE_IMAP_PASSWORD'),
|
||||
);
|
||||
console.error(chalk.yellow('Run: docker compose run --rm protonmail-bridge init'));
|
||||
console.error(chalk.yellow('Then use `login` and `info` commands to get IMAP credentials'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.blue(`🔗 Connecting to Proton Bridge at ${PROTON_BRIDGE_HOST}:${PROTON_BRIDGE_PORT}`),
|
||||
);
|
||||
console.log(chalk.blue(`📨 Monitoring mailbox: ${BRIDGE_IMAP_USERNAME}`));
|
||||
|
||||
const imap = new Imap({
|
||||
user: BRIDGE_IMAP_USERNAME,
|
||||
password: BRIDGE_IMAP_PASSWORD,
|
||||
host: PROTON_BRIDGE_HOST,
|
||||
port: PROTON_BRIDGE_PORT,
|
||||
tls: true,
|
||||
tlsOptions: { rejectUnauthorized: false },
|
||||
keepalive: true,
|
||||
});
|
||||
|
||||
async function sendNotification(title: string, message: string) {
|
||||
try {
|
||||
const topicKey = `proton-${SUP_TOPIC}`;
|
||||
const groupId = getGroupId(topicKey) ?? (await createGroup(SUP_TOPIC));
|
||||
|
||||
if (!getGroupId(topicKey)) {
|
||||
register(topicKey, groupId, SUP_TOPIC);
|
||||
}
|
||||
|
||||
const prefix = ENABLE_PROTON_ANDROID
|
||||
? `${LAUNCH_ENDPOINT_PREFIX}ch.protonmail.android]\n`
|
||||
: '';
|
||||
await sendGroupMessage(groupId, `${prefix}**${title}**\n${message}`);
|
||||
|
||||
console.log(chalk.green(`✅ Notification sent: ${title}`));
|
||||
} catch (error) {
|
||||
console.error(chalk.red('❌ Failed to send notification:'), error);
|
||||
}
|
||||
}
|
||||
|
||||
function openInbox() {
|
||||
imap.openBox('INBOX', false, (err, box) => {
|
||||
if (err) {
|
||||
console.error(chalk.red('Failed to open inbox:'), err);
|
||||
return;
|
||||
}
|
||||
|
||||
log(`✅ Connected to inbox (${box.messages.total} messages)`);
|
||||
|
||||
imap.on('mail', async (numNewMsgs: number) => {
|
||||
log(`📬 ${numNewMsgs} new message(s) received`);
|
||||
|
||||
const fetch = imap.seq.fetch(`${box.messages.total}:*`, {
|
||||
bodies: 'HEADER.FIELDS (FROM SUBJECT)',
|
||||
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 from = header.from?.[0] || 'Unknown sender';
|
||||
const subject = header.subject?.[0] || 'No subject';
|
||||
|
||||
sendNotification(`New Mail from ${from}`, subject);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
imap.on('update', () => {
|
||||
log('📊 Mailbox updated');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
imap.once('ready', () => {
|
||||
log('✅ IMAP connection ready');
|
||||
openInbox();
|
||||
});
|
||||
|
||||
imap.once('error', (err: Error) => {
|
||||
console.error(chalk.red('❌ IMAP error:'), err);
|
||||
});
|
||||
|
||||
imap.once('end', () => {
|
||||
log('⚠️ IMAP connection ended, reconnecting...');
|
||||
setTimeout(() => imap.connect(), 5000);
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
imap.end();
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
imap.end();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { rm } from 'node:fs/promises';
|
||||
import chalk from 'chalk';
|
||||
import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from './types/signal';
|
||||
import { DAEMON_START_MAX_ATTEMPTS, DEVICE_NAME, VERBOSE } from '../constants/config';
|
||||
import { SIGNAL_CLI_BIN, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '../constants/paths';
|
||||
import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '../types';
|
||||
|
||||
const SIGNAL_CLI_PATH = 'signal-cli/bin/signal-cli';
|
||||
const SOCKET_PATH = '/tmp/signal-cli.sock';
|
||||
// Local development uses relative path, Docker uses PATH
|
||||
const SIGNAL_CLI_PATH = (await Bun.file(SIGNAL_CLI_BIN).exists()) ? SIGNAL_CLI_BIN : 'signal-cli';
|
||||
const MESSAGE_DELIMITER = '\n';
|
||||
const DEVICE_NAME = 'SUP';
|
||||
let account: string | null = null;
|
||||
let currentLinkUri: string | null = null;
|
||||
let rpcId = 1;
|
||||
|
|
@ -30,7 +32,7 @@ async function rpcCall(method: string, params: Record<string, unknown>) {
|
|||
let response = '';
|
||||
|
||||
Bun.connect({
|
||||
unix: SOCKET_PATH,
|
||||
unix: SIGNAL_CLI_SOCKET,
|
||||
socket: {
|
||||
data(socket, data) {
|
||||
response += new TextDecoder().decode(data);
|
||||
|
|
@ -73,7 +75,9 @@ async function rpcCall(method: string, params: Record<string, unknown>) {
|
|||
}
|
||||
|
||||
export async function generateLinkQR() {
|
||||
const result = (await rpcCall('startLink', { deviceName: DEVICE_NAME })) as StartLinkResult;
|
||||
const result = (await rpcCall('startLink', {
|
||||
deviceName: DEVICE_NAME,
|
||||
})) as StartLinkResult;
|
||||
const uri = result.deviceLinkUri;
|
||||
|
||||
if (!uri) {
|
||||
|
|
@ -101,9 +105,8 @@ export async function unlinkDevice() {
|
|||
account = null;
|
||||
currentLinkUri = null;
|
||||
|
||||
const dataPath = `${process.env.HOME}/.local/share/signal-cli/data`;
|
||||
try {
|
||||
await Bun.spawn(['rm', '-rf', dataPath], { stdout: 'pipe' });
|
||||
await rm(SIGNAL_CLI_DATA, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
|
@ -146,13 +149,14 @@ export async function hasValidAccount() {
|
|||
}
|
||||
|
||||
export async function startDaemon() {
|
||||
const proc = Bun.spawn([SIGNAL_CLI_PATH, 'daemon', '--socket', SOCKET_PATH], {
|
||||
let authError = false;
|
||||
let cleaned = false;
|
||||
|
||||
const proc = Bun.spawn([SIGNAL_CLI_PATH, 'daemon', '--socket', SIGNAL_CLI_SOCKET], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
const verbose = Bun.env.VERBOSE === 'true';
|
||||
|
||||
(async () => {
|
||||
for await (const chunk of proc.stderr) {
|
||||
const text = new TextDecoder().decode(chunk);
|
||||
|
|
@ -160,9 +164,13 @@ export async function startDaemon() {
|
|||
|
||||
if (!trimmed) continue;
|
||||
|
||||
if (trimmed.includes('Authorization failed') || trimmed.includes('AccountCheckException')) {
|
||||
authError = true;
|
||||
}
|
||||
|
||||
if (trimmed.includes('ConcurrentModificationException')) continue;
|
||||
|
||||
if (verbose) {
|
||||
if (VERBOSE) {
|
||||
if (trimmed.includes('ERROR')) {
|
||||
console.error(chalk.red('[signal-cli]'), trimmed);
|
||||
} else if (trimmed.includes('WARN')) {
|
||||
|
|
@ -182,10 +190,10 @@ export async function startDaemon() {
|
|||
})();
|
||||
|
||||
let attempts = 0;
|
||||
while (attempts < 30) {
|
||||
while (attempts < DAEMON_START_MAX_ATTEMPTS) {
|
||||
try {
|
||||
const socket = await Bun.connect({
|
||||
unix: SOCKET_PATH,
|
||||
unix: SIGNAL_CLI_SOCKET,
|
||||
socket: {
|
||||
data() {},
|
||||
error() {},
|
||||
|
|
@ -195,6 +203,14 @@ export async function startDaemon() {
|
|||
console.log(chalk.green('✓ signal-cli daemon started'));
|
||||
return proc;
|
||||
} catch (_error) {
|
||||
if (authError && attempts > 5 && !cleaned) {
|
||||
console.log(chalk.yellow('⚠ Detected stale account data, cleaning up and retrying...'));
|
||||
proc.kill();
|
||||
await unlinkDevice();
|
||||
cleaned = true;
|
||||
return startDaemon();
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
50
server/modules/store.ts
Normal file
50
server/modules/store.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Database } from 'bun:sqlite';
|
||||
import { SUP_DATA_DIR, SUP_DB } from '../constants/paths';
|
||||
|
||||
interface EndpointMapping {
|
||||
endpoint: string;
|
||||
groupId: string;
|
||||
appName: string;
|
||||
}
|
||||
|
||||
await Bun.write(`${SUP_DATA_DIR}/.keep`, '');
|
||||
|
||||
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 = () => {
|
||||
return db.query('SELECT endpoint, groupId, appName FROM mappings').all() as EndpointMapping[];
|
||||
};
|
||||
|
||||
export const remove = (endpoint: string) => {
|
||||
db.run('DELETE FROM mappings WHERE endpoint = ?', [endpoint]);
|
||||
};
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { SUP_ENDPOINT_PREFIX } from '../constants/config';
|
||||
|
||||
export interface UnifiedPushMessage {
|
||||
endpoint: string;
|
||||
title?: string;
|
||||
|
|
@ -5,8 +7,6 @@ export interface UnifiedPushMessage {
|
|||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const formatUpPrefix = (endpoint: string) => `[UP:${endpoint}]`;
|
||||
|
||||
export const parseUnifiedPushRequest = async (req: Request) => {
|
||||
const url = new URL(req.url);
|
||||
const endpointId = url.pathname.split('/').pop() ?? '';
|
||||
|
|
@ -36,9 +36,7 @@ export const parseUnifiedPushRequest = async (req: Request) => {
|
|||
};
|
||||
|
||||
export const formatAsSignalMessage = (msg: UnifiedPushMessage) => {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push(formatUpPrefix(msg.endpoint));
|
||||
const parts: string[] = [`${SUP_ENDPOINT_PREFIX}${msg.endpoint}]`];
|
||||
|
||||
if (msg.title) {
|
||||
parts.push(`**${msg.title}**`);
|
||||
|
|
@ -52,5 +50,5 @@ export const formatAsSignalMessage = (msg: UnifiedPushMessage) => {
|
|||
parts.push(JSON.stringify(msg.data, null, 2));
|
||||
}
|
||||
|
||||
return parts.join('\n\n') || `${formatUpPrefix(msg.endpoint)}\nEmpty notification`;
|
||||
return parts.join('\n');
|
||||
};
|
||||
|
|
@ -7,13 +7,18 @@
|
|||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2"
|
||||
"chalk": "^5.6.2",
|
||||
"imap": "^0.8.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/imap": "^0.8.43"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "bun run ../scripts/install-signal-cli.ts || true",
|
||||
"dev": "bun --watch index.ts",
|
||||
"start": "bun run index.ts",
|
||||
"build": "bun build --compile index.ts --outfile sup-server",
|
||||
"check": "tsc --noEmit"
|
||||
"check": "tsc --noEmit && biome check .",
|
||||
"fix": "biome check --write ."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { checkSignalCli, hasValidAccount } from '../signal';
|
||||
import { checkSignalCli, hasValidAccount } from '../modules/signal';
|
||||
|
||||
export const handleHealth = async () => {
|
||||
const signalOk = await checkSignalCli();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
import { API_KEY } from '../constants/config';
|
||||
import { CONTENT_TYPE, ROUTES, TEMPLATES } from '../constants/server';
|
||||
import { finishLink, generateLinkQR, hasValidAccount, initSignal, unlinkDevice } from '../signal';
|
||||
import {
|
||||
finishLink,
|
||||
generateLinkQR,
|
||||
hasValidAccount,
|
||||
initSignal,
|
||||
unlinkDevice,
|
||||
} from '../modules/signal';
|
||||
|
||||
export const handleLink = async () => {
|
||||
const linked = await hasValidAccount();
|
||||
const template = linked ? TEMPLATES.LINKED : TEMPLATES.LINK;
|
||||
let html = await Bun.file(template).text();
|
||||
|
||||
if (linked && Bun.env.API_KEY) {
|
||||
if (linked && API_KEY) {
|
||||
const passwordField =
|
||||
'<input type="password" name="password" placeholder="Enter API_KEY" required style="padding: 8px; margin-right: 10px; border: 1px solid #ccc; border-radius: 4px;" />';
|
||||
html = html.replace('{{PASSWORD_FIELD}}', passwordField);
|
||||
|
|
@ -41,11 +48,10 @@ export const handleLinkStatus = async () => {
|
|||
};
|
||||
|
||||
export const handleUnlink = async (req: Request, daemon: ReturnType<typeof Bun.spawn> | null) => {
|
||||
const API_KEY = Bun.env.API_KEY;
|
||||
|
||||
if (API_KEY) {
|
||||
const formData = await req.formData();
|
||||
const password = formData.get('password');
|
||||
const body = await req.text();
|
||||
const params = new URLSearchParams(body);
|
||||
const password = params.get('password');
|
||||
|
||||
if (password !== API_KEY) {
|
||||
return new Response(null, { status: 403 });
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createGroup, sendGroupMessage } from '../signal';
|
||||
import { addNotification, getAllMappings, getGroupId, getNotifications, register } from '../store';
|
||||
import { createGroup, sendGroupMessage } from '../modules/signal';
|
||||
import { getAllMappings, getGroupId, register } from '../modules/store';
|
||||
|
||||
interface NotificationMessage {
|
||||
topic: string;
|
||||
|
|
@ -18,12 +18,6 @@ const formatNotification = (notification: NotificationMessage) => {
|
|||
};
|
||||
|
||||
export const handleNotify = async (req: Request, url: URL) => {
|
||||
const API_KEY = Bun.env.API_KEY;
|
||||
|
||||
if (API_KEY && req.headers.get('authorization') !== `Bearer ${API_KEY}`) {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
|
||||
const topic = url.pathname.split('/')[2];
|
||||
if (!topic) {
|
||||
return new Response('Topic required', { status: 400 });
|
||||
|
|
@ -52,12 +46,6 @@ export const handleNotify = async (req: Request, url: URL) => {
|
|||
const signalMessage = formatNotification(notification);
|
||||
await sendGroupMessage(groupId, signalMessage);
|
||||
|
||||
addNotification({
|
||||
topic,
|
||||
title,
|
||||
message: body,
|
||||
});
|
||||
|
||||
return Response.json({ success: true, topic, groupId });
|
||||
};
|
||||
|
||||
|
|
@ -72,12 +60,3 @@ export const handleTopics = () => {
|
|||
|
||||
return Response.json({ topics });
|
||||
};
|
||||
|
||||
export const handleGetNotifications = (_req: Request, url: URL) => {
|
||||
const topic = url.searchParams.get('topic') || undefined;
|
||||
const endpoint = url.searchParams.get('endpoint') || undefined;
|
||||
|
||||
const notifications = getNotifications({ topic, endpoint });
|
||||
|
||||
return Response.json({ notifications });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ROUTES } from '../constants/server';
|
||||
import { createGroup, sendGroupMessage } from '../signal';
|
||||
import { getAllMappings, getGroupId, register, remove } from '../store';
|
||||
import { formatAsSignalMessage, parseUnifiedPushRequest } from '../unifiedpush';
|
||||
import { createGroup, sendGroupMessage } from '../modules/signal';
|
||||
import { getAllMappings, getGroupId, register, remove } from '../modules/store';
|
||||
import { formatAsSignalMessage, parseUnifiedPushRequest } from '../modules/unifiedpush';
|
||||
|
||||
export const handleMatrixNotify = async (req: Request) => {
|
||||
const message = await parseUnifiedPushRequest(req);
|
||||
|
|
@ -18,12 +18,6 @@ export const handleMatrixNotify = async (req: Request) => {
|
|||
};
|
||||
|
||||
export const handleRegister = async (req: Request, url: URL) => {
|
||||
const API_KEY = Bun.env.API_KEY;
|
||||
|
||||
if (API_KEY && req.headers.get('authorization') !== `Bearer ${API_KEY}`) {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
|
||||
const endpointId = url.pathname.split('/')[2] ?? '';
|
||||
const { appName } = (await req.json()) as {
|
||||
appName: string;
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
interface EndpointMapping {
|
||||
endpoint: string;
|
||||
groupId: string;
|
||||
appName: string;
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
topic: string;
|
||||
endpoint?: string;
|
||||
time: number;
|
||||
title?: string;
|
||||
message: string;
|
||||
priority?: number;
|
||||
tags?: string[];
|
||||
click?: string;
|
||||
}
|
||||
|
||||
const mappings = new Map<string, EndpointMapping>();
|
||||
const notifications: Notification[] = [];
|
||||
const MAX_NOTIFICATIONS = 1000;
|
||||
const RETENTION_DAYS = 7;
|
||||
|
||||
export const register = (endpoint: string, groupId: string, appName: string) => {
|
||||
mappings.set(endpoint, { endpoint, groupId, appName });
|
||||
};
|
||||
|
||||
export const getGroupId = (endpoint: string) => mappings.get(endpoint)?.groupId;
|
||||
|
||||
export const getAppName = (endpoint: string) => mappings.get(endpoint)?.appName;
|
||||
|
||||
export const getAllMappings = () => Array.from(mappings.values());
|
||||
|
||||
export const remove = (endpoint: string) => {
|
||||
mappings.delete(endpoint);
|
||||
};
|
||||
|
||||
export const addNotification = (notification: Omit<Notification, 'id' | 'time'>) => {
|
||||
const id = crypto.randomUUID();
|
||||
const time = Date.now();
|
||||
|
||||
notifications.unshift({
|
||||
id,
|
||||
time,
|
||||
...notification,
|
||||
});
|
||||
|
||||
if (notifications.length > MAX_NOTIFICATIONS) {
|
||||
notifications.splice(MAX_NOTIFICATIONS);
|
||||
}
|
||||
|
||||
const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
||||
const firstOldIndex = notifications.findIndex((n) => n.time < cutoff);
|
||||
if (firstOldIndex > 0) {
|
||||
notifications.splice(firstOldIndex);
|
||||
}
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
export const getNotifications = (filter?: { topic?: string; endpoint?: string }) => {
|
||||
let result = notifications;
|
||||
|
||||
if (filter?.topic) {
|
||||
result = result.filter((n) => n.topic === filter.topic);
|
||||
}
|
||||
|
||||
if (filter?.endpoint) {
|
||||
result = result.filter((n) => n.endpoint === filter.endpoint);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
0
server/types/signal.ts → server/types.d.ts
vendored
0
server/types/signal.ts → server/types.d.ts
vendored
19
server/utils/auth.ts
Normal file
19
server/utils/auth.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { API_KEY } from '../constants/config';
|
||||
|
||||
export const checkAuth = (req: Request) => {
|
||||
if (!API_KEY) return null;
|
||||
|
||||
const proto = req.headers.get('x-forwarded-proto') || 'http';
|
||||
const host = req.headers.get('host') || '';
|
||||
const isLocalhost = host.startsWith('localhost') || host.startsWith('127.0.0.1');
|
||||
|
||||
if (proto !== 'https' && !isLocalhost) {
|
||||
return new Response('HTTPS required when API_KEY is configured', { status: 403 });
|
||||
}
|
||||
|
||||
if (req.headers.get('authorization') !== `Bearer ${API_KEY}`) {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue