mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
cleaner routing, need auth for ntfy. more secure auth
This commit is contained in:
parent
f5caec736f
commit
e2f794edaf
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 { ROUTES } from '@/constants/server';
|
||||
import { cleanupDaemon, initSignal } from '@/modules/signal';
|
||||
import { adminRoutes } from '@/routes/admin/index';
|
||||
import { ntfyRoutes } from '@/routes/ntfy';
|
||||
import { unifiedPushRoutes } from '@/routes/unifiedpush';
|
||||
import { logError, logInfo, logWarn } from '@/utils/log';
|
||||
|
||||
|
|
@ -30,12 +30,18 @@ const server = Bun.serve({
|
|||
idleTimeout: 60,
|
||||
|
||||
routes: {
|
||||
[ROUTES.FAVICON]: Bun.file('public/favicon.png'),
|
||||
[ROUTES.HTMX]: Bun.file('node_modules/htmx.org/dist/htmx.min.js'),
|
||||
'/favicon.png': {
|
||||
GET: () => new Response(Bun.file('public/favicon.png')),
|
||||
},
|
||||
'/htmx.js': {
|
||||
GET: () => new Response(Bun.file('node_modules/htmx.org/dist/htmx.min.js')),
|
||||
},
|
||||
|
||||
...adminRoutes,
|
||||
|
||||
...unifiedPushRoutes,
|
||||
|
||||
...ntfyRoutes,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -153,6 +153,11 @@ export async function startDaemon() {
|
|||
let authError = false;
|
||||
let cleaned = false;
|
||||
|
||||
if (daemon && !daemon.killed) {
|
||||
daemon.kill();
|
||||
await Bun.sleep(500);
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(SIGNAL_CLI_SOCKET);
|
||||
logVerbose('Removed stale socket file');
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ h2 {
|
|||
|
||||
.status {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ import {
|
|||
unlinkDevice,
|
||||
} from '@/modules/signal';
|
||||
import { getAllMappings } from '@/modules/store';
|
||||
import { withAuth } from '@/utils/auth';
|
||||
|
||||
export const handleHealthFragment = async () => {
|
||||
const handleHealthFragment = async () => {
|
||||
const signalOk = await checkSignalCli();
|
||||
const linked = signalOk && (await hasValidAccount());
|
||||
const imap = isImapConnected();
|
||||
|
|
@ -27,7 +28,7 @@ export const handleHealthFragment = async () => {
|
|||
Account: ${linked ? 'Linked' : 'Unlinked'}
|
||||
</div>
|
||||
<div class="status-item ${imap ? 'status-ok' : 'status-error'}">
|
||||
IMAP: ${imap ? 'Connected' : 'Disconnected'}
|
||||
ProtonMail: ${imap ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -37,7 +38,7 @@ export const handleHealthFragment = async () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const handleSignalInfoFragment = async () => {
|
||||
const handleSignalInfoFragment = async () => {
|
||||
const linked = await hasValidAccount();
|
||||
|
||||
const html = linked
|
||||
|
|
@ -63,7 +64,7 @@ export const handleSignalInfoFragment = async () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const handleEndpointsFragment = async () => {
|
||||
const handleEndpointsFragment = async () => {
|
||||
const endpoints = getAllMappings();
|
||||
|
||||
if (endpoints.length === 0) {
|
||||
|
|
@ -83,7 +84,7 @@ export const handleEndpointsFragment = async () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const handleQRSection = async () => {
|
||||
const handleQRSection = async () => {
|
||||
const html = `
|
||||
<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>
|
||||
|
|
@ -103,7 +104,7 @@ export const handleQRSection = async () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const handleQRImage = async () => {
|
||||
const handleQRImage = async () => {
|
||||
const linked = await hasValidAccount();
|
||||
if (!linked && (await Bun.file(SIGNAL_CLI_DATA).exists())) {
|
||||
await unlinkDevice();
|
||||
|
|
@ -118,7 +119,7 @@ export const handleQRImage = async () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const handleLinkStatusCheck = async () => {
|
||||
const handleLinkStatusCheck = async () => {
|
||||
let linked = await hasValidAccount();
|
||||
|
||||
if (!linked && hasLinkUri()) {
|
||||
|
|
@ -144,3 +145,29 @@ export const handleLinkStatusCheck = async () => {
|
|||
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 { withAuth } from '@/utils/auth';
|
||||
import {
|
||||
handleEndpointsFragment,
|
||||
handleHealthFragment,
|
||||
handleLinkStatusCheck,
|
||||
handleQRImage,
|
||||
handleQRSection,
|
||||
handleSignalInfoFragment,
|
||||
} from './fragments';
|
||||
import { fragmentRoutes } from './fragments';
|
||||
|
||||
export const adminRoutes = {
|
||||
[ROUTES.ADMIN]: {
|
||||
'/': {
|
||||
GET: () => new Response(Bun.file('public/admin.html')),
|
||||
},
|
||||
|
||||
[ROUTES.ADMIN_CSS]: Bun.file('public/admin.css'),
|
||||
|
||||
[ROUTES.HEALTH_FRAGMENT]: {
|
||||
GET: withAuth(handleHealthFragment),
|
||||
'/admin.css': {
|
||||
GET: () => new Response(Bun.file('public/admin.css')),
|
||||
},
|
||||
|
||||
[ROUTES.SIGNAL_INFO_FRAGMENT]: {
|
||||
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),
|
||||
},
|
||||
...fragmentRoutes,
|
||||
};
|
||||
|
|
|
|||
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 { getGroupId, register, remove } from '@/modules/store';
|
||||
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 host = req.headers.get('host') || 'localhost:8080';
|
||||
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' });
|
||||
};
|
||||
|
|
@ -53,15 +52,15 @@ const handleDiscovery = () =>
|
|||
});
|
||||
|
||||
export const unifiedPushRoutes = {
|
||||
[ROUTES.UP]: {
|
||||
'/up': {
|
||||
GET: handleDiscovery,
|
||||
},
|
||||
|
||||
[ROUTES.MATRIX_NOTIFY]: {
|
||||
'/_matrix/push/v1/notify': {
|
||||
POST: handleMatrixNotify,
|
||||
},
|
||||
|
||||
[ROUTES.UP_INSTANCE]: {
|
||||
'/up/:instance': {
|
||||
POST: handleRegister,
|
||||
DELETE: handleUnregister,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,58 @@
|
|||
import { timingSafeEqual } from 'node:crypto';
|
||||
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) => {
|
||||
if (!API_KEY) {
|
||||
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 host = req.headers.get('host') || '';
|
||||
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 });
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue