mirror of
https://github.com/WonderAliceUwU/OmniArr.git
synced 2026-11-11 04:01:01 +00:00
462 lines
14 KiB
TypeScript
462 lines
14 KiB
TypeScript
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { resolve } from 'node:path';
|
|
import { defaultConfig, type AppConfig, type AuthPayload } from '../shared/contracts.js';
|
|
import { decryptJson, deriveEncryptionKey, encryptJson, randomToken, verifyPassword, createPasswordRecord } from './lib/security.js';
|
|
import { loadState, readAdmin, readEncryptedConfig, saveAdminAndConfig, updateEncryptedConfig } from './lib/store.js';
|
|
import {
|
|
getConnectionStatuses,
|
|
getDashboard,
|
|
getDownloads,
|
|
getHubPage,
|
|
getServiceOptions,
|
|
loadJellyfinUsers,
|
|
proxyJellyfinImage,
|
|
requestMovie,
|
|
requestSeries,
|
|
sanitizeConfig
|
|
} from './lib/services.js';
|
|
|
|
interface SessionRecord {
|
|
encryptionKey: Buffer;
|
|
expiresAt: number;
|
|
}
|
|
|
|
const PORT = Number(process.env.PORT || 8787);
|
|
const SESSION_COOKIE = 'omniarr_session';
|
|
const SESSION_TTL_MS = 1000 * 60 * 60 * 12;
|
|
const sessions = new Map<string, SessionRecord>();
|
|
const failedAttempts = new Map<string, { count: number; lastAttempt: number }>();
|
|
|
|
const server = createServer(async (request, response) => {
|
|
try {
|
|
await routeRequest(request, response);
|
|
} catch (error) {
|
|
console.error('Unhandled request error:', error);
|
|
sendJson(response, 500, {
|
|
error: error instanceof Error ? error.message : 'Internal server error'
|
|
});
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`OmniArr server listening on http://127.0.0.1:${PORT}`);
|
|
});
|
|
|
|
setInterval(cleanupSessions, 1000 * 60 * 15).unref();
|
|
|
|
async function routeRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
const method = request.method || 'GET';
|
|
const url = new URL(request.url || '/', `http://${request.headers.host || '127.0.0.1'}`);
|
|
console.log(`${method} ${url.pathname}`);
|
|
|
|
if (url.pathname === '/api/health' && method === 'GET') {
|
|
sendJson(response, 200, {
|
|
ok: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/api/auth/session' && method === 'GET') {
|
|
const state = await loadState();
|
|
const session = getSession(request);
|
|
sendJson(response, 200, {
|
|
needsSetup: !state.admin,
|
|
authenticated: Boolean(session)
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/api/auth/register' && method === 'POST') {
|
|
const state = await loadState();
|
|
if (state.admin) {
|
|
console.warn('Register blocked: app already initialized');
|
|
sendJson(response, 409, { error: 'The app has already been initialized.' });
|
|
return;
|
|
}
|
|
|
|
const body = await readJson<AuthPayload>(request);
|
|
const password = String(body.password || '');
|
|
if (password.length < 8) {
|
|
console.warn('Register blocked: password too short');
|
|
sendJson(response, 400, { error: 'Password must be at least 8 characters long.' });
|
|
return;
|
|
}
|
|
|
|
const record = createPasswordRecord(password);
|
|
const encryptionKey = deriveEncryptionKey(password, record.encryptionSalt);
|
|
await saveAdminAndConfig(
|
|
{
|
|
...record,
|
|
createdAt: new Date().toISOString()
|
|
},
|
|
encryptJson(defaultConfig, encryptionKey)
|
|
);
|
|
|
|
const token = createSession(encryptionKey);
|
|
setSessionCookie(request, response, token);
|
|
resetFailedAttempts(getClientAddress(request));
|
|
console.log('Register completed successfully');
|
|
sendJson(response, 200, {
|
|
ok: true,
|
|
needsSetup: false
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/api/auth/login' && method === 'POST') {
|
|
const admin = await readAdmin();
|
|
if (!admin) {
|
|
console.warn('Login blocked: no master password configured');
|
|
sendJson(response, 409, { error: 'The app does not have a master password yet.' });
|
|
return;
|
|
}
|
|
|
|
if (isRateLimited(getClientAddress(request))) {
|
|
console.warn('Login blocked: rate limited');
|
|
sendJson(response, 429, { error: 'Too many attempts. Wait one minute.' });
|
|
return;
|
|
}
|
|
|
|
const body = await readJson<AuthPayload>(request);
|
|
const password = String(body.password || '');
|
|
if (!verifyPassword(password, admin)) {
|
|
trackFailedAttempt(getClientAddress(request));
|
|
console.warn('Login blocked: incorrect password');
|
|
sendJson(response, 401, { error: 'Incorrect password.' });
|
|
return;
|
|
}
|
|
|
|
const encryptionKey = deriveEncryptionKey(password, admin.encryptionSalt);
|
|
const token = createSession(encryptionKey);
|
|
setSessionCookie(request, response, token);
|
|
resetFailedAttempts(getClientAddress(request));
|
|
console.log('Login completed successfully');
|
|
sendJson(response, 200, {
|
|
ok: true,
|
|
needsSetup: false
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/api/auth/logout' && method === 'POST') {
|
|
const token = getSessionToken(request);
|
|
if (token) {
|
|
sessions.delete(token);
|
|
}
|
|
|
|
clearSessionCookie(request, response);
|
|
sendJson(response, 200, { ok: true, needsSetup: false });
|
|
return;
|
|
}
|
|
|
|
const session = getSession(request);
|
|
if (!session && url.pathname.startsWith('/api/')) {
|
|
sendJson(response, 401, { error: 'Unauthorized.' });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/api/settings' && method === 'GET') {
|
|
const config = await readConfig(session!.encryptionKey);
|
|
sendJson(response, 200, config);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/api/settings' && method === 'PUT') {
|
|
const body = await readJson<AppConfig>(request);
|
|
const config = sanitizeConfig(body);
|
|
await updateEncryptedConfig(encryptJson(config, session!.encryptionKey));
|
|
const connections = await getConnectionStatuses(config);
|
|
sendJson(response, 200, {
|
|
config,
|
|
connections
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/api/settings/options' && method === 'GET') {
|
|
const config = await readConfig(session!.encryptionKey);
|
|
sendJson(response, 200, await getServiceOptions(config));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/api/settings/jellyfin-users' && method === 'POST') {
|
|
const body = await readJson<{ baseUrl: string; apiKey: string }>(request);
|
|
sendJson(response, 200, {
|
|
jellyfinUsers: await loadJellyfinUsers(String(body.baseUrl || ''), String(body.apiKey || ''))
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/api/dashboard' && method === 'GET') {
|
|
const config = await readConfig(session!.encryptionKey);
|
|
sendJson(response, 200, await getDashboard(config));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/api/hubs/') && method === 'GET') {
|
|
const config = await readConfig(session!.encryptionKey);
|
|
const page = decodeURIComponent(url.pathname.replace('/api/hubs/', '')) as
|
|
| 'stream'
|
|
| 'home'
|
|
| 'anime'
|
|
| 'dorama'
|
|
| 'library';
|
|
sendJson(response, 200, await getHubPage(config, page));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/api/downloads' && method === 'GET') {
|
|
const config = await readConfig(session!.encryptionKey);
|
|
sendJson(response, 200, await getDownloads(config));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/api/requests/movie' && method === 'POST') {
|
|
const config = await readConfig(session!.encryptionKey);
|
|
const body = await readJson<{ tmdbId: number }>(request);
|
|
await requestMovie(config, Number(body.tmdbId));
|
|
sendJson(response, 200, { ok: true });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/api/requests/series' && method === 'POST') {
|
|
const config = await readConfig(session!.encryptionKey);
|
|
const body = await readJson<{ tmdbId: number }>(request);
|
|
await requestSeries(config, Number(body.tmdbId));
|
|
sendJson(response, 200, { ok: true });
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/api/assets/jellyfin/') && method === 'GET') {
|
|
const config = await readConfig(session!.encryptionKey);
|
|
const itemId = decodeURIComponent(url.pathname.replace('/api/assets/jellyfin/', ''));
|
|
const kind = url.searchParams.get('kind') || 'Primary';
|
|
const maxWidth = Number(url.searchParams.get('maxWidth') || 640);
|
|
const tag = url.searchParams.get('tag') || '';
|
|
const image = await proxyJellyfinImage(config, itemId, kind, maxWidth, tag);
|
|
if (!image) {
|
|
response.writeHead(302, {
|
|
Location: '/omniarr/placeholder.jpg',
|
|
'Cache-Control': 'private, max-age=300'
|
|
});
|
|
response.end();
|
|
return;
|
|
}
|
|
response.writeHead(200, {
|
|
'Content-Type': image.contentType || 'image/jpeg',
|
|
'Cache-Control': 'private, max-age=300'
|
|
});
|
|
response.end(Buffer.from(image.data));
|
|
return;
|
|
}
|
|
|
|
await serveApp(url.pathname, response);
|
|
}
|
|
|
|
function createSession(encryptionKey: Buffer): string {
|
|
const token = randomToken();
|
|
sessions.set(token, {
|
|
encryptionKey,
|
|
expiresAt: Date.now() + SESSION_TTL_MS
|
|
});
|
|
return token;
|
|
}
|
|
|
|
function getSession(request: IncomingMessage): SessionRecord | null {
|
|
const token = getSessionToken(request);
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
|
|
const session = sessions.get(token);
|
|
if (!session) {
|
|
return null;
|
|
}
|
|
|
|
if (session.expiresAt < Date.now()) {
|
|
sessions.delete(token);
|
|
return null;
|
|
}
|
|
|
|
session.expiresAt = Date.now() + SESSION_TTL_MS;
|
|
return session;
|
|
}
|
|
|
|
function getSessionToken(request: IncomingMessage): string | null {
|
|
const cookieHeader = request.headers.cookie;
|
|
if (!cookieHeader) {
|
|
return null;
|
|
}
|
|
|
|
for (const part of cookieHeader.split(';')) {
|
|
const [key, value] = part.trim().split('=');
|
|
if (key === SESSION_COOKIE && value) {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function setSessionCookie(request: IncomingMessage, response: ServerResponse, token: string): void {
|
|
const isSecure = request.headers['x-forwarded-proto'] === 'https' || process.env.NODE_ENV === 'production';
|
|
response.setHeader(
|
|
'Set-Cookie',
|
|
`${SESSION_COOKIE}=${token}; HttpOnly; Path=/; SameSite=Strict; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}${isSecure ? '; Secure' : ''}`
|
|
);
|
|
}
|
|
|
|
function clearSessionCookie(request: IncomingMessage, response: ServerResponse): void {
|
|
const isSecure = request.headers['x-forwarded-proto'] === 'https' || process.env.NODE_ENV === 'production';
|
|
response.setHeader(
|
|
'Set-Cookie',
|
|
`${SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0${isSecure ? '; Secure' : ''}`
|
|
);
|
|
}
|
|
|
|
async function readConfig(encryptionKey: Buffer): Promise<AppConfig> {
|
|
const encrypted = await readEncryptedConfig();
|
|
if (!encrypted) {
|
|
return defaultConfig;
|
|
}
|
|
|
|
const stored = decryptJson<Partial<AppConfig>>(encrypted, encryptionKey);
|
|
|
|
return sanitizeConfig({
|
|
preferences: {
|
|
...defaultConfig.preferences,
|
|
...(stored.preferences || {})
|
|
},
|
|
jellyfin: {
|
|
...defaultConfig.jellyfin,
|
|
...(stored.jellyfin || {})
|
|
},
|
|
sonarr: {
|
|
...defaultConfig.sonarr,
|
|
...(stored.sonarr || {})
|
|
},
|
|
radarr: {
|
|
...defaultConfig.radarr,
|
|
...(stored.radarr || {})
|
|
},
|
|
qbittorrent: {
|
|
...defaultConfig.qbittorrent,
|
|
...(stored.qbittorrent || {})
|
|
},
|
|
tmdb: {
|
|
...defaultConfig.tmdb,
|
|
...(stored.tmdb || {})
|
|
}
|
|
});
|
|
}
|
|
|
|
async function readJson<T>(request: IncomingMessage): Promise<T> {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of request) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
}
|
|
|
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
if (!raw) {
|
|
return {} as T;
|
|
}
|
|
|
|
return JSON.parse(raw) as T;
|
|
}
|
|
|
|
function sendJson(response: ServerResponse, statusCode: number, payload: unknown): void {
|
|
response.writeHead(statusCode, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Cache-Control': 'no-store'
|
|
});
|
|
response.end(JSON.stringify(payload));
|
|
}
|
|
|
|
async function serveApp(pathname: string, response: ServerResponse): Promise<void> {
|
|
const relativePath = pathname === '/' ? '/index.html' : pathname;
|
|
const filePath = resolve(process.cwd(), 'dist', `.${relativePath}`);
|
|
|
|
try {
|
|
const content = await readFile(filePath);
|
|
response.writeHead(200, {
|
|
'Content-Type': contentType(filePath)
|
|
});
|
|
response.end(content);
|
|
} catch {
|
|
try {
|
|
const indexHtml = await readFile(resolve(process.cwd(), 'dist', 'index.html'));
|
|
response.writeHead(200, {
|
|
'Content-Type': 'text/html; charset=utf-8'
|
|
});
|
|
response.end(indexHtml);
|
|
} catch {
|
|
response.writeHead(404, {
|
|
'Content-Type': 'text/plain; charset=utf-8'
|
|
});
|
|
response.end('Build the frontend first with `npm run build`.');
|
|
}
|
|
}
|
|
}
|
|
|
|
function contentType(filePath: string): string {
|
|
if (filePath.endsWith('.js')) {
|
|
return 'text/javascript; charset=utf-8';
|
|
}
|
|
if (filePath.endsWith('.css')) {
|
|
return 'text/css; charset=utf-8';
|
|
}
|
|
if (filePath.endsWith('.svg')) {
|
|
return 'image/svg+xml';
|
|
}
|
|
if (filePath.endsWith('.png')) {
|
|
return 'image/png';
|
|
}
|
|
if (filePath.endsWith('.jpg') || filePath.endsWith('.jpeg')) {
|
|
return 'image/jpeg';
|
|
}
|
|
if (filePath.endsWith('.html')) {
|
|
return 'text/html; charset=utf-8';
|
|
}
|
|
return 'application/octet-stream';
|
|
}
|
|
|
|
function getClientAddress(request: IncomingMessage): string {
|
|
return request.socket.remoteAddress || 'unknown';
|
|
}
|
|
|
|
function trackFailedAttempt(address: string): void {
|
|
const current = failedAttempts.get(address);
|
|
failedAttempts.set(address, {
|
|
count: (current?.count || 0) + 1,
|
|
lastAttempt: Date.now()
|
|
});
|
|
}
|
|
|
|
function resetFailedAttempts(address: string): void {
|
|
failedAttempts.delete(address);
|
|
}
|
|
|
|
function isRateLimited(address: string): boolean {
|
|
const current = failedAttempts.get(address);
|
|
if (!current) {
|
|
return false;
|
|
}
|
|
|
|
if (Date.now() - current.lastAttempt > 60_000) {
|
|
failedAttempts.delete(address);
|
|
return false;
|
|
}
|
|
|
|
return current.count >= 5;
|
|
}
|
|
|
|
function cleanupSessions(): void {
|
|
const now = Date.now();
|
|
for (const [token, session] of sessions.entries()) {
|
|
if (session.expiresAt < now) {
|
|
sessions.delete(token);
|
|
}
|
|
}
|
|
}
|