import express, { Request, Response, Express } from 'express'; import { userApi, healthApi, statusApi, formatApi, catalogApi, postersApi, gdriveApi, debridApi, searchApi, animeApi, proxyApi, templatesApi, syncApi, authApi, dashboardApi, } from './routes/api/index.js'; import { configure, manifest, stream, catalog, meta, subtitle, addonCatalog, alias, } from './routes/stremio/index.js'; import { manifest as chillLinkManifest, streams as chillLinkStreams, } from './routes/chilllink/index.js'; import seanimeExtensionsRouter from './routes/seanime/extensions.js'; import { gdrive, torboxSearch, torznab, newznab, prowlarr, knaben, eztv, torrentGalaxy, seadex, easynews, library, } from './routes/builtins/index.js'; import { ipMiddleware, loggerMiddleware, userDataMiddleware, errorMiddleware, corsMiddleware, staticRateLimiter, internalMiddleware, stremioStreamRateLimiter, requireSessionIfAuthRequired, } from './middlewares/index.js'; import { config as appConfig, constants, createLogger, Env, } from '@aiostreams/core'; import { StremioTransformer } from '@aiostreams/core'; import { createResponse } from './utils/responses.js'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; const app: Express = express(); const logger = createLogger('server'); export enum StaticFiles { DOWNLOAD_FAILED = 'download_failed.mp4', DOWNLOADING = 'downloading.mp4', UNAVAILABLE_FOR_LEGAL_REASONS = 'unavailable_for_legal_reasons.mp4', STORE_LIMIT_EXCEEDED = 'store_limit_exceeded.mp4', CONTENT_PROXY_LIMIT_REACHED = 'content_proxy_limit_reached.mp4', INTERNAL_SERVER_ERROR = '500.mp4', TOO_MANY_REQUESTS = '429.mp4', FORBIDDEN = '403.mp4', UNAUTHORIZED = '401.mp4', NO_MATCHING_FILE = 'no_matching_file.mp4', PAYMENT_REQUIRED = 'payment_required.mp4', OK = '200.mp4', } const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); export const frontendRoot = path.join(__dirname, '../../frontend/dist'); export const staticRoot = path.join(__dirname, './static'); app.use(ipMiddleware); app.use(loggerMiddleware); app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Allow all origins in development for easier testing if (appConfig.bootstrap.nodeEnv === 'development') { logger.info('CORS enabled for all origins in development'); app.use(corsMiddleware); } // API Routes const apiRouter = express.Router(); apiRouter.use('/user', userApi); apiRouter.use('/health', healthApi); apiRouter.use('/status', statusApi); apiRouter.use('/format', formatApi); apiRouter.use('/catalogs', catalogApi); apiRouter.use('/posters', postersApi); apiRouter.use('/oauth/exchange/gdrive', gdriveApi); apiRouter.use('/debrid', debridApi); apiRouter.use( '/search', (req, res, next) => { if (!appConfig.api.enableSearchApi) { res.status(403).json({ error: 'Search API is disabled', success: false }); return; } next(); }, searchApi ); apiRouter.use('/anime', animeApi); apiRouter.use('/proxy', proxyApi); apiRouter.use('/templates', templatesApi); apiRouter.use('/sync', syncApi); apiRouter.use('/auth', authApi); apiRouter.use('/dashboard', dashboardApi); apiRouter.use((req, res) => { res.status(404).json( createResponse({ success: false, detail: 'Not Found', }) ); }); app.use(`/api/v${constants.API_VERSION}`, apiRouter); // Stremio Routes const stremioRouter = express.Router({ mergeParams: true }); stremioRouter.use(corsMiddleware); // Public routes - no auth needed stremioRouter.use('/manifest.json', manifest); stremioRouter.use('/stream', stream); stremioRouter.use('/configure', requireSessionIfAuthRequired, configure); stremioRouter.use('/u', alias); // Protected routes with authentication const stremioAuthRouter = express.Router({ mergeParams: true }); stremioAuthRouter.use(corsMiddleware); stremioAuthRouter.use(userDataMiddleware); stremioAuthRouter.use('/manifest.json', manifest); stremioAuthRouter.use('/stream', stream); stremioAuthRouter.use('/configure', requireSessionIfAuthRequired, configure); stremioAuthRouter.use('/meta', meta); stremioAuthRouter.use('/catalog', catalog); stremioAuthRouter.use('/subtitles', subtitle); stremioAuthRouter.use('/addon_catalog', addonCatalog); app.use('/stremio', stremioRouter); // For public routes app.use('/stremio/:uuid/:encryptedPassword', stremioAuthRouter); // For authenticated routes const chillLinkRouter = express.Router({ mergeParams: true }); chillLinkRouter.use(corsMiddleware); chillLinkRouter.use(userDataMiddleware); chillLinkRouter.use('/manifest', chillLinkManifest); chillLinkRouter.use('/streams', chillLinkStreams); app.use('/chilllink/:uuid/:encryptedPassword', chillLinkRouter); const seanimeRouter = express.Router({ mergeParams: true }); seanimeRouter.use(corsMiddleware); seanimeRouter.use(seanimeExtensionsRouter); app.use('/seanime', seanimeRouter); const builtinsRouter = express.Router(); builtinsRouter.use(internalMiddleware); builtinsRouter.use('/gdrive', gdrive); builtinsRouter.use('/torbox-search', torboxSearch); builtinsRouter.use('/torznab', torznab); builtinsRouter.use('/newznab', newznab); builtinsRouter.use('/prowlarr', prowlarr); builtinsRouter.use('/knaben', knaben); builtinsRouter.use('/eztv', eztv); builtinsRouter.use('/torrent-galaxy', torrentGalaxy); builtinsRouter.use('/seadex', seadex); builtinsRouter.use('/easynews', easynews); builtinsRouter.use('/library', library); app.use('/builtins', builtinsRouter); // Content-hashed build assets. These filenames change on every content // change, so they are immutable and safe to cache aggressively. Deliberately // NOT behind staticRateLimiter: a single page load pulls many of these and // rate-limiting them is what caused asset fetch failures + the logo flash. app.get('/assets/*any', (req, res, next) => { const filePath = path.resolve(frontendRoot, req.path.replace(/^\//, '')); if (filePath.startsWith(frontendRoot) && fs.existsSync(filePath)) { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); res.sendFile(filePath); return; } next(); }); // Root-level static files (not content-hashed). Short cache; kept behind the // static rate limiter. The logo honours the alternate-design branding flag. app.get('/logo.png', staticRateLimiter, (req, res, next) => { const filePath = path.resolve( frontendRoot, appConfig.branding.alternateDesign ? 'logo_alt.png' : 'logo.png' ); if (filePath.startsWith(frontendRoot) && fs.existsSync(filePath)) { res.setHeader('Cache-Control', 'public, max-age=3600'); res.sendFile(filePath); return; } next(); }); app.get( [ '/favicon.ico', '/manifest.json', '/web-app-manifest-192x192.png', '/web-app-manifest-512x512.png', '/apple-icon.png', '/mini-nightly-white.png', '/mini-stable-white.png', '/icon0.svg', '/icon1.png', '/logo_alt.png', ], staticRateLimiter, (req, res, next) => { const filePath = path.resolve(frontendRoot, req.path.replace(/^\//, '')); if (filePath.startsWith(frontendRoot) && fs.existsSync(filePath)) { res.setHeader('Cache-Control', 'public, max-age=3600'); res.sendFile(filePath); return; } next(); } ); app.get('/static/*any', corsMiddleware, (req, res, next) => { const filePath = path.resolve( staticRoot, req.path.replace(/^\/static\//, '') ); logger.debug(`Static file requested: ${filePath}`); if (filePath.startsWith(staticRoot) && fs.existsSync(filePath)) { res.sendFile(filePath); return; } next(); }); // legacy route handlers app.get( '{/:config}/stream/:type/:id.json', stremioStreamRateLimiter, (req, res) => { const baseUrl = appConfig.bootstrap.baseUrl || `${req.protocol}://${req.hostname}${ req.hostname === 'localhost' ? `:${appConfig.bootstrap.port}` : '' }`; res.json({ streams: [ StremioTransformer.createErrorStream({ errorDescription: 'AIOStreams v2 requires you to reconfigure. Please click this stream to reconfigure.', errorUrl: `${baseUrl}/stremio/configure`, }), ], }); } ); // redirect for legacy paths app.get('{/:config}/configure', (req, res) => { res.redirect('/stremio/configure'); }); app.get('/configure', (req, res) => { res.redirect('/stremio/configure'); }); // SPA fallback. app.get('*splat', staticRateLimiter, (req, res, next) => { if (req.method !== 'GET' || !req.accepts('html')) { next(); return; } const indexPath = path.join(frontendRoot, 'index.html'); if (fs.existsSync(indexPath)) { res.setHeader('Cache-Control', 'no-cache'); res.sendFile(indexPath); return; } next(); }); // Error handling middleware should be last app.use(errorMiddleware); export default app;