diff --git a/server/index.ts b/server/index.ts index fa0f5ed..a47cc32 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,4 +1,3 @@ -import { networkInterfaces } from 'node:os'; import { Hono } from 'hono'; import { getConnInfo, serveStatic } from 'hono/bun'; import { compress } from 'hono/compress'; @@ -15,12 +14,23 @@ import { } from '@/constants/config'; import { PUBLIC_DIR } from '@/constants/paths'; import { cleanupDaemon, initSignal } from '@/modules/signal'; -import admin from '@/routes/admin'; -import ntfy from '@/routes/ntfy'; -import unifiedpush from '@/routes/unified-push'; -import { isLocalIP } from '@/utils/auth'; +import { admin } from '@/routes/admin'; +import { ntfy } from '@/routes/ntfy'; +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'; +const cspConfig = { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:'], + formAction: ["'self'"], + frameAncestors: ["'none'"], + objectSrc: ["'none'"], +}; + initSignal(); if (!API_KEY) { @@ -37,43 +47,8 @@ if (PROTON_IMAP_USERNAME && PROTON_IMAP_PASSWORD) { } } -const getLanIP = () => { - const nets = networkInterfaces(); - - for (const name of Object.keys(nets)) { - const interfaces = nets[name]; - - if (!interfaces) continue; - - for (const iface of interfaces) { - if (iface.family === 'IPv4' && !iface.internal) { - return iface.address; - } - } - } - return null; -}; - const app = new Hono(); -const cspConfig = { - defaultSrc: ["'self'"], - scriptSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'"], - imgSrc: ["'self'", 'data:'], - formAction: ["'self'"], - frameAncestors: ["'none'"], - objectSrc: ["'none'"], -}; - -const cspString = Object.entries(cspConfig) - .map(([key, values]) => { - const directive = key.replace(/([A-Z])/g, '-$1').toLowerCase(); - - return `${directive} ${values.join(' ')}`; - }) - .join('; '); - app.use('*', (c, next) => { const proto = c.req.header('x-forwarded-proto') || 'http'; const addr = getConnInfo(c).remote.address; @@ -84,18 +59,15 @@ app.use('*', (c, next) => { })(c, next); } - c.header('Content-Security-Policy', cspString); + c.header('Content-Security-Policy', formatToCspString(cspConfig)); + return next(); }); -app.use('*', (c, next) => { - if (c.req.path === '/link/status-check') { - return next(); - } +app.use('*', timeout(5000)); - return timeout(5000)(c, next); -}); app.use('*', compress()); + app.use( '*', rateLimiter({ diff --git a/server/modules/proton-mail.ts b/server/modules/proton-mail.ts index e8225fc..7d43597 100644 --- a/server/modules/proton-mail.ts +++ b/server/modules/proton-mail.ts @@ -24,6 +24,27 @@ const getReconnectDelay = () => { return 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, { + androidPackage: 'ch.protonmail.android', + 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'); @@ -33,11 +54,6 @@ export async function startProtonMonitor() { return; } - if (!(await hasValidAccount())) { - logWarn('Signal account not linked. Proton Mail notifications will be skipped.'); - logWarn('Link your Signal account to enable email notifications.'); - } - logInfo(`Connecting to Proton Bridge at ${PROTON_BRIDGE_HOST}:${PROTON_BRIDGE_PORT}`); logInfo(`Monitoring mailbox: ${PROTON_IMAP_USERNAME}`); @@ -49,36 +65,16 @@ export async function startProtonMonitor() { keepalive: true, }); - 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, { - androidPackage: 'ch.protonmail.android', - title: from, - }); - - logVerbose(`Email from ${from}: ${subject}`); - } catch (error) { - logError('Failed to send notification:', error); - } - } - const openInbox = () => imap.openBox('INBOX', false, (err, box) => { if (err) { logError('Failed to open inbox:', err); + return; } if (!monitorStartTime) { monitorStartTime = Date.now(); - logVerbose(`Inbox opened with ${box.messages.total} existing messages`); } else { logVerbose('Inbox reopened (reconnection)'); } @@ -124,6 +120,7 @@ export async function startProtonMonitor() { const from = nameMatch?.[1]?.trim() || rawFrom; reconnectAttempts = 0; + sendNotification(from, subject); }); }); @@ -149,6 +146,7 @@ export async function startProtonMonitor() { const delay = getReconnectDelay(); logInfo(`Attempting to reconnect in ${delay / 1000}s (attempt ${reconnectAttempts})...`); + setTimeout(() => { if (!imapConnected) { logInfo('Reconnecting to Proton Bridge...'); diff --git a/server/modules/signal.ts b/server/modules/signal.ts index 3c82a4e..66a6260 100644 --- a/server/modules/signal.ts +++ b/server/modules/signal.ts @@ -6,11 +6,24 @@ import { VERBOSE_LOGGING, } from '@/constants/config'; import { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '@/constants/paths'; -import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '@/types'; import { formatPhoneNumber } from '@/utils/format'; import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log'; import { call } from '@/utils/rpc'; +interface StartLinkResult { + deviceLinkUri: string; +} + +interface UpdateGroupResult { + groupId: string; +} + +type ListAccountsResult = { + number: string; + name?: string; + uuid?: string; +}[]; + let account: string | null = null; let currentLinkUri: string | null = null; let daemon: ReturnType | null = null; @@ -27,20 +40,21 @@ export async function initSignal({ accountOverride }: { accountOverride?: string if (accountOverride) { account = accountOverride; logVerbose(`Signal account set: ${account}`); - logSuccess('Signal account linked'); + return { linked: true, account }; } const result = (await call('listAccounts', {}, null)) as ListAccountsResult; const [firstAccount] = result; + if (firstAccount) { account = firstAccount.number; logVerbose(`Signal account initialized: ${formatPhoneNumber(account)}`); - logSuccess('Signal account linked'); return { linked: true, account }; } logVerbose('No Signal accounts found'); + return { linked: false, account: null }; } @@ -90,7 +104,9 @@ export async function finishLink() { }, null, ); + logSuccess('Device linked successfully'); + return result; } @@ -103,6 +119,7 @@ async function unlinkDevice() { try { await rm(SIGNAL_CLI_DATA, { recursive: true, force: true }); + logSuccess('Account data removed'); } catch (error) { logError('Failed to remove account data directory:', error); @@ -155,6 +172,7 @@ export async function sendGroupMessage( export async function checkSignalCli() { try { await call('listAccounts', {}, account); + return true; } catch { return false; @@ -164,6 +182,7 @@ export async function checkSignalCli() { export async function hasValidAccount() { try { const result = (await call('listAccounts', {}, account)) as ListAccountsResult; + return result.length > 0; } catch { return false; @@ -233,6 +252,7 @@ export async function startDaemon() { for (let i = 0; i < 60; i++) { await Bun.sleep(500); + try { const socket = await Bun.connect({ unix: SIGNAL_CLI_SOCKET, @@ -240,22 +260,31 @@ export async function startDaemon() { data() {}, }, }); + socket.end(); + logSuccess(`signal-cli daemon started (${(i + 1) * 0.5}s)`); + daemon = proc; + return proc; } catch {} } if (authError && !cleaned) { logWarn('Detected stale account data, cleaning up and retrying...'); + proc.kill(); + await unlinkDevice(); + cleaned = true; + return startDaemon(); } logError('Failed to connect to signal-cli socket: daemon did not start within 30 seconds'); + if (proc.exitCode !== null) { logError('signal-cli process exited with code:', proc.exitCode); } diff --git a/server/modules/unified-push.ts b/server/modules/unified-push.ts index 4159e47..cd5b1c0 100644 --- a/server/modules/unified-push.ts +++ b/server/modules/unified-push.ts @@ -1,6 +1,6 @@ import { SUP_ENDPOINT_PREFIX } from '@/constants/config'; -export interface UnifiedPushMessage { +interface UnifiedPushMessage { endpoint: string; title?: string; body?: string; diff --git a/server/public/index.html b/server/public/index.html index fa37cb4..676ca4e 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -42,7 +42,7 @@

Endpoints

diff --git a/server/routes/admin.ts b/server/routes/admin.ts index d13744c..70c3f02 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -8,7 +8,6 @@ import { generateLinkQR, getAccount, hasValidAccount, - initSignal, } from '@/modules/signal'; import { getAllMappings, remove } from '@/modules/store'; import { verifyApiKey } from '@/utils/auth'; @@ -19,7 +18,7 @@ let qrCacheTime = 0; let generatingPromise: Promise | null = null; const QR_CACHE_TTL = 10 * 60 * 1000; -const admin = new Hono(); +export const admin = new Hono(); admin.use( '*', @@ -166,16 +165,11 @@ const handleQRSection = async () => { cachedQR = qr; qrCacheTime = Date.now(); - finishLink() - .then(async () => { - await initSignal(); - }) - .catch(() => {}) - .finally(() => { - generatingPromise = null; - cachedQR = null; - qrCacheTime = 0; - }); + finishLink().finally(() => { + generatingPromise = null; + cachedQR = null; + qrCacheTime = 0; + }); return qr; } catch (error) { @@ -201,5 +195,3 @@ const handleQRSection = async () => {
`; }; - -export default admin; diff --git a/server/routes/ntfy.ts b/server/routes/ntfy.ts index fa15336..a2c68f9 100644 --- a/server/routes/ntfy.ts +++ b/server/routes/ntfy.ts @@ -5,7 +5,7 @@ import { getOrCreateGroup } from '@/modules/store'; import { verifyApiKey } from '@/utils/auth'; import { logError, logVerbose } from '@/utils/log'; -const ntfy = new Hono(); +export const ntfy = new Hono(); ntfy.use( '*', @@ -64,5 +64,3 @@ ntfy.post('/:topic', async (c) => { return c.text('Internal server error', 500); } }); - -export default ntfy; diff --git a/server/routes/unified-push.ts b/server/routes/unified-push.ts index 07e866d..a9ae347 100644 --- a/server/routes/unified-push.ts +++ b/server/routes/unified-push.ts @@ -3,7 +3,7 @@ import { sendGroupMessage } from '@/modules/signal'; import { getGroupId, getOrCreateGroup, remove } from '@/modules/store'; import { formatAsSignalMessage, parseUnifiedPushRequest } from '@/modules/unified-push'; -const unifiedpush = new Hono(); +export const unifiedpush = new Hono(); unifiedpush.get('/up', (c) => c.json({ @@ -45,5 +45,3 @@ unifiedpush.delete('/up/:instance', async (c) => { remove(endpointId); return c.body(null, 204); }); - -export default unifiedpush; diff --git a/server/types.d.ts b/server/types.d.ts deleted file mode 100644 index 6bbabc6..0000000 --- a/server/types.d.ts +++ /dev/null @@ -1,36 +0,0 @@ -export interface JsonRpcRequest { - jsonrpc: '2.0'; - id: number; - method: string; - params: Record; -} - -export interface JsonRpcResponse { - jsonrpc: '2.0'; - id: number; - result?: T; - error?: { - code: number; - message: string; - }; -} - -export interface StartLinkResult { - deviceLinkUri: string; -} - -export interface FinishLinkResult { - account: string; -} - -export interface UpdateGroupResult { - groupId: string; -} - -export interface AccountInfo { - number: string; - name?: string; - uuid?: string; -} - -export type ListAccountsResult = AccountInfo[]; diff --git a/server/utils/auth.ts b/server/utils/auth.ts index aa31358..abb6aec 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -1,4 +1,5 @@ import { timingSafeEqual } from 'node:crypto'; +import { networkInterfaces } from 'node:os'; import type { Context } from 'hono'; import { getConnInfo } from 'hono/bun'; import { ALLOW_INSECURE_HTTP, API_KEY } from '@/constants/config'; @@ -10,6 +11,7 @@ export const isLocalIP = (addr: string | undefined) => { if (octets.length !== 4 || octets.some((n) => Number.isNaN(n))) return false; const [a, b] = octets; + return ( a === 127 || a === 10 || @@ -35,3 +37,21 @@ export const verifyApiKey = (password: string, c?: Context) => { return timingSafeEqual(providedBuffer, keyBuffer); }; + +export const getLanIP = () => { + const nets = networkInterfaces(); + + for (const name of Object.keys(nets)) { + const interfaces = nets[name]; + + if (!interfaces) continue; + + for (const iface of interfaces) { + if (iface.family === 'IPv4' && !iface.internal) { + return iface.address; + } + } + } + + return null; +}; diff --git a/server/utils/format.ts b/server/utils/format.ts index b0d4407..e1a83d2 100644 --- a/server/utils/format.ts +++ b/server/utils/format.ts @@ -33,3 +33,8 @@ export const formatPhoneNumber = (phone: string): string => { return phone; }; + +export const formatToCspString = (cspConfig: Record) => + Object.entries(cspConfig) + .map(([key, values]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()} ${values.join(' ')}`) + .join('; '); diff --git a/server/utils/rpc.ts b/server/utils/rpc.ts index eb0753d..652934a 100644 --- a/server/utils/rpc.ts +++ b/server/utils/rpc.ts @@ -26,14 +26,15 @@ export const call = (method: string, params: Record, account: s } } }, - error(_socket, error) { + error(_, error) { reject(error); }, - connectError(_socket, error) { + connectError(_, error) { reject(error); }, close() { const isComplete = response.includes(MESSAGE_DELIMITER); + if (!isComplete) { reject(new Error('Connection closed before response received')); }