improvements and small additions to the server ntfy notification flow

This commit is contained in:
Egor 2026-01-20 22:45:00 -08:00
parent e2f794edaf
commit 7e7ddd7d0e
12 changed files with 283 additions and 132 deletions

View file

@ -6,11 +6,15 @@
# 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)
# ALLOW_INSECURE_HTTP=true
# Optional: Enable verbose signal-cli logging
# Default: false
# VERBOSE=true
# Optional: ProtonMail integration - receive email notifications via Signal
# 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
@ -19,8 +23,8 @@
# 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.
# Optional: Enable Android app integration for notifications
# When enabled, messages include app launch codes that SUP Android app intercepts to open
# the relevant app (Proton Mail or Home Assistant) when notification is tapped.
# Default: false
# ENABLE_PROTON_ANDROID=true
# ENABLE_ANDROID_INTEGRATION=true

View file

@ -1,6 +1,7 @@
export const PORT = Bun.env.PORT || 8080;
export const API_KEY = Bun.env.API_KEY;
export const VERBOSE = Bun.env.VERBOSE === 'true';
export const ALLOW_INSECURE_HTTP = Bun.env.ALLOW_INSECURE_HTTP === 'true';
export const DEVICE_NAME = 'SUP';
@ -12,4 +13,4 @@ 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';
export const ENABLE_ANDROID_INTEGRATION = Bun.env.ENABLE_ANDROID_INTEGRATION === 'true';

View file

@ -1,9 +1,10 @@
import { API_KEY, BRIDGE_IMAP_PASSWORD, BRIDGE_IMAP_USERNAME, PORT } from '@/constants/config';
import { cleanupDaemon, initSignal } from '@/modules/signal';
import { adminRoutes } from '@/routes/admin/index';
import { adminRoutes } from '@/routes/admin';
import { ntfyRoutes } from '@/routes/ntfy';
import { unifiedPushRoutes } from '@/routes/unifiedpush';
import { logError, logInfo, logWarn } from '@/utils/log';
import { getLanIP } from '@/utils/ip';
import { logError, logInfo, logVerbose, logWarn } from '@/utils/log';
try {
await initSignal();
@ -20,8 +21,8 @@ if (BRIDGE_IMAP_USERNAME && BRIDGE_IMAP_PASSWORD) {
const { startProtonMonitor } = await import('./modules/protonmail');
await startProtonMonitor();
} catch (err) {
logError('Failed to start ProtonMail monitor:', err);
logWarn('Continuing without ProtonMail integration');
logError('Failed to start Proton Mail monitor:', err);
logWarn('Continuing without Proton Mail integration');
}
}
@ -45,7 +46,13 @@ const server = Bun.serve({
},
});
logInfo(`\nSUP running on http://localhost:${server.port} 🚀`);
logInfo(`\nSUP running on:`);
logInfo(` Local: http://localhost:${server.port}`);
const lanIP = getLanIP();
if (lanIP) {
logVerbose(` Network: http://${lanIP}:${server.port}\n`);
}
process.on('SIGINT', () => {
cleanupDaemon();

View file

@ -2,8 +2,6 @@ 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,
@ -21,12 +19,12 @@ export async function startProtonMonitor() {
logError('Missing required env vars: BRIDGE_IMAP_USERNAME and BRIDGE_IMAP_PASSWORD');
logWarn('Run: docker compose run --rm protonmail-bridge init');
logWarn('Then use `login` and `info` commands to get IMAP credentials');
return;
}
const linked = await hasValidAccount();
if (!linked) {
logWarn('Signal account not linked. ProtonMail notifications will be skipped.');
if (!(await hasValidAccount())) {
logWarn('Signal account not linked. Proton Mail notifications will be skipped.');
logWarn('Link your Signal account at /link to enable email notifications.');
}
@ -57,14 +55,10 @@ export async function startProtonMonitor() {
register(topicKey, groupId, SUP_TOPIC);
}
if (ENABLE_PROTON_ANDROID) {
await sendGroupMessage(
groupId,
`${LAUNCH_ENDPOINT_PREFIX}ch.protonmail.android]\n**${title}**\n${message}`,
);
} else {
await sendGroupMessage(groupId, `${title}\n${message}`);
}
await sendGroupMessage(groupId, message, {
androidPackage: 'ch.protonmail.android',
title,
});
logVerbose(`Notification sent: ${title}`);
} catch (error) {
@ -72,7 +66,7 @@ export async function startProtonMonitor() {
}
}
function openInbox() {
const openInbox = () =>
imap.openBox('INBOX', false, (err, box) => {
if (err) {
logError('Failed to open inbox:', err);
@ -101,13 +95,12 @@ export async function startProtonMonitor() {
const nameMatch = rawFrom.match(/^"?([^"<]+)"?\s*<?/);
const from = nameMatch ? nameMatch[1]?.trim() : rawFrom;
sendNotification('New Email Received', `From: ${from} - ${subject}`);
sendNotification(`From: ${from}`, subject);
});
});
});
});
});
}
imap.on('ready', () => {
imapConnected = true;
@ -132,11 +125,7 @@ export async function startProtonMonitor() {
imap.connect();
process.on('SIGTERM', () => {
imap.end();
});
process.on('SIGTERM', () => imap.end());
process.on('SIGINT', () => {
imap.end();
});
process.on('SIGINT', () => imap.end());
}

View file

@ -1,5 +1,11 @@
import { rm, unlink } from 'node:fs/promises';
import { DEVICE_NAME, PORT, VERBOSE } from '@/constants/config';
import {
DEVICE_NAME,
ENABLE_ANDROID_INTEGRATION,
LAUNCH_ENDPOINT_PREFIX,
PORT,
VERBOSE,
} from '@/constants/config';
import { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '@/constants/paths';
import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '@/types';
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
@ -118,14 +124,23 @@ export async function createGroup(name: string, members: string[] = []) {
export async function sendGroupMessage(
groupId: string,
message: string,
{ notifySelf = true }: { notifySelf?: boolean } = {},
options?: { androidPackage?: string; title?: string },
) {
let formattedMessage = message;
if (ENABLE_ANDROID_INTEGRATION && options?.androidPackage) {
const title = options.title ? `**${options.title}**\n` : '';
formattedMessage = `${LAUNCH_ENDPOINT_PREFIX}${options.androidPackage}]\n${title}${message}`;
} else if (options?.title) {
formattedMessage = `${options.title}\n${message}`;
}
await call(
'send',
{
groupId,
message,
'notify-self': notifySelf,
message: formattedMessage,
'notify-self': true,
},
account,
);

View file

@ -44,33 +44,33 @@ h6 {
/* Styles */
body {
font-family: system-ui;
max-width: 800px;
margin: 20px auto;
padding: 20px;
max-width: 50rem;
margin: 1.25rem auto;
padding: 1.25rem;
background: #f5f5f5;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.25rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.1);
}
h2 {
margin-bottom: 20px;
margin-bottom: 1.25rem;
}
.status {
display: flex;
gap: 15px;
gap: 1rem;
flex-wrap: wrap;
}
.status-item {
padding: 10px 16px;
border-radius: 4px;
padding: 0.625rem 1rem;
border-radius: 0.25rem;
font-weight: 500;
}
@ -87,26 +87,88 @@ h2 {
.endpoint-list {
list-style: none;
padding: 0;
max-height: 300px;
max-height: 18.75rem;
overflow-y: auto;
}
.endpoint-list li {
padding: 8px 12px;
.endpoint-item {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
background: #f8f9fa;
border-radius: 4px;
margin-bottom: 6px;
font-size: 0.9em;
border-radius: 0.25rem;
margin-bottom: 0.375rem;
}
.endpoint-name {
font-size: 1.1em;
flex: 1;
}
.btn-delete {
padding: 0.375rem 0.75rem;
background: #dc3545;
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
.btn-delete:hover {
background: #c82333;
}
.btn-cancel {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: #6c757d;
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
.btn-cancel:hover {
background: #5a6268;
}
.unlink-details {
margin-top: 1rem;
}
.unlink-summary {
cursor: pointer;
font-weight: bold;
}
.unlink-instructions {
margin-top: 1rem;
}
.unlink-instructions ol {
margin-left: 1.25rem;
}
.qr-instructions {
font-size: 1.05em;
}
.qr-container {
margin-top: 1rem;
}
.qr-image {
max-width: 18.75rem;
}
.link-button {
display: inline-block;
padding: 10px 20px;
padding: 0.625rem 1.25rem;
background: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
margin-top: 10px;
border-radius: 0.25rem;
margin-top: 0.625rem;
border: none;
cursor: pointer;
}

View file

@ -8,7 +8,7 @@
<link rel="stylesheet" href="/admin.css">
<script src="/htmx.js"></script>
</head>
<body hx-headers='{"Authorization": "Bearer __API_KEY__"}'>
<body>
<div class="card">
<div id="health-status"
hx-get="/health/fragment"
@ -39,28 +39,32 @@
</div>
<script>
let apiKey = sessionStorage.getItem('sup_api_key');
if (!apiKey) {
apiKey = prompt('Enter API_KEY:');
if (!apiKey) {
document.body.innerHTML = '<div class="card"><h1>Access Denied</h1><p>API_KEY required</p></div>';
} else {
sessionStorage.setItem('sup_api_key', apiKey);
}
}
if (apiKey) {
const authHeader = JSON.stringify({ 'Authorization': `Bearer ${apiKey}` });
document.body.setAttribute('hx-headers', authHeader);
document.body.addEventListener('htmx:responseError', (e) => {
const status = e.detail.xhr.status;
const response = e.detail.xhr.responseText;
document.body.addEventListener('htmx:responseError', (e) => {
if (e.detail.xhr.status === 401) {
sessionStorage.removeItem('sup_api_key');
alert('Invalid API_KEY. Please refresh and try again.');
location.reload();
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>

View file

@ -11,10 +11,10 @@ import {
restartDaemon,
unlinkDevice,
} from '@/modules/signal';
import { getAllMappings } from '@/modules/store';
import { getAllMappings, remove } from '@/modules/store';
import { withAuth } from '@/utils/auth';
const handleHealthFragment = async () => {
export const handleHealthFragment = async () => {
const signalOk = await checkSignalCli();
const linked = signalOk && (await hasValidAccount());
const imap = isImapConnected();
@ -28,7 +28,7 @@ const handleHealthFragment = async () => {
Account: ${linked ? 'Linked' : 'Unlinked'}
</div>
<div class="status-item ${imap ? 'status-ok' : 'status-error'}">
ProtonMail: ${imap ? 'Connected' : 'Disconnected'}
Proton Mail: ${imap ? 'Connected' : 'Disconnected'}
</div>
</div>
`;
@ -38,14 +38,12 @@ const handleHealthFragment = async () => {
});
};
const handleSignalInfoFragment = async () => {
const linked = await hasValidAccount();
const html = linked
? `<details style="margin-top: 15px;">
<summary style="cursor: pointer; font-weight: bold;">Unlink and remove device</summary>
<div style="margin-top: 15px;">
<ol style="margin-left: 20px;">
export const handleSignalInfoFragment = async () => {
const html = (await hasValidAccount())
? `<details class="unlink-details">
<summary class="unlink-summary">Unlink and remove device</summary>
<div class="unlink-instructions">
<ol>
<li>Open Signal app <strong>Settings Linked Devices</strong></li>
<li>Find <strong>"${DEVICE_NAME}"</strong> and tap it</li>
<li>Tap <strong>"Unlink Device"</strong></li>
@ -55,7 +53,7 @@ const handleSignalInfoFragment = async () => {
: `<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" style="border:none;cursor:pointer;">
class="link-button">
Link Signal Device
</button>`;
@ -64,7 +62,7 @@ const handleSignalInfoFragment = async () => {
});
};
const handleEndpointsFragment = async () => {
export const handleEndpointsFragment = async () => {
const endpoints = getAllMappings();
if (endpoints.length === 0) {
@ -75,7 +73,23 @@ const handleEndpointsFragment = async () => {
const html = `
<ul class="endpoint-list">
${endpoints.map((e) => `<li><strong>${e.appName}</strong><br><code>${e.endpoint}</code> → ${e.groupId}</li>`).join('')}
${endpoints
.map(
(e) => `
<li class="endpoint-item">
<div class="endpoint-name">
<strong>${e.appName}</strong>
</div>
<button
class="btn-delete"
hx-delete="/endpoint/delete/${encodeURIComponent(e.endpoint)}"
hx-target="#endpoints-list"
hx-swap="innerHTML"
>Delete</button>
</li>
`,
)
.join('')}
</ul>
`;
@ -84,17 +98,17 @@ const handleEndpointsFragment = async () => {
});
};
const handleQRSection = async () => {
export const handleQRSection = async () => {
const html = `
<p>Scan this QR code with your Signal app:</p>
<p style="font-size: 1.05em;"><strong>Settings Linked Devices Link New Device</strong></p>
<div hx-get="/link/qr-image" hx-trigger="load, every 30s" style="margin-top:15px;">
<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'"
style="margin-top:15px;padding:8px 16px;background:#6c757d;color:white;border:none;border-radius:4px;cursor:pointer;">
class="btn-cancel">
Cancel
</button>
`;
@ -104,22 +118,21 @@ const handleQRSection = async () => {
});
};
const handleQRImage = async () => {
const linked = await hasValidAccount();
if (!linked && (await Bun.file(SIGNAL_CLI_DATA).exists())) {
export const handleQRImage = async () => {
if (!(await hasValidAccount()) && (await Bun.file(SIGNAL_CLI_DATA).exists())) {
await unlinkDevice();
await restartDaemon();
}
const qrDataUrl = await generateLinkQR();
const html = `<img src="${qrDataUrl}" style="max-width: 300px;" alt="QR Code" />`;
const html = `<img src="${qrDataUrl}" class="qr-image" alt="QR Code" />`;
return new Response(html, {
headers: { 'content-type': 'text/html' },
});
};
const handleLinkStatusCheck = async () => {
export const handleLinkStatusCheck = async () => {
let linked = await hasValidAccount();
if (!linked && hasLinkUri()) {
@ -146,7 +159,28 @@ const handleLinkStatusCheck = async () => {
});
};
export const fragmentRoutes = {
export const handleDeleteEndpoint = async (req: Request) => {
const url = new URL(req.url);
const endpoint = decodeURIComponent(url.pathname.split('/').pop() || '');
if (!endpoint) {
return new Response('Invalid endpoint', { status: 400 });
}
remove(endpoint);
return handleEndpointsFragment();
};
export const adminRoutes = {
'/': {
GET: () => new Response(Bun.file('public/admin.html')),
},
'/admin.css': {
GET: () => new Response(Bun.file('public/admin.css')),
},
'/health/fragment': {
GET: withAuth(handleHealthFragment),
},
@ -170,4 +204,8 @@ export const fragmentRoutes = {
'/link/status-check': {
GET: withAuth(handleLinkStatusCheck),
},
'/endpoint/delete/:endpoint': {
DELETE: withAuth(handleDeleteEndpoint),
},
};

View file

@ -1,13 +0,0 @@
import { fragmentRoutes } from './fragments';
export const adminRoutes = {
'/': {
GET: () => new Response(Bun.file('public/admin.html')),
},
'/admin.css': {
GET: () => new Response(Bun.file('public/admin.css')),
},
...fragmentRoutes,
};

View file

@ -6,18 +6,28 @@ import { logError, logVerbose } from '@/utils/log';
const handleNtfyPublish = async (req: Request) => {
try {
const url = new URL(req.url);
const topic = url.pathname.slice(1);
const topic = decodeURIComponent(url.pathname.slice(1));
if (!topic || topic.includes('/')) {
return new Response('Invalid topic', { status: 400 });
}
const body = await req.text();
if (!body) {
let message = await req.text();
if (!message) {
return new Response('Message required', { status: 400 });
}
const title = req.headers.get('X-Title') || req.headers.get('Title') || req.headers.get('t');
const contentType = req.headers.get('content-type') || '';
let title = req.headers.get('X-Title') || req.headers.get('Title') || req.headers.get('t');
let androidPackage =
req.headers.get('X-Package') || req.headers.get('Package') || req.headers.get('p');
if (contentType.includes('application/x-www-form-urlencoded')) {
const params = new URLSearchParams(message);
message = params.get('message') || message;
title = title || params.get('title') || params.get('t');
androidPackage = androidPackage || params.get('package') || params.get('p');
}
const topicKey = `ntfy-${topic}`;
let groupId = getGroupId(topicKey);
@ -28,11 +38,12 @@ const handleNtfyPublish = async (req: Request) => {
logVerbose(`Created new group for ntfy topic: ${topic}`);
}
const message = title ? `**${title}**\n${body}` : body;
await sendGroupMessage(groupId, message, {
androidPackage: androidPackage || undefined,
title: title || undefined,
});
await sendGroupMessage(groupId, message);
logVerbose(`Sent ntfy message to topic ${topic}: ${title || body.substring(0, 50)}`);
logVerbose(`Sent ntfy message to topic ${topic}: ${title || message.substring(0, 50)}`);
return new Response(
JSON.stringify({
@ -40,7 +51,7 @@ const handleNtfyPublish = async (req: Request) => {
time: Math.floor(Date.now() / 1000),
event: 'message',
topic,
message: body,
message,
}),
{
status: 200,

View file

@ -1,5 +1,5 @@
import { timingSafeEqual } from 'node:crypto';
import { API_KEY } from '@/constants/config';
import { ALLOW_INSECURE_HTTP, API_KEY } from '@/constants/config';
import { logWarn } from '@/utils/log';
// Rate limiing: track failed auth attempts per IP
@ -57,21 +57,33 @@ const checkAuth = (req: Request) => {
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 (proto !== 'https' && !isLocalhost && !ALLOW_INSECURE_HTTP) {
return new Response('HTTPS required when API_KEY is configured', {
status: 426,
headers: { Upgrade: 'TLS/1.2, HTTP/1.1' },
});
}
const authHeader = req.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
if (!authHeader || !authHeader.startsWith('Basic ')) {
recordFailedAttempt(clientIP);
return new Response(null, { status: 401 });
return new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="SUP Admin - Username: any, Password: API_KEY"' },
});
}
const providedKey = authHeader.slice(7);
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 });
return new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="SUP Admin - Username: any, Password: API_KEY"' },
});
}
const providedBuffer = Buffer.from(providedKey);
@ -79,7 +91,10 @@ const checkAuth = (req: Request) => {
if (!timingSafeEqual(providedBuffer, keyBuffer)) {
recordFailedAttempt(clientIP);
return new Response(null, { status: 401 });
return new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="SUP Admin - Username: any, Password: API_KEY"' },
});
}
failedAttempts.delete(clientIP);

18
server/utils/ip.ts Normal file
View file

@ -0,0 +1,18 @@
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;
};