more minor refacotring and cleanup

This commit is contained in:
Egor 2026-01-22 14:10:41 -08:00
parent 128aa9ffe5
commit eeea750873
12 changed files with 113 additions and 136 deletions

View file

@ -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({

View file

@ -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...');

View file

@ -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<typeof Bun.spawn> | 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);
}

View file

@ -1,6 +1,6 @@
import { SUP_ENDPOINT_PREFIX } from '@/constants/config';
export interface UnifiedPushMessage {
interface UnifiedPushMessage {
endpoint: string;
title?: string;
body?: string;

View file

@ -42,7 +42,7 @@
<h2>Endpoints</h2>
<div id="endpoints-list"
hx-get="/fragment/endpoints"
hx-trigger="load"
hx-trigger="load, every 10s"
hx-indicator="#endpoints-list">
<div class="loading">
<div class="spinner"></div>

View file

@ -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<string> | 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 () => {
</div>
`;
};
export default admin;

View file

@ -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;

View file

@ -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;

36
server/types.d.ts vendored
View file

@ -1,36 +0,0 @@
export interface JsonRpcRequest {
jsonrpc: '2.0';
id: number;
method: string;
params: Record<string, unknown>;
}
export interface JsonRpcResponse<T = unknown> {
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[];

View file

@ -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;
};

View file

@ -33,3 +33,8 @@ export const formatPhoneNumber = (phone: string): string => {
return phone;
};
export const formatToCspString = (cspConfig: Record<string, string[]>) =>
Object.entries(cspConfig)
.map(([key, values]) => `${key.replace(/([A-Z])/g, '-$1').toLowerCase()} ${values.join(' ')}`)
.join('; ');

View file

@ -26,14 +26,15 @@ export const call = (method: string, params: Record<string, unknown>, 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'));
}