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
|
# Default: false
|
||||||
# VERBOSE_LOGGING=true
|
# 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
|
# 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
|
||||||
|
|
|
||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
bun-version: 1.3.6
|
bun-version: 1.3.7
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install && cd server && bun install
|
run: bun install && cd server && bun install
|
||||||
|
|
|
||||||
6
bun.lock
6
bun.lock
|
|
@ -12,7 +12,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.3.13",
|
"@biomejs/biome": "2.3.13",
|
||||||
"@types/bun": "1.3.6",
|
"@types/bun": "1.3.7",
|
||||||
"@types/imap": "0.8.43",
|
"@types/imap": "0.8.43",
|
||||||
"typescript": "5.9.3",
|
"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=="],
|
"@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/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=="],
|
"@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=="],
|
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "sup",
|
"name": "sup",
|
||||||
"version": "0.1.4",
|
"version": "0.2.0",
|
||||||
"description": "Privacy-preserving push notifications using Signal as transport",
|
"description": "Privacy-preserving push notifications using Signal as transport",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.3.13",
|
"@biomejs/biome": "2.3.13",
|
||||||
"@types/bun": "1.3.6",
|
"@types/bun": "1.3.7",
|
||||||
"@types/imap": "0.8.43",
|
"@types/imap": "0.8.43",
|
||||||
"typescript": "5.9.3"
|
"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_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`;
|
const SIGNAL_CLI_DIR = `${import.meta.dir}/../signal-cli`;
|
||||||
|
|
||||||
async function installSignalCli() {
|
async function installSignalCli() {
|
||||||
if (await Bun.file(`${SIGNAL_CLI_DIR}/bin/signal-cli`).exists()) {
|
const binaryPath = `${SIGNAL_CLI_DIR}/bin/signal-cli`;
|
||||||
return;
|
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...');
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
@ -11,7 +11,7 @@ RUN bun build --compile server/index.ts --outfile sup
|
||||||
|
|
||||||
FROM debian:13.3-slim
|
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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
openjdk-21-jre-headless \
|
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_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 PROTON_SUP_TOPIC = Bun.env.PROTON_SUP_TOPIC || 'Proton Mail';
|
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 { admin } from '@/routes/admin';
|
||||||
import { ntfy } from '@/routes/ntfy';
|
import { ntfy } from '@/routes/ntfy';
|
||||||
import { protonMail } from '@/routes/proton-mail';
|
import { protonMail } from '@/routes/proton-mail';
|
||||||
|
import { unifiedPush } from '@/routes/unified-push';
|
||||||
import { getLanIP, isLocalIP } from '@/utils/auth';
|
import { getLanIP, isLocalIP } from '@/utils/auth';
|
||||||
import { formatToCspString } from '@/utils/format';
|
import { formatToCspString } from '@/utils/format';
|
||||||
import { logError, logInfo, logVerbose, logWarn } from '@/utils/log';
|
import { logError, logInfo, logVerbose, logWarn } from '@/utils/log';
|
||||||
|
|
@ -81,7 +82,8 @@ app.use('*', serveStatic({ root: PUBLIC_DIR }));
|
||||||
|
|
||||||
app.route('/', ntfy);
|
app.route('/', ntfy);
|
||||||
app.route('/', admin);
|
app.route('/', admin);
|
||||||
app.route('/', protonMail);
|
app.route('/api', protonMail);
|
||||||
|
app.route('/api', unifiedPush);
|
||||||
|
|
||||||
app.notFound((c) => c.text('Not Found', 404));
|
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 Imap from 'imap';
|
||||||
import {
|
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_HOST,
|
||||||
PROTON_BRIDGE_PORT,
|
PROTON_BRIDGE_PORT,
|
||||||
PROTON_IMAP_PASSWORD,
|
PROTON_IMAP_PASSWORD,
|
||||||
PROTON_IMAP_USERNAME,
|
PROTON_IMAP_USERNAME,
|
||||||
PROTON_SUP_TOPIC,
|
PROTON_SUP_TOPIC,
|
||||||
} from '@/constants/config';
|
} from '@/constants/config';
|
||||||
import { hasValidAccount, sendGroupMessage } from '@/modules/signal';
|
import { sendNotification as sendChannelNotification } from '@/modules/notifications';
|
||||||
import { getOrCreateGroup } from '@/modules/store';
|
|
||||||
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
|
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
|
||||||
|
|
||||||
let imapConnected = false;
|
let imapConnected = false;
|
||||||
let monitorStartTime = 0;
|
let monitorStartTime = 0;
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
const MAX_RECONNECT_DELAY = 300000; // 5 minutes
|
|
||||||
let imapInstance: Imap | null = null;
|
let imapInstance: Imap | null = null;
|
||||||
|
let reconnectTimeout: Timer | null = null;
|
||||||
|
|
||||||
export const isImapConnected = () => imapConnected;
|
export const isImapConnected = () => imapConnected;
|
||||||
|
|
||||||
const getReconnectDelay = () => {
|
const scheduleReconnect = () => {
|
||||||
const baseDelay = 10000; // 10 seconds
|
const delay = Math.min(
|
||||||
const delay = Math.min(baseDelay * 2 ** reconnectAttempts, MAX_RECONNECT_DELAY);
|
IMAP_RECONNECT_BASE_DELAY * 2 ** reconnectAttempts,
|
||||||
|
IMAP_MAX_RECONNECT_DELAY,
|
||||||
|
);
|
||||||
reconnectAttempts++;
|
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() {
|
export async function startProtonMonitor() {
|
||||||
if (!PROTON_IMAP_USERNAME || !PROTON_IMAP_PASSWORD) {
|
if (!PROTON_IMAP_USERNAME || !PROTON_IMAP_PASSWORD) {
|
||||||
logError('Missing required env vars: PROTON_IMAP_USERNAME and 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 = () =>
|
const openInbox = () =>
|
||||||
imap.openBox('INBOX', false, (err, box) => {
|
imap.openBox(IMAP_INBOX, false, (err, box) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logError('Failed to open inbox:', err);
|
logError('Failed to open inbox:', err);
|
||||||
|
|
||||||
|
|
@ -87,7 +82,13 @@ export async function startProtonMonitor() {
|
||||||
struct: true,
|
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) => {
|
msg.on('body', (stream) => {
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
|
|
||||||
|
|
@ -95,7 +96,7 @@ export async function startProtonMonitor() {
|
||||||
buffer += chunk.toString('utf8');
|
buffer += chunk.toString('utf8');
|
||||||
});
|
});
|
||||||
|
|
||||||
stream.once('end', () => {
|
stream.once('end', async () => {
|
||||||
const header = Imap.parseHeader(buffer);
|
const header = Imap.parseHeader(buffer);
|
||||||
const rawFrom = header.from?.[0] || 'Unknown sender';
|
const rawFrom = header.from?.[0] || 'Unknown sender';
|
||||||
const subject = header.subject?.[0] || 'No subject';
|
const subject = header.subject?.[0] || 'No subject';
|
||||||
|
|
@ -121,7 +122,22 @@ export async function startProtonMonitor() {
|
||||||
|
|
||||||
reconnectAttempts = 0;
|
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);
|
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) => {
|
imap.on('close', (hadError: boolean) => {
|
||||||
handleReconnect(`IMAP connection closed (hadError: ${hadError})`);
|
imapConnected = false;
|
||||||
|
logError(`IMAP connection closed (hadError: ${hadError})`);
|
||||||
|
scheduleReconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
imap.on('end', () => {
|
imap.on('end', () => {
|
||||||
handleReconnect('IMAP connection ended by server');
|
imapConnected = false;
|
||||||
|
logError('IMAP connection ended by server');
|
||||||
|
scheduleReconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
imap.connect();
|
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;
|
imapInstance = imap;
|
||||||
}
|
}
|
||||||
|
|
@ -179,7 +188,7 @@ export async function markEmailAsRead(uid: number) {
|
||||||
|
|
||||||
return new Promise<{ success: boolean; error?: string }>((resolve) => {
|
return new Promise<{ success: boolean; error?: string }>((resolve) => {
|
||||||
try {
|
try {
|
||||||
imapInstance?.addFlags(uid, '\\Seen', (err) => {
|
imapInstance?.addFlags(uid, IMAP_SEEN_FLAG, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
logError('Failed to mark email as read:', err);
|
logError('Failed to mark email as read:', err);
|
||||||
resolve({ success: false, error: err.message });
|
resolve({ success: false, error: err.message });
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { rm } from 'node:fs/promises';
|
import { rm } from 'node:fs/promises';
|
||||||
import { DEVICE_NAME, VERBOSE_LOGGING } from '@/constants/config';
|
import { DEVICE_NAME, VERBOSE_LOGGING } 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 { Notification } from '@/types/notifications';
|
||||||
import { formatPhoneNumber } from '@/utils/format';
|
import { formatPhoneNumber } from '@/utils/format';
|
||||||
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
|
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
|
||||||
import { call } from '@/utils/rpc';
|
import { call } from '@/utils/rpc';
|
||||||
|
|
@ -139,16 +140,10 @@ export async function createGroup(name: string, members: string[] = []) {
|
||||||
return result.groupId;
|
return result.groupId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendGroupMessage(
|
export async function sendGroupMessage(groupId: string, notification: Notification) {
|
||||||
groupId: string,
|
const formattedMessage = notification.title
|
||||||
message: string,
|
? `${notification.title}\n${notification.message}`
|
||||||
options?: { title?: string },
|
: notification.message;
|
||||||
) {
|
|
||||||
let formattedMessage = message;
|
|
||||||
|
|
||||||
if (options?.title) {
|
|
||||||
formattedMessage = `${options.title}\n${message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
await call(
|
await call(
|
||||||
'send',
|
'send',
|
||||||
|
|
@ -285,7 +280,16 @@ export async function startDaemon() {
|
||||||
logError('Account authorization failed. You may need to unlink and re-link your device.');
|
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();
|
export const cleanupDaemon = () => daemon?.kill();
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,69 @@
|
||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
import { SUP_DB } from '@/constants/paths';
|
import { SUP_DB } from '@/constants/paths';
|
||||||
import { createGroup } from '@/modules/signal';
|
import type { NotificationChannel } from '@/types/notifications';
|
||||||
|
|
||||||
interface EndpointMapping {
|
type EndpointMapping = {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
groupId: string;
|
|
||||||
appName: string;
|
appName: string;
|
||||||
}
|
channel: NotificationChannel;
|
||||||
|
groupId: string | null;
|
||||||
|
upEndpoint: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const db = new Database(SUP_DB);
|
const db = new Database(SUP_DB);
|
||||||
|
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS mappings (
|
CREATE TABLE IF NOT EXISTS mappings (
|
||||||
endpoint TEXT PRIMARY KEY,
|
endpoint TEXT PRIMARY KEY,
|
||||||
groupId TEXT NOT NULL,
|
groupId TEXT,
|
||||||
appName TEXT NOT NULL
|
appName TEXT NOT NULL,
|
||||||
|
channel TEXT NOT NULL DEFAULT 'signal',
|
||||||
|
upEndpoint TEXT
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
export const register = (endpoint: string, groupId: string, appName: string) =>
|
export function register(
|
||||||
db.run('INSERT OR REPLACE INTO mappings (endpoint, groupId, appName) VALUES (?, ?, ?)', [
|
endpoint: string,
|
||||||
endpoint,
|
appName: string,
|
||||||
groupId,
|
channel: 'signal',
|
||||||
appName,
|
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) => {
|
export const getMapping = (endpoint: string) => {
|
||||||
const row = db.query('SELECT groupId FROM mappings WHERE endpoint = ?').get(endpoint) as
|
const row = db
|
||||||
| { groupId: string }
|
.query(
|
||||||
| undefined;
|
'SELECT endpoint, groupId, appName, channel, upEndpoint FROM mappings WHERE endpoint = ?',
|
||||||
|
)
|
||||||
|
.get(endpoint) as EndpointMapping | undefined;
|
||||||
|
|
||||||
return row?.groupId;
|
return row;
|
||||||
};
|
|
||||||
|
|
||||||
export const getAppName = (endpoint: string) => {
|
|
||||||
const row = db.query('SELECT appName FROM mappings WHERE endpoint = ?').get(endpoint) as
|
|
||||||
| { appName: string }
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
return row?.appName;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllMappings = () =>
|
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]);
|
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;
|
--success: #28a745;
|
||||||
--error: #dc3545;
|
--error: #dc3545;
|
||||||
--spinner-track: #e0e0e0;
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|
@ -20,6 +22,8 @@
|
||||||
--success: #28a745;
|
--success: #28a745;
|
||||||
--error: #ef4444;
|
--error: #ef4444;
|
||||||
--spinner-track: #4a4a4a;
|
--spinner-track: #4a4a4a;
|
||||||
|
--badge-signal-bg: #8159b8;
|
||||||
|
--badge-up-bg: #28a745;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,6 +36,8 @@
|
||||||
--success: #28a745;
|
--success: #28a745;
|
||||||
--error: #dc3545;
|
--error: #dc3545;
|
||||||
--spinner-track: #e0e0e0;
|
--spinner-track: #e0e0e0;
|
||||||
|
--badge-signal-bg: rgba(129, 89, 184, 0.2);
|
||||||
|
--badge-up-bg: rgba(40, 167, 69, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
|
|
@ -43,6 +49,8 @@
|
||||||
--success: #28a745;
|
--success: #28a745;
|
||||||
--error: #ef4444;
|
--error: #ef4444;
|
||||||
--spinner-track: #4a4a4a;
|
--spinner-track: #4a4a4a;
|
||||||
|
--badge-signal-bg: #8159b8;
|
||||||
|
--badge-up-bg: #28a745;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CSS Reset */
|
/* CSS Reset */
|
||||||
|
|
@ -203,15 +211,68 @@ h2 {
|
||||||
.endpoint-item {
|
.endpoint-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.5rem 0.75rem;
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border-radius: 0.25rem;
|
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 {
|
.endpoint-name {
|
||||||
font-size: 1.1em;
|
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 {
|
.btn-delete {
|
||||||
|
|
@ -222,6 +283,7 @@ h2 {
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: filter 0.2s;
|
transition: filter 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete:hover {
|
.btn-delete:hover {
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ import {
|
||||||
getAccount,
|
getAccount,
|
||||||
hasValidAccount,
|
hasValidAccount,
|
||||||
} from '@/modules/signal';
|
} 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 { verifyApiKey } from '@/utils/auth';
|
||||||
import { formatPhoneNumber, formatUptime } from '@/utils/format';
|
import { formatPhoneNumber, formatUptime } from '@/utils/format';
|
||||||
|
import { logError } from '@/utils/log';
|
||||||
|
|
||||||
let cachedQR: string | null = null;
|
let cachedQR: string | null = null;
|
||||||
let qrCacheTime = 0;
|
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.get('/fragment/link-qr', async (c) => c.html(await handleQRSection()));
|
||||||
|
|
||||||
admin.delete('/action/delete-endpoint/:endpoint', async (c) => {
|
admin.delete('/action/delete-endpoint', async (c) => {
|
||||||
const endpoint = decodeURIComponent(c.req.param('endpoint'));
|
const formData = await c.req.formData();
|
||||||
|
const endpoint = formData.get('endpoint') as string;
|
||||||
|
|
||||||
if (!endpoint) {
|
if (!endpoint) {
|
||||||
return c.text('Invalid endpoint', 400);
|
return c.text('Invalid endpoint', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(endpoint);
|
removeEndpoint(endpoint);
|
||||||
|
|
||||||
return c.html(await handleEndpointsFragment());
|
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 handleHealthFragment = async () => {
|
||||||
const signalOk = await checkSignalCli();
|
const signalOk = await checkSignalCli();
|
||||||
const linked = signalOk && (await hasValidAccount());
|
const linked = signalOk && (await hasValidAccount());
|
||||||
|
|
@ -129,17 +157,49 @@ const handleEndpointsFragment = async () => {
|
||||||
<ul class="endpoint-list">
|
<ul class="endpoint-list">
|
||||||
${endpoints
|
${endpoints
|
||||||
.map(
|
.map(
|
||||||
(e: { appName: string; endpoint: string }) => `
|
(e) => `
|
||||||
<li class="endpoint-item">
|
<li class="endpoint-item">
|
||||||
<div class="endpoint-name">
|
<div class="endpoint-info">
|
||||||
<strong>${e.appName}</strong>
|
<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>
|
</div>
|
||||||
<button
|
|
||||||
class="btn-delete"
|
|
||||||
hx-delete="/action/delete-endpoint/${encodeURIComponent(e.endpoint)}"
|
|
||||||
hx-target="#endpoints-list"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
>Delete</button>
|
|
||||||
</li>
|
</li>
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { basicAuth } from 'hono/basic-auth';
|
import { basicAuth } from 'hono/basic-auth';
|
||||||
import { sendGroupMessage } from '@/modules/signal';
|
import { sendNotification } from '@/modules/notifications';
|
||||||
import { getOrCreateGroup } from '@/modules/store';
|
|
||||||
import { verifyApiKey } from '@/utils/auth';
|
import { verifyApiKey } from '@/utils/auth';
|
||||||
import { logError, logVerbose } from '@/utils/log';
|
import { logError, logVerbose } from '@/utils/log';
|
||||||
|
|
||||||
|
|
@ -40,10 +39,9 @@ ntfy.post('/:topic', async (c) => {
|
||||||
|
|
||||||
if (title === topic) title = undefined;
|
if (title === topic) title = undefined;
|
||||||
|
|
||||||
const groupId = await getOrCreateGroup(`ntfy-${topic}`, topic);
|
await sendNotification(`ntfy-${topic}`, {
|
||||||
|
|
||||||
await sendGroupMessage(groupId, message, {
|
|
||||||
title: title || undefined,
|
title: title || undefined,
|
||||||
|
message: message,
|
||||||
});
|
});
|
||||||
|
|
||||||
logVerbose(`Sent ntfy message to topic ${topic}: ${title || message.substring(0, 50)}`);
|
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 {
|
try {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { uid } = body;
|
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