mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 19:54:44 -07:00
cleaner routing, need auth for ntfy. more secure auth
This commit is contained in:
parent
24cccf9740
commit
1db47e4829
9 changed files with 185 additions and 66 deletions
|
|
@ -1,15 +0,0 @@
|
||||||
export const ROUTES = {
|
|
||||||
FAVICON: '/favicon.png',
|
|
||||||
HTMX: '/htmx.js',
|
|
||||||
ADMIN: '/',
|
|
||||||
ADMIN_CSS: '/admin.css',
|
|
||||||
HEALTH_FRAGMENT: '/health/fragment',
|
|
||||||
SIGNAL_INFO_FRAGMENT: '/signal-info/fragment',
|
|
||||||
ENDPOINTS_FRAGMENT: '/endpoints/fragment',
|
|
||||||
QR_SECTION: '/link/qr-section',
|
|
||||||
QR_IMAGE: '/link/qr-image',
|
|
||||||
STATUS_CHECK: '/link/status-check',
|
|
||||||
MATRIX_NOTIFY: '/_matrix/push/v1/notify',
|
|
||||||
UP: '/up',
|
|
||||||
UP_INSTANCE: '/up/:instance',
|
|
||||||
} as const;
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
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 { ROUTES } from '@/constants/server';
|
|
||||||
import { cleanupDaemon, initSignal } from '@/modules/signal';
|
import { cleanupDaemon, initSignal } from '@/modules/signal';
|
||||||
import { adminRoutes } from '@/routes/admin/index';
|
import { adminRoutes } from '@/routes/admin/index';
|
||||||
|
import { ntfyRoutes } from '@/routes/ntfy';
|
||||||
import { unifiedPushRoutes } from '@/routes/unifiedpush';
|
import { unifiedPushRoutes } from '@/routes/unifiedpush';
|
||||||
import { logError, logInfo, logWarn } from '@/utils/log';
|
import { logError, logInfo, logWarn } from '@/utils/log';
|
||||||
|
|
||||||
|
|
@ -30,12 +30,18 @@ const server = Bun.serve({
|
||||||
idleTimeout: 60,
|
idleTimeout: 60,
|
||||||
|
|
||||||
routes: {
|
routes: {
|
||||||
[ROUTES.FAVICON]: Bun.file('public/favicon.png'),
|
'/favicon.png': {
|
||||||
[ROUTES.HTMX]: Bun.file('node_modules/htmx.org/dist/htmx.min.js'),
|
GET: () => new Response(Bun.file('public/favicon.png')),
|
||||||
|
},
|
||||||
|
'/htmx.js': {
|
||||||
|
GET: () => new Response(Bun.file('node_modules/htmx.org/dist/htmx.min.js')),
|
||||||
|
},
|
||||||
|
|
||||||
...adminRoutes,
|
...adminRoutes,
|
||||||
|
|
||||||
...unifiedPushRoutes,
|
...unifiedPushRoutes,
|
||||||
|
|
||||||
|
...ntfyRoutes,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,11 @@ export async function startDaemon() {
|
||||||
let authError = false;
|
let authError = false;
|
||||||
let cleaned = false;
|
let cleaned = false;
|
||||||
|
|
||||||
|
if (daemon && !daemon.killed) {
|
||||||
|
daemon.kill();
|
||||||
|
await Bun.sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await unlink(SIGNAL_CLI_SOCKET);
|
await unlink(SIGNAL_CLI_SOCKET);
|
||||||
logVerbose('Removed stale socket file');
|
logVerbose('Removed stale socket file');
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ h2 {
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 15px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,9 @@ import {
|
||||||
unlinkDevice,
|
unlinkDevice,
|
||||||
} from '@/modules/signal';
|
} from '@/modules/signal';
|
||||||
import { getAllMappings } from '@/modules/store';
|
import { getAllMappings } from '@/modules/store';
|
||||||
|
import { withAuth } from '@/utils/auth';
|
||||||
|
|
||||||
export const handleHealthFragment = async () => {
|
const handleHealthFragment = async () => {
|
||||||
const signalOk = await checkSignalCli();
|
const signalOk = await checkSignalCli();
|
||||||
const linked = signalOk && (await hasValidAccount());
|
const linked = signalOk && (await hasValidAccount());
|
||||||
const imap = isImapConnected();
|
const imap = isImapConnected();
|
||||||
|
|
@ -27,7 +28,7 @@ export const handleHealthFragment = async () => {
|
||||||
Account: ${linked ? 'Linked' : 'Unlinked'}
|
Account: ${linked ? 'Linked' : 'Unlinked'}
|
||||||
</div>
|
</div>
|
||||||
<div class="status-item ${imap ? 'status-ok' : 'status-error'}">
|
<div class="status-item ${imap ? 'status-ok' : 'status-error'}">
|
||||||
IMAP: ${imap ? 'Connected' : 'Disconnected'}
|
ProtonMail: ${imap ? 'Connected' : 'Disconnected'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -37,7 +38,7 @@ export const handleHealthFragment = async () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleSignalInfoFragment = async () => {
|
const handleSignalInfoFragment = async () => {
|
||||||
const linked = await hasValidAccount();
|
const linked = await hasValidAccount();
|
||||||
|
|
||||||
const html = linked
|
const html = linked
|
||||||
|
|
@ -63,7 +64,7 @@ export const handleSignalInfoFragment = async () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleEndpointsFragment = async () => {
|
const handleEndpointsFragment = async () => {
|
||||||
const endpoints = getAllMappings();
|
const endpoints = getAllMappings();
|
||||||
|
|
||||||
if (endpoints.length === 0) {
|
if (endpoints.length === 0) {
|
||||||
|
|
@ -83,7 +84,7 @@ export const handleEndpointsFragment = async () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleQRSection = async () => {
|
const handleQRSection = async () => {
|
||||||
const html = `
|
const html = `
|
||||||
<p>Scan this QR code with your Signal app:</p>
|
<p>Scan this QR code with your Signal app:</p>
|
||||||
<p style="font-size: 1.05em;"><strong>Settings → Linked Devices → Link New Device</strong></p>
|
<p style="font-size: 1.05em;"><strong>Settings → Linked Devices → Link New Device</strong></p>
|
||||||
|
|
@ -103,7 +104,7 @@ export const handleQRSection = async () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleQRImage = async () => {
|
const handleQRImage = async () => {
|
||||||
const linked = await hasValidAccount();
|
const linked = await hasValidAccount();
|
||||||
if (!linked && (await Bun.file(SIGNAL_CLI_DATA).exists())) {
|
if (!linked && (await Bun.file(SIGNAL_CLI_DATA).exists())) {
|
||||||
await unlinkDevice();
|
await unlinkDevice();
|
||||||
|
|
@ -118,7 +119,7 @@ export const handleQRImage = async () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleLinkStatusCheck = async () => {
|
const handleLinkStatusCheck = async () => {
|
||||||
let linked = await hasValidAccount();
|
let linked = await hasValidAccount();
|
||||||
|
|
||||||
if (!linked && hasLinkUri()) {
|
if (!linked && hasLinkUri()) {
|
||||||
|
|
@ -144,3 +145,29 @@ export const handleLinkStatusCheck = async () => {
|
||||||
headers: { 'content-type': 'text/html' },
|
headers: { 'content-type': 'text/html' },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fragmentRoutes = {
|
||||||
|
'/health/fragment': {
|
||||||
|
GET: withAuth(handleHealthFragment),
|
||||||
|
},
|
||||||
|
|
||||||
|
'/signal-info/fragment': {
|
||||||
|
GET: withAuth(handleSignalInfoFragment),
|
||||||
|
},
|
||||||
|
|
||||||
|
'/endpoints/fragment': {
|
||||||
|
GET: withAuth(handleEndpointsFragment),
|
||||||
|
},
|
||||||
|
|
||||||
|
'/link/qr-section': {
|
||||||
|
GET: withAuth(handleQRSection),
|
||||||
|
},
|
||||||
|
|
||||||
|
'/link/qr-image': {
|
||||||
|
GET: withAuth(handleQRImage),
|
||||||
|
},
|
||||||
|
|
||||||
|
'/link/status-check': {
|
||||||
|
GET: withAuth(handleLinkStatusCheck),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,13 @@
|
||||||
import { ROUTES } from '@/constants/server';
|
import { fragmentRoutes } from './fragments';
|
||||||
import { withAuth } from '@/utils/auth';
|
|
||||||
import {
|
|
||||||
handleEndpointsFragment,
|
|
||||||
handleHealthFragment,
|
|
||||||
handleLinkStatusCheck,
|
|
||||||
handleQRImage,
|
|
||||||
handleQRSection,
|
|
||||||
handleSignalInfoFragment,
|
|
||||||
} from './fragments';
|
|
||||||
|
|
||||||
export const adminRoutes = {
|
export const adminRoutes = {
|
||||||
[ROUTES.ADMIN]: {
|
'/': {
|
||||||
GET: () => new Response(Bun.file('public/admin.html')),
|
GET: () => new Response(Bun.file('public/admin.html')),
|
||||||
},
|
},
|
||||||
|
|
||||||
[ROUTES.ADMIN_CSS]: Bun.file('public/admin.css'),
|
'/admin.css': {
|
||||||
|
GET: () => new Response(Bun.file('public/admin.css')),
|
||||||
[ROUTES.HEALTH_FRAGMENT]: {
|
|
||||||
GET: withAuth(handleHealthFragment),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
[ROUTES.SIGNAL_INFO_FRAGMENT]: {
|
...fragmentRoutes,
|
||||||
GET: withAuth(handleSignalInfoFragment),
|
|
||||||
},
|
|
||||||
|
|
||||||
[ROUTES.ENDPOINTS_FRAGMENT]: {
|
|
||||||
GET: withAuth(handleEndpointsFragment),
|
|
||||||
},
|
|
||||||
|
|
||||||
[ROUTES.QR_SECTION]: {
|
|
||||||
GET: withAuth(handleQRSection),
|
|
||||||
},
|
|
||||||
|
|
||||||
[ROUTES.QR_IMAGE]: {
|
|
||||||
GET: withAuth(handleQRImage),
|
|
||||||
},
|
|
||||||
|
|
||||||
[ROUTES.STATUS_CHECK]: {
|
|
||||||
GET: withAuth(handleLinkStatusCheck),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
60
server/routes/ntfy.ts
Normal file
60
server/routes/ntfy.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { createGroup, sendGroupMessage } from '@/modules/signal';
|
||||||
|
import { getGroupId, register } from '@/modules/store';
|
||||||
|
import { withAuth } from '@/utils/auth';
|
||||||
|
import { logError, logVerbose } from '@/utils/log';
|
||||||
|
|
||||||
|
const handleNtfyPublish = async (req: Request) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const topic = url.pathname.slice(1);
|
||||||
|
|
||||||
|
if (!topic || topic.includes('/')) {
|
||||||
|
return new Response('Invalid topic', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.text();
|
||||||
|
if (!body) {
|
||||||
|
return new Response('Message required', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = req.headers.get('X-Title') || req.headers.get('Title') || req.headers.get('t');
|
||||||
|
|
||||||
|
const topicKey = `ntfy-${topic}`;
|
||||||
|
let groupId = getGroupId(topicKey);
|
||||||
|
|
||||||
|
if (!groupId) {
|
||||||
|
groupId = await createGroup(topic);
|
||||||
|
register(topicKey, groupId, topic);
|
||||||
|
logVerbose(`Created new group for ntfy topic: ${topic}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = title ? `**${title}**\n${body}` : body;
|
||||||
|
|
||||||
|
await sendGroupMessage(groupId, message);
|
||||||
|
|
||||||
|
logVerbose(`Sent ntfy message to topic ${topic}: ${title || body.substring(0, 50)}`);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
id: Date.now().toString(),
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
event: 'message',
|
||||||
|
topic,
|
||||||
|
message: body,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logError('Failed to handle ntfy publish:', error);
|
||||||
|
return new Response('Internal server error', { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ntfyRoutes = {
|
||||||
|
'/:topic': {
|
||||||
|
POST: withAuth(handleNtfyPublish),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { ROUTES } from '@/constants/server';
|
|
||||||
import { createGroup, sendGroupMessage } from '@/modules/signal';
|
import { createGroup, sendGroupMessage } from '@/modules/signal';
|
||||||
import { getGroupId, register, remove } from '@/modules/store';
|
import { getGroupId, register, remove } from '@/modules/store';
|
||||||
import { formatAsSignalMessage, parseUnifiedPushRequest } from '@/modules/unifiedpush';
|
import { formatAsSignalMessage, parseUnifiedPushRequest } from '@/modules/unifiedpush';
|
||||||
|
|
@ -34,7 +33,7 @@ const handleRegister = async (req: Request) => {
|
||||||
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';
|
||||||
const baseUrl = `${proto}://${host}`;
|
const baseUrl = `${proto}://${host}`;
|
||||||
const endpoint = `${baseUrl}${ROUTES.MATRIX_NOTIFY}/${endpointId}`;
|
const endpoint = `${baseUrl}/_matrix/push/v1/notify/${endpointId}`;
|
||||||
|
|
||||||
return Response.json({ endpoint, gateway: 'matrix' });
|
return Response.json({ endpoint, gateway: 'matrix' });
|
||||||
};
|
};
|
||||||
|
|
@ -53,15 +52,15 @@ const handleDiscovery = () =>
|
||||||
});
|
});
|
||||||
|
|
||||||
export const unifiedPushRoutes = {
|
export const unifiedPushRoutes = {
|
||||||
[ROUTES.UP]: {
|
'/up': {
|
||||||
GET: handleDiscovery,
|
GET: handleDiscovery,
|
||||||
},
|
},
|
||||||
|
|
||||||
[ROUTES.MATRIX_NOTIFY]: {
|
'/_matrix/push/v1/notify': {
|
||||||
POST: handleMatrixNotify,
|
POST: handleMatrixNotify,
|
||||||
},
|
},
|
||||||
|
|
||||||
[ROUTES.UP_INSTANCE]: {
|
'/up/:instance': {
|
||||||
POST: handleRegister,
|
POST: handleRegister,
|
||||||
DELETE: handleUnregister,
|
DELETE: handleUnregister,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,58 @@
|
||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
import { API_KEY } from '@/constants/config';
|
import { API_KEY } from '@/constants/config';
|
||||||
|
import { logWarn } from '@/utils/log';
|
||||||
|
|
||||||
|
// Rate limiing: 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) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const record = failedAttempts.get(ip);
|
||||||
|
|
||||||
|
if (!record) return false;
|
||||||
|
|
||||||
|
if (now > record.resetAt) {
|
||||||
|
failedAttempts.delete(ip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return record.count >= MAX_FAILED_ATTEMPTS;
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordFailedAttempt = (ip: string) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const record = failedAttempts.get(ip);
|
||||||
|
|
||||||
|
if (!record || now > record.resetAt) {
|
||||||
|
failedAttempts.set(ip, { count: 1, resetAt: now + LOCKOUT_DURATION });
|
||||||
|
} else {
|
||||||
|
record.count++;
|
||||||
|
if (record.count >= MAX_FAILED_ATTEMPTS) {
|
||||||
|
logWarn(`IP ${ip} locked out after ${MAX_FAILED_ATTEMPTS} failed auth attempts`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const checkAuth = (req: Request) => {
|
const checkAuth = (req: Request) => {
|
||||||
if (!API_KEY) {
|
if (!API_KEY) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response('Unauthorized', { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clientIP = getClientIP(req);
|
||||||
|
|
||||||
|
if (isRateLimited(clientIP)) {
|
||||||
|
return new Response('Too many failed attempts. Try again later.', { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
const proto = req.headers.get('x-forwarded-proto') || 'http';
|
const proto = req.headers.get('x-forwarded-proto') || 'http';
|
||||||
const host = req.headers.get('host') || '';
|
const host = req.headers.get('host') || '';
|
||||||
const isLocalhost = host.startsWith('localhost') || host.startsWith('127.0.0.1');
|
const isLocalhost = host.startsWith('localhost') || host.startsWith('127.0.0.1');
|
||||||
|
|
@ -13,10 +61,28 @@ const checkAuth = (req: Request) => {
|
||||||
return new Response('HTTPS required when API_KEY is configured', { status: 403 });
|
return new Response('HTTPS required when API_KEY is configured', { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.headers.get('authorization') !== `Bearer ${API_KEY}`) {
|
const authHeader = req.headers.get('authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
recordFailedAttempt(clientIP);
|
||||||
return new Response(null, { status: 401 });
|
return new Response(null, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const providedKey = authHeader.slice(7);
|
||||||
|
|
||||||
|
if (providedKey.length !== API_KEY.length) {
|
||||||
|
recordFailedAttempt(clientIP);
|
||||||
|
return new Response(null, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const providedBuffer = Buffer.from(providedKey);
|
||||||
|
const keyBuffer = Buffer.from(API_KEY);
|
||||||
|
|
||||||
|
if (!timingSafeEqual(providedBuffer, keyBuffer)) {
|
||||||
|
recordFailedAttempt(clientIP);
|
||||||
|
return new Response(null, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
failedAttempts.delete(clientIP);
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue