mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
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:
parent
2716fa1b57
commit
e7a42eeb6c
20 changed files with 460 additions and 135 deletions
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.6
|
||||
bun-version: 1.3.7
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install && cd server && bun install
|
||||
|
|
|
|||
6
bun.lock
6
bun.lock
|
|
@ -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=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...');
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
48
server/modules/notifications.ts
Normal file
48
server/modules/notifications.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
32
server/modules/unified-push.ts
Normal file
32
server/modules/unified-push.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
2
server/public/htmx.min.js
vendored
2
server/public/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, '"')}" />
|
||||
<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, '"')}" />
|
||||
<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>
|
||||
`,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
53
server/routes/unified-push.ts
Normal file
53
server/routes/unified-push.ts
Normal 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
14
server/types/notifications.d.ts
vendored
Normal 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';
|
||||
Loading…
Add table
Reference in a new issue