mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
update to latest bun, better docker container names, dont poll the UI fragments, unifiedpush -> webhook rename
This commit is contained in:
parent
e7a42eeb6c
commit
8d25ab7bf2
18 changed files with 70 additions and 70 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.7
|
||||
bun-version: 1.3.8
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install && cd server && bun install
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# SUP
|
||||
|
||||
**Push notification system using Signal as transport**
|
||||
**Self-hosted notification gateway using Signal and Webhooks for transport**
|
||||
|
||||
[Setup](#setup) • [Real-World Examples](#real-world-examples) • [Architecture](#architecture)
|
||||
|
||||
|
|
@ -12,11 +12,16 @@
|
|||
|
||||
<!-- markdownlint-enable MD033 -->
|
||||
|
||||
SUP is a self-hosted server that routes push notifications through Signal, allowing you to receive app notifications without exposing unique network fingerprints to any network observers. All notification traffic appears as regular Signal messages.
|
||||
SUP is a self-hosted notification gateway that receives HTTP requests and routes them through Signal groups or custom webhooks. Route notifications through Signal to avoid exposing unique network fingerprints, or forward them to your own webhook endpoints for custom handling.
|
||||
|
||||
## How?
|
||||
|
||||
SUP functions as a UnifiedPush server to proxy http-based requests to Signal groups via [signal-cli](https://github.com/AsamK/signal-cli).
|
||||
SUP accepts notifications via HTTP POST requests and routes them based on your configured delivery method:
|
||||
|
||||
- **Signal groups**: Uses [signal-cli](https://github.com/AsamK/signal-cli) to create a Signal group for each app and send notifications as messages
|
||||
- **Webhook forwarding**: Forwards notifications to your own webhook URL (useful for UnifiedPush distributors, ntfy, or custom handlers)
|
||||
|
||||
Each endpoint can be independently configured to use either delivery method through the admin UI.
|
||||
|
||||
For the optional Proton Mail integration, SUP requires a server that runs Proton's official [proton-bridge](https://github.com/ProtonMail/proton-bridge). SUP's docker compose process will run an image from [protonmail-bridge-docker](https://github.com/shenxn/protonmail-bridge-docker). Once authenticated, the communication between SUP and proton-bridge will be over IMAP.
|
||||
|
||||
|
|
@ -170,7 +175,7 @@ Add the Base64 version of your API_KEY environment variable secret to your secre
|
|||
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.
|
||||
Reboot your Home Assistant system and you'll then be able to send Signal notifications to yourself by using this notify sup action.
|
||||
|
||||
## Monitoring
|
||||
|
||||
|
|
|
|||
6
bun.lock
6
bun.lock
|
|
@ -12,7 +12,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.13",
|
||||
"@types/bun": "1.3.7",
|
||||
"@types/bun": "1.3.8",
|
||||
"@types/imap": "0.8.43",
|
||||
"typescript": "5.9.3",
|
||||
},
|
||||
|
|
@ -37,13 +37,13 @@
|
|||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
|
||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@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.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
|
||||
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
services:
|
||||
server:
|
||||
container_name: sup-server
|
||||
build:
|
||||
context: .
|
||||
dockerfile: server/Dockerfile
|
||||
|
|
@ -21,6 +22,7 @@ services:
|
|||
restart: unless-stopped
|
||||
|
||||
protonmail-bridge:
|
||||
container_name: protonmail-bridge
|
||||
image: shenxn/protonmail-bridge:build
|
||||
profiles: ['protonmail']
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
services:
|
||||
server:
|
||||
container_name: sup-server
|
||||
image: ghcr.io/lone-cloud/sup:latest
|
||||
ports:
|
||||
- '8080:8080'
|
||||
|
|
@ -20,6 +21,7 @@ services:
|
|||
restart: unless-stopped
|
||||
|
||||
protonmail-bridge:
|
||||
container_name: protonmail-bridge
|
||||
image: shenxn/protonmail-bridge:build
|
||||
profiles: ['protonmail']
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.13",
|
||||
"@types/bun": "1.3.7",
|
||||
"@types/bun": "1.3.8",
|
||||
"@types/imap": "0.8.43",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM oven/bun:1.3.7-debian AS builder
|
||||
FROM oven/bun:1.3.8-debian AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { cleanupDaemon, initSignal } from '@/modules/signal';
|
|||
import { admin } from '@/routes/admin';
|
||||
import { ntfy } from '@/routes/ntfy';
|
||||
import { protonMail } from '@/routes/proton-mail';
|
||||
import { unifiedPush } from '@/routes/unified-push';
|
||||
import { webhook } from '@/routes/webhook';
|
||||
import { getLanIP, isLocalIP } from '@/utils/auth';
|
||||
import { formatToCspString } from '@/utils/format';
|
||||
import { logError, logInfo, logVerbose, logWarn } from '@/utils/log';
|
||||
|
|
@ -82,8 +82,8 @@ app.use('*', serveStatic({ root: PUBLIC_DIR }));
|
|||
|
||||
app.route('/', ntfy);
|
||||
app.route('/', admin);
|
||||
app.route('/api', protonMail);
|
||||
app.route('/api', unifiedPush);
|
||||
app.route('/api/proton-mail', protonMail);
|
||||
app.route('/api/webhook', webhook);
|
||||
|
||||
app.notFound((c) => c.text('Not Found', 404));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createGroup, sendGroupMessage } from '@/modules/signal';
|
||||
import { getMapping, register } from '@/modules/store';
|
||||
import { sendUnifiedPushNotification } from '@/modules/unified-push';
|
||||
import { sendWebhookNotification } from '@/modules/webhook';
|
||||
import type { Notification } from '@/types/notifications';
|
||||
import { logError, logVerbose } from '@/utils/log';
|
||||
|
||||
|
|
@ -22,13 +22,13 @@ export const sendNotification = async (endpoint: string, notification: Notificat
|
|||
const { channel, upEndpoint, appName } = mapping;
|
||||
let { groupId } = mapping;
|
||||
|
||||
if (channel === 'unifiedpush') {
|
||||
if (channel === 'webhook') {
|
||||
if (!upEndpoint) {
|
||||
logError(`UnifiedPush endpoint not configured for ${appName}`);
|
||||
logError(`Webhook endpoint not configured for ${appName}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return await sendUnifiedPushNotification(upEndpoint, notification);
|
||||
return await sendWebhookNotification(upEndpoint, notification);
|
||||
}
|
||||
|
||||
if (!groupId) {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function register(
|
|||
export function register(
|
||||
endpoint: string,
|
||||
appName: string,
|
||||
channel: 'unifiedpush',
|
||||
channel: 'webhook',
|
||||
options: { upEndpoint: string },
|
||||
): void;
|
||||
export function register(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Notification } from '@/types/notifications';
|
||||
import { logError, logVerbose } from '@/utils/log';
|
||||
|
||||
export const sendUnifiedPushNotification = async (endpoint: string, notification: Notification) => {
|
||||
export const sendWebhookNotification = async (endpoint: string, notification: Notification) => {
|
||||
try {
|
||||
const payload = {
|
||||
message: notification.message,
|
||||
|
|
@ -18,15 +18,17 @@ export const sendUnifiedPushNotification = async (endpoint: string, notification
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logError(`Failed to send UP notification: ${response.status} ${response.statusText}`);
|
||||
logError(`Failed to send webhook notification: ${response.status} ${response.statusText}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logVerbose(`Sent UP notification to ${endpoint}: ${notification.message.substring(0, 50)}`);
|
||||
logVerbose(
|
||||
`Sent webhook notification to ${endpoint}: ${notification.message.substring(0, 50)}`,
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logError('Failed to send UP notification:', error);
|
||||
logError('Failed to send webhook notification:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -8,12 +8,12 @@
|
|||
--success: #28a745;
|
||||
--error: #dc3545;
|
||||
--spinner-track: #e0e0e0;
|
||||
--badge-signal-bg: rgba(129, 89, 184, 0.2);
|
||||
--badge-up-bg: rgba(40, 167, 69, 0.2);
|
||||
--badge-signal-bg: #8159b8;
|
||||
--badge-webhook-bg: rgba(40, 167, 69, 0.2);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
--text-primary: #e0e0e0;
|
||||
|
|
@ -23,23 +23,10 @@
|
|||
--error: #ef4444;
|
||||
--spinner-track: #4a4a4a;
|
||||
--badge-signal-bg: #8159b8;
|
||||
--badge-up-bg: #28a745;
|
||||
--badge-webhook-bg: #28a745;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f5f5f5;
|
||||
--bg-secondary: #fdfdfd;
|
||||
--text-primary: #000;
|
||||
--text-secondary: #666;
|
||||
--border-color: rgba(0, 0, 0, 0.1);
|
||||
--success: #28a745;
|
||||
--error: #dc3545;
|
||||
--spinner-track: #e0e0e0;
|
||||
--badge-signal-bg: rgba(129, 89, 184, 0.2);
|
||||
--badge-up-bg: rgba(40, 167, 69, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #2d2d2d;
|
||||
|
|
@ -50,7 +37,7 @@
|
|||
--error: #ef4444;
|
||||
--spinner-track: #4a4a4a;
|
||||
--badge-signal-bg: #8159b8;
|
||||
--badge-up-bg: #28a745;
|
||||
--badge-webhook-bg: #28a745;
|
||||
}
|
||||
|
||||
/* CSS Reset */
|
||||
|
|
@ -249,8 +236,8 @@ h2 {
|
|||
color: white;
|
||||
}
|
||||
|
||||
.channel-unifiedpush {
|
||||
background: var(--badge-up-bg);
|
||||
.channel-webhook {
|
||||
background: var(--badge-webhook-bg);
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,7 @@
|
|||
<div class="card">
|
||||
<div id="health-status"
|
||||
hx-get="/fragment/health"
|
||||
hx-trigger="load, every 10s"
|
||||
hx-indicator="#health-status">
|
||||
hx-trigger="load">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
|
@ -45,8 +44,7 @@
|
|||
<h2>Endpoints</h2>
|
||||
<div id="endpoints-list"
|
||||
hx-get="/fragment/endpoints"
|
||||
hx-trigger="load, every 10s"
|
||||
hx-indicator="#endpoints-list">
|
||||
hx-trigger="load">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "SUP Admin",
|
||||
"short_name": "SUP",
|
||||
"description": "Signal Unified Push Admin Panel",
|
||||
"description": "Notification gateway admin panel",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a1a",
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ admin.get('/api/health', async (c) => {
|
|||
});
|
||||
|
||||
admin.get('/fragment/health', async (c) => {
|
||||
const { html } = await handleHealthFragment();
|
||||
const html = await handleHealthFragment();
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
|
|
@ -84,7 +84,7 @@ admin.post('/action/toggle-channel', async (c) => {
|
|||
return c.json({ error: 'Invalid endpoint' }, 400);
|
||||
}
|
||||
|
||||
if (!channel || !['signal', 'unifiedpush'].includes(channel)) {
|
||||
if (!channel || !['signal', 'webhook'].includes(channel)) {
|
||||
return c.json({ error: 'Invalid channel' }, 400);
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ const handleHealthFragment = async () => {
|
|||
</div>
|
||||
`;
|
||||
|
||||
return { html, linked };
|
||||
return html;
|
||||
};
|
||||
|
||||
const handleSignalInfoFragment = async () => {
|
||||
|
|
@ -156,16 +156,20 @@ const handleEndpointsFragment = async () => {
|
|||
return `
|
||||
<ul class="endpoint-list">
|
||||
${endpoints
|
||||
.map(
|
||||
(e) => `
|
||||
.map((e) => {
|
||||
const isSignal = e.channel === 'signal';
|
||||
const isWebhook = e.channel === 'webhook';
|
||||
|
||||
return `
|
||||
<li class="endpoint-item">
|
||||
<div class="endpoint-info">
|
||||
<div class="endpoint-name">
|
||||
<strong>${e.appName}</strong>
|
||||
</div>
|
||||
<div class="endpoint-channel">
|
||||
<span class="channel-badge channel-${e.channel}">${e.channel === 'signal' ? 'Signal' : 'UnifiedPush'}</span>
|
||||
${e.upEndpoint ? `<span class="endpoint-detail">${new URL(e.upEndpoint).hostname}</span>` : ''}
|
||||
<span class="channel-badge channel-${e.channel}">${isSignal ? 'Signal' : 'Webhook'}</span>
|
||||
${e.upEndpoint && isWebhook ? `<span class="endpoint-detail">${new URL(e.upEndpoint).hostname}</span>` : ``}
|
||||
${e.groupId && isSignal ? `<span class="endpoint-detail">${e.groupId}</span>` : ``}
|
||||
</div>
|
||||
</div>
|
||||
<div class="endpoint-actions">
|
||||
|
|
@ -182,8 +186,8 @@ const handleEndpointsFragment = async () => {
|
|||
hx-swap="innerHTML"
|
||||
hx-include="closest form"
|
||||
>
|
||||
<option value="signal" ${e.channel === 'signal' ? 'selected' : ''}>Signal</option>
|
||||
<option value="unifiedpush" ${e.channel === 'unifiedpush' ? 'selected' : ''}>UnifiedPush</option>
|
||||
<option value="signal" ${isSignal ? 'selected' : ''}>Signal</option>
|
||||
<option value="webhook" ${isWebhook ? 'selected' : ''}>Webhook</option>
|
||||
</select>
|
||||
</form>
|
||||
`
|
||||
|
|
@ -201,8 +205,8 @@ const handleEndpointsFragment = async () => {
|
|||
</form>
|
||||
</div>
|
||||
</li>
|
||||
`,
|
||||
)
|
||||
`;
|
||||
})
|
||||
.join('')}
|
||||
</ul>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ protonMail.use(
|
|||
}),
|
||||
);
|
||||
|
||||
protonMail.post('/proton-mail/mark-read', async (c) => {
|
||||
protonMail.post('/mark-read', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const { uid } = body;
|
||||
|
|
|
|||
|
|
@ -5,17 +5,17 @@ import { register } from '@/modules/store';
|
|||
import { verifyApiKey } from '@/utils/auth';
|
||||
import { logError, logVerbose } from '@/utils/log';
|
||||
|
||||
export const unifiedPush = new Hono();
|
||||
export const webhook = new Hono();
|
||||
|
||||
unifiedPush.use(
|
||||
webhook.use(
|
||||
'*',
|
||||
basicAuth({
|
||||
verifyUser: (_, password) => verifyApiKey(password),
|
||||
realm: 'SUP UnifiedPush - Username: any, Password: API_KEY',
|
||||
realm: 'SUP Webhook - Username: any, Password: API_KEY',
|
||||
}),
|
||||
);
|
||||
|
||||
unifiedPush.post('/up/register', async (c) => {
|
||||
webhook.post('/register', async (c) => {
|
||||
try {
|
||||
const body = await c.req.json<{
|
||||
appName: string;
|
||||
|
|
@ -36,17 +36,17 @@ unifiedPush.post('/up/register', async (c) => {
|
|||
|
||||
const endpoint = `${ENDPOINT_PREFIX_UP}${appName}`;
|
||||
|
||||
register(endpoint, appName, 'unifiedpush', { upEndpoint });
|
||||
register(endpoint, appName, 'webhook', { upEndpoint });
|
||||
|
||||
logVerbose(`Registered UP endpoint for ${appName}: ${upEndpoint}`);
|
||||
logVerbose(`Registered webhook endpoint for ${appName}: ${upEndpoint}`);
|
||||
|
||||
return c.json({
|
||||
endpoint,
|
||||
appName,
|
||||
channel: 'unifiedpush',
|
||||
channel: 'webhook',
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Failed to register UP endpoint:', error);
|
||||
logError('Failed to register webhook endpoint:', error);
|
||||
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
}
|
||||
6
server/types/notifications.d.ts
vendored
6
server/types/notifications.d.ts
vendored
|
|
@ -1,4 +1,4 @@
|
|||
export interface UnifiedPushAction {
|
||||
export interface WebPushAction {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
method: 'POST' | 'GET' | 'DELETE';
|
||||
|
|
@ -8,7 +8,7 @@ export interface UnifiedPushAction {
|
|||
export interface Notification {
|
||||
title?: string;
|
||||
message: string;
|
||||
actions?: UnifiedPushAction[];
|
||||
actions?: WebPushAction[];
|
||||
}
|
||||
|
||||
export type NotificationChannel = 'signal' | 'unifiedpush';
|
||||
export type NotificationChannel = 'signal' | 'webhook';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue