mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
adding hono to use as a web server instead of raw bun, switching to use hono middleware instead of custom
This commit is contained in:
parent
cc52eda92a
commit
a1fe052a8e
20 changed files with 562 additions and 653 deletions
146
README.md
146
README.md
|
|
@ -4,24 +4,31 @@
|
|||
|
||||
# SUP
|
||||
|
||||
**SUP (Signal Unified Push) is a privacy-preserving push notifications using Signal as transport**
|
||||
**SUP (Signal Unified Push) is a privacy-preserving push notification system using Signal as transport**
|
||||
|
||||
|
||||
[Setup](#setup) • [Architecture](#architecture)
|
||||
[Setup](#setup) • [Real-World Examples](#real-world-examples) • [Architecture](#architecture)
|
||||
|
||||
</div>
|
||||
|
||||
<!-- markdownlint-enable MD033 -->
|
||||
|
||||
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.
|
||||
> ⚠️ **Early Alpha**: SUP is under rapid development. The Android app is being actively developed and the current version is not thoroughly tested. There are no stable releases yet. Use at your own risk.
|
||||
|
||||
SUP is a UnifiedPush distributor 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.
|
||||
|
||||
## 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.
|
||||
Traditional push notification systems require persistent connections to specific servers, creating unique network fingerprints. Relying on traditional push notification services like Android's built-in FCM (Firebase Cloud Messaging) may also expose your notification metadata. SUP blends your notification traffic with regular Signal usage for better privacy.
|
||||
|
||||
SUP also includes an optional Proton Mail integration, allowing you to receive email notifications as Signal messages without exposing IMAP connections.
|
||||
|
||||
Note that you'll need to run SUP on your own server (either at home or on a VPS) since it uses your personal Signal and Proton Mail credentials. A Raspberry Pi, which is a small and affordable micro-computer, works perfectly for this, using minimal power (3-5W) while running SUP 24/7.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install Android App
|
||||
### 1. Install Android App (Optional)
|
||||
|
||||
An Android app is optionally available to connect UnifiedPush Android apps to the SUP server. It can also provide a better experience for displaying SUP-based notifications if the `ENABLE_ANDROID_INTEGRATION` environment variable is enabled on the server.
|
||||
|
||||
Download the latest APK from [GitHub Releases](https://github.com/lone-cloud/sup/releases).
|
||||
|
||||
|
|
@ -31,9 +38,55 @@ Download the latest APK from [GitHub Releases](https://github.com/lone-cloud/sup
|
|||
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
|
||||
```
|
||||
|
||||
### 2. Start SUP Server with Docker Compose on a self-hosted server
|
||||
### 2. Proton Mail Integration (Optional)
|
||||
|
||||
**Without ProtonMail** (just UnifiedPush):
|
||||
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):
|
||||
|
||||
```bash
|
||||
# Download docker-compose.yml
|
||||
curl -L -O https://raw.githubusercontent.com/lone-cloud/sup/master/docker-compose.yml
|
||||
|
||||
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.
|
||||
|
||||
### 3. SUP Server integration
|
||||
|
||||
```bash
|
||||
# Download docker-compose.yml
|
||||
|
|
@ -42,7 +95,7 @@ curl -L -O https://raw.githubusercontent.com/lone-cloud/sup/master/docker-compos
|
|||
# Download .env.example (optional)
|
||||
curl -L -O https://raw.githubusercontent.com/lone-cloud/sup/master/server/.env.example
|
||||
|
||||
# Configure (optional)
|
||||
# Configure SUP server through environment variables (optional)
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
|
||||
|
|
@ -50,48 +103,9 @@ nano .env
|
|||
docker compose up -d
|
||||
|
||||
# Link your Signal account (one-time setup)
|
||||
# Visit http://localhost:8080/link and scan QR code with Signal app
|
||||
# Visit http://localhost:8080 and scan QR code with Signal app
|
||||
```
|
||||
|
||||
### 3. ProtonMail Integration (Optional)
|
||||
|
||||
> **Note:** The default ProtonMail 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 ProtonMail notifications via Signal:
|
||||
|
||||
1. **Initialize ProtonMail Bridge** (one-time setup):
|
||||
|
||||
```bash
|
||||
docker compose run --rm protonmail-bridge init
|
||||
```
|
||||
|
||||
2. **Login to ProtonMail**:
|
||||
- At the `>>>` prompt, run: `login`
|
||||
- Enter your ProtonMail email
|
||||
- Enter your ProtonMail 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
|
||||
BRIDGE_IMAP_USERNAME=bridge-username-from-info-command
|
||||
BRIDGE_IMAP_PASSWORD=bridge-generated-password-from-info-command
|
||||
```
|
||||
|
||||
5. **Start all services with ProtonMail**:
|
||||
|
||||
```bash
|
||||
docker compose --profile protonmail up -d
|
||||
```
|
||||
|
||||
Your phone will now receive Signal notifications when ProtonMail receives new emails.
|
||||
|
||||
### Development
|
||||
|
||||
For local development, install Bun and signal-cli:
|
||||
|
|
@ -125,14 +139,44 @@ bun install
|
|||
bun --filter sup-server dev
|
||||
```
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Proton Mail Notifications
|
||||
|
||||
Receive instant Signal notifications when new emails arrive in your Proton Mail inbox. SUP monitors your Proton Mail account via the local Proton Mail Bridge and forwards email alerts through Signal, eliminating the need for persistent IMAP connections from your phone.
|
||||
|
||||
### Home Assistant Alerts
|
||||
|
||||
Add a rest notification configuration (eg. add to configuration.yaml) to Home Assistant like:
|
||||
|
||||
```bash
|
||||
notify:
|
||||
- platform: rest
|
||||
name: SUP
|
||||
resource: "http://<Your SUP server network IP>/Home Assistant"
|
||||
method: POST
|
||||
data:
|
||||
package: "io.homeassistant.companion.android"
|
||||
headers:
|
||||
Authorization: !secret sup_basic_auth
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```bash
|
||||
sup_basic_auth: "Basic <Base64 Hash value>"
|
||||
```
|
||||
|
||||
Reboot your Home Assistant system and you'll then be able to send Signal notifications to yourself by using this notify sup action.
|
||||
|
||||
## 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
|
||||
- **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.**
|
||||
|
||||
|
|
|
|||
|
|
@ -8,15 +8,14 @@ services:
|
|||
environment:
|
||||
- PORT=8080
|
||||
- API_KEY=${API_KEY:-}
|
||||
- VERBOSE=${VERBOSE:-false}
|
||||
- VERBOSE_LOGGING=${VERBOSE_LOGGING:-false}
|
||||
- ALLOW_INSECURE_HTTP=${ALLOW_INSECURE_HTTP:-false}
|
||||
- ENABLE_ANDROID_INTEGRATION=${ENABLE_ANDROID_INTEGRATION:-false}
|
||||
# ProtonMail integration (optional)
|
||||
- BRIDGE_IMAP_USERNAME=${BRIDGE_IMAP_USERNAME:-}
|
||||
- BRIDGE_IMAP_PASSWORD=${BRIDGE_IMAP_PASSWORD:-}
|
||||
- PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-}
|
||||
- PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-}
|
||||
- PROTON_BRIDGE_HOST=protonmail-bridge
|
||||
- PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143}
|
||||
- SUP_TOPIC=${SUP_TOPIC:-Proton Mail}
|
||||
- PROTON_SUP_TOPIC=${PROTON_SUP_TOPIC:-Proton Mail}
|
||||
volumes:
|
||||
- signal-data:/root/.local/share/signal-cli
|
||||
- sup-data:/root/.local/share/sup
|
||||
|
|
|
|||
|
|
@ -6,15 +6,15 @@ services:
|
|||
environment:
|
||||
- PORT=8080
|
||||
- API_KEY=${API_KEY:-}
|
||||
- VERBOSE=${VERBOSE:-false}
|
||||
- VERBOSE_LOGGING=${VERBOSE_LOGGING:-false}
|
||||
- ALLOW_INSECURE_HTTP=${ALLOW_INSECURE_HTTP:-false}
|
||||
- RATE_LIMIT=${RATE_LIMIT:-100}
|
||||
- ENABLE_ANDROID_INTEGRATION=${ENABLE_ANDROID_INTEGRATION:-false}
|
||||
# ProtonMail integration (optional)
|
||||
- BRIDGE_IMAP_USERNAME=${BRIDGE_IMAP_USERNAME:-}
|
||||
- BRIDGE_IMAP_PASSWORD=${BRIDGE_IMAP_PASSWORD:-}
|
||||
- PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-}
|
||||
- PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-}
|
||||
- PROTON_BRIDGE_HOST=protonmail-bridge
|
||||
- PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143}
|
||||
- SUP_TOPIC=${SUP_TOPIC:-Proton Mail}
|
||||
- PROTON_SUP_TOPIC=${PROTON_SUP_TOPIC:-Proton Mail}
|
||||
volumes:
|
||||
- signal-data:/root/.local/share/signal-cli
|
||||
- sup-data:/root/.local/share/sup
|
||||
|
|
|
|||
|
|
@ -6,22 +6,30 @@
|
|||
# Default: unset (no authentication required)
|
||||
# API_KEY=your-secret-key-here
|
||||
|
||||
# Optional: Allow HTTP connections without HTTPS (LAN-only deployments)
|
||||
# Default: false (HTTPS required for non-localhost)
|
||||
# Optional: Allow HTTP connections without HTTPS
|
||||
# Default: false
|
||||
# ALLOW_INSECURE_HTTP=true
|
||||
|
||||
# Optional: Enable verbose signal-cli logging
|
||||
# Optional: Rate limit for requests (per 15 minute window)
|
||||
# Default: 100 (requests per 15 minutes)
|
||||
# RATE_LIMIT=100
|
||||
|
||||
# Optional: Device name shown in Signal app's linked devices list
|
||||
# Default: SUP
|
||||
# DEVICE_NAME=SUP dev
|
||||
|
||||
# Optional: Enable verbose logging
|
||||
# Default: false
|
||||
# VERBOSE=true
|
||||
# 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
|
||||
# BRIDGE_IMAP_USERNAME=your-email@proton.me
|
||||
# BRIDGE_IMAP_PASSWORD=bridge-generated-password
|
||||
# PROTON_IMAP_USERNAME=your-email@proton.me
|
||||
# PROTON_IMAP_PASSWORD=bridge-generated-password
|
||||
# PROTON_BRIDGE_HOST=protonmail-bridge
|
||||
# PROTON_BRIDGE_PORT=143
|
||||
# SUP_TOPIC=Proton Mail
|
||||
# PROTON_SUP_TOPIC=Proton Mail
|
||||
|
||||
# Optional: Enable Android app integration for notifications
|
||||
# When enabled, messages include app launch codes that SUP Android app intercepts to open
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
"name": "sup-server",
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"hono": "^4.11.5",
|
||||
"hono-rate-limiter": "^0.5.3",
|
||||
"imap": "^0.8.19",
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -22,6 +24,10 @@
|
|||
|
||||
"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=="],
|
||||
|
|
|
|||
|
|
@ -1,16 +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 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 = 'SUP';
|
||||
export const DEVICE_NAME = Bun.env.DEVICE_NAME || 'SUP';
|
||||
|
||||
export const SUP_ENDPOINT_PREFIX = `[${DEVICE_NAME}:`;
|
||||
export const LAUNCH_ENDPOINT_PREFIX = '[LAUNCH:';
|
||||
|
||||
export const BRIDGE_IMAP_USERNAME = Bun.env.BRIDGE_IMAP_USERNAME;
|
||||
export const BRIDGE_IMAP_PASSWORD = Bun.env.BRIDGE_IMAP_PASSWORD;
|
||||
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 SUP_TOPIC = Bun.env.SUP_TOPIC || 'Proton Mail';
|
||||
export const PROTON_SUP_TOPIC = Bun.env.PROTON_SUP_TOPIC || 'Proton Mail';
|
||||
export const ENABLE_ANDROID_INTEGRATION = Bun.env.ENABLE_ANDROID_INTEGRATION === 'true';
|
||||
|
|
|
|||
135
server/index.ts
135
server/index.ts
|
|
@ -1,11 +1,24 @@
|
|||
import { API_KEY, BRIDGE_IMAP_PASSWORD, BRIDGE_IMAP_USERNAME, PORT } from '@/constants/config';
|
||||
import { networkInterfaces } from 'node:os';
|
||||
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 { adminRoutes } from '@/routes/admin';
|
||||
import { ntfyRoutes } from '@/routes/ntfy';
|
||||
import { unifiedPushRoutes } from '@/routes/unifiedpush';
|
||||
import { maybeCompress } from '@/utils/compress';
|
||||
import { getLanIP } from '@/utils/ip';
|
||||
import admin from '@/routes/admin';
|
||||
import ntfy from '@/routes/ntfy';
|
||||
import unifiedpush from '@/routes/unifiedpush';
|
||||
import { isLocalIP } from '@/utils/auth';
|
||||
import { logError, logInfo, logVerbose, logWarn } from '@/utils/log';
|
||||
|
||||
try {
|
||||
|
|
@ -18,7 +31,7 @@ if (!API_KEY) {
|
|||
logWarn('Server running without API_KEY');
|
||||
}
|
||||
|
||||
if (BRIDGE_IMAP_USERNAME && BRIDGE_IMAP_PASSWORD) {
|
||||
if (PROTON_IMAP_USERNAME && PROTON_IMAP_PASSWORD) {
|
||||
try {
|
||||
const { startProtonMonitor } = await import('./modules/protonmail');
|
||||
await startProtonMonitor();
|
||||
|
|
@ -28,37 +41,87 @@ if (BRIDGE_IMAP_USERNAME && BRIDGE_IMAP_PASSWORD) {
|
|||
}
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
const cspConfig = {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'https://api.qrserver.com'],
|
||||
formAction: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
objectSrc: ["'none'"],
|
||||
};
|
||||
|
||||
const cspString = Object.entries(cspConfig)
|
||||
.map(([key, values]) => {
|
||||
const directive = key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
||||
|
||||
return `${directive} ${values.join(' ')}`;
|
||||
})
|
||||
.join('; ');
|
||||
|
||||
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', cspString);
|
||||
return next();
|
||||
});
|
||||
|
||||
app.use('*', (c, next) => {
|
||||
if (c.req.path === '/link/status-check') {
|
||||
return next();
|
||||
}
|
||||
|
||||
return timeout(5000)(c, next);
|
||||
});
|
||||
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('/', unifiedpush);
|
||||
app.route('/', ntfy);
|
||||
app.route('/', admin);
|
||||
|
||||
app.notFound((c) => c.text('Not Found', 404));
|
||||
|
||||
const server = Bun.serve({
|
||||
port: PORT,
|
||||
idleTimeout: 60,
|
||||
maxRequestBodySize: 1024 * 1024, // 1MB limit
|
||||
|
||||
error(error) {
|
||||
logError('Unhandled server error:', error);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
},
|
||||
|
||||
routes: {
|
||||
'/favicon.webp': {
|
||||
GET: () => new Response(Bun.file(`${PUBLIC_DIR}/favicon.webp`)),
|
||||
},
|
||||
'/icon-512.webp': {
|
||||
GET: () => new Response(Bun.file(`${PUBLIC_DIR}/icon-512.webp`)),
|
||||
},
|
||||
'/manifest.json': {
|
||||
GET: () => new Response(Bun.file(`${PUBLIC_DIR}/manifest.json`)),
|
||||
},
|
||||
'/htmx.js': {
|
||||
GET: async (req) =>
|
||||
maybeCompress(req, await Bun.file(`${PUBLIC_DIR}/htmx.min.js`).text(), 'text/javascript'),
|
||||
},
|
||||
|
||||
...adminRoutes,
|
||||
|
||||
...unifiedPushRoutes,
|
||||
|
||||
...ntfyRoutes,
|
||||
},
|
||||
fetch: app.fetch,
|
||||
maxRequestBodySize: 1024 * 1024,
|
||||
idleTimeout: 30,
|
||||
});
|
||||
|
||||
logInfo(`\nSUP running on:`);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import Imap from 'imap';
|
||||
import {
|
||||
BRIDGE_IMAP_PASSWORD,
|
||||
BRIDGE_IMAP_USERNAME,
|
||||
PROTON_BRIDGE_HOST,
|
||||
PROTON_BRIDGE_PORT,
|
||||
SUP_TOPIC,
|
||||
PROTON_IMAP_PASSWORD,
|
||||
PROTON_IMAP_USERNAME,
|
||||
PROTON_SUP_TOPIC,
|
||||
} from '@/constants/config';
|
||||
import { hasValidAccount, sendGroupMessage } from '@/modules/signal';
|
||||
import { getOrCreateGroup } from '@/modules/store';
|
||||
|
|
@ -15,8 +15,8 @@ let imapConnected = false;
|
|||
export const isImapConnected = () => imapConnected;
|
||||
|
||||
export async function startProtonMonitor() {
|
||||
if (!BRIDGE_IMAP_USERNAME || !BRIDGE_IMAP_PASSWORD) {
|
||||
logError('Missing required env vars: BRIDGE_IMAP_USERNAME and BRIDGE_IMAP_PASSWORD');
|
||||
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');
|
||||
|
||||
|
|
@ -29,11 +29,11 @@ export async function startProtonMonitor() {
|
|||
}
|
||||
|
||||
logInfo(`Connecting to Proton Bridge at ${PROTON_BRIDGE_HOST}:${PROTON_BRIDGE_PORT}`);
|
||||
logInfo(`Monitoring mailbox: ${BRIDGE_IMAP_USERNAME}`);
|
||||
logInfo(`Monitoring mailbox: ${PROTON_IMAP_USERNAME}`);
|
||||
|
||||
const imap = new Imap({
|
||||
user: BRIDGE_IMAP_USERNAME,
|
||||
password: BRIDGE_IMAP_PASSWORD,
|
||||
user: PROTON_IMAP_USERNAME,
|
||||
password: PROTON_IMAP_PASSWORD,
|
||||
host: PROTON_BRIDGE_HOST,
|
||||
port: PROTON_BRIDGE_PORT,
|
||||
tls: false,
|
||||
|
|
@ -48,7 +48,7 @@ export async function startProtonMonitor() {
|
|||
}
|
||||
|
||||
try {
|
||||
const groupId = await getOrCreateGroup(`proton-${SUP_TOPIC}`, SUP_TOPIC);
|
||||
const groupId = await getOrCreateGroup(`proton-${PROTON_SUP_TOPIC}`, PROTON_SUP_TOPIC);
|
||||
|
||||
await sendGroupMessage(groupId, subject, {
|
||||
androidPackage: 'ch.protonmail.android',
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { rm, unlink } from 'node:fs/promises';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import {
|
||||
DEVICE_NAME,
|
||||
ENABLE_ANDROID_INTEGRATION,
|
||||
LAUNCH_ENDPOINT_PREFIX,
|
||||
PORT,
|
||||
VERBOSE,
|
||||
VERBOSE_LOGGING,
|
||||
} from '@/constants/config';
|
||||
import { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '@/constants/paths';
|
||||
import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '@/types';
|
||||
|
|
@ -15,16 +14,12 @@ let account: string | null = null;
|
|||
let currentLinkUri: string | null = null;
|
||||
let daemon: ReturnType<typeof Bun.spawn> | null = null;
|
||||
|
||||
export const hasLinkUri = () => currentLinkUri !== null;
|
||||
|
||||
export async function initSignal({ accountOverride }: { accountOverride?: string } = {}) {
|
||||
await startDaemon();
|
||||
|
||||
const isLinked = await checkSignalCli();
|
||||
|
||||
if (!isLinked) {
|
||||
logWarn('No Signal account linked');
|
||||
logInfo(`Visit http://localhost:${PORT} to link your device`);
|
||||
return { linked: false, account: null };
|
||||
}
|
||||
|
||||
|
|
@ -45,27 +40,37 @@ export async function initSignal({ accountOverride }: { accountOverride?: string
|
|||
}
|
||||
|
||||
logVerbose('No Signal accounts found');
|
||||
logWarn('No Signal account linked');
|
||||
logInfo(`Visit http://localhost:${PORT} to link your device`);
|
||||
return { linked: false, account: null };
|
||||
}
|
||||
|
||||
export async function generateLinkQR() {
|
||||
const result = (await call(
|
||||
'startLink',
|
||||
{
|
||||
deviceName: DEVICE_NAME,
|
||||
},
|
||||
account,
|
||||
)) as StartLinkResult;
|
||||
const uri = result.deviceLinkUri;
|
||||
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');
|
||||
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;
|
||||
}
|
||||
|
||||
currentLinkUri = uri;
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(uri)}`;
|
||||
}
|
||||
|
||||
export async function finishLink() {
|
||||
|
|
@ -82,7 +87,7 @@ export async function finishLink() {
|
|||
deviceLinkUri: uri,
|
||||
deviceName: DEVICE_NAME,
|
||||
},
|
||||
account,
|
||||
null,
|
||||
);
|
||||
logSuccess('Device linked successfully');
|
||||
return result;
|
||||
|
|
@ -170,11 +175,11 @@ export async function startDaemon() {
|
|||
|
||||
if (daemon && !daemon.killed) {
|
||||
daemon.kill();
|
||||
await Bun.sleep(5000);
|
||||
await Bun.sleep(2000);
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(SIGNAL_CLI_SOCKET);
|
||||
await Bun.file(SIGNAL_CLI_SOCKET).delete();
|
||||
logVerbose('Removed stale socket file');
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
|
||||
|
|
@ -206,7 +211,7 @@ export async function startDaemon() {
|
|||
)
|
||||
continue;
|
||||
|
||||
if (VERBOSE) {
|
||||
if (VERBOSE_LOGGING) {
|
||||
if (trimmed.includes('ERROR')) {
|
||||
logError('[signal-cli]', trimmed);
|
||||
} else if (trimmed.includes('WARN')) {
|
||||
|
|
@ -225,47 +230,40 @@ export async function startDaemon() {
|
|||
}
|
||||
})();
|
||||
|
||||
await Bun.sleep(5000);
|
||||
|
||||
try {
|
||||
const socket = await Bun.connect({
|
||||
unix: SIGNAL_CLI_SOCKET,
|
||||
socket: {
|
||||
data() {},
|
||||
},
|
||||
});
|
||||
socket.end();
|
||||
logSuccess('signal-cli daemon started');
|
||||
daemon = proc;
|
||||
return proc;
|
||||
} catch (error) {
|
||||
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:', error);
|
||||
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');
|
||||
for (let i = 0; i < 20; 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 {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartDaemon() {
|
||||
if (daemon) {
|
||||
daemon.kill();
|
||||
await Bun.sleep(5000);
|
||||
if (authError && !cleaned) {
|
||||
logWarn('Detected stale account data, cleaning up and retrying...');
|
||||
proc.kill();
|
||||
await unlinkDevice();
|
||||
cleaned = true;
|
||||
return startDaemon();
|
||||
}
|
||||
daemon = await startDaemon();
|
||||
|
||||
logError('Failed to connect to signal-cli socket: daemon did not start within 10 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();
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"hono": "^4.11.5",
|
||||
"hono-rate-limiter": "^0.5.3",
|
||||
"imap": "^0.8.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -15,7 +17,6 @@
|
|||
},
|
||||
"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 && biome check .",
|
||||
|
|
|
|||
|
|
@ -1,75 +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="/admin.css">
|
||||
<script src="/htmx.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div id="health-status"
|
||||
hx-get="/health/fragment"
|
||||
hx-trigger="load, every 10s"
|
||||
class="loading">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Signal</h2>
|
||||
<div id="signal-info"
|
||||
hx-get="/signal-info/fragment"
|
||||
hx-trigger="load, accountLinked from:body">
|
||||
Loading...
|
||||
</div>
|
||||
<div id="qr-section"
|
||||
hx-get="/signal-info/fragment"
|
||||
hx-trigger="accountLinked from:body"
|
||||
hx-swap="none"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Endpoints</h2>
|
||||
<div id="endpoints-list"
|
||||
hx-get="/endpoints/fragment"
|
||||
hx-trigger="load"
|
||||
class="loading">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.body.addEventListener('htmx:responseError', (e) => {
|
||||
const status = e.detail.xhr.status;
|
||||
const response = e.detail.xhr.responseText;
|
||||
|
||||
if (status === 426) {
|
||||
const healthDiv = document.getElementById('health-status');
|
||||
|
||||
if (healthDiv) {
|
||||
healthDiv.innerHTML = `<div class="status status-error">HTTPS Required</div><p>${response}</p><p>Access this page over HTTPS or from localhost.</p>`;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.card:not(:first-child)').forEach(card => {
|
||||
card.style.display = 'none';
|
||||
});
|
||||
} else if (status === 429) {
|
||||
const healthDiv = document.getElementById('health-status');
|
||||
|
||||
if (healthDiv) {
|
||||
healthDiv.innerHTML = `<div class="status status-error">Rate Limited</div><p>${response}</p>`;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.card:not(:first-child)').forEach(card => {
|
||||
card.style.display = 'none';
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -155,10 +155,17 @@ h2 {
|
|||
|
||||
.qr-container {
|
||||
margin-top: 1rem;
|
||||
width: 18.75rem;
|
||||
height: 18.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
max-width: 18.75rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
|
|
@ -180,3 +187,9 @@ h2 {
|
|||
.loading {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
49
server/public/index.html
Normal file
49
server/public/index.html
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<!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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div id="health-status"
|
||||
hx-get="/health/fragment"
|
||||
hx-trigger="load, every 10s"
|
||||
hx-indicator="#health-status"
|
||||
class="loading">
|
||||
Loading health status...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Signal</h2>
|
||||
<div id="signal-info"
|
||||
hx-get="/signal-info/fragment"
|
||||
hx-trigger="load, accountLinked from:body"
|
||||
hx-indicator="#signal-info">
|
||||
<div class="loading">Loading Signal status...</div>
|
||||
</div>
|
||||
<div id="qr-section"
|
||||
hx-get="/signal-info/fragment"
|
||||
hx-trigger="accountLinked from:body"
|
||||
hx-swap="none"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Endpoints</h2>
|
||||
<div id="endpoints-list"
|
||||
hx-get="/endpoints/fragment"
|
||||
hx-trigger="load"
|
||||
hx-indicator="#endpoints-list"
|
||||
class="loading">
|
||||
Loading endpoints...
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,50 +1,56 @@
|
|||
import { DEVICE_NAME } from '@/constants/config';
|
||||
import { PUBLIC_DIR, SIGNAL_CLI_DATA } from '@/constants/paths';
|
||||
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/protonmail';
|
||||
import {
|
||||
checkSignalCli,
|
||||
finishLink,
|
||||
generateLinkQR,
|
||||
hasLinkUri,
|
||||
hasValidAccount,
|
||||
initSignal,
|
||||
restartDaemon,
|
||||
unlinkDevice,
|
||||
} from '@/modules/signal';
|
||||
import { getAllMappings, remove } from '@/modules/store';
|
||||
import { withAuth } from '@/utils/auth';
|
||||
import { maybeCompress } from '@/utils/compress';
|
||||
import { verifyApiKey } from '@/utils/auth';
|
||||
|
||||
let cachedQR: string | null = null;
|
||||
let qrCacheTime = 0;
|
||||
let generatingPromise: Promise<string> | null = null;
|
||||
const QR_CACHE_TTL = 10 * 60 * 1000;
|
||||
|
||||
export const handleHealthFragment = async () => {
|
||||
const signalOk = await checkSignalCli();
|
||||
const linked = signalOk && (await hasValidAccount());
|
||||
const imap = isImapConnected();
|
||||
const hasProtonConfig = PROTON_IMAP_USERNAME && PROTON_IMAP_PASSWORD;
|
||||
|
||||
const html = `
|
||||
<div class="status">
|
||||
<div class="status-item ${signalOk ? 'status-ok' : 'status-error'}">
|
||||
Signal: ${signalOk ? 'Connected' : 'Disconnected'}
|
||||
Signal Daemon: ${signalOk ? 'Running' : 'Stopped'}
|
||||
</div>
|
||||
<div class="status-item ${linked ? 'status-ok' : 'status-error'}">
|
||||
Account: ${linked ? 'Linked' : 'Unlinked'}
|
||||
</div>
|
||||
<div class="status-item ${imap ? 'status-ok' : 'status-error'}">
|
||||
${
|
||||
hasProtonConfig
|
||||
? `<div class="status-item ${imap ? 'status-ok' : 'status-error'}">
|
||||
Proton Mail: ${imap ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
<div id="signal-info" hx-swap-oob="true">
|
||||
${await handleSignalInfoFragment()}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return new Response(html, {
|
||||
headers: {
|
||||
'content-type': 'text/html',
|
||||
'HX-Trigger': linked ? 'accountLinked' : '',
|
||||
},
|
||||
});
|
||||
return { html, linked };
|
||||
};
|
||||
|
||||
export const handleSignalInfoFragment = async () => {
|
||||
const html = (await hasValidAccount())
|
||||
? `<details class="unlink-details">
|
||||
if (await hasValidAccount()) {
|
||||
cachedQR = null;
|
||||
return `<details class="unlink-details">
|
||||
<summary class="unlink-summary">Unlink and remove device</summary>
|
||||
<div class="unlink-instructions">
|
||||
<ol>
|
||||
|
|
@ -53,33 +59,24 @@ export const handleSignalInfoFragment = async () => {
|
|||
<li>Tap <strong>"Unlink Device"</strong></li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>`
|
||||
: `<button onclick="document.getElementById('qr-section').style.display='block';
|
||||
this.parentElement.style.display='none';
|
||||
htmx.ajax('GET', '/link/qr-section', {target: '#qr-section', swap: 'innerHTML'})"
|
||||
class="link-button">
|
||||
Link Signal Device
|
||||
</button>`;
|
||||
</details>`;
|
||||
}
|
||||
|
||||
return new Response(html, {
|
||||
headers: { 'content-type': 'text/html' },
|
||||
});
|
||||
return handleQRSection();
|
||||
};
|
||||
|
||||
export const handleEndpointsFragment = async () => {
|
||||
const endpoints = getAllMappings();
|
||||
|
||||
if (endpoints.length === 0) {
|
||||
return new Response('<p>No endpoints registered</p>', {
|
||||
headers: { 'content-type': 'text/html' },
|
||||
});
|
||||
return '<p>No endpoints registered</p>';
|
||||
}
|
||||
|
||||
const html = `
|
||||
return `
|
||||
<ul class="endpoint-list">
|
||||
${endpoints
|
||||
.map(
|
||||
(e) => `
|
||||
(e: { appName: string; endpoint: string }) => `
|
||||
<li class="endpoint-item">
|
||||
<div class="endpoint-name">
|
||||
<strong>${e.appName}</strong>
|
||||
|
|
@ -96,124 +93,104 @@ export const handleEndpointsFragment = async () => {
|
|||
.join('')}
|
||||
</ul>
|
||||
`;
|
||||
|
||||
return new Response(html, {
|
||||
headers: { 'content-type': 'text/html' },
|
||||
});
|
||||
};
|
||||
|
||||
export const handleQRSection = async () => {
|
||||
const html = `
|
||||
<p>Scan this QR code with your Signal app:</p>
|
||||
<p class="qr-instructions"><strong>Settings → Linked Devices → Link New Device</strong></p>
|
||||
<div hx-get="/link/qr-image" hx-trigger="load, every 30s" class="qr-container">
|
||||
Generating QR code...
|
||||
</div>
|
||||
<div hx-get="/link/status-check" hx-trigger="every 2s" hx-swap="none"></div>
|
||||
<button onclick="document.getElementById('qr-section').style.display='none';
|
||||
document.getElementById('signal-info').style.display='block'"
|
||||
class="btn-cancel">
|
||||
Cancel
|
||||
</button>
|
||||
`;
|
||||
|
||||
return new Response(html, {
|
||||
headers: { 'content-type': 'text/html' },
|
||||
});
|
||||
};
|
||||
|
||||
export const handleQRImage = async () => {
|
||||
if (!(await hasValidAccount()) && (await Bun.file(SIGNAL_CLI_DATA).exists())) {
|
||||
await unlinkDevice();
|
||||
await restartDaemon();
|
||||
if (await hasValidAccount()) {
|
||||
return '<p>Account already linked</p>';
|
||||
}
|
||||
|
||||
const qrDataUrl = await generateLinkQR();
|
||||
const html = `<img src="${qrDataUrl}" class="qr-image" alt="QR Code" />`;
|
||||
const now = Date.now();
|
||||
|
||||
return new Response(html, {
|
||||
headers: { 'content-type': 'text/html' },
|
||||
});
|
||||
if ((!cachedQR || now - qrCacheTime > QR_CACHE_TTL) && !generatingPromise) {
|
||||
generatingPromise = (async () => {
|
||||
const qr = await generateLinkQR();
|
||||
cachedQR = qr;
|
||||
qrCacheTime = Date.now();
|
||||
|
||||
finishLink()
|
||||
.then(async () => {
|
||||
await initSignal();
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
generatingPromise = null;
|
||||
cachedQR = null;
|
||||
qrCacheTime = 0;
|
||||
});
|
||||
|
||||
return qr;
|
||||
})();
|
||||
}
|
||||
|
||||
if (generatingPromise && !cachedQR) {
|
||||
await generatingPromise;
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
};
|
||||
|
||||
export const handleLinkStatusCheck = async () => {
|
||||
let linked = await hasValidAccount();
|
||||
|
||||
if (!linked && hasLinkUri()) {
|
||||
try {
|
||||
await finishLink();
|
||||
const result = await initSignal();
|
||||
linked = result.linked;
|
||||
} catch (error) {
|
||||
console.error('Failed to finish link:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (linked) {
|
||||
return new Response('', {
|
||||
headers: {
|
||||
'content-type': 'text/html',
|
||||
'HX-Refresh': 'true',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('', {
|
||||
headers: { 'content-type': 'text/html' },
|
||||
});
|
||||
const linked = await hasValidAccount();
|
||||
return { linked };
|
||||
};
|
||||
|
||||
export const handleDeleteEndpoint = async (req: Request) => {
|
||||
const url = new URL(req.url);
|
||||
const endpoint = decodeURIComponent(url.pathname.split('/').pop() || '');
|
||||
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: process.uptime(),
|
||||
signal: {
|
||||
daemon: signalOk ? 'running' : 'stopped',
|
||||
linked,
|
||||
},
|
||||
};
|
||||
|
||||
if (hasProtonConfig) {
|
||||
result.protonMail = isImapConnected() ? 'connected' : 'disconnected';
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
admin.get('/health/fragment', async (c) => {
|
||||
const { html } = await handleHealthFragment();
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
admin.get('/signal-info/fragment', async (c) => c.html(await handleSignalInfoFragment()));
|
||||
|
||||
admin.get('/endpoints/fragment', async (c) => c.html(await handleEndpointsFragment()));
|
||||
|
||||
admin.get('/link/qr-section', async (c) => c.html(await handleQRSection()));
|
||||
|
||||
admin.delete('/endpoint/delete/:endpoint', async (c) => {
|
||||
const endpoint = decodeURIComponent(c.req.param('endpoint'));
|
||||
|
||||
if (!endpoint) {
|
||||
return new Response('Invalid endpoint', { status: 400 });
|
||||
return c.text('Invalid endpoint', 400);
|
||||
}
|
||||
|
||||
remove(endpoint);
|
||||
return c.html(await handleEndpointsFragment());
|
||||
});
|
||||
|
||||
return handleEndpointsFragment();
|
||||
};
|
||||
|
||||
export const adminRoutes = {
|
||||
'/': {
|
||||
GET: withAuth(async (req: Request) =>
|
||||
maybeCompress(req, await Bun.file(`${PUBLIC_DIR}/admin.html`).text()),
|
||||
),
|
||||
},
|
||||
|
||||
'/admin.css': {
|
||||
GET: withAuth(async (req: Request) =>
|
||||
maybeCompress(req, await Bun.file(`${PUBLIC_DIR}/admin.css`).text(), 'text/css'),
|
||||
),
|
||||
},
|
||||
|
||||
'/health/fragment': {
|
||||
GET: withAuth(handleHealthFragment),
|
||||
},
|
||||
|
||||
'/signal-info/fragment': {
|
||||
GET: withAuth(handleSignalInfoFragment),
|
||||
},
|
||||
|
||||
'/endpoints/fragment': {
|
||||
GET: withAuth(handleEndpointsFragment),
|
||||
},
|
||||
|
||||
'/link/qr-section': {
|
||||
GET: withAuth(handleQRSection),
|
||||
},
|
||||
|
||||
'/link/qr-image': {
|
||||
GET: withAuth(handleQRImage),
|
||||
},
|
||||
|
||||
'/link/status-check': {
|
||||
GET: withAuth(handleLinkStatusCheck),
|
||||
},
|
||||
|
||||
'/endpoint/delete/:endpoint': {
|
||||
DELETE: withAuth(handleDeleteEndpoint),
|
||||
},
|
||||
};
|
||||
export default admin;
|
||||
|
|
|
|||
|
|
@ -1,30 +1,38 @@
|
|||
import { Hono } from 'hono';
|
||||
import { basicAuth } from 'hono/basic-auth';
|
||||
import { sendGroupMessage } from '@/modules/signal';
|
||||
import { getOrCreateGroup } from '@/modules/store';
|
||||
import { withAuth } from '@/utils/auth';
|
||||
import { verifyApiKey } from '@/utils/auth';
|
||||
import { logError, logVerbose } from '@/utils/log';
|
||||
|
||||
const handleNtfyPublish = async (req: Request) => {
|
||||
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 url = new URL(req.url);
|
||||
const topic = decodeURIComponent(url.pathname.slice(1));
|
||||
const topic = decodeURIComponent(c.req.param('topic'));
|
||||
|
||||
if (!topic || topic.includes('/')) {
|
||||
return new Response('Invalid topic', { status: 400 });
|
||||
return c.text('Invalid topic', 400);
|
||||
}
|
||||
|
||||
let message = await req.text();
|
||||
let message = await c.req.text();
|
||||
if (!message) {
|
||||
return new Response('Message required', { status: 400 });
|
||||
return c.text('Message required', 400);
|
||||
}
|
||||
|
||||
const contentType = req.headers.get('content-type') || '';
|
||||
const contentType = c.req.header('content-type') || '';
|
||||
let title: string | undefined =
|
||||
req.headers.get('X-Title') || req.headers.get('Title') || req.headers.get('t') || undefined;
|
||||
c.req.header('X-Title') || c.req.header('Title') || c.req.header('t') || undefined;
|
||||
let androidPackage: string | undefined =
|
||||
req.headers.get('X-Package') ||
|
||||
req.headers.get('Package') ||
|
||||
req.headers.get('p') ||
|
||||
undefined;
|
||||
c.req.header('X-Package') || c.req.header('Package') || c.req.header('p') || undefined;
|
||||
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const params = new URLSearchParams(message);
|
||||
|
|
@ -44,27 +52,17 @@ const handleNtfyPublish = async (req: Request) => {
|
|||
|
||||
logVerbose(`Sent ntfy message to topic ${topic}: ${title || message.substring(0, 50)}`);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: Date.now().toString(),
|
||||
time: Math.floor(Date.now() / 1000),
|
||||
event: 'message',
|
||||
topic,
|
||||
message,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
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 new Response('Internal server error', { status: 500 });
|
||||
return c.text('Internal server error', 500);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const ntfyRoutes = {
|
||||
'/:topic': {
|
||||
POST: withAuth(handleNtfyPublish),
|
||||
},
|
||||
};
|
||||
export default ntfy;
|
||||
|
|
|
|||
|
|
@ -1,63 +1,49 @@
|
|||
import { Hono } from 'hono';
|
||||
import { sendGroupMessage } from '@/modules/signal';
|
||||
import { getGroupId, getOrCreateGroup, remove } from '@/modules/store';
|
||||
import { formatAsSignalMessage, parseUnifiedPushRequest } from '@/modules/unifiedpush';
|
||||
|
||||
const handleMatrixNotify = async (req: Request) => {
|
||||
const message = await parseUnifiedPushRequest(req);
|
||||
const unifiedpush = new Hono();
|
||||
|
||||
unifiedpush.get('/up', (c) =>
|
||||
c.json({
|
||||
unifiedpush: { version: 1 },
|
||||
gateway: 'matrix',
|
||||
}),
|
||||
);
|
||||
|
||||
unifiedpush.post('/_matrix/push/v1/notify', async (c) => {
|
||||
const message = await parseUnifiedPushRequest(c.req.raw);
|
||||
const groupId = getGroupId(message.endpoint);
|
||||
|
||||
if (!groupId) {
|
||||
return new Response('Endpoint not registered', { status: 404 });
|
||||
return c.text('Endpoint not registered', 404);
|
||||
}
|
||||
|
||||
const signalMessage = formatAsSignalMessage(message);
|
||||
await sendGroupMessage(groupId, signalMessage);
|
||||
|
||||
return Response.json({ success: true });
|
||||
};
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
const handleRegister = async (req: Request) => {
|
||||
const url = new URL(req.url);
|
||||
const endpointId = url.pathname.split('/')[2] ?? '';
|
||||
const { appName } = (await req.json()) as {
|
||||
appName: string;
|
||||
token?: string;
|
||||
};
|
||||
unifiedpush.post('/up/:instance', async (c) => {
|
||||
const endpointId = c.req.param('instance');
|
||||
const { appName } = await c.req.json<{ appName: string; token?: string }>();
|
||||
|
||||
await getOrCreateGroup(endpointId, appName);
|
||||
|
||||
const proto = req.headers.get('x-forwarded-proto') || 'http';
|
||||
const host = req.headers.get('host') || 'localhost:8080';
|
||||
const proto = c.req.header('x-forwarded-proto') || 'http';
|
||||
const host = c.req.header('host') || 'localhost:8080';
|
||||
const baseUrl = `${proto}://${host}`;
|
||||
const endpoint = `${baseUrl}/_matrix/push/v1/notify/${endpointId}`;
|
||||
|
||||
return Response.json({ endpoint, gateway: 'matrix' });
|
||||
};
|
||||
return c.json({ endpoint, gateway: 'matrix' });
|
||||
});
|
||||
|
||||
const handleUnregister = async (req: Request) => {
|
||||
const url = new URL(req.url);
|
||||
const endpointId = url.pathname.split('/')[2] ?? '';
|
||||
unifiedpush.delete('/up/:instance', async (c) => {
|
||||
const endpointId = c.req.param('instance');
|
||||
remove(endpointId);
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
||||
const handleDiscovery = () =>
|
||||
Response.json({
|
||||
unifiedpush: { version: 1 },
|
||||
gateway: 'matrix',
|
||||
});
|
||||
|
||||
export const unifiedPushRoutes = {
|
||||
'/up': {
|
||||
GET: handleDiscovery,
|
||||
},
|
||||
|
||||
'/_matrix/push/v1/notify': {
|
||||
POST: handleMatrixNotify,
|
||||
},
|
||||
|
||||
'/up/:instance': {
|
||||
POST: handleRegister,
|
||||
DELETE: handleUnregister,
|
||||
},
|
||||
};
|
||||
export default unifiedpush;
|
||||
|
|
|
|||
|
|
@ -1,107 +1,37 @@
|
|||
import { timingSafeEqual } from 'node:crypto';
|
||||
import type { Context } from 'hono';
|
||||
import { getConnInfo } from 'hono/bun';
|
||||
import { ALLOW_INSECURE_HTTP, API_KEY } from '@/constants/config';
|
||||
import { getClientIP, isLocalIP } from '@/utils/ip';
|
||||
import { logWarn } from '@/utils/log';
|
||||
|
||||
// Rate limiting: track failed auth attempts per IP
|
||||
const failedAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||
const MAX_FAILED_ATTEMPTS = 5;
|
||||
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
|
||||
export const isLocalIP = (addr: string | undefined) => {
|
||||
if (!addr || addr === '::1' || addr === 'localhost') return true;
|
||||
|
||||
const isRateLimited = (ip: string) => {
|
||||
if (ALLOW_INSECURE_HTTP || isLocalIP(ip)) return false;
|
||||
const octets = addr.split('.').map(Number);
|
||||
if (octets.length !== 4 || octets.some((n) => Number.isNaN(n))) return false;
|
||||
|
||||
const now = Date.now();
|
||||
const record = failedAttempts.get(ip);
|
||||
|
||||
if (!record) return false;
|
||||
|
||||
if (now > record.resetAt) {
|
||||
failedAttempts.delete(ip);
|
||||
return false;
|
||||
}
|
||||
|
||||
return record.count >= MAX_FAILED_ATTEMPTS;
|
||||
const [a, b] = octets;
|
||||
return (
|
||||
a === 127 ||
|
||||
a === 10 ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 172 && b !== undefined && b >= 16 && b <= 31)
|
||||
);
|
||||
};
|
||||
|
||||
const recordFailedAttempt = (ip: string) => {
|
||||
if (ALLOW_INSECURE_HTTP || isLocalIP(ip)) return;
|
||||
export const verifyApiKey = (password: string, c?: Context) => {
|
||||
if (!API_KEY || password.length !== API_KEY.length) return false;
|
||||
|
||||
const now = Date.now();
|
||||
const record = failedAttempts.get(ip);
|
||||
if (c && !ALLOW_INSECURE_HTTP) {
|
||||
const addr = getConnInfo(c).remote.address;
|
||||
const proto = c.req.header('x-forwarded-proto') || 'http';
|
||||
|
||||
if (!record || now > record.resetAt) {
|
||||
failedAttempts.set(ip, { count: 1, resetAt: now + LOCKOUT_DURATION });
|
||||
} else {
|
||||
record.count++;
|
||||
if (record.count >= MAX_FAILED_ATTEMPTS) {
|
||||
logWarn(`IP ${ip} locked out after ${MAX_FAILED_ATTEMPTS} failed auth attempts`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkAuth = (req: Request) => {
|
||||
if (!API_KEY) {
|
||||
return new Response('API_KEY environment variable not configured', { status: 401 });
|
||||
}
|
||||
|
||||
const clientIP = getClientIP(req);
|
||||
|
||||
if (isRateLimited(clientIP)) {
|
||||
return new Response('Too many failed attempts. Try again later.', { status: 429 });
|
||||
}
|
||||
|
||||
if (!ALLOW_INSECURE_HTTP) {
|
||||
const proto = req.headers.get('x-forwarded-proto') || 'http';
|
||||
if (proto !== 'https') {
|
||||
return new Response('HTTPS required when API_KEY is configured', {
|
||||
status: 426,
|
||||
headers: { Upgrade: 'TLS/1.2, HTTP/1.1' },
|
||||
});
|
||||
if (proto !== 'https' && !isLocalIP(addr)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
||||
recordFailedAttempt(clientIP);
|
||||
return new Response(null, {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': 'Basic realm="SUP Admin - Username: any, Password: API_KEY"' },
|
||||
});
|
||||
}
|
||||
|
||||
const base64Credentials = authHeader.slice(6);
|
||||
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
|
||||
const [, password] = credentials.split(':');
|
||||
const providedKey = password || '';
|
||||
|
||||
if (providedKey.length !== API_KEY.length) {
|
||||
recordFailedAttempt(clientIP);
|
||||
return new Response(null, {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': 'Basic realm="SUP Admin - Username: any, Password: API_KEY"' },
|
||||
});
|
||||
}
|
||||
|
||||
const providedBuffer = Buffer.from(providedKey);
|
||||
const providedBuffer = Buffer.from(password);
|
||||
const keyBuffer = Buffer.from(API_KEY);
|
||||
|
||||
if (!timingSafeEqual(providedBuffer, keyBuffer)) {
|
||||
recordFailedAttempt(clientIP);
|
||||
return new Response(null, {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': 'Basic realm="SUP Admin - Username: any, Password: API_KEY"' },
|
||||
});
|
||||
}
|
||||
|
||||
failedAttempts.delete(clientIP);
|
||||
return null;
|
||||
return timingSafeEqual(providedBuffer, keyBuffer);
|
||||
};
|
||||
|
||||
export const withAuth =
|
||||
<T extends unknown[]>(handler: (req: Request, ...args: T) => Response | Promise<Response>) =>
|
||||
(req: Request, ...args: T) => {
|
||||
const auth = checkAuth(req);
|
||||
if (auth) return auth;
|
||||
return handler(req, ...args);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
export function maybeCompress(
|
||||
request: Request,
|
||||
body: string,
|
||||
contentType = 'text/html; charset=utf-8',
|
||||
): Response {
|
||||
const acceptEncoding = request.headers.get('accept-encoding') || '';
|
||||
const bodyBytes = new TextEncoder().encode(body);
|
||||
|
||||
if (bodyBytes.length < 1024) {
|
||||
return new Response(body, {
|
||||
headers: { 'Content-Type': contentType },
|
||||
});
|
||||
}
|
||||
|
||||
if (acceptEncoding.includes('gzip')) {
|
||||
const compressed = Bun.gzipSync(bodyBytes);
|
||||
return new Response(compressed, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Encoding': 'gzip',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (acceptEncoding.includes('deflate')) {
|
||||
const compressed = Bun.deflateSync(bodyBytes);
|
||||
return new Response(compressed, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Encoding': 'deflate',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(body, {
|
||||
headers: { 'Content-Type': contentType },
|
||||
});
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import { networkInterfaces } from 'node:os';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const getClientIP = (req: Request) => {
|
||||
// Cloudflare Tunnel
|
||||
const cfIP = req.headers.get('cf-connecting-ip');
|
||||
if (cfIP) return cfIP;
|
||||
|
||||
// Standard reverse proxy headers
|
||||
const forwardedFor = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
|
||||
if (forwardedFor) return forwardedFor;
|
||||
|
||||
const realIP = req.headers.get('x-real-ip');
|
||||
if (realIP) return realIP;
|
||||
|
||||
const remoteAddr = req.headers.get('remote-addr');
|
||||
if (remoteAddr) return remoteAddr;
|
||||
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
export const isLocalIP = (ip: string) => {
|
||||
if (ip === '::1' || ip === 'localhost') return true;
|
||||
|
||||
const octets = ip.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)
|
||||
);
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import chalk from 'chalk';
|
||||
import { VERBOSE } from '@/constants/config';
|
||||
import { VERBOSE_LOGGING } from '@/constants/config';
|
||||
|
||||
export const logVerbose = (...args: unknown[]) => VERBOSE && console.log(...args);
|
||||
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));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue