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) # Default: unset (no authentication required)
# API_KEY=your-secret-key-here # 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 # Optional: Enable verbose signal-cli logging
# Default: false # Default: false
# VERBOSE=true # 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 # Get credentials by running: docker compose run --rm protonmail-bridge init
# Then use 'login' and 'info' commands to get IMAP username/password # Then use 'login' and 'info' commands to get IMAP username/password
# BRIDGE_IMAP_USERNAME=your-email@proton.me # BRIDGE_IMAP_USERNAME=your-email@proton.me
@ -19,8 +23,8 @@
# PROTON_BRIDGE_PORT=143 # PROTON_BRIDGE_PORT=143
# SUP_TOPIC=Proton Mail # SUP_TOPIC=Proton Mail
# Optional: Enable Android app integration for ProtonMail notifications # Optional: Enable Android app integration for notifications
# When enabled, sends special format that SUP Android app intercepts to show custom notifications # When enabled, messages include app launch codes that SUP Android app intercepts to open
# that open ProtonMail app on click. When disabled, ProtonMail notifications appear as plain Signal messages. # the relevant app (Proton Mail or Home Assistant) when notification is tapped.
# Default: false # 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 PORT = Bun.env.PORT || 8080;
export const API_KEY = Bun.env.API_KEY; export const API_KEY = Bun.env.API_KEY;
export const VERBOSE = Bun.env.VERBOSE === 'true'; export const VERBOSE = Bun.env.VERBOSE === 'true';
export const ALLOW_INSECURE_HTTP = Bun.env.ALLOW_INSECURE_HTTP === 'true';
export const DEVICE_NAME = 'SUP'; 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_HOST = Bun.env.PROTON_BRIDGE_HOST || 'protonmail-bridge';
export const PROTON_BRIDGE_PORT = Number.parseInt(Bun.env.PROTON_BRIDGE_PORT || '143', 10); export const PROTON_BRIDGE_PORT = Number.parseInt(Bun.env.PROTON_BRIDGE_PORT || '143', 10);
export const SUP_TOPIC = Bun.env.SUP_TOPIC || 'Proton Mail'; 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 { API_KEY, BRIDGE_IMAP_PASSWORD, BRIDGE_IMAP_USERNAME, PORT } from '@/constants/config';
import { cleanupDaemon, initSignal } from '@/modules/signal'; import { cleanupDaemon, initSignal } from '@/modules/signal';
import { adminRoutes } from '@/routes/admin/index'; import { adminRoutes } from '@/routes/admin';
import { ntfyRoutes } from '@/routes/ntfy'; import { ntfyRoutes } from '@/routes/ntfy';
import { unifiedPushRoutes } from '@/routes/unifiedpush'; 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 { try {
await initSignal(); await initSignal();
@ -20,8 +21,8 @@ if (BRIDGE_IMAP_USERNAME && BRIDGE_IMAP_PASSWORD) {
const { startProtonMonitor } = await import('./modules/protonmail'); const { startProtonMonitor } = await import('./modules/protonmail');
await startProtonMonitor(); await startProtonMonitor();
} catch (err) { } catch (err) {
logError('Failed to start ProtonMail monitor:', err); logError('Failed to start Proton Mail monitor:', err);
logWarn('Continuing without ProtonMail integration'); 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', () => { process.on('SIGINT', () => {
cleanupDaemon(); cleanupDaemon();

View file

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

View file

@ -1,5 +1,11 @@
import { rm, unlink } from 'node:fs/promises'; 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 { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '@/constants/paths';
import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '@/types'; import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '@/types';
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log'; 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( export async function sendGroupMessage(
groupId: string, groupId: string,
message: 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( await call(
'send', 'send',
{ {
groupId, groupId,
message, message: formattedMessage,
'notify-self': notifySelf, 'notify-self': true,
}, },
account, account,
); );

View file

@ -44,33 +44,33 @@ h6 {
/* Styles */ /* Styles */
body { body {
font-family: system-ui; font-family: system-ui;
max-width: 800px; max-width: 50rem;
margin: 20px auto; margin: 1.25rem auto;
padding: 20px; padding: 1.25rem;
background: #f5f5f5; background: #f5f5f5;
} }
.card { .card {
background: white; background: white;
border-radius: 8px; border-radius: 0.5rem;
padding: 20px; padding: 1.25rem;
margin-bottom: 20px; margin-bottom: 1.25rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.1);
} }
h2 { h2 {
margin-bottom: 20px; margin-bottom: 1.25rem;
} }
.status { .status {
display: flex; display: flex;
gap: 15px; gap: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.status-item { .status-item {
padding: 10px 16px; padding: 0.625rem 1rem;
border-radius: 4px; border-radius: 0.25rem;
font-weight: 500; font-weight: 500;
} }
@ -87,26 +87,88 @@ h2 {
.endpoint-list { .endpoint-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
max-height: 300px; max-height: 18.75rem;
overflow-y: auto; overflow-y: auto;
} }
.endpoint-list li { .endpoint-item {
padding: 8px 12px; display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
background: #f8f9fa; background: #f8f9fa;
border-radius: 4px; border-radius: 0.25rem;
margin-bottom: 6px; margin-bottom: 0.375rem;
font-size: 0.9em; }
.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 { .link-button {
display: inline-block; display: inline-block;
padding: 10px 20px; padding: 0.625rem 1.25rem;
background: #007bff; background: #007bff;
color: white; color: white;
text-decoration: none; text-decoration: none;
border-radius: 4px; border-radius: 0.25rem;
margin-top: 10px; margin-top: 0.625rem;
border: none; border: none;
cursor: pointer; cursor: pointer;
} }

View file

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

View file

@ -11,10 +11,10 @@ import {
restartDaemon, restartDaemon,
unlinkDevice, unlinkDevice,
} from '@/modules/signal'; } from '@/modules/signal';
import { getAllMappings } from '@/modules/store'; import { getAllMappings, remove } from '@/modules/store';
import { withAuth } from '@/utils/auth'; import { withAuth } from '@/utils/auth';
const handleHealthFragment = async () => { export const handleHealthFragment = async () => {
const signalOk = await checkSignalCli(); const signalOk = await checkSignalCli();
const linked = signalOk && (await hasValidAccount()); const linked = signalOk && (await hasValidAccount());
const imap = isImapConnected(); const imap = isImapConnected();
@ -28,7 +28,7 @@ const handleHealthFragment = async () => {
Account: ${linked ? 'Linked' : 'Unlinked'} Account: ${linked ? 'Linked' : 'Unlinked'}
</div> </div>
<div class="status-item ${imap ? 'status-ok' : 'status-error'}"> <div class="status-item ${imap ? 'status-ok' : 'status-error'}">
ProtonMail: ${imap ? 'Connected' : 'Disconnected'} Proton Mail: ${imap ? 'Connected' : 'Disconnected'}
</div> </div>
</div> </div>
`; `;
@ -38,14 +38,12 @@ const handleHealthFragment = async () => {
}); });
}; };
const handleSignalInfoFragment = async () => { export const handleSignalInfoFragment = async () => {
const linked = await hasValidAccount(); const html = (await hasValidAccount())
? `<details class="unlink-details">
const html = linked <summary class="unlink-summary">Unlink and remove device</summary>
? `<details style="margin-top: 15px;"> <div class="unlink-instructions">
<summary style="cursor: pointer; font-weight: bold;">Unlink and remove device</summary> <ol>
<div style="margin-top: 15px;">
<ol style="margin-left: 20px;">
<li>Open Signal app <strong>Settings Linked Devices</strong></li> <li>Open Signal app <strong>Settings Linked Devices</strong></li>
<li>Find <strong>"${DEVICE_NAME}"</strong> and tap it</li> <li>Find <strong>"${DEVICE_NAME}"</strong> and tap it</li>
<li>Tap <strong>"Unlink Device"</strong></li> <li>Tap <strong>"Unlink Device"</strong></li>
@ -55,7 +53,7 @@ const handleSignalInfoFragment = async () => {
: `<button onclick="document.getElementById('qr-section').style.display='block'; : `<button onclick="document.getElementById('qr-section').style.display='block';
this.parentElement.style.display='none'; this.parentElement.style.display='none';
htmx.ajax('GET', '/link/qr-section', {target: '#qr-section', swap: 'innerHTML'})" 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 Link Signal Device
</button>`; </button>`;
@ -64,7 +62,7 @@ const handleSignalInfoFragment = async () => {
}); });
}; };
const handleEndpointsFragment = async () => { export const handleEndpointsFragment = async () => {
const endpoints = getAllMappings(); const endpoints = getAllMappings();
if (endpoints.length === 0) { if (endpoints.length === 0) {
@ -75,7 +73,23 @@ const handleEndpointsFragment = async () => {
const html = ` const html = `
<ul class="endpoint-list"> <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> </ul>
`; `;
@ -84,17 +98,17 @@ const handleEndpointsFragment = async () => {
}); });
}; };
const handleQRSection = async () => { export const handleQRSection = async () => {
const html = ` const html = `
<p>Scan this QR code with your Signal app:</p> <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> <p class="qr-instructions"><strong>Settings Linked Devices Link New Device</strong></p>
<div hx-get="/link/qr-image" hx-trigger="load, every 30s" style="margin-top:15px;"> <div hx-get="/link/qr-image" hx-trigger="load, every 30s" class="qr-container">
Generating QR code... Generating QR code...
</div> </div>
<div hx-get="/link/status-check" hx-trigger="every 2s" hx-swap="none"></div> <div hx-get="/link/status-check" hx-trigger="every 2s" hx-swap="none"></div>
<button onclick="document.getElementById('qr-section').style.display='none'; <button onclick="document.getElementById('qr-section').style.display='none';
document.getElementById('signal-info').style.display='block'" 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 Cancel
</button> </button>
`; `;
@ -104,22 +118,21 @@ const handleQRSection = async () => {
}); });
}; };
const handleQRImage = async () => { export const handleQRImage = async () => {
const linked = await hasValidAccount(); if (!(await hasValidAccount()) && (await Bun.file(SIGNAL_CLI_DATA).exists())) {
if (!linked && (await Bun.file(SIGNAL_CLI_DATA).exists())) {
await unlinkDevice(); await unlinkDevice();
await restartDaemon(); await restartDaemon();
} }
const qrDataUrl = await generateLinkQR(); 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, { return new Response(html, {
headers: { 'content-type': 'text/html' }, headers: { 'content-type': 'text/html' },
}); });
}; };
const handleLinkStatusCheck = async () => { export const handleLinkStatusCheck = async () => {
let linked = await hasValidAccount(); let linked = await hasValidAccount();
if (!linked && hasLinkUri()) { 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': { '/health/fragment': {
GET: withAuth(handleHealthFragment), GET: withAuth(handleHealthFragment),
}, },
@ -170,4 +204,8 @@ export const fragmentRoutes = {
'/link/status-check': { '/link/status-check': {
GET: withAuth(handleLinkStatusCheck), 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) => { const handleNtfyPublish = async (req: Request) => {
try { try {
const url = new URL(req.url); const url = new URL(req.url);
const topic = url.pathname.slice(1); const topic = decodeURIComponent(url.pathname.slice(1));
if (!topic || topic.includes('/')) { if (!topic || topic.includes('/')) {
return new Response('Invalid topic', { status: 400 }); return new Response('Invalid topic', { status: 400 });
} }
const body = await req.text(); let message = await req.text();
if (!body) { if (!message) {
return new Response('Message required', { status: 400 }); 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}`; const topicKey = `ntfy-${topic}`;
let groupId = getGroupId(topicKey); let groupId = getGroupId(topicKey);
@ -28,11 +38,12 @@ const handleNtfyPublish = async (req: Request) => {
logVerbose(`Created new group for ntfy topic: ${topic}`); 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 || message.substring(0, 50)}`);
logVerbose(`Sent ntfy message to topic ${topic}: ${title || body.substring(0, 50)}`);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
@ -40,7 +51,7 @@ const handleNtfyPublish = async (req: Request) => {
time: Math.floor(Date.now() / 1000), time: Math.floor(Date.now() / 1000),
event: 'message', event: 'message',
topic, topic,
message: body, message,
}), }),
{ {
status: 200, status: 200,

View file

@ -1,5 +1,5 @@
import { timingSafeEqual } from 'node:crypto'; 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'; import { logWarn } from '@/utils/log';
// Rate limiing: track failed auth attempts per IP // Rate limiing: track failed auth attempts per IP
@ -57,21 +57,33 @@ const checkAuth = (req: Request) => {
const host = req.headers.get('host') || ''; const host = req.headers.get('host') || '';
const isLocalhost = host.startsWith('localhost') || host.startsWith('127.0.0.1'); const isLocalhost = host.startsWith('localhost') || host.startsWith('127.0.0.1');
if (proto !== 'https' && !isLocalhost) { if (proto !== 'https' && !isLocalhost && !ALLOW_INSECURE_HTTP) {
return new Response('HTTPS required when API_KEY is configured', { status: 403 }); 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'); const authHeader = req.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Basic ')) {
recordFailedAttempt(clientIP); 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) { if (providedKey.length !== API_KEY.length) {
recordFailedAttempt(clientIP); 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); const providedBuffer = Buffer.from(providedKey);
@ -79,7 +91,10 @@ const checkAuth = (req: Request) => {
if (!timingSafeEqual(providedBuffer, keyBuffer)) { if (!timingSafeEqual(providedBuffer, keyBuffer)) {
recordFailedAttempt(clientIP); 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); 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;
};