import express, { Request, Response } from 'express'; import path from 'path'; import { AIOStreams } from './addon'; import { Config, StreamRequest } from '@aiostreams/types'; import { validateConfig } from './config'; import manifest from './manifest'; import { errorResponse } from './responses'; import { Settings, addonDetails, parseAndDecryptString, Cache, unminifyConfig, minifyConfig, crushJson, compressData, encryptData, decompressData, decryptData, uncrushJson, loadSecretKey, createLogger, getTimeTakenSincePoint, isValueEncrypted, maskSensitiveInfo, } from '@aiostreams/utils'; const logger = createLogger('server'); const app = express(); //logger.info(`Starting server and loading settings...`); logger.info('Starting server and loading settings...', { func: 'init' }); Object.entries(Settings).forEach(([key, value]) => { switch (key) { case 'SECRET_KEY': if (value) { logger.info(`${key} = ${value.replace(/./g, '*').slice(0, 64)}`); } break; case 'BRANDING': case 'CUSTOM_CONFIGS': // Skip CUSTOM_CONFIGS processing here, handled later break; default: logger.info(`${key} = ${value}`); } }); // attempt to load the secret key try { if (Settings.SECRET_KEY) loadSecretKey(true); } catch (error: any) { // determine command to run based on system OS const command = process.platform === 'win32' ? '[System.Guid]::NewGuid().ToString("N").Substring(0, 32) + [System.Guid]::NewGuid().ToString("N").Substring(0, 32)' : 'openssl rand -hex 32'; logger.error( `The secret key is invalid. You will not be able to generate configurations. You can generate a new secret key by running the following command\n${command}` ); } // Built-in middleware for parsing JSON app.use(express.json()); // Built-in middleware for parsing URL-encoded data app.use(express.urlencoded({ extended: true })); // unhandled errors app.use((err: any, req: Request, res: Response, next: any) => { logger.error(`${err.message}`); res.status(500).send('Internal server error'); }); app.use((req, res, next) => { res.append('Access-Control-Allow-Origin', '*'); res.append('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE'); const start = Date.now(); res.on('finish', () => { logger.info( `${req.method} ${req.path .replace(/\/ey[JI][\w\=]+/g, '/*******') .replace( /\/(E2?|B)?-[\w-\%]+/g, '/*******' )} - ${getIp(req) ? maskSensitiveInfo(getIp(req)!) : 'Unknown IP'} - ${res.statusCode} - ${getTimeTakenSincePoint(start)}` ); }); next(); }); app.get('/', (req, res) => { res.redirect('/configure'); }); app.get( ['/_next/*', '/assets/*', '/icon.ico', '/configure.txt'], (req, res) => { res.sendFile(path.join(__dirname, '../../frontend/out', req.path)); } ); if (!Settings.DISABLE_CUSTOM_CONFIG_GENERATOR_ROUTE) { app.get('/custom-config-generator', (req, res) => { res.sendFile( path.join(__dirname, '../../frontend/out/custom-config-generator.html') ); }); } app.get('/configure', (req, res) => { res.sendFile(path.join(__dirname, '../../frontend/out/configure.html')); }); app.get('/:config/configure', (req, res) => { const config = req.params.config; if (config.startsWith('eyJ') || config.startsWith('eyI')) { return res.sendFile( path.join(__dirname, '../../frontend/out/configure.html') ); } try { let configJson = extractJsonConfig(config); let configString = config; if (Settings.CUSTOM_CONFIGS) { const customConfig = extractCustomConfig(config); if (customConfig) { configJson = customConfig; configString = decodeURIComponent(Settings.CUSTOM_CONFIGS[config]); } } if (isValueEncrypted(configString)) { logger.info(`Encrypted config detected, encrypting credentials`); configJson = encryptInfoInConfig(configJson); } const base64Config = Buffer.from(JSON.stringify(configJson)).toString( 'base64' ); res.redirect(`/${encodeURIComponent(base64Config)}/configure`); } catch (error: any) { logger.error(`Failed to extract config: ${error.message}`); res.status(400).send('Invalid config'); } }); app.get('/manifest.json', (req, res) => { res.status(200).json(manifest()); }); app.get('/:config/manifest.json', (req, res) => { const config = decodeURIComponent(req.params.config); let configJson: Config; try { configJson = extractJsonConfig(config); logger.info(`Extracted config for manifest request`); configJson = decryptEncryptedInfoFromConfig(configJson); if (Settings.LOG_SENSITIVE_INFO) { logger.info(`Final config: ${JSON.stringify(configJson)}`); } logger.info(`Successfully removed or decrypted sensitive info`); const { valid, errorMessage } = validateConfig(configJson); if (!valid) { logger.error( `Received invalid config for manifest request: ${errorMessage}` ); res.status(400).json({ error: 'Invalid config', message: errorMessage }); return; } } catch (error: any) { logger.error(`Failed to extract config: ${error.message}`); res.status(400).json({ error: 'Invalid config' }); return; } res.status(200).json(manifest(configJson)); }); // Route for /stream app.get('/stream/:type/:id', (req: Request, res: Response) => { res .status(200) .json( errorResponse( 'You must configure this addon to use it', rootUrl(req), '/configure' ) ); }); app.get('/:config/stream/:type/:id.json', (req, res: Response): void => { const { config, type, id } = req.params; let configJson: Config; try { configJson = extractJsonConfig(config); logger.info(`Extracted config for stream request`); configJson = decryptEncryptedInfoFromConfig(configJson); if (Settings.LOG_SENSITIVE_INFO) { logger.info(`Final config: ${JSON.stringify(configJson)}`); } logger.info(`Successfully removed or decrypted sensitive info`); } catch (error: any) { logger.error(`Failed to extract config: ${error.message}`); res.json( errorResponse( `${error.message}, please check the logs or click this stream to create an issue on GitHub`, rootUrl(req), undefined, 'https://github.com/Viren070/AIOStreams/issues/new?template=bug_report.yml' ) ); return; } logger.info(`Requesting streams for ${type} ${id}`); if (type !== 'movie' && type !== 'series') { logger.error(`Invalid type for stream request`); res.json( errorResponse( 'Invalid type for stream request, must be movie or series', rootUrl(req), '/' ) ); return; } let streamRequest: StreamRequest = { id, type }; try { const { valid, errorCode, errorMessage } = validateConfig(configJson); if (!valid) { logger.error(`Received invalid config: ${errorCode} - ${errorMessage}`); res.json( errorResponse(errorMessage ?? 'Unknown', rootUrl(req), '/configure') ); return; } configJson.requestingIp = getIp(req); const aioStreams = new AIOStreams(configJson); aioStreams .getStreams(streamRequest) .then((streams) => { res.json({ streams: streams }); }) .catch((error: any) => { logger.error(`Internal addon error: ${error.message}`); res.json( errorResponse( 'An unexpected error occurred, please check the logs or create an issue on GitHub', rootUrl(req), undefined, 'https://github.com/Viren070/AIOStreams/issues/new?template=bug_report.yml' ) ); }); } catch (error: any) { logger.error(`Internal addon error: ${error.message}`); res.json( errorResponse( 'An unexpected error occurred, please check the logs or create an issue on GitHub', rootUrl(req), undefined, 'https://github.com/Viren070/AIOStreams/issues/new?template=bug_report.yml' ) ); } }); app.post('/encrypt-user-data', (req, res) => { const { data } = req.body; let finalString: string = ''; if (!data) { logger.error('/encrypt-user-data: No data provided'); res.json({ success: false, message: 'No data provided' }); return; } // First, validate the config try { const config = JSON.parse(data); const { valid, errorCode, errorMessage } = validateConfig(config); if (!valid) { logger.error( `generateConfig: Invalid config: ${errorCode} - ${errorMessage}` ); res.json({ success: false, message: errorMessage, error: errorMessage }); return; } } catch (error: any) { logger.error(`/encrypt-user-data: Invalid JSON: ${error.message}`); res.json({ success: false, message: 'Malformed configuration' }); return; } try { const minified = minifyConfig(JSON.parse(data)); const crushed = crushJson(JSON.stringify(minified)); const compressed = compressData(crushed); if (!Settings.SECRET_KEY) { // use base64 encoding if no secret key is set finalString = `B-${encodeURIComponent(compressed.toString('base64'))}`; } else { const { iv, data } = encryptData(compressed); finalString = `E2-${encodeURIComponent(iv)}-${encodeURIComponent(data)}`; } logger.info( `|INF| server > /encrypt-user-data: Encrypted user data, compression report:` ); logger.info(`+--------------------------------------------+`); logger.info(`| Original: ${data.length} bytes`); logger.info(`| URL Encoded: ${encodeURIComponent(data).length} bytes`); logger.info(`| Minified: ${JSON.stringify(minified).length} bytes`); logger.info(`| Crushed: ${crushed.length} bytes`); logger.info(`| Compressed: ${compressed.length} bytes`); logger.info(`| Final String: ${finalString.length} bytes`); logger.info( `| Ratio: ${((finalString.length / data.length) * 100).toFixed(2)}%` ); logger.info( `| Reduction: ${data.length - finalString.length} bytes (${(((data.length - finalString.length) / data.length) * 100).toFixed(2)}%)` ); logger.info(`+--------------------------------------------+`); res.json({ success: true, data: finalString }); } catch (error: any) { logger.error(`/encrypt-user-data: ${error.message}`); logger.error(error); res.json({ success: false, message: error.message }); } }); app.get('/get-addon-config', (req, res) => { res.status(200).json({ success: true, maxMovieSize: Settings.MAX_MOVIE_SIZE, maxEpisodeSize: Settings.MAX_EPISODE_SIZE, torrentioDisabled: Settings.DISABLE_TORRENTIO, apiKeyRequired: !!Settings.API_KEY, }); }); app.get('/health', (req, res) => { res.status(200).json({ status: 'ok' }); }); // define 404 app.use((req, res) => { res.status(404).sendFile(path.join(__dirname, '../../frontend/out/404.html')); }); app.listen(Settings.PORT, () => { logger.info(`Listening on port ${Settings.PORT}`); }); function getIp(req: Request): string | undefined { return ( req.get('X-Client-IP') || req.get('X-Forwarded-For')?.split(',')[0].trim() || req.get('X-Real-IP') || req.get('CF-Connecting-IP') || req.get('True-Client-IP') || req.get('X-Forwarded')?.split(',')[0].trim() || req.get('Forwarded-For')?.split(',')[0].trim() || req.ip ); } function extractJsonConfig(config: string): Config { if ( config.startsWith('eyJ') || config.startsWith('eyI') || config.startsWith('B-') || isValueEncrypted(config) ) { return extractEncryptedOrEncodedConfig(config, 'Config'); } if (Settings.CUSTOM_CONFIGS) { const customConfig = extractCustomConfig(config); if (customConfig) return customConfig; } throw new Error('Config was in an unexpected format'); } function extractCustomConfig(config: string): Config | undefined { const customConfig = Settings.CUSTOM_CONFIGS[config]; if (!customConfig) return undefined; logger.info( `Found custom config for alias ${config}, attempting to extract config` ); return extractEncryptedOrEncodedConfig( decodeURIComponent(customConfig), `CustomConfig ${config}` ); } function extractEncryptedOrEncodedConfig( config: string, label: string ): Config { let decodedConfig: Config; try { if (config.startsWith('E-')) { // compressed and encrypted (hex) logger.info(`Extracting encrypted (v1) config`); const parts = config.split('-'); if (parts.length !== 3) { throw new Error('Invalid encrypted config format'); } const iv = Buffer.from(decodeURIComponent(parts[1]), 'hex'); const data = Buffer.from(decodeURIComponent(parts[2]), 'hex'); decodedConfig = JSON.parse(decompressData(decryptData(data, iv))); } else if (config.startsWith('E2-')) { // minified, crushed, compressed and encrypted (base64) logger.info(`Extracting encrypted (v2) config`); const parts = config.split('-'); if (parts.length !== 3) { throw new Error('Invalid encrypted config format'); } const iv = Buffer.from(decodeURIComponent(parts[1]), 'base64'); const data = Buffer.from(decodeURIComponent(parts[2]), 'base64'); const compressedCrushedJson = decryptData(data, iv); const crushedJson = decompressData(compressedCrushedJson); const minifiedConfig = uncrushJson(crushedJson); decodedConfig = unminifyConfig(JSON.parse(minifiedConfig)); } else if (config.startsWith('B-')) { // minifed, crushed, compressed, base64 encoded logger.info(`Extracting base64 encoded and compressed config`); decodedConfig = unminifyConfig( JSON.parse( uncrushJson(decompressData(Buffer.from(config.slice(2), 'base64'))) ) ); } else { // plain base64 encoded logger.info(`Extracting plain base64 encoded config`); decodedConfig = JSON.parse( Buffer.from(config, 'base64').toString('utf-8') ); } return decodedConfig; } catch (error: any) { logger.error(`Failed to parse ${label}: ${error.message}`, { func: 'extractJsonConfig', }); logger.error(error, { func: 'extractJsonConfig' }); throw new Error(`Failed to parse ${label}`); } } function decryptEncryptedInfoFromConfig(config: Config): Config { if (config.services) { config.services.forEach( (service) => service.credentials && processObjectValues( service.credentials, `service ${service.id}`, true, (key, value) => isValueEncrypted(value) ) ); } if (config.mediaFlowConfig) { decryptMediaFlowConfig(config.mediaFlowConfig); } if (config.stremThruConfig) { decryptStremThruConfig(config.stremThruConfig); } if (config.apiKey) { config.apiKey = decryptValue(config.apiKey, 'aioStreams apiKey'); } if (config.addons) { config.addons.forEach((addon) => { if (addon.options) { processObjectValues( addon.options, `addon ${addon.id}`, true, (key, value) => isValueEncrypted(value) && // Decrypt only if the option is secret ( addonDetails.find((addonDetail) => addonDetail.id === addon.id) ?.options ?? [] ).some((option) => option.id === key && option.secret) ); } }); } return config; } function decryptMediaFlowConfig(mediaFlowConfig: { apiPassword: string; proxyUrl: string; publicIp: string; }): void { const { apiPassword, proxyUrl, publicIp } = mediaFlowConfig; mediaFlowConfig.apiPassword = decryptValue( apiPassword, 'MediaFlow apiPassword' ); mediaFlowConfig.proxyUrl = decryptValue(proxyUrl, 'MediaFlow proxyUrl'); mediaFlowConfig.publicIp = decryptValue(publicIp, 'MediaFlow publicIp'); } function decryptStremThruConfig( stremThruConfig: Config['stremThruConfig'] ): void { if (!stremThruConfig) return; const { url, credential, publicIp } = stremThruConfig; stremThruConfig.url = decryptValue(url, 'StremThru url'); stremThruConfig.credential = decryptValue(credential, 'StremThru credential'); stremThruConfig.publicIp = decryptValue(publicIp, 'StremThru publicIp'); } function encryptInfoInConfig(config: Config): Config { if (config.services) { config.services.forEach( (service) => service.credentials && processObjectValues( service.credentials, `service ${service.id}`, false, () => true ) ); } if (config.mediaFlowConfig) { encryptMediaFlowConfig(config.mediaFlowConfig); } if (config.stremThruConfig) { encryptStremThruConfig(config.stremThruConfig); } if (config.apiKey) { // we can either remove the api key for better security or encrypt it for usability // removing it means the user has to enter it every time upon reconfiguration. config.apiKey = encryptValue(config.apiKey, 'aioStreams apiKey'); } if (config.addons) { config.addons.forEach((addon) => { if (addon.options) { processObjectValues( addon.options, `addon ${addon.id}`, false, (key) => { const addonDetail = addonDetails.find( (addonDetail) => addonDetail.id === addon.id ); if (!addonDetail) return false; const optionDetail = addonDetail.options?.find( (option) => option.id === key ); // Encrypt only if the option is secret return optionDetail?.secret ?? false; } ); } }); } return config; } function encryptMediaFlowConfig(mediaFlowConfig: { apiPassword: string; proxyUrl: string; publicIp: string; }): void { const { apiPassword, proxyUrl, publicIp } = mediaFlowConfig; mediaFlowConfig.apiPassword = encryptValue( apiPassword, 'MediaFlow apiPassword' ); mediaFlowConfig.proxyUrl = encryptValue(proxyUrl, 'MediaFlow proxyUrl'); mediaFlowConfig.publicIp = encryptValue(publicIp, 'MediaFlow publicIp'); } function encryptStremThruConfig( stremThruConfig: Config['stremThruConfig'] ): void { if (!stremThruConfig) return; const { url, credential, publicIp } = stremThruConfig; stremThruConfig.url = encryptValue(url, 'StremThru url'); stremThruConfig.credential = encryptValue(credential, 'StremThru credential'); stremThruConfig.publicIp = encryptValue(publicIp, 'StremThru publicIp'); } function processObjectValues( obj: Record, labelPrefix: string, decrypt: boolean, condition: (key: string, value: any) => boolean ): void { Object.keys(obj).forEach((key) => { const value = obj[key]; if (condition(key, value)) { logger.debug(`Processing ${labelPrefix} ${key}`); obj[key] = decrypt ? decryptValue(value, `${labelPrefix} ${key}`) : encryptValue(value, `${labelPrefix} ${key}`); } }); } function encryptValue(value: any, label: string): any { if (value && !isValueEncrypted(value)) { try { const { iv, data } = encryptData(compressData(value)); return `E2-${iv}-${data}`; } catch (error: any) { logger.error(`Failed to encrypt ${label}`, { func: 'encryptValue' }); logger.error(error, { func: 'encryptValue' }); return ''; } } return value; } function decryptValue(value: any, label: string): any { try { if (!isValueEncrypted(value)) return value; const decrypted = parseAndDecryptString(value); if (decrypted === null) throw new Error('Decryption failed'); return decrypted; } catch (error: any) { logger.error(`Failed to decrypt ${label}: ${error.message}`, { func: 'decryptValue', }); logger.error(error, { func: 'decryptValue' }); throw new Error('Failed to decrypt config'); } } const rootUrl = (req: Request) => `${req.protocol}://${req.hostname}${req.hostname === 'localhost' ? `:${Settings.PORT}` : ''}`;