optimize serving with compressions and more, fix to work when compiled

This commit is contained in:
Egor 2026-01-21 00:56:56 -08:00
parent 49d36e0150
commit 27b6b83ce2
13 changed files with 144 additions and 60 deletions

View file

@ -8,3 +8,5 @@ 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_DB = `${HOME}/.local/share/sup/store.db`; export const SUP_DB = `${HOME}/.local/share/sup/store.db`;
export const PUBLIC_DIR = (await Bun.file('public/favicon.webp').exists()) ? 'public' : '/public';

View file

@ -1,8 +1,10 @@
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 { PUBLIC_DIR } from '@/constants/paths';
import { cleanupDaemon, initSignal } from '@/modules/signal'; import { cleanupDaemon, initSignal } from '@/modules/signal';
import { adminRoutes } from '@/routes/admin'; import { adminRoutes } from '@/routes/admin';
import { ntfyRoutes } from '@/routes/ntfy'; import { ntfyRoutes } from '@/routes/ntfy';
import { unifiedPushRoutes } from '@/routes/unifiedpush'; import { unifiedPushRoutes } from '@/routes/unifiedpush';
import { maybeCompress } from '@/utils/compress';
import { getLanIP } from '@/utils/ip'; import { getLanIP } from '@/utils/ip';
import { logError, logInfo, logVerbose, logWarn } from '@/utils/log'; import { logError, logInfo, logVerbose, logWarn } from '@/utils/log';
@ -29,13 +31,20 @@ if (BRIDGE_IMAP_USERNAME && BRIDGE_IMAP_PASSWORD) {
const server = Bun.serve({ const server = Bun.serve({
port: PORT, port: PORT,
idleTimeout: 60, idleTimeout: 60,
maxRequestBodySize: 1024 * 1024, // 1MB limit
error(error) {
logError('Unhandled server error:', error);
return new Response('Internal Server Error', { status: 500 });
},
routes: { routes: {
'/favicon.png': { '/favicon.webp': {
GET: () => new Response(Bun.file('public/favicon.png')), GET: () => new Response(Bun.file(`${PUBLIC_DIR}/favicon.webp`)),
}, },
'/htmx.js': { '/htmx.js': {
GET: () => new Response(Bun.file('public/htmx.min.js')), GET: async (req) =>
maybeCompress(req, await Bun.file(`${PUBLIC_DIR}/htmx.min.js`).text(), 'text/javascript'),
}, },
...adminRoutes, ...adminRoutes,

View file

@ -6,8 +6,8 @@ import {
PROTON_BRIDGE_PORT, PROTON_BRIDGE_PORT,
SUP_TOPIC, SUP_TOPIC,
} from '@/constants/config'; } from '@/constants/config';
import { createGroup, hasValidAccount, sendGroupMessage } from '@/modules/signal'; import { hasValidAccount, sendGroupMessage } from '@/modules/signal';
import { getGroupId, register } from '@/modules/store'; 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;
@ -41,26 +41,21 @@ export async function startProtonMonitor() {
keepalive: true, keepalive: true,
}); });
async function sendNotification(title: string, message: string) { async function sendNotification(from: string, subject: string) {
if (!(await hasValidAccount())) { if (!(await hasValidAccount())) {
logVerbose('Skipping notification (Signal not linked)'); logVerbose('Skipping notification (Signal not linked)');
return; return;
} }
try { try {
const topicKey = `proton-${SUP_TOPIC}`; const groupId = await getOrCreateGroup(`proton-${SUP_TOPIC}`, SUP_TOPIC);
const groupId = getGroupId(topicKey) ?? (await createGroup(SUP_TOPIC));
if (!getGroupId(topicKey)) { await sendGroupMessage(groupId, subject, {
register(topicKey, groupId, SUP_TOPIC);
}
await sendGroupMessage(groupId, message, {
androidPackage: 'ch.protonmail.android', androidPackage: 'ch.protonmail.android',
title, title: from,
}); });
logVerbose(`Notification sent: ${title}`); logVerbose(`Email from ${from}: ${subject}`);
} catch (error) { } catch (error) {
logError('Failed to send notification:', error); logError('Failed to send notification:', error);
} }
@ -93,9 +88,9 @@ export async function startProtonMonitor() {
const subject = header.subject?.[0] || 'No subject'; const subject = header.subject?.[0] || 'No subject';
const nameMatch = rawFrom.match(/^"?([^"<]+)"?\s*<?/); const nameMatch = rawFrom.match(/^"?([^"<]+)"?\s*<?/);
const from = nameMatch ? nameMatch[1]?.trim() : rawFrom; const from = nameMatch?.[1]?.trim() || rawFrom;
sendNotification(`From: ${from}`, subject); sendNotification(from, subject);
}); });
}); });
}); });

View file

@ -1,5 +1,6 @@
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 './signal';
interface EndpointMapping { interface EndpointMapping {
endpoint: string; endpoint: string;
@ -45,3 +46,12 @@ export const getAllMappings = () =>
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]);
}; };
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;
};

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>SUP Admin</title> <title>SUP Admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/favicon.png"> <link rel="icon" type="image/webp" href="/favicon.webp">
<link rel="stylesheet" href="/admin.css"> <link rel="stylesheet" href="/admin.css">
<script src="/htmx.js"></script> <script src="/htmx.js"></script>
</head> </head>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

BIN
server/public/favicon.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 B

View file

@ -1,5 +1,5 @@
import { DEVICE_NAME } from '@/constants/config'; import { DEVICE_NAME } from '@/constants/config';
import { SIGNAL_CLI_DATA } from '@/constants/paths'; import { PUBLIC_DIR, SIGNAL_CLI_DATA } from '@/constants/paths';
import { isImapConnected } from '@/modules/protonmail'; import { isImapConnected } from '@/modules/protonmail';
import { import {
checkSignalCli, checkSignalCli,
@ -13,6 +13,7 @@ import {
} from '@/modules/signal'; } from '@/modules/signal';
import { getAllMappings, remove } from '@/modules/store'; import { getAllMappings, remove } from '@/modules/store';
import { withAuth } from '@/utils/auth'; import { withAuth } from '@/utils/auth';
import { maybeCompress } from '@/utils/compress';
export const handleHealthFragment = async () => { export const handleHealthFragment = async () => {
const signalOk = await checkSignalCli(); const signalOk = await checkSignalCli();
@ -174,11 +175,15 @@ export const handleDeleteEndpoint = async (req: Request) => {
export const adminRoutes = { export const adminRoutes = {
'/': { '/': {
GET: () => new Response(Bun.file('public/admin.html')), GET: withAuth(async (req: Request) =>
maybeCompress(req, await Bun.file(`${PUBLIC_DIR}/admin.html`).text()),
),
}, },
'/admin.css': { '/admin.css': {
GET: () => new Response(Bun.file('public/admin.css')), GET: withAuth(async (req: Request) =>
maybeCompress(req, await Bun.file(`${PUBLIC_DIR}/admin.css`).text(), 'text/css'),
),
}, },
'/health/fragment': { '/health/fragment': {

View file

@ -1,5 +1,5 @@
import { createGroup, sendGroupMessage } from '@/modules/signal'; import { sendGroupMessage } from '@/modules/signal';
import { getGroupId, register } from '@/modules/store'; import { getOrCreateGroup } from '@/modules/store';
import { withAuth } from '@/utils/auth'; import { withAuth } from '@/utils/auth';
import { logError, logVerbose } from '@/utils/log'; import { logError, logVerbose } from '@/utils/log';
@ -18,25 +18,24 @@ const handleNtfyPublish = async (req: Request) => {
} }
const contentType = req.headers.get('content-type') || ''; const contentType = req.headers.get('content-type') || '';
let title = req.headers.get('X-Title') || req.headers.get('Title') || req.headers.get('t'); let title: string | undefined =
let androidPackage = req.headers.get('X-Title') || req.headers.get('Title') || req.headers.get('t') || undefined;
req.headers.get('X-Package') || req.headers.get('Package') || req.headers.get('p'); let androidPackage: string | undefined =
req.headers.get('X-Package') ||
req.headers.get('Package') ||
req.headers.get('p') ||
undefined;
if (contentType.includes('application/x-www-form-urlencoded')) { if (contentType.includes('application/x-www-form-urlencoded')) {
const params = new URLSearchParams(message); const params = new URLSearchParams(message);
message = params.get('message') || message; message = params.get('message') || message;
title = title || params.get('title') || params.get('t'); title = title || params.get('title') || params.get('t') || undefined;
androidPackage = androidPackage || params.get('package') || params.get('p'); androidPackage = androidPackage || params.get('package') || params.get('p') || undefined;
} }
const topicKey = `ntfy-${topic}`; if (title === topic) title = undefined;
let groupId = getGroupId(topicKey);
if (!groupId) { const groupId = await getOrCreateGroup(`ntfy-${topic}`, topic);
groupId = await createGroup(topic);
register(topicKey, groupId, topic);
logVerbose(`Created new group for ntfy topic: ${topic}`);
}
await sendGroupMessage(groupId, message, { await sendGroupMessage(groupId, message, {
androidPackage: androidPackage || undefined, androidPackage: androidPackage || undefined,

View file

@ -1,5 +1,5 @@
import { createGroup, sendGroupMessage } from '@/modules/signal'; import { sendGroupMessage } from '@/modules/signal';
import { getGroupId, register, remove } from '@/modules/store'; import { getGroupId, getOrCreateGroup, remove } from '@/modules/store';
import { formatAsSignalMessage, parseUnifiedPushRequest } from '@/modules/unifiedpush'; import { formatAsSignalMessage, parseUnifiedPushRequest } from '@/modules/unifiedpush';
const handleMatrixNotify = async (req: Request) => { const handleMatrixNotify = async (req: Request) => {
@ -24,11 +24,7 @@ const handleRegister = async (req: Request) => {
token?: string; token?: string;
}; };
const groupId: string = getGroupId(endpointId) ?? (await createGroup(appName)); await getOrCreateGroup(endpointId, appName);
if (!getGroupId(endpointId)) {
register(endpointId, groupId, appName);
}
const proto = req.headers.get('x-forwarded-proto') || 'http'; const proto = req.headers.get('x-forwarded-proto') || 'http';
const host = req.headers.get('host') || 'localhost:8080'; const host = req.headers.get('host') || 'localhost:8080';

View file

@ -1,20 +1,16 @@
import { timingSafeEqual } from 'node:crypto'; import { timingSafeEqual } from 'node:crypto';
import { ALLOW_INSECURE_HTTP, API_KEY } from '@/constants/config'; import { ALLOW_INSECURE_HTTP, API_KEY } from '@/constants/config';
import { getClientIP, isLocalIP } from '@/utils/ip';
import { logWarn } from '@/utils/log'; import { logWarn } from '@/utils/log';
// Rate limiing: track failed auth attempts per IP // Rate limiting: track failed auth attempts per IP
const failedAttempts = new Map<string, { count: number; resetAt: number }>(); const failedAttempts = new Map<string, { count: number; resetAt: number }>();
const MAX_FAILED_ATTEMPTS = 5; const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
// Cloudflare Tunnel sends real IP in CF-Connecting-IP
const getClientIP = (req: Request) =>
req.headers.get('cf-connecting-ip') ||
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
req.headers.get('x-real-ip') ||
'unknown';
const isRateLimited = (ip: string) => { const isRateLimited = (ip: string) => {
if (ALLOW_INSECURE_HTTP || isLocalIP(ip)) return false;
const now = Date.now(); const now = Date.now();
const record = failedAttempts.get(ip); const record = failedAttempts.get(ip);
@ -29,6 +25,8 @@ const isRateLimited = (ip: string) => {
}; };
const recordFailedAttempt = (ip: string) => { const recordFailedAttempt = (ip: string) => {
if (ALLOW_INSECURE_HTTP || isLocalIP(ip)) return;
const now = Date.now(); const now = Date.now();
const record = failedAttempts.get(ip); const record = failedAttempts.get(ip);
@ -44,7 +42,7 @@ const recordFailedAttempt = (ip: string) => {
const checkAuth = (req: Request) => { const checkAuth = (req: Request) => {
if (!API_KEY) { if (!API_KEY) {
return new Response('Unauthorized', { status: 401 }); return new Response('API_KEY environment variable not configured', { status: 401 });
} }
const clientIP = getClientIP(req); const clientIP = getClientIP(req);
@ -53,15 +51,14 @@ const checkAuth = (req: Request) => {
return new Response('Too many failed attempts. Try again later.', { status: 429 }); return new Response('Too many failed attempts. Try again later.', { status: 429 });
} }
const proto = req.headers.get('x-forwarded-proto') || 'http'; if (!ALLOW_INSECURE_HTTP) {
const host = req.headers.get('host') || ''; const proto = req.headers.get('x-forwarded-proto') || 'http';
const isLocalhost = host.startsWith('localhost') || host.startsWith('127.0.0.1'); if (proto !== 'https') {
return new Response('HTTPS required when API_KEY is configured', {
if (proto !== 'https' && !isLocalhost && !ALLOW_INSECURE_HTTP) { status: 426,
return new Response('HTTPS required when API_KEY is configured', { headers: { Upgrade: 'TLS/1.2, HTTP/1.1' },
status: 426, });
headers: { Upgrade: 'TLS/1.2, HTTP/1.1' }, }
});
} }
const authHeader = req.headers.get('authorization'); const authHeader = req.headers.get('authorization');

38
server/utils/compress.ts Normal file
View file

@ -0,0 +1,38 @@
export function maybeCompress(
request: Request,
body: string,
contentType = 'text/html; charset=utf-8',
): Response {
const acceptEncoding = request.headers.get('accept-encoding') || '';
const bodyBytes = new TextEncoder().encode(body);
if (bodyBytes.length < 1024) {
return new Response(body, {
headers: { 'Content-Type': contentType },
});
}
if (acceptEncoding.includes('gzip')) {
const compressed = Bun.gzipSync(bodyBytes);
return new Response(compressed, {
headers: {
'Content-Type': contentType,
'Content-Encoding': 'gzip',
},
});
}
if (acceptEncoding.includes('deflate')) {
const compressed = Bun.deflateSync(bodyBytes);
return new Response(compressed, {
headers: {
'Content-Type': contentType,
'Content-Encoding': 'deflate',
},
});
}
return new Response(body, {
headers: { 'Content-Type': contentType },
});
}

View file

@ -16,3 +16,36 @@ export const getLanIP = () => {
return null; return null;
}; };
export const getClientIP = (req: Request) => {
// Cloudflare Tunnel
const cfIP = req.headers.get('cf-connecting-ip');
if (cfIP) return cfIP;
// Standard reverse proxy headers
const forwardedFor = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
if (forwardedFor) return forwardedFor;
const realIP = req.headers.get('x-real-ip');
if (realIP) return realIP;
const remoteAddr = req.headers.get('remote-addr');
if (remoteAddr) return remoteAddr;
return 'unknown';
};
export const isLocalIP = (ip: string) => {
if (ip === '::1' || ip === 'localhost') return true;
const octets = ip.split('.').map(Number);
if (octets.length !== 4 || octets.some((n) => Number.isNaN(n))) return false;
const [a, b] = octets;
return (
a === 127 ||
a === 10 ||
(a === 192 && b === 168) ||
(a === 172 && b !== undefined && b >= 16 && b <= 31)
);
};