mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
optimize serving with compressions and more, fix to work when compiled
This commit is contained in:
parent
49d36e0150
commit
27b6b83ce2
13 changed files with 144 additions and 60 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<title>SUP Admin</title>
|
||||
<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">
|
||||
<script src="/htmx.js"></script>
|
||||
</head>
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 133 KiB |
BIN
server/public/favicon.webp
Normal file
BIN
server/public/favicon.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 950 B |
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<string, { count: number; resetAt: number }>();
|
||||
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');
|
||||
|
|
|
|||
38
server/utils/compress.ts
Normal file
38
server/utils/compress.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue