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*/);
- const from = nameMatch ? nameMatch[1]?.trim() : rawFrom;
+ const from = nameMatch?.[1]?.trim() || rawFrom;
- sendNotification(`From: ${from}`, subject);
+ sendNotification(from, subject);
});
});
});
diff --git a/server/modules/store.ts b/server/modules/store.ts
index 515f6bd..091308d 100644
--- a/server/modules/store.ts
+++ b/server/modules/store.ts
@@ -1,5 +1,6 @@
import { Database } from 'bun:sqlite';
import { SUP_DB } from '@/constants/paths';
+import { createGroup } from './signal';
interface EndpointMapping {
endpoint: string;
@@ -45,3 +46,12 @@ export const getAllMappings = () =>
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)
+ );
+};