update to latest signal-cli and bun, install-signal-cli should now handle updates on new versions, htmx upgrade, adding unifiedpush support

This commit is contained in:
Egor 2026-01-28 20:59:06 -08:00
parent 2716fa1b57
commit e7a42eeb6c
20 changed files with 460 additions and 135 deletions

View file

@ -21,6 +21,10 @@
# Default: false
# VERBOSE_LOGGING=true
# Optional: signal-cli version to install/use
# Default: 0.13.23
# SIGNAL_CLI_VERSION=0.13.23
# 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

View file

@ -15,7 +15,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.6
bun-version: 1.3.7
- name: Install dependencies
run: bun install && cd server && bun install

View file

@ -12,7 +12,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.3.13",
"@types/bun": "1.3.6",
"@types/bun": "1.3.7",
"@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.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
"@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.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],

View file

@ -1,6 +1,6 @@
{
"name": "sup",
"version": "0.1.4",
"version": "0.2.0",
"description": "Privacy-preserving push notifications using Signal as transport",
"private": true,
"type": "module",
@ -29,7 +29,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.3.13",
"@types/bun": "1.3.6",
"@types/bun": "1.3.7",
"@types/imap": "0.8.43",
"typescript": "5.9.3"
}

View file

@ -1,12 +1,29 @@
import { chmod, rename } from 'node:fs/promises';
import { chmod, rename, rm } from 'node:fs/promises';
const SIGNAL_CLI_VERSION = '0.13.22';
const SIGNAL_CLI_VERSION = process.env.SIGNAL_CLI_VERSION || '0.13.23';
const SIGNAL_CLI_URL = `https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}.tar.gz`;
const SIGNAL_CLI_DIR = `${import.meta.dir}/../signal-cli`;
async function installSignalCli() {
if (await Bun.file(`${SIGNAL_CLI_DIR}/bin/signal-cli`).exists()) {
return;
const binaryPath = `${SIGNAL_CLI_DIR}/bin/signal-cli`;
const binaryExists = await Bun.file(binaryPath).exists();
if (binaryExists) {
try {
const proc = Bun.spawn([binaryPath, '--version'], { stdout: 'pipe' });
const output = await new Response(proc.stdout).text();
const installedVersion = output.trim().replace(/^signal-cli\s+/, '');
if (installedVersion === SIGNAL_CLI_VERSION) {
return;
}
console.log(`Upgrading signal-cli from ${installedVersion} to ${SIGNAL_CLI_VERSION}...`);
await rm(SIGNAL_CLI_DIR, { recursive: true, force: true });
} catch {
console.log('Reinstalling signal-cli (version check failed)...');
await rm(SIGNAL_CLI_DIR, { recursive: true, force: true });
}
}
console.log('Downloading signal-cli...');

View file

@ -1,4 +1,4 @@
FROM oven/bun:1.3.6-debian AS builder
FROM oven/bun:1.3.7-debian AS builder
WORKDIR /app
@ -11,7 +11,7 @@ RUN bun build --compile server/index.ts --outfile sup
FROM debian:13.3-slim
ARG SIGNAL_CLI_VERSION=0.13.22
ARG SIGNAL_CLI_VERSION=${SIGNAL_CLI_VERSION:-0.13.23}
RUN apt-get update && apt-get install -y --no-install-recommends \
openjdk-21-jre-headless \

View file

@ -13,3 +13,14 @@ 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 PROTON_SUP_TOPIC = Bun.env.PROTON_SUP_TOPIC || 'Proton Mail';
export const IMAP_INBOX = 'INBOX';
export const IMAP_SEEN_FLAG = '\\Seen';
export const IMAP_RECONNECT_BASE_DELAY = 10000;
export const IMAP_MAX_RECONNECT_DELAY = 300000;
export const ENDPOINT_PREFIX_PROTON = 'proton-';
export const ENDPOINT_PREFIX_NTFY = 'ntfy-';
export const ENDPOINT_PREFIX_UP = 'up-';
export const ACTION_MARK_READ = 'mark-read';

View file

@ -17,6 +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 { getLanIP, isLocalIP } from '@/utils/auth';
import { formatToCspString } from '@/utils/format';
import { logError, logInfo, logVerbose, logWarn } from '@/utils/log';
@ -81,7 +82,8 @@ app.use('*', serveStatic({ root: PUBLIC_DIR }));
app.route('/', ntfy);
app.route('/', admin);
app.route('/', protonMail);
app.route('/api', protonMail);
app.route('/api', unifiedPush);
app.notFound((c) => c.text('Not Found', 404));

View file

@ -0,0 +1,48 @@
import { createGroup, sendGroupMessage } from '@/modules/signal';
import { getMapping, register } from '@/modules/store';
import { sendUnifiedPushNotification } from '@/modules/unified-push';
import type { Notification } from '@/types/notifications';
import { logError, logVerbose } from '@/utils/log';
export const sendNotification = async (endpoint: string, notification: Notification) => {
try {
let mapping = getMapping(endpoint);
if (!mapping) {
const appName = endpoint.replace(/^(ntfy|proton)-/, '');
register(endpoint, appName, 'signal');
mapping = getMapping(endpoint);
if (!mapping) {
logError(`Failed to create mapping for endpoint: ${endpoint}`);
return false;
}
}
const { channel, upEndpoint, appName } = mapping;
let { groupId } = mapping;
if (channel === 'unifiedpush') {
if (!upEndpoint) {
logError(`UnifiedPush endpoint not configured for ${appName}`);
return false;
}
return await sendUnifiedPushNotification(upEndpoint, notification);
}
if (!groupId) {
groupId = await createGroup(appName);
register(endpoint, appName, 'signal', { groupId });
}
await sendGroupMessage(groupId, notification);
logVerbose(`Sent Signal notification to ${appName}: ${notification.message.substring(0, 50)}`);
return true;
} catch (error) {
logError('Failed to send notification:', error);
return false;
}
};

View file

@ -1,50 +1,45 @@
import Imap from 'imap';
import {
ACTION_MARK_READ,
ENDPOINT_PREFIX_PROTON,
IMAP_INBOX,
IMAP_MAX_RECONNECT_DELAY,
IMAP_RECONNECT_BASE_DELAY,
IMAP_SEEN_FLAG,
PROTON_BRIDGE_HOST,
PROTON_BRIDGE_PORT,
PROTON_IMAP_PASSWORD,
PROTON_IMAP_USERNAME,
PROTON_SUP_TOPIC,
} from '@/constants/config';
import { hasValidAccount, sendGroupMessage } from '@/modules/signal';
import { getOrCreateGroup } from '@/modules/store';
import { sendNotification as sendChannelNotification } from '@/modules/notifications';
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
let imapConnected = false;
let monitorStartTime = 0;
let reconnectAttempts = 0;
const MAX_RECONNECT_DELAY = 300000; // 5 minutes
let imapInstance: Imap | null = null;
let reconnectTimeout: Timer | null = null;
export const isImapConnected = () => imapConnected;
const getReconnectDelay = () => {
const baseDelay = 10000; // 10 seconds
const delay = Math.min(baseDelay * 2 ** reconnectAttempts, MAX_RECONNECT_DELAY);
const scheduleReconnect = () => {
const delay = Math.min(
IMAP_RECONNECT_BASE_DELAY * 2 ** reconnectAttempts,
IMAP_MAX_RECONNECT_DELAY,
);
reconnectAttempts++;
return delay;
logInfo(`Reconnecting in ${delay / 1000}s (attempt ${reconnectAttempts})...`);
reconnectTimeout = setTimeout(() => {
if (!imapConnected && imapInstance) {
logInfo('Reconnecting to Proton Bridge...');
imapInstance.connect();
}
}, delay);
};
async function sendNotification(from: string, subject: string) {
if (!(await hasValidAccount())) {
logVerbose('Skipping notification (Signal not linked)');
return;
}
try {
const groupId = await getOrCreateGroup(`proton-${PROTON_SUP_TOPIC}`, PROTON_SUP_TOPIC);
await sendGroupMessage(groupId, subject, {
title: from,
});
logVerbose(`Email from ${from}: ${subject}`);
} catch (error) {
logError('Failed to send notification:', error);
}
}
export async function startProtonMonitor() {
if (!PROTON_IMAP_USERNAME || !PROTON_IMAP_PASSWORD) {
logError('Missing required env vars: PROTON_IMAP_USERNAME and PROTON_IMAP_PASSWORD');
@ -66,7 +61,7 @@ export async function startProtonMonitor() {
});
const openInbox = () =>
imap.openBox('INBOX', false, (err, box) => {
imap.openBox(IMAP_INBOX, false, (err, box) => {
if (err) {
logError('Failed to open inbox:', err);
@ -87,7 +82,13 @@ export async function startProtonMonitor() {
struct: true,
});
fetch.on('message', (msg) => {
fetch.on('message', async (msg) => {
const uidPromise = new Promise<number>((resolve) =>
msg.once('attributes', (attrs) => {
resolve(attrs.uid);
}),
);
msg.on('body', (stream) => {
let buffer = '';
@ -95,7 +96,7 @@ export async function startProtonMonitor() {
buffer += chunk.toString('utf8');
});
stream.once('end', () => {
stream.once('end', async () => {
const header = Imap.parseHeader(buffer);
const rawFrom = header.from?.[0] || 'Unknown sender';
const subject = header.subject?.[0] || 'No subject';
@ -121,7 +122,22 @@ export async function startProtonMonitor() {
reconnectAttempts = 0;
sendNotification(from, subject);
const uid = await uidPromise;
await sendChannelNotification(`${ENDPOINT_PREFIX_PROTON}${PROTON_SUP_TOPIC}`, {
title: from,
message: subject,
actions: [
{
id: ACTION_MARK_READ,
endpoint: '/api/proton-mail/mark-read',
method: 'POST',
data: { uid },
},
],
});
logVerbose(`Email from ${from}: ${subject}`);
});
});
});
@ -140,34 +156,27 @@ export async function startProtonMonitor() {
logError('IMAP error:', err.message);
});
const handleReconnect = (reason: string) => {
imapConnected = false;
logError(reason);
const delay = getReconnectDelay();
logInfo(`Attempting to reconnect in ${delay / 1000}s (attempt ${reconnectAttempts})...`);
setTimeout(() => {
if (!imapConnected) {
logInfo('Reconnecting to Proton Bridge...');
imap.connect();
}
}, delay);
};
imap.on('close', (hadError: boolean) => {
handleReconnect(`IMAP connection closed (hadError: ${hadError})`);
imapConnected = false;
logError(`IMAP connection closed (hadError: ${hadError})`);
scheduleReconnect();
});
imap.on('end', () => {
handleReconnect('IMAP connection ended by server');
imapConnected = false;
logError('IMAP connection ended by server');
scheduleReconnect();
});
imap.connect();
process.on('SIGTERM', () => imap.end());
const cleanup = () => {
if (reconnectTimeout) clearTimeout(reconnectTimeout);
imap.end();
};
process.on('SIGINT', () => imap.end());
process.on('SIGTERM', cleanup);
process.on('SIGINT', cleanup);
imapInstance = imap;
}
@ -179,7 +188,7 @@ export async function markEmailAsRead(uid: number) {
return new Promise<{ success: boolean; error?: string }>((resolve) => {
try {
imapInstance?.addFlags(uid, '\\Seen', (err) => {
imapInstance?.addFlags(uid, IMAP_SEEN_FLAG, (err) => {
if (err) {
logError('Failed to mark email as read:', err);
resolve({ success: false, error: err.message });

View file

@ -1,6 +1,7 @@
import { rm } from 'node:fs/promises';
import { DEVICE_NAME, VERBOSE_LOGGING } from '@/constants/config';
import { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '@/constants/paths';
import type { Notification } from '@/types/notifications';
import { formatPhoneNumber } from '@/utils/format';
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
import { call } from '@/utils/rpc';
@ -139,16 +140,10 @@ export async function createGroup(name: string, members: string[] = []) {
return result.groupId;
}
export async function sendGroupMessage(
groupId: string,
message: string,
options?: { title?: string },
) {
let formattedMessage = message;
if (options?.title) {
formattedMessage = `${options.title}\n${message}`;
}
export async function sendGroupMessage(groupId: string, notification: Notification) {
const formattedMessage = notification.title
? `${notification.title}\n${notification.message}`
: notification.message;
await call(
'send',
@ -285,7 +280,16 @@ export async function startDaemon() {
logError('Account authorization failed. You may need to unlink and re-link your device.');
}
throw new Error('Failed to start signal-cli daemon');
logError('Failed to start signal-cli daemon, will retry later...');
setTimeout(() => {
logInfo('Retrying signal-cli daemon startup...');
startDaemon().catch(() => {
// Already logged, just prevent unhandled rejection
});
}, 30000);
return null;
}
export const cleanupDaemon = () => daemon?.kill();

View file

@ -1,58 +1,69 @@
import { Database } from 'bun:sqlite';
import { SUP_DB } from '@/constants/paths';
import { createGroup } from '@/modules/signal';
import type { NotificationChannel } from '@/types/notifications';
interface EndpointMapping {
type EndpointMapping = {
endpoint: string;
groupId: string;
appName: string;
}
channel: NotificationChannel;
groupId: string | null;
upEndpoint: string | null;
};
const db = new Database(SUP_DB);
db.run(`
CREATE TABLE IF NOT EXISTS mappings (
endpoint TEXT PRIMARY KEY,
groupId TEXT NOT NULL,
appName TEXT NOT NULL
groupId TEXT,
appName TEXT NOT NULL,
channel TEXT NOT NULL DEFAULT 'signal',
upEndpoint TEXT
)
`);
export const register = (endpoint: string, groupId: string, appName: string) =>
db.run('INSERT OR REPLACE INTO mappings (endpoint, groupId, appName) VALUES (?, ?, ?)', [
endpoint,
groupId,
appName,
]);
export function register(
endpoint: string,
appName: string,
channel: 'signal',
options?: { groupId?: string },
): void;
export function register(
endpoint: string,
appName: string,
channel: 'unifiedpush',
options: { upEndpoint: string },
): void;
export function register(
endpoint: string,
appName: string,
channel: NotificationChannel,
options: { groupId?: string; upEndpoint?: string } = {},
) {
const { groupId = null, upEndpoint = null } = options;
db.run(
'INSERT OR IGNORE INTO mappings (endpoint, groupId, appName, channel, upEndpoint) VALUES (?, ?, ?, ?, ?)',
[endpoint, groupId, appName, channel, upEndpoint],
);
}
export const getGroupId = (endpoint: string) => {
const row = db.query('SELECT groupId FROM mappings WHERE endpoint = ?').get(endpoint) as
| { groupId: string }
| undefined;
export const getMapping = (endpoint: string) => {
const row = db
.query(
'SELECT endpoint, groupId, appName, channel, upEndpoint FROM mappings WHERE endpoint = ?',
)
.get(endpoint) as EndpointMapping | undefined;
return row?.groupId;
};
export const getAppName = (endpoint: string) => {
const row = db.query('SELECT appName FROM mappings WHERE endpoint = ?').get(endpoint) as
| { appName: string }
| undefined;
return row?.appName;
return row;
};
export const getAllMappings = () =>
db.query('SELECT endpoint, groupId, appName FROM mappings').all() as EndpointMapping[];
db
.query('SELECT endpoint, groupId, appName, channel, upEndpoint FROM mappings')
.all() as EndpointMapping[];
export const remove = (endpoint: string) =>
export const updateChannel = (endpoint: string, channel: NotificationChannel) =>
db.run('UPDATE mappings SET channel = ? WHERE endpoint = ?', [channel, endpoint]);
export const removeEndpoint = (endpoint: string) =>
db.run('DELETE FROM mappings WHERE endpoint = ?', [endpoint]);
export const getOrCreateGroup = async (key: string, name: string) => {
const existingGroupId = getGroupId(key);
if (existingGroupId) return existingGroupId;
const groupId = await createGroup(name);
register(key, groupId, name);
return groupId;
};

View file

@ -0,0 +1,32 @@
import type { Notification } from '@/types/notifications';
import { logError, logVerbose } from '@/utils/log';
export const sendUnifiedPushNotification = async (endpoint: string, notification: Notification) => {
try {
const payload = {
message: notification.message,
...(notification.title && { title: notification.title }),
...(notification.actions && { actions: notification.actions }),
};
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
logError(`Failed to send UP notification: ${response.status} ${response.statusText}`);
return false;
}
logVerbose(`Sent UP notification to ${endpoint}: ${notification.message.substring(0, 50)}`);
return true;
} catch (error) {
logError('Failed to send UP notification:', error);
return false;
}
};

File diff suppressed because one or more lines are too long

View file

@ -8,6 +8,8 @@
--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);
}
@media (prefers-color-scheme: dark) {
@ -20,6 +22,8 @@
--success: #28a745;
--error: #ef4444;
--spinner-track: #4a4a4a;
--badge-signal-bg: #8159b8;
--badge-up-bg: #28a745;
}
}
@ -32,6 +36,8 @@
--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"] {
@ -43,6 +49,8 @@
--success: #28a745;
--error: #ef4444;
--spinner-track: #4a4a4a;
--badge-signal-bg: #8159b8;
--badge-up-bg: #28a745;
}
/* CSS Reset */
@ -203,15 +211,68 @@ h2 {
.endpoint-item {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
justify-content: space-between;
padding: 0.75rem;
background: var(--bg-primary);
border-radius: 0.25rem;
margin-bottom: 0.375rem;
margin-bottom: 0.5rem;
gap: 1rem;
}
.endpoint-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.endpoint-name {
font-size: 1.1em;
flex: 1;
}
.endpoint-channel {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9em;
}
.channel-badge {
padding: 0.15rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.85em;
font-weight: 500;
}
.channel-signal {
background: var(--badge-signal-bg);
color: white;
}
.channel-unifiedpush {
background: var(--badge-up-bg);
color: white;
}
.endpoint-detail {
color: var(--text-secondary);
font-size: 0.85em;
}
.endpoint-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.channel-select {
padding: 0.375rem 0.5rem;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.9em;
}
.btn-delete {
@ -222,6 +283,7 @@ h2 {
border-radius: 0.25rem;
cursor: pointer;
transition: filter 0.2s;
white-space: nowrap;
}
.btn-delete:hover {

View file

@ -9,9 +9,11 @@ import {
getAccount,
hasValidAccount,
} from '@/modules/signal';
import { getAllMappings, remove } from '@/modules/store';
import { getAllMappings, removeEndpoint, updateChannel } from '@/modules/store';
import type { NotificationChannel } from '@/types/notifications';
import { verifyApiKey } from '@/utils/auth';
import { formatPhoneNumber, formatUptime } from '@/utils/format';
import { logError } from '@/utils/log';
let cachedQR: string | null = null;
let qrCacheTime = 0;
@ -59,17 +61,43 @@ admin.get('/fragment/endpoints', async (c) => c.html(await handleEndpointsFragme
admin.get('/fragment/link-qr', async (c) => c.html(await handleQRSection()));
admin.delete('/action/delete-endpoint/:endpoint', async (c) => {
const endpoint = decodeURIComponent(c.req.param('endpoint'));
admin.delete('/action/delete-endpoint', async (c) => {
const formData = await c.req.formData();
const endpoint = formData.get('endpoint') as string;
if (!endpoint) {
return c.text('Invalid endpoint', 400);
}
remove(endpoint);
removeEndpoint(endpoint);
return c.html(await handleEndpointsFragment());
});
admin.post('/action/toggle-channel', async (c) => {
try {
const formData = await c.req.formData();
const endpoint = formData.get('endpoint') as string;
const channel = formData.get('channel') as NotificationChannel;
if (!endpoint) {
return c.json({ error: 'Invalid endpoint' }, 400);
}
if (!channel || !['signal', 'unifiedpush'].includes(channel)) {
return c.json({ error: 'Invalid channel' }, 400);
}
updateChannel(endpoint, channel);
return c.html(await handleEndpointsFragment());
} catch (err) {
logError('Failed to toggle channel:', err);
return c.text('Invalid request', 400);
}
});
const handleHealthFragment = async () => {
const signalOk = await checkSignalCli();
const linked = signalOk && (await hasValidAccount());
@ -129,17 +157,49 @@ const handleEndpointsFragment = async () => {
<ul class="endpoint-list">
${endpoints
.map(
(e: { appName: string; endpoint: string }) => `
(e) => `
<li class="endpoint-item">
<div class="endpoint-name">
<strong>${e.appName}</strong>
<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>` : ''}
</div>
</div>
<div class="endpoint-actions">
${
e.upEndpoint
? `
<form style="display: inline;">
<input type="hidden" name="endpoint" value="${e.endpoint.replace(/"/g, '&quot;')}" />
<select
class="channel-select"
name="channel"
hx-post="/action/toggle-channel"
hx-target="#endpoints-list"
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>
</select>
</form>
`
: ''
}
<form style="display: inline;">
<input type="hidden" name="endpoint" value="${e.endpoint.replace(/"/g, '&quot;')}" />
<button
class="btn-delete"
hx-delete="/action/delete-endpoint"
hx-target="#endpoints-list"
hx-swap="innerHTML"
hx-include="closest form"
>Delete</button>
</form>
</div>
<button
class="btn-delete"
hx-delete="/action/delete-endpoint/${encodeURIComponent(e.endpoint)}"
hx-target="#endpoints-list"
hx-swap="innerHTML"
>Delete</button>
</li>
`,
)

View file

@ -1,7 +1,6 @@
import { Hono } from 'hono';
import { basicAuth } from 'hono/basic-auth';
import { sendGroupMessage } from '@/modules/signal';
import { getOrCreateGroup } from '@/modules/store';
import { sendNotification } from '@/modules/notifications';
import { verifyApiKey } from '@/utils/auth';
import { logError, logVerbose } from '@/utils/log';
@ -40,10 +39,9 @@ ntfy.post('/:topic', async (c) => {
if (title === topic) title = undefined;
const groupId = await getOrCreateGroup(`ntfy-${topic}`, topic);
await sendGroupMessage(groupId, message, {
await sendNotification(`ntfy-${topic}`, {
title: title || undefined,
message: message,
});
logVerbose(`Sent ntfy message to topic ${topic}: ${title || message.substring(0, 50)}`);

View file

@ -14,7 +14,7 @@ protonMail.use(
}),
);
protonMail.post('/api/proton-mail/mark-read', async (c) => {
protonMail.post('/proton-mail/mark-read', async (c) => {
try {
const body = await c.req.json();
const { uid } = body;

View file

@ -0,0 +1,53 @@
import { Hono } from 'hono';
import { basicAuth } from 'hono/basic-auth';
import { ENDPOINT_PREFIX_UP } from '@/constants/config';
import { register } from '@/modules/store';
import { verifyApiKey } from '@/utils/auth';
import { logError, logVerbose } from '@/utils/log';
export const unifiedPush = new Hono();
unifiedPush.use(
'*',
basicAuth({
verifyUser: (_, password) => verifyApiKey(password),
realm: 'SUP UnifiedPush - Username: any, Password: API_KEY',
}),
);
unifiedPush.post('/up/register', async (c) => {
try {
const body = await c.req.json<{
appName: string;
upEndpoint: string;
}>();
const { appName, upEndpoint } = body;
if (!appName || !upEndpoint) {
return c.json({ error: 'appName and upEndpoint are required' }, 400);
}
try {
new URL(upEndpoint);
} catch {
return c.json({ error: 'Invalid upEndpoint URL' }, 400);
}
const endpoint = `${ENDPOINT_PREFIX_UP}${appName}`;
register(endpoint, appName, 'unifiedpush', { upEndpoint });
logVerbose(`Registered UP endpoint for ${appName}: ${upEndpoint}`);
return c.json({
endpoint,
appName,
channel: 'unifiedpush',
});
} catch (error) {
logError('Failed to register UP endpoint:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});

14
server/types/notifications.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
export interface UnifiedPushAction {
id: string;
endpoint: string;
method: 'POST' | 'GET' | 'DELETE';
data?: Record<string, unknown>;
}
export interface Notification {
title?: string;
message: string;
actions?: UnifiedPushAction[];
}
export type NotificationChannel = 'signal' | 'unifiedpush';