diff --git a/server/constants/paths.ts b/server/constants/paths.ts index d387596..5eff36c 100644 --- a/server/constants/paths.ts +++ b/server/constants/paths.ts @@ -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 SUP_DB = `${HOME}/.local/share/sup/store.db`; + +export const PUBLIC_DIR = (await Bun.file('public/favicon.webp').exists()) ? 'public' : '/public'; diff --git a/server/index.ts b/server/index.ts index 9b989cd..ef2d971 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,8 +1,10 @@ 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 { adminRoutes } from '@/routes/admin'; import { ntfyRoutes } from '@/routes/ntfy'; import { unifiedPushRoutes } from '@/routes/unifiedpush'; +import { maybeCompress } from '@/utils/compress'; import { getLanIP } from '@/utils/ip'; import { logError, logInfo, logVerbose, logWarn } from '@/utils/log'; @@ -29,13 +31,20 @@ if (BRIDGE_IMAP_USERNAME && BRIDGE_IMAP_PASSWORD) { const server = Bun.serve({ port: PORT, idleTimeout: 60, + maxRequestBodySize: 1024 * 1024, // 1MB limit + + error(error) { + logError('Unhandled server error:', error); + return new Response('Internal Server Error', { status: 500 }); + }, routes: { - '/favicon.png': { - GET: () => new Response(Bun.file('public/favicon.png')), + '/favicon.webp': { + GET: () => new Response(Bun.file(`${PUBLIC_DIR}/favicon.webp`)), }, '/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, diff --git a/server/modules/protonmail.ts b/server/modules/protonmail.ts index 8f144f9..12add94 100644 --- a/server/modules/protonmail.ts +++ b/server/modules/protonmail.ts @@ -6,8 +6,8 @@ import { PROTON_BRIDGE_PORT, SUP_TOPIC, } from '@/constants/config'; -import { createGroup, hasValidAccount, sendGroupMessage } from '@/modules/signal'; -import { getGroupId, register } from '@/modules/store'; +import { hasValidAccount, sendGroupMessage } from '@/modules/signal'; +import { getOrCreateGroup } from '@/modules/store'; import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log'; let imapConnected = false; @@ -41,26 +41,21 @@ export async function startProtonMonitor() { keepalive: true, }); - async function sendNotification(title: string, message: string) { + async function sendNotification(from: string, subject: string) { if (!(await hasValidAccount())) { logVerbose('Skipping notification (Signal not linked)'); return; } try { - const topicKey = `proton-${SUP_TOPIC}`; - const groupId = getGroupId(topicKey) ?? (await createGroup(SUP_TOPIC)); + const groupId = await getOrCreateGroup(`proton-${SUP_TOPIC}`, SUP_TOPIC); - if (!getGroupId(topicKey)) { - register(topicKey, groupId, SUP_TOPIC); - } - - await sendGroupMessage(groupId, message, { + await sendGroupMessage(groupId, subject, { androidPackage: 'ch.protonmail.android', - title, + title: from, }); - logVerbose(`Notification sent: ${title}`); + logVerbose(`Email from ${from}: ${subject}`); } catch (error) { logError('Failed to send notification:', error); } @@ -93,9 +88,9 @@ export async function startProtonMonitor() { const subject = header.subject?.[0] || 'No subject'; const nameMatch = rawFrom.match(/^"?([^"<]+)"?\s* export const remove = (endpoint: string) => { 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; +}; diff --git a/server/public/admin.html b/server/public/admin.html index acba49b..1fa3159 100644 --- a/server/public/admin.html +++ b/server/public/admin.html @@ -4,7 +4,7 @@ SUP Admin - + diff --git a/server/public/favicon.png b/server/public/favicon.png deleted file mode 100644 index a11ef99..0000000 Binary files a/server/public/favicon.png and /dev/null differ diff --git a/server/public/favicon.webp b/server/public/favicon.webp new file mode 100644 index 0000000..2a011b0 Binary files /dev/null and b/server/public/favicon.webp differ diff --git a/server/routes/admin.ts b/server/routes/admin.ts index 35c0d9c..5484949 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -1,5 +1,5 @@ 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 { checkSignalCli, @@ -13,6 +13,7 @@ import { } from '@/modules/signal'; import { getAllMappings, remove } from '@/modules/store'; import { withAuth } from '@/utils/auth'; +import { maybeCompress } from '@/utils/compress'; export const handleHealthFragment = async () => { const signalOk = await checkSignalCli(); @@ -174,11 +175,15 @@ export const handleDeleteEndpoint = async (req: Request) => { 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': { - 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': { diff --git a/server/routes/ntfy.ts b/server/routes/ntfy.ts index dd4d6a4..17b83b2 100644 --- a/server/routes/ntfy.ts +++ b/server/routes/ntfy.ts @@ -1,5 +1,5 @@ -import { createGroup, sendGroupMessage } from '@/modules/signal'; -import { getGroupId, register } from '@/modules/store'; +import { sendGroupMessage } from '@/modules/signal'; +import { getOrCreateGroup } from '@/modules/store'; import { withAuth } from '@/utils/auth'; import { logError, logVerbose } from '@/utils/log'; @@ -18,25 +18,24 @@ const handleNtfyPublish = async (req: Request) => { } const contentType = req.headers.get('content-type') || ''; - let title = req.headers.get('X-Title') || req.headers.get('Title') || req.headers.get('t'); - let androidPackage = - req.headers.get('X-Package') || req.headers.get('Package') || req.headers.get('p'); + let title: string | undefined = + req.headers.get('X-Title') || req.headers.get('Title') || req.headers.get('t') || undefined; + 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')) { const params = new URLSearchParams(message); message = params.get('message') || message; - title = title || params.get('title') || params.get('t'); - androidPackage = androidPackage || params.get('package') || params.get('p'); + title = title || params.get('title') || params.get('t') || undefined; + androidPackage = androidPackage || params.get('package') || params.get('p') || undefined; } - const topicKey = `ntfy-${topic}`; - let groupId = getGroupId(topicKey); + if (title === topic) title = undefined; - if (!groupId) { - groupId = await createGroup(topic); - register(topicKey, groupId, topic); - logVerbose(`Created new group for ntfy topic: ${topic}`); - } + const groupId = await getOrCreateGroup(`ntfy-${topic}`, topic); await sendGroupMessage(groupId, message, { androidPackage: androidPackage || undefined, diff --git a/server/routes/unifiedpush.ts b/server/routes/unifiedpush.ts index 9e6b1d3..4da9a65 100644 --- a/server/routes/unifiedpush.ts +++ b/server/routes/unifiedpush.ts @@ -1,5 +1,5 @@ -import { createGroup, sendGroupMessage } from '@/modules/signal'; -import { getGroupId, register, remove } from '@/modules/store'; +import { sendGroupMessage } from '@/modules/signal'; +import { getGroupId, getOrCreateGroup, remove } from '@/modules/store'; import { formatAsSignalMessage, parseUnifiedPushRequest } from '@/modules/unifiedpush'; const handleMatrixNotify = async (req: Request) => { @@ -24,11 +24,7 @@ const handleRegister = async (req: Request) => { token?: string; }; - const groupId: string = getGroupId(endpointId) ?? (await createGroup(appName)); - - if (!getGroupId(endpointId)) { - register(endpointId, groupId, appName); - } + await getOrCreateGroup(endpointId, appName); const proto = req.headers.get('x-forwarded-proto') || 'http'; const host = req.headers.get('host') || 'localhost:8080'; diff --git a/server/utils/auth.ts b/server/utils/auth.ts index 93dce22..3887c17 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -1,20 +1,16 @@ import { timingSafeEqual } from 'node:crypto'; import { ALLOW_INSECURE_HTTP, API_KEY } from '@/constants/config'; +import { getClientIP, isLocalIP } from '@/utils/ip'; 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(); const MAX_FAILED_ATTEMPTS = 5; 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) => { + if (ALLOW_INSECURE_HTTP || isLocalIP(ip)) return false; + const now = Date.now(); const record = failedAttempts.get(ip); @@ -29,6 +25,8 @@ const isRateLimited = (ip: string) => { }; const recordFailedAttempt = (ip: string) => { + if (ALLOW_INSECURE_HTTP || isLocalIP(ip)) return; + const now = Date.now(); const record = failedAttempts.get(ip); @@ -44,7 +42,7 @@ const recordFailedAttempt = (ip: string) => { const checkAuth = (req: Request) => { if (!API_KEY) { - return new Response('Unauthorized', { status: 401 }); + return new Response('API_KEY environment variable not configured', { status: 401 }); } const clientIP = getClientIP(req); @@ -53,15 +51,14 @@ const checkAuth = (req: Request) => { return new Response('Too many failed attempts. Try again later.', { status: 429 }); } - const proto = req.headers.get('x-forwarded-proto') || 'http'; - const host = req.headers.get('host') || ''; - const isLocalhost = host.startsWith('localhost') || host.startsWith('127.0.0.1'); - - if (proto !== 'https' && !isLocalhost && !ALLOW_INSECURE_HTTP) { - return new Response('HTTPS required when API_KEY is configured', { - status: 426, - headers: { Upgrade: 'TLS/1.2, HTTP/1.1' }, - }); + if (!ALLOW_INSECURE_HTTP) { + const proto = req.headers.get('x-forwarded-proto') || 'http'; + if (proto !== 'https') { + return new Response('HTTPS required when API_KEY is configured', { + status: 426, + headers: { Upgrade: 'TLS/1.2, HTTP/1.1' }, + }); + } } const authHeader = req.headers.get('authorization'); diff --git a/server/utils/compress.ts b/server/utils/compress.ts new file mode 100644 index 0000000..808ede1 --- /dev/null +++ b/server/utils/compress.ts @@ -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 }, + }); +} diff --git a/server/utils/ip.ts b/server/utils/ip.ts index 94aaca0..595e8fb 100644 --- a/server/utils/ip.ts +++ b/server/utils/ip.ts @@ -16,3 +16,36 @@ export const getLanIP = () => { 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) + ); +};