mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
improvements and small additions to the server ntfy notification flow
This commit is contained in:
parent
e2f794edaf
commit
7e7ddd7d0e
12 changed files with 283 additions and 132 deletions
|
|
@ -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
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
18
server/utils/ip.ts
Normal 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;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue