milestone: proton-bridge notifications are working well, new admin view

This commit is contained in:
lone-cloud 2026-01-20 00:25:11 -08:00
parent 08e5db0597
commit 44804ac425
30 changed files with 571 additions and 400 deletions

View file

@ -70,7 +70,6 @@ To receive ProtonMail notifications via Signal:
- Enter your ProtonMail email - Enter your ProtonMail email
- Enter your ProtonMail password - Enter your ProtonMail password
- Enter your 2FA code - Enter your 2FA code
- Wait (potentially a long time) for ProtonMail Bridge to sync emails
3. **Get IMAP credentials**: 3. **Get IMAP credentials**:
- Run: `info` - Run: `info`
@ -113,6 +112,12 @@ Then build and run with docker-compose.dev.yml:
docker compose --profile protonmail -f docker-compose.dev.yml up -d docker compose --profile protonmail -f docker-compose.dev.yml up -d
``` ```
or just the proton-bridge:
```bash
docker compose -f docker-compose.dev.yml up protonmail-bridge
```
Or run services directly with Bun: Or run services directly with Bun:
```bash ```bash

View file

@ -22,6 +22,8 @@ services:
protonmail-bridge: protonmail-bridge:
image: shenxn/protonmail-bridge:build image: shenxn/protonmail-bridge:build
profiles: ['protonmail'] profiles: ['protonmail']
ports:
- '127.0.0.1:143:143'
volumes: volumes:
- proton-bridge-data:/root - proton-bridge-data:/root
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro - /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro

View file

@ -14,8 +14,6 @@
"url": "https://github.com/lone-cloud/sup" "url": "https://github.com/lone-cloud/sup"
}, },
"scripts": { "scripts": {
"check": "tsc --noEmit && biome check .",
"fix": "biome check --write .",
"android:build": "bun run scripts/test-android-build.ts", "android:build": "bun run scripts/test-android-build.ts",
"android:release": "bun run scripts/release-android.ts", "android:release": "bun run scripts/release-android.ts",
"android:deps": "bun run scripts/check-android-deps.ts", "android:deps": "bun run scripts/check-android-deps.ts",

View file

@ -35,7 +35,7 @@ RUN ARCH=$(uname -m) && \
fi fi
COPY --from=builder /app/sup-server /usr/local/bin/sup-server COPY --from=builder /app/sup-server /usr/local/bin/sup-server
COPY --from=builder /app/templates /templates COPY --from=builder /app/public /public
ENV PATH="/usr/local/signal-cli/bin:${PATH}" ENV PATH="/usr/local/signal-cli/bin:${PATH}"
ENV LD_LIBRARY_PATH="/usr/local/signal-cli/lib:${LD_LIBRARY_PATH}" ENV LD_LIBRARY_PATH="/usr/local/signal-cli/lib:${LD_LIBRARY_PATH}"

View file

@ -6,6 +6,7 @@
"name": "sup-server", "name": "sup-server",
"dependencies": { "dependencies": {
"chalk": "^5.6.2", "chalk": "^5.6.2",
"htmx.org": "^2.0.8",
"imap": "^0.8.19", "imap": "^0.8.19",
}, },
"devDependencies": { "devDependencies": {
@ -22,6 +23,8 @@
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"htmx.org": ["htmx.org@2.0.8", "", {}, "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q=="],
"imap": ["imap@0.8.19", "", { "dependencies": { "readable-stream": "1.1.x", "utf7": ">=1.0.2" } }, "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw=="], "imap": ["imap@0.8.19", "", { "dependencies": { "readable-stream": "1.1.x", "utf7": ">=1.0.2" } }, "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],

View file

@ -7,7 +7,6 @@ export const DEVICE_NAME = 'SUP';
export const SUP_ENDPOINT_PREFIX = `[${DEVICE_NAME}:`; export const SUP_ENDPOINT_PREFIX = `[${DEVICE_NAME}:`;
export const LAUNCH_ENDPOINT_PREFIX = '[LAUNCH:'; export const LAUNCH_ENDPOINT_PREFIX = '[LAUNCH:';
// ProtonMail Integration
export const BRIDGE_IMAP_USERNAME = Bun.env.BRIDGE_IMAP_USERNAME; export const BRIDGE_IMAP_USERNAME = Bun.env.BRIDGE_IMAP_USERNAME;
export const BRIDGE_IMAP_PASSWORD = Bun.env.BRIDGE_IMAP_PASSWORD; export const BRIDGE_IMAP_PASSWORD = Bun.env.BRIDGE_IMAP_PASSWORD;
export const PROTON_BRIDGE_HOST = Bun.env.PROTON_BRIDGE_HOST || 'protonmail-bridge'; export const PROTON_BRIDGE_HOST = Bun.env.PROTON_BRIDGE_HOST || 'protonmail-bridge';

View file

@ -7,5 +7,4 @@ export const SIGNAL_CLI_SOCKET = '/tmp/signal-cli.sock';
export const SIGNAL_CLI_DATA_DIR = `${HOME}/.local/share/signal-cli`; export const SIGNAL_CLI_DATA_DIR = `${HOME}/.local/share/signal-cli`;
export const SIGNAL_CLI_DATA = `${HOME}/.local/share/signal-cli/data`; export const SIGNAL_CLI_DATA = `${HOME}/.local/share/signal-cli/data`;
export const SUP_DATA_DIR = `${HOME}/.local/share/sup`;
export const SUP_DB = `${HOME}/.local/share/sup/store.db`; export const SUP_DB = `${HOME}/.local/share/sup/store.db`;

View file

@ -1,20 +1,15 @@
export const ROUTES = { export const ROUTES = {
HEALTH: '/health',
LINK: '/link',
LINK_QR: '/link/qr',
LINK_STATUS: '/link/status',
LINK_UNLINK: '/link/unlink',
FAVICON: '/favicon.png', FAVICON: '/favicon.png',
HTMX: '/htmx.js',
ADMIN: '/',
ADMIN_CSS: '/admin.css',
HEALTH_FRAGMENT: '/health/fragment',
SIGNAL_INFO_FRAGMENT: '/signal-info/fragment',
ENDPOINTS_FRAGMENT: '/endpoints/fragment',
QR_SECTION: '/link/qr-section',
QR_IMAGE: '/link/qr-image',
STATUS_CHECK: '/link/status-check',
MATRIX_NOTIFY: '/_matrix/push/v1/notify', MATRIX_NOTIFY: '/_matrix/push/v1/notify',
UP: '/up', UP: '/up',
UP_INSTANCE: '/up/:instance', UP_INSTANCE: '/up/:instance',
ENDPOINTS: '/endpoints',
NOTIFY_TOPIC: '/notify/:topic',
TOPICS: '/topics',
} as const;
export const TEMPLATES = {
LINKED: 'templates/linked.html',
LINK: 'templates/link.html',
SETUP: 'templates/setup.html',
} as const; } as const;

View file

@ -1,39 +1,18 @@
import { API_KEY, BRIDGE_IMAP_PASSWORD, BRIDGE_IMAP_USERNAME, PORT } from './constants/config'; import { API_KEY, BRIDGE_IMAP_PASSWORD, BRIDGE_IMAP_USERNAME, PORT } from '@/constants/config';
import { ROUTES } from './constants/server'; import { ROUTES } from '@/constants/server';
import { checkSignalCli, initSignal, startDaemon } from './modules/signal'; import { cleanupDaemon, initSignal } from '@/modules/signal';
import { handleHealth } from './routes/health'; import { adminRoutes } from '@/routes/admin/index';
import { handleLink, handleLinkQR, handleLinkStatus, handleUnlink } from './routes/link'; import { unifiedPushRoutes } from '@/routes/unifiedpush';
import { handleNotify, handleTopics } from './routes/notify'; import { logError, logInfo, logWarn } from '@/utils/log';
import {
handleDiscovery,
handleEndpoints,
handleMatrixNotify,
handleRegister,
handleUnregister,
} from './routes/unifiedpush';
import { withAuth, withFormAuth } from './utils/auth';
import { logError, logInfo, logSuccess, logWarn } from './utils/log';
let daemon: ReturnType<typeof Bun.spawn> | null = null;
try { try {
daemon = await startDaemon(); await initSignal();
const isLinked = await checkSignalCli();
const hasAccount = isLinked && (await initSignal({}));
if (hasAccount) {
logSuccess('Signal account linked');
} else {
logWarn('No Signal account linked');
logInfo(`Visit http://localhost:${PORT}/link to link your device`);
}
} catch (error) { } catch (error) {
logError(` ${error instanceof Error ? error.message : String(error)}`); logError(`${error instanceof Error ? error.message : String(error)}`);
} }
if (!API_KEY) { if (!API_KEY) {
logWarn('Server running without API_KEY'); logWarn('Server running without API_KEY');
console.warn('Set API_KEY env var for production deployments.');
} }
if (BRIDGE_IMAP_USERNAME && BRIDGE_IMAP_PASSWORD) { if (BRIDGE_IMAP_USERNAME && BRIDGE_IMAP_PASSWORD) {
@ -51,60 +30,23 @@ const server = Bun.serve({
idleTimeout: 60, idleTimeout: 60,
routes: { routes: {
[ROUTES.FAVICON]: Bun.file('assets/favicon.png'), [ROUTES.FAVICON]: Bun.file('public/favicon.png'),
[ROUTES.HTMX]: Bun.file('node_modules/htmx.org/dist/htmx.min.js'),
[ROUTES.HEALTH]: handleHealth, ...adminRoutes,
[ROUTES.LINK]: { ...unifiedPushRoutes,
GET: handleLink,
},
[ROUTES.LINK_QR]: {
GET: () =>
handleLinkQR(async () => {
daemon?.kill();
daemon = await startDaemon();
}),
},
[ROUTES.LINK_STATUS]: {
GET: handleLinkStatus,
},
[ROUTES.LINK_UNLINK]: {
POST: withFormAuth(() =>
handleUnlink(async () => {
daemon?.kill();
daemon = await startDaemon();
}),
),
},
[ROUTES.UP]: {
GET: handleDiscovery,
},
[ROUTES.ENDPOINTS]: {
GET: withAuth(handleEndpoints),
},
[ROUTES.TOPICS]: {
GET: withAuth(handleTopics),
},
[ROUTES.MATRIX_NOTIFY]: {
POST: handleMatrixNotify,
},
[ROUTES.UP_INSTANCE]: {
POST: withAuth((req) => handleRegister(req, new URL(req.url))),
DELETE: withAuth((req) => handleUnregister(new URL(req.url))),
},
[ROUTES.NOTIFY_TOPIC]: {
POST: withAuth((req) => handleNotify(req, new URL(req.url))),
},
}, },
}); });
logInfo(`\nSUP running on http://localhost:${server.port} 🚀`); logInfo(`\nSUP running on http://localhost:${server.port} 🚀`);
process.on('SIGINT', () => {
cleanupDaemon();
process.exit(0);
});
process.on('SIGTERM', () => {
cleanupDaemon();
process.exit(0);
});

View file

@ -7,10 +7,14 @@ import {
PROTON_BRIDGE_HOST, PROTON_BRIDGE_HOST,
PROTON_BRIDGE_PORT, PROTON_BRIDGE_PORT,
SUP_TOPIC, SUP_TOPIC,
} from '../constants/config'; } from '@/constants/config';
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '../utils/log'; import { createGroup, hasValidAccount, sendGroupMessage } from '@/modules/signal';
import { createGroup, sendGroupMessage } from './signal'; import { getGroupId, register } from '@/modules/store';
import { getGroupId, register } from './store'; import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
let imapConnected = false;
export const isImapConnected = () => imapConnected;
export async function startProtonMonitor() { export async function startProtonMonitor() {
if (!BRIDGE_IMAP_USERNAME || !BRIDGE_IMAP_PASSWORD) { if (!BRIDGE_IMAP_USERNAME || !BRIDGE_IMAP_PASSWORD) {
@ -20,20 +24,31 @@ export async function startProtonMonitor() {
return; return;
} }
logInfo(`🔗 Connecting to Proton Bridge at ${PROTON_BRIDGE_HOST}:${PROTON_BRIDGE_PORT}`); const linked = await hasValidAccount();
logInfo(`📨 Monitoring mailbox: ${BRIDGE_IMAP_USERNAME}`); if (!linked) {
logWarn('Signal account not linked. ProtonMail notifications will be skipped.');
logWarn('Link your Signal account at /link to enable email notifications.');
}
logInfo(`Connecting to Proton Bridge at ${PROTON_BRIDGE_HOST}:${PROTON_BRIDGE_PORT}`);
logInfo(`Monitoring mailbox: ${BRIDGE_IMAP_USERNAME}`);
const imap = new Imap({ const imap = new Imap({
user: BRIDGE_IMAP_USERNAME, user: BRIDGE_IMAP_USERNAME,
password: BRIDGE_IMAP_PASSWORD, password: BRIDGE_IMAP_PASSWORD,
host: PROTON_BRIDGE_HOST, host: PROTON_BRIDGE_HOST,
port: PROTON_BRIDGE_PORT, port: PROTON_BRIDGE_PORT,
tls: true, tls: false,
tlsOptions: { rejectUnauthorized: false }, tlsOptions: { rejectUnauthorized: false },
keepalive: true, keepalive: true,
}); });
async function sendNotification(title: string, message: string) { async function sendNotification(title: string, message: string) {
if (!(await hasValidAccount())) {
logVerbose('Skipping notification (Signal not linked)');
return;
}
try { try {
const topicKey = `proton-${SUP_TOPIC}`; const topicKey = `proton-${SUP_TOPIC}`;
const groupId = getGroupId(topicKey) ?? (await createGroup(SUP_TOPIC)); const groupId = getGroupId(topicKey) ?? (await createGroup(SUP_TOPIC));
@ -42,12 +57,16 @@ export async function startProtonMonitor() {
register(topicKey, groupId, SUP_TOPIC); register(topicKey, groupId, SUP_TOPIC);
} }
const prefix = ENABLE_PROTON_ANDROID if (ENABLE_PROTON_ANDROID) {
? `${LAUNCH_ENDPOINT_PREFIX}ch.protonmail.android]\n` await sendGroupMessage(
: ''; groupId,
await sendGroupMessage(groupId, `${prefix}**${title}**\n${message}`); `${LAUNCH_ENDPOINT_PREFIX}ch.protonmail.android]\n**${title}**\n${message}`,
);
} else {
await sendGroupMessage(groupId, `${title}\n${message}`);
}
logSuccess(`Notification sent: ${title}`); logVerbose(`Notification sent: ${title}`);
} catch (error) { } catch (error) {
logError('Failed to send notification:', error); logError('Failed to send notification:', error);
} }
@ -60,10 +79,8 @@ export async function startProtonMonitor() {
return; return;
} }
logVerbose(`Connected to inbox (${box.messages.total} messages)`);
imap.on('mail', async (numNewMsgs: number) => { imap.on('mail', async (numNewMsgs: number) => {
logVerbose(`📬 ${numNewMsgs} new message(s) received`); logVerbose(`${numNewMsgs} new message(s) received`);
const fetch = imap.seq.fetch(`${box.messages.total}:*`, { const fetch = imap.seq.fetch(`${box.messages.total}:*`, {
bodies: 'HEADER.FIELDS (FROM SUBJECT)', bodies: 'HEADER.FIELDS (FROM SUBJECT)',
@ -78,34 +95,39 @@ export async function startProtonMonitor() {
}); });
stream.once('end', () => { stream.once('end', () => {
const header = Imap.parseHeader(buffer); const header = Imap.parseHeader(buffer);
const from = 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';
sendNotification(`New Mail from ${from}`, subject); const nameMatch = rawFrom.match(/^"?([^"<]+)"?\s*<?/);
const from = nameMatch ? nameMatch[1]?.trim() : rawFrom;
sendNotification('New Email Received', `From: ${from} - ${subject}`);
}); });
}); });
}); });
}); });
imap.on('update', () => {
logVerbose('📊 Mailbox updated');
});
}); });
} }
imap.once('ready', () => { imap.on('ready', () => {
logVerbose('IMAP connection ready'); imapConnected = true;
logSuccess('IMAP is ready');
openInbox(); openInbox();
}); });
imap.once('error', (err: Error) => { imap.on('error', (err: Error) => {
logError('IMAP error:', err); imapConnected = false;
logWarn('ProtonMail integration disabled due to connection error'); logError('IMAP error:', err.message);
}); });
imap.once('end', () => { imap.on('close', (hadError: boolean) => {
logVerbose('IMAP connection ended, reconnecting...'); imapConnected = false;
setTimeout(() => imap.connect(), 5000); logError(`IMAP connection closed (hadError: ${hadError})`);
});
imap.on('end', () => {
imapConnected = false;
logError('IMAP connection ended by server');
}); });
imap.connect(); imap.connect();

View file

@ -1,31 +1,47 @@
import { rm, unlink } from 'node:fs/promises'; import { rm, unlink } from 'node:fs/promises';
import { DEVICE_NAME, VERBOSE } from '../constants/config'; import { DEVICE_NAME, PORT, VERBOSE } from '@/constants/config';
import { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '../constants/paths'; import { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '@/constants/paths';
import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '../types'; import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '@/types';
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '../utils/log'; import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
import { call } from '../utils/rpc'; import { call } from '@/utils/rpc';
logVerbose(`Running signal-cli from ${SIGNAL_CLI}`);
let account: string | null = null; let account: string | null = null;
let currentLinkUri: string | null = null; let currentLinkUri: string | null = null;
let daemon: ReturnType<typeof Bun.spawn> | null = null;
export const hasLinkUri = () => currentLinkUri !== null; export const hasLinkUri = () => currentLinkUri !== null;
export async function initSignal({ accountOverride }: { accountOverride?: string }) { export async function initSignal({ accountOverride }: { accountOverride?: string } = {}) {
await startDaemon();
const isLinked = await checkSignalCli();
if (!isLinked) {
logWarn('No Signal account linked');
logInfo(`Visit http://localhost:${PORT} to link your device`);
return { linked: false, account: null };
}
if (accountOverride) { if (accountOverride) {
account = accountOverride; account = accountOverride;
return true; logVerbose(`Signal account set: ${account}`);
logSuccess('Signal account linked');
return { linked: true, account };
} }
const result = (await call('listAccounts', {}, account)) as ListAccountsResult; const result = (await call('listAccounts', {}, null)) as ListAccountsResult;
const [firstAccount] = result; const [firstAccount] = result;
if (firstAccount) { if (firstAccount) {
account = firstAccount; account = firstAccount.number;
return true; logVerbose(`Signal account initialized: ${account}`);
logSuccess('Signal account linked');
return { linked: true, account };
} }
return false; logVerbose('No Signal accounts found');
logWarn('No Signal account linked');
logInfo(`Visit http://localhost:${PORT} to link your device`);
return { linked: false, account: null };
} }
export async function generateLinkQR() { export async function generateLinkQR() {
@ -71,12 +87,13 @@ export async function unlinkDevice() {
currentLinkUri = null; currentLinkUri = null;
if (await Bun.file(SIGNAL_CLI_DATA).exists()) { if (await Bun.file(SIGNAL_CLI_DATA).exists()) {
logWarn('Unlinking device and removing account data...'); logWarn('Removing local account data...');
try { try {
await rm(SIGNAL_CLI_DATA, { recursive: true, force: true }); await rm(SIGNAL_CLI_DATA, { recursive: true, force: true });
logSuccess('Account data removed');
} catch (error) { } catch (error) {
logWarn('Failed to remove account data directory:', error); logError('Failed to remove account data directory:', error);
} }
} }
} }
@ -98,12 +115,17 @@ export async function createGroup(name: string, members: string[] = []) {
return result.groupId; return result.groupId;
} }
export async function sendGroupMessage(groupId: string, message: string) { export async function sendGroupMessage(
groupId: string,
message: string,
{ notifySelf = true }: { notifySelf?: boolean } = {},
) {
await call( await call(
'send', 'send',
{ {
groupId, groupId,
message, message,
'notify-self': notifySelf,
}, },
account, account,
); );
@ -134,8 +156,8 @@ export async function startDaemon() {
try { try {
await unlink(SIGNAL_CLI_SOCKET); await unlink(SIGNAL_CLI_SOCKET);
logVerbose('Removed stale socket file'); logVerbose('Removed stale socket file');
} catch (error: any) { } catch (error) {
if (error.code !== 'ENOENT') { if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
logError('Failed to remove stale socket file:', error); logError('Failed to remove stale socket file:', error);
} }
} }
@ -194,6 +216,7 @@ export async function startDaemon() {
}); });
socket.end(); socket.end();
logSuccess('signal-cli daemon started'); logSuccess('signal-cli daemon started');
daemon = proc;
return proc; return proc;
} catch (error) { } catch (error) {
if (authError && !cleaned) { if (authError && !cleaned) {
@ -211,3 +234,13 @@ export async function startDaemon() {
throw new Error('Failed to start signal-cli daemon'); throw new Error('Failed to start signal-cli daemon');
} }
} }
export async function restartDaemon() {
if (daemon) {
daemon.kill();
await Bun.sleep(500);
}
daemon = await startDaemon();
}
export const cleanupDaemon = () => daemon?.kill();

View file

@ -1,5 +1,5 @@
import { Database } from 'bun:sqlite'; import { Database } from 'bun:sqlite';
import { SUP_DATA_DIR, SUP_DB } from '../constants/paths'; import { SUP_DB } from '@/constants/paths';
interface EndpointMapping { interface EndpointMapping {
endpoint: string; endpoint: string;
@ -7,8 +7,6 @@ interface EndpointMapping {
appName: string; appName: string;
} }
await Bun.write(`${SUP_DATA_DIR}/.keep`, '');
const db = new Database(SUP_DB); const db = new Database(SUP_DB);
db.run(` db.run(`
@ -41,9 +39,8 @@ export const getAppName = (endpoint: string) => {
return row?.appName; return row?.appName;
}; };
export const getAllMappings = () => { export const getAllMappings = () =>
return db.query('SELECT endpoint, groupId, appName FROM mappings').all() as EndpointMapping[]; db.query('SELECT endpoint, groupId, appName FROM mappings').all() as EndpointMapping[];
};
export const remove = (endpoint: string) => { export const remove = (endpoint: string) => {
db.run('DELETE FROM mappings WHERE endpoint = ?', [endpoint]); db.run('DELETE FROM mappings WHERE endpoint = ?', [endpoint]);

View file

@ -1,4 +1,4 @@
import { SUP_ENDPOINT_PREFIX } from '../constants/config'; import { SUP_ENDPOINT_PREFIX } from '@/constants/config';
export interface UnifiedPushMessage { export interface UnifiedPushMessage {
endpoint: string; endpoint: string;

View file

@ -8,6 +8,7 @@
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"chalk": "^5.6.2", "chalk": "^5.6.2",
"htmx.org": "^2.0.8",
"imap": "^0.8.19" "imap": "^0.8.19"
}, },
"devDependencies": { "devDependencies": {
@ -19,6 +20,6 @@
"start": "bun run index.ts", "start": "bun run index.ts",
"build": "bun build --compile index.ts --outfile sup-server", "build": "bun build --compile index.ts --outfile sup-server",
"check": "tsc --noEmit && biome check .", "check": "tsc --noEmit && biome check .",
"fix": "biome check --write ." "fix": "biome check --write --unsafe ."
} }
} }

120
server/public/admin.css Normal file
View file

@ -0,0 +1,120 @@
/* CSS Reset */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
padding: 0;
}
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
}
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
/* Styles */
body {
font-family: system-ui;
max-width: 800px;
margin: 20px auto;
padding: 20px;
background: #f5f5f5;
}
.card {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h2 {
margin-bottom: 20px;
}
.status {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.status-item {
padding: 10px 16px;
border-radius: 4px;
font-weight: 500;
}
.status-ok {
background: #d4edda;
color: #155724;
}
.status-error {
background: #f8d7da;
color: #721c24;
}
.endpoint-list {
list-style: none;
padding: 0;
max-height: 300px;
overflow-y: auto;
}
.endpoint-list li {
padding: 8px 12px;
background: #f8f9fa;
border-radius: 4px;
margin-bottom: 6px;
font-size: 0.9em;
}
.link-button {
display: inline-block;
padding: 10px 20px;
background: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
margin-top: 10px;
border: none;
cursor: pointer;
}
.link-button:hover {
background: #0056b3;
}
.loading {
color: #666;
}

66
server/public/admin.html Normal file
View file

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SUP Admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="stylesheet" href="/admin.css">
<script src="/htmx.js"></script>
</head>
<body hx-headers='{"Authorization": "Bearer __API_KEY__"}'>
<div class="card">
<div id="health-status"
hx-get="/health/fragment"
hx-trigger="load, every 10s"
class="loading">
Loading...
</div>
</div>
<div class="card">
<h2>Signal</h2>
<div id="signal-info"
hx-get="/signal-info/fragment"
hx-trigger="load">
Loading...
</div>
<div id="qr-section"></div>
</div>
<div class="card">
<h2>Endpoints</h2>
<div id="endpoints-list"
hx-get="/endpoints/fragment"
hx-trigger="load"
class="loading">
Loading...
</div>
</div>
<script>
let apiKey = sessionStorage.getItem('sup_api_key');
if (!apiKey) {
apiKey = prompt('Enter API_KEY:');
if (!apiKey) {
document.body.innerHTML = '<div class="card"><h1>Access Denied</h1><p>API_KEY required</p></div>';
} else {
sessionStorage.setItem('sup_api_key', apiKey);
}
}
if (apiKey) {
const authHeader = JSON.stringify({ 'Authorization': `Bearer ${apiKey}` });
document.body.setAttribute('hx-headers', authHeader);
document.body.addEventListener('htmx:responseError', (e) => {
if (e.detail.xhr.status === 401) {
sessionStorage.removeItem('sup_api_key');
alert('Invalid API_KEY. Please refresh and try again.');
location.reload();
}
});
}
</script>
</body>
</html>

View file

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

View file

@ -0,0 +1,146 @@
import { DEVICE_NAME } from '@/constants/config';
import { SIGNAL_CLI_DATA } from '@/constants/paths';
import { isImapConnected } from '@/modules/protonmail';
import {
checkSignalCli,
finishLink,
generateLinkQR,
hasLinkUri,
hasValidAccount,
initSignal,
restartDaemon,
unlinkDevice,
} from '@/modules/signal';
import { getAllMappings } from '@/modules/store';
export const handleHealthFragment = async () => {
const signalOk = await checkSignalCli();
const linked = signalOk && (await hasValidAccount());
const imap = isImapConnected();
const html = `
<div class="status">
<div class="status-item ${signalOk ? 'status-ok' : 'status-error'}">
Signal: ${signalOk ? 'Connected' : 'Disconnected'}
</div>
<div class="status-item ${linked ? 'status-ok' : 'status-error'}">
Account: ${linked ? 'Linked' : 'Unlinked'}
</div>
<div class="status-item ${imap ? 'status-ok' : 'status-error'}">
IMAP: ${imap ? 'Connected' : 'Disconnected'}
</div>
</div>
`;
return new Response(html, {
headers: { 'content-type': 'text/html' },
});
};
export const handleSignalInfoFragment = async () => {
const linked = await hasValidAccount();
const html = linked
? `<details style="margin-top: 15px;">
<summary style="cursor: pointer; font-weight: bold;">Unlink and remove device</summary>
<div style="margin-top: 15px;">
<ol style="margin-left: 20px;">
<li>Open Signal app <strong>Settings Linked Devices</strong></li>
<li>Find <strong>"${DEVICE_NAME}"</strong> and tap it</li>
<li>Tap <strong>"Unlink Device"</strong></li>
</ol>
</div>
</details>`
: `<button onclick="document.getElementById('qr-section').style.display='block';
this.parentElement.style.display='none';
htmx.ajax('GET', '/link/qr-section', {target: '#qr-section', swap: 'innerHTML'})"
class="link-button" style="border:none;cursor:pointer;">
Link Signal Device
</button>`;
return new Response(html, {
headers: { 'content-type': 'text/html' },
});
};
export const handleEndpointsFragment = async () => {
const endpoints = getAllMappings();
if (endpoints.length === 0) {
return new Response('<p>No endpoints registered</p>', {
headers: { 'content-type': 'text/html' },
});
}
const html = `
<ul class="endpoint-list">
${endpoints.map((e) => `<li><strong>${e.appName}</strong><br><code>${e.endpoint}</code> → ${e.groupId}</li>`).join('')}
</ul>
`;
return new Response(html, {
headers: { 'content-type': 'text/html' },
});
};
export const handleQRSection = async () => {
const html = `
<p>Scan this QR code with your Signal app:</p>
<p style="font-size: 1.05em;"><strong>Settings Linked Devices Link New Device</strong></p>
<div hx-get="/link/qr-image" hx-trigger="load, every 30s" style="margin-top:15px;">
Generating QR code...
</div>
<div hx-get="/link/status-check" hx-trigger="every 2s" hx-swap="none"></div>
<button onclick="document.getElementById('qr-section').style.display='none';
document.getElementById('signal-info').style.display='block'"
style="margin-top:15px;padding:8px 16px;background:#6c757d;color:white;border:none;border-radius:4px;cursor:pointer;">
Cancel
</button>
`;
return new Response(html, {
headers: { 'content-type': 'text/html' },
});
};
export const handleQRImage = async () => {
const linked = await hasValidAccount();
if (!linked && (await Bun.file(SIGNAL_CLI_DATA).exists())) {
await unlinkDevice();
await restartDaemon();
}
const qrDataUrl = await generateLinkQR();
const html = `<img src="${qrDataUrl}" style="max-width: 300px;" alt="QR Code" />`;
return new Response(html, {
headers: { 'content-type': 'text/html' },
});
};
export const handleLinkStatusCheck = async () => {
let linked = await hasValidAccount();
if (!linked && hasLinkUri()) {
try {
await finishLink();
const result = await initSignal();
linked = result.linked;
} catch (error) {
console.error('Failed to finish link:', error);
}
}
if (linked) {
return new Response('', {
headers: {
'content-type': 'text/html',
'HX-Refresh': 'true',
},
});
}
return new Response('', {
headers: { 'content-type': 'text/html' },
});
};

View file

@ -0,0 +1,42 @@
import { ROUTES } from '@/constants/server';
import { withAuth } from '@/utils/auth';
import {
handleEndpointsFragment,
handleHealthFragment,
handleLinkStatusCheck,
handleQRImage,
handleQRSection,
handleSignalInfoFragment,
} from './fragments';
export const adminRoutes = {
[ROUTES.ADMIN]: {
GET: () => new Response(Bun.file('public/admin.html')),
},
[ROUTES.ADMIN_CSS]: Bun.file('public/admin.css'),
[ROUTES.HEALTH_FRAGMENT]: {
GET: withAuth(handleHealthFragment),
},
[ROUTES.SIGNAL_INFO_FRAGMENT]: {
GET: withAuth(handleSignalInfoFragment),
},
[ROUTES.ENDPOINTS_FRAGMENT]: {
GET: withAuth(handleEndpointsFragment),
},
[ROUTES.QR_SECTION]: {
GET: withAuth(handleQRSection),
},
[ROUTES.QR_IMAGE]: {
GET: withAuth(handleQRImage),
},
[ROUTES.STATUS_CHECK]: {
GET: withAuth(handleLinkStatusCheck),
},
};

View file

@ -1,13 +0,0 @@
import { checkSignalCli, hasValidAccount } from '../modules/signal';
export const handleHealth = async () => {
const signalOk = await checkSignalCli();
const linked = signalOk && (await hasValidAccount());
return Response.json({
status: 'ok',
signal: signalOk ? 'connected' : 'disconnected',
linked,
mode: 'daemon',
});
};

View file

@ -1,67 +0,0 @@
import { API_KEY } from '../constants/config';
import { SIGNAL_CLI_DATA } from '../constants/paths';
import { ROUTES, TEMPLATES } from '../constants/server';
import {
finishLink,
generateLinkQR,
hasLinkUri,
hasValidAccount,
initSignal,
unlinkDevice,
} from '../modules/signal';
export const handleLink = async () => {
const linked = await hasValidAccount();
const template = linked ? TEMPLATES.LINKED : TEMPLATES.LINK;
let html = await Bun.file(template).text();
if (linked && API_KEY) {
const passwordField =
'<input type="password" name="password" placeholder="Enter API_KEY" required style="padding: 8px; margin-right: 10px; border: 1px solid #ccc; border-radius: 4px;" />';
html = html.replace('{{PASSWORD_FIELD}}', passwordField);
} else if (linked) {
html = html.replace('{{PASSWORD_FIELD}}', '');
}
return new Response(html, {
headers: { 'content-type': 'text/html' },
});
};
export const handleLinkQR = async (restartDaemon: () => Promise<void>) => {
const linked = await hasValidAccount();
if (!linked && (await Bun.file(SIGNAL_CLI_DATA).exists())) {
await unlinkDevice();
await restartDaemon();
}
const qrDataUrl = await generateLinkQR();
return new Response(qrDataUrl, {
headers: { 'content-type': 'text/plain' },
});
};
export const handleLinkStatus = async () => {
let linked = await hasValidAccount();
if (!linked && hasLinkUri()) {
try {
await finishLink();
await initSignal({});
linked = true;
} catch {}
}
return Response.json({ linked });
};
export const handleUnlink = async (killAndRestart: () => Promise<void>) => {
await unlinkDevice();
await killAndRestart();
return new Response('', {
status: 303,
headers: { Location: ROUTES.LINK },
});
};

View file

@ -1,62 +0,0 @@
import { createGroup, sendGroupMessage } from '../modules/signal';
import { getAllMappings, getGroupId, register } from '../modules/store';
interface NotificationMessage {
topic: string;
title?: string;
message: string;
}
const formatNotification = (notification: NotificationMessage) => {
const parts: string[] = [];
const title = notification.title || notification.topic;
parts.push(`**${title}**`);
parts.push(notification.message);
return parts.join('\n');
};
export const handleNotify = async (req: Request, url: URL) => {
const topic = url.pathname.split('/')[2];
if (!topic) {
return new Response('Topic required', { status: 400 });
}
const body = await req.text();
if (!body) {
return new Response('Message required', { status: 400 });
}
const title = req.headers.get('x-title') || undefined;
const notification: NotificationMessage = {
topic,
title,
message: body,
};
const topicKey = `notify-${topic}`;
const groupId = getGroupId(topicKey) ?? (await createGroup(topic));
if (!getGroupId(topicKey)) {
register(topicKey, groupId, topic);
}
const signalMessage = formatNotification(notification);
await sendGroupMessage(groupId, signalMessage);
return Response.json({ success: true, topic, groupId });
};
export const handleTopics = () => {
const allMappings = getAllMappings();
const topics = allMappings
.filter((m) => m.endpoint.startsWith('notify-'))
.map((m) => ({
topic: m.appName,
groupId: m.groupId,
}));
return Response.json({ topics });
};

View file

@ -1,9 +1,9 @@
import { ROUTES } from '../constants/server'; import { ROUTES } from '@/constants/server';
import { createGroup, sendGroupMessage } from '../modules/signal'; import { createGroup, sendGroupMessage } from '@/modules/signal';
import { getAllMappings, getGroupId, register, remove } from '../modules/store'; import { getGroupId, register, remove } from '@/modules/store';
import { formatAsSignalMessage, parseUnifiedPushRequest } from '../modules/unifiedpush'; import { formatAsSignalMessage, parseUnifiedPushRequest } from '@/modules/unifiedpush';
export const handleMatrixNotify = async (req: Request) => { const handleMatrixNotify = async (req: Request) => {
const message = await parseUnifiedPushRequest(req); const message = await parseUnifiedPushRequest(req);
const groupId = getGroupId(message.endpoint); const groupId = getGroupId(message.endpoint);
@ -17,7 +17,8 @@ export const handleMatrixNotify = async (req: Request) => {
return Response.json({ success: true }); return Response.json({ success: true });
}; };
export const handleRegister = async (req: Request, url: URL) => { const handleRegister = async (req: Request) => {
const url = new URL(req.url);
const endpointId = url.pathname.split('/')[2] ?? ''; const endpointId = url.pathname.split('/')[2] ?? '';
const { appName } = (await req.json()) as { const { appName } = (await req.json()) as {
appName: string; appName: string;
@ -38,16 +39,30 @@ export const handleRegister = async (req: Request, url: URL) => {
return Response.json({ endpoint, gateway: 'matrix' }); return Response.json({ endpoint, gateway: 'matrix' });
}; };
export const handleUnregister = async (url: URL) => { const handleUnregister = async (req: Request) => {
const url = new URL(req.url);
const endpointId = url.pathname.split('/')[2] ?? ''; const endpointId = url.pathname.split('/')[2] ?? '';
remove(endpointId); remove(endpointId);
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
}; };
export const handleDiscovery = () => const handleDiscovery = () =>
Response.json({ Response.json({
unifiedpush: { version: 1 }, unifiedpush: { version: 1 },
gateway: 'matrix', gateway: 'matrix',
}); });
export const handleEndpoints = () => Response.json(getAllMappings()); export const unifiedPushRoutes = {
[ROUTES.UP]: {
GET: handleDiscovery,
},
[ROUTES.MATRIX_NOTIFY]: {
POST: handleMatrixNotify,
},
[ROUTES.UP_INSTANCE]: {
POST: handleRegister,
DELETE: handleUnregister,
},
};

View file

@ -1,38 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SUP - Link Signal</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/favicon.png">
</head>
<body style="font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px; text-align: center;">
<h1>Link Signal Account</h1>
<p>Scan this QR code with your Signal app:</p>
<p style="font-size: 1.1em;"><strong>Settings <span style="font-size: 1.5em;"></span> Linked Devices <span style="font-size: 1.5em;"></span> Link New Device</strong></p>
<div id="qr">Generating QR code...</div>
<script>
let refreshInterval;
function loadQR() {
fetch('/link/qr').then(r => r.text()).then(qr => {
document.getElementById('qr').innerHTML = `<img src="${qr}" style="max-width: 300px;" />`;
});
}
loadQR();
refreshInterval = setInterval(loadQR, 30000);
const check = setInterval(() => {
fetch('/link/status').then(r => r.json()).then(data => {
if (data.linked) {
clearInterval(check);
clearInterval(refreshInterval);
location.reload();
}
});
}, 2000);
</script>
</body>
</html>

View file

@ -1,29 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SUP - Linked</title>
<link rel="icon" type="image/png" href="/favicon.png">
</head>
<body style="font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px;">
<h1>Signal Linked</h1>
<p>Your Signal account is already linked to this server.</p>
<details style="margin-top: 20px;">
<summary style="cursor: pointer; font-weight: bold;">Unlink and re-link with different account</summary>
<div style="margin-top: 15px; text-align: left;">
<p><strong>Steps to unlink:</strong></p>
<ol>
<li>Open Signal app → <strong>Settings → Linked Devices</strong></li>
<li>Find <strong>"SUP"</strong> and tap it</li>
<li>Tap <strong>"Unlink Device"</strong></li>
<li>Click the button below to clear server data:</li>
</ol>
<form method="POST" action="/link/unlink" style="margin-top: 15px;">
{{PASSWORD_FIELD}}
<button type="submit" style="padding: 10px 20px; cursor: pointer; background: #ff5555; color: white; border: none; border-radius: 4px; font-size: 14px;">Clear Server Data & Restart</button>
</form>
</div>
</details>
</body>
</html>

9
server/tsconfig.json Normal file
View file

@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}

8
server/types.d.ts vendored
View file

@ -27,4 +27,10 @@ export interface UpdateGroupResult {
groupId: string; groupId: string;
} }
export type ListAccountsResult = string[]; export interface AccountInfo {
number: string;
name?: string;
uuid?: string;
}
export type ListAccountsResult = AccountInfo[];

View file

@ -1,7 +1,9 @@
import { API_KEY } from '../constants/config'; import { API_KEY } from '@/constants/config';
const checkAuth = (req: Request) => { const checkAuth = (req: Request) => {
if (!API_KEY) return null; if (!API_KEY) {
return new Response('Unauthorized', { status: 401 });
}
const proto = req.headers.get('x-forwarded-proto') || 'http'; const proto = req.headers.get('x-forwarded-proto') || 'http';
const host = req.headers.get('host') || ''; const host = req.headers.get('host') || '';
@ -18,20 +20,6 @@ const checkAuth = (req: Request) => {
return null; return null;
}; };
export const checkFormAuth = async (req: Request) => {
const bearerAuth = checkAuth(req);
if (!bearerAuth) return null;
// Fall back to form password
const body = await req.text();
const params = new URLSearchParams(body);
if (params.get('password') === API_KEY) {
return null;
}
return new Response(null, { status: 403 });
};
export const withAuth = export const withAuth =
<T extends unknown[]>(handler: (req: Request, ...args: T) => Response | Promise<Response>) => <T extends unknown[]>(handler: (req: Request, ...args: T) => Response | Promise<Response>) =>
(req: Request, ...args: T) => { (req: Request, ...args: T) => {
@ -39,11 +27,3 @@ export const withAuth =
if (auth) return auth; if (auth) return auth;
return handler(req, ...args); return handler(req, ...args);
}; };
export const withFormAuth =
<T extends unknown[]>(handler: (req: Request, ...args: T) => Response | Promise<Response>) =>
async (req: Request, ...args: T) => {
const auth = await checkFormAuth(req);
if (auth) return auth;
return handler(req, ...args);
};

View file

@ -1,5 +1,5 @@
import chalk from 'chalk'; import chalk from 'chalk';
import { VERBOSE } from '../constants/config'; import { VERBOSE } from '@/constants/config';
export const logVerbose = (...args: unknown[]) => VERBOSE && console.log(...args); export const logVerbose = (...args: unknown[]) => VERBOSE && console.log(...args);

View file

@ -1,4 +1,4 @@
import { SIGNAL_CLI_SOCKET } from '../constants/paths'; import { SIGNAL_CLI_SOCKET } from '@/constants/paths';
const MESSAGE_DELIMITER = '\n'; const MESSAGE_DELIMITER = '\n';
let rpcId = 1; let rpcId = 1;