Spaces:
Paused
Paused
| 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, | |
| compressAndEncrypt, | |
| parseAndDecryptString, | |
| Cache, | |
| } from '@aiostreams/utils'; | |
| const app = express(); | |
| console.log(`|INF| server > init: Starting server and loading settings...`); | |
| Object.entries(Settings).forEach(([key, value]) => { | |
| switch (key) { | |
| case 'SECRET_KEY': | |
| if (value) { | |
| console.log( | |
| `|INF| server > init: ${key} = ${value.replace(/./g, '*').slice(0, 32)}` | |
| ); | |
| } | |
| break; | |
| case 'BRANDING': | |
| case 'CUSTOM_CONFIGS': | |
| // Skip CUSTOM_CONFIGS processing here, handled later | |
| break; | |
| default: | |
| console.log(`|INF| server > init: ${key} = ${value}`); | |
| } | |
| }); | |
| if (!Settings.SECRET_KEY) { | |
| console.warn( | |
| '|WRN| server > init: SECRET_KEY is not set, data encryption is disabled!' | |
| ); | |
| } | |
| let CUSTOM_CONFIGS: Record<string, string> = {}; | |
| if (Settings.CUSTOM_CONFIGS) { | |
| try { | |
| CUSTOM_CONFIGS = JSON.parse(Settings.CUSTOM_CONFIGS); | |
| console.log( | |
| `|INF| server > init: Loaded ${Object.keys(CUSTOM_CONFIGS).length} custom configs under aliases ${Object.keys(CUSTOM_CONFIGS).join(', ')}` | |
| ); | |
| } catch (error: any) { | |
| console.error( | |
| `|ERR| server > init: CUSTOM_CONFIGS is not valid JSON: ${error.message}` | |
| ); | |
| } | |
| } | |
| const cache = new Cache(Settings.MAX_CACHE_SIZE); | |
| // Built-in middleware for parsing JSON | |
| app.use(express.json()); | |
| // Built-in middleware for parsing URL-encoded data | |
| app.use(express.urlencoded({ extended: true })); | |
| app.use((req, res, next) => { | |
| res.append('Access-Control-Allow-Origin', '*'); | |
| res.append('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE'); | |
| console.log( | |
| `|DBG| server > ${req.method} ${req.path.replace(/\/eyJ[\w\=]+/g, '/*******').replace(/\/E-[\w-]+/g, '/*******')}` | |
| ); | |
| 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')) { | |
| return res.sendFile( | |
| path.join(__dirname, '../../frontend/out/configure.html') | |
| ); | |
| } | |
| try { | |
| const configJson = encryptInfoInConfig(extractJsonConfig(config)); | |
| const base64Config = Buffer.from(JSON.stringify(configJson)).toString( | |
| 'base64' | |
| ); | |
| res.redirect(`/${base64Config}/configure`); | |
| } catch (error: any) { | |
| console.error(`|ERR| server > 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 = req.params.config; | |
| let configJson: Config; | |
| try { | |
| configJson = extractJsonConfig(config); | |
| console.log(`|DBG| server > Extracted config for manifest request`); | |
| configJson = decryptEncryptedInfoFromConfig(configJson); | |
| if (Settings.LOG_SENSITIVE_INFO) { | |
| console.log(`|DBG| server > Final config: ${JSON.stringify(configJson)}`); | |
| } | |
| console.log( | |
| `|DBG| server > Successfully removed or decrypted sensitive info` | |
| ); | |
| const { valid, errorMessage } = validateConfig(configJson); | |
| if (!valid) { | |
| console.error( | |
| `|ERR| server > Received invalid config for manifest request: ${errorMessage}` | |
| ); | |
| res.status(400).json({ error: 'Invalid config', message: errorMessage }); | |
| return; | |
| } | |
| } catch (error: any) { | |
| console.error(`|ERR| server > 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); | |
| console.log(`|DBG| server > Extracted config for stream request`); | |
| configJson = decryptEncryptedInfoFromConfig(configJson); | |
| if (Settings.LOG_SENSITIVE_INFO) { | |
| console.log(`|DBG| server > Final config: ${JSON.stringify(configJson)}`); | |
| } | |
| console.log( | |
| `|DBG| server > Successfully removed or decrypted sensitive info` | |
| ); | |
| } catch (error: any) { | |
| console.error(`|ERR| server > 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; | |
| } | |
| console.log(`|DBG| server > Requesting streams for ${type} ${id}`); | |
| if (type !== 'movie' && type !== 'series') { | |
| console.error(`|ERR| server > 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) { | |
| console.error( | |
| `|ERR| server > Received invalid config: ${errorCode} - ${errorMessage}` | |
| ); | |
| res.json( | |
| errorResponse(errorMessage ?? 'Unknown', rootUrl(req), '/configure') | |
| ); | |
| return; | |
| } | |
| configJson.requestingIp = | |
| req.get('X-Forwarded-For') || | |
| req.get('X-Real-IP') || | |
| req.get('CF-Connecting-IP') || | |
| req.ip; | |
| configJson.instanceCache = cache; | |
| const aioStreams = new AIOStreams(configJson); | |
| aioStreams.getStreams(streamRequest).then((streams) => { | |
| res.json({ streams: streams }); | |
| }); | |
| } catch (error: any) { | |
| console.error(`|ERR| server > 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; | |
| if (!data) { | |
| console.error('|ERR| server > /encrypt-user-data: No data provided'); | |
| res.status(400).json({ success: false, message: 'No data provided' }); | |
| return; | |
| } | |
| try { | |
| if (!Settings.SECRET_KEY) { | |
| console.error('|ERR| server > /encrypt-user-data: Secret key not set'); | |
| res.status(500).json({ success: false, message: 'Secret key not set' }); | |
| return; | |
| } | |
| const { valid, errorCode, errorMessage } = validateConfig(JSON.parse(data)); | |
| if (!valid) { | |
| console.error( | |
| `|ERR| server > /encrypt-user-data: Invalid config: ${errorCode} - ${errorMessage}` | |
| ); | |
| res | |
| .status(200) | |
| .json({ success: false, message: errorMessage, error: errorMessage }); | |
| return; | |
| } | |
| const encryptedData = compressAndEncrypt(data); | |
| console.log(`|DBG| server > /encrypt-user-data: Encrypted data`); | |
| res.status(200).json({ success: true, data: encryptedData }); | |
| } catch (error: any) { | |
| console.error(`|ERR| server > /encrypt-user-data: ${error.message}`); | |
| res.status(500).json({ success: false, message: error.message }); | |
| } | |
| }); | |
| 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, () => { | |
| console.log(`|INF| server > init: Listening on port ${Settings.PORT}`); | |
| }); | |
| function extractJsonConfig(config: string): Config { | |
| if (config.startsWith('E-') || config.startsWith('eyJ')) { | |
| return extractEncryptedOrEncodedConfig(config, 'Config'); | |
| } | |
| if (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 = CUSTOM_CONFIGS?.[config]; | |
| if (!customConfig) return undefined; | |
| console.log( | |
| `|DBG| server > Found custom config for alias ${config}, attempting to extract config` | |
| ); | |
| return extractEncryptedOrEncodedConfig( | |
| customConfig, | |
| `CustomConfig ${config}` | |
| ); | |
| } | |
| function extractEncryptedOrEncodedConfig( | |
| config: string, | |
| label: string | |
| ): Config { | |
| let decodedConfig: string; | |
| try { | |
| decodedConfig = isValueEncrypted(config) | |
| ? decryptValue(config, `${label} (encrypted)`) | |
| : Buffer.from(config, 'base64').toString('utf-8'); | |
| return JSON.parse(decodedConfig); | |
| } catch (error: any) { | |
| console.error(`|ERR| Failed to parse ${label}: ${error.message}`); | |
| 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.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 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.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 processObjectValues( | |
| obj: Record<string, any>, | |
| labelPrefix: string, | |
| decrypt: boolean, | |
| condition: (key: string, value: any) => boolean | |
| ): void { | |
| Object.keys(obj).forEach((key) => { | |
| const value = obj[key]; | |
| if (condition(key, value)) { | |
| console.log(`|DBG| Processing ${labelPrefix} ${key}`); | |
| obj[key] = decrypt | |
| ? decryptValue(value, `${labelPrefix} ${key}`) | |
| : encryptValue(value, `${labelPrefix} ${key}`); | |
| } | |
| }); | |
| } | |
| function encryptValue(value: any, label: string): any { | |
| if (!isValueEncrypted(value)) { | |
| try { | |
| return compressAndEncrypt(value); | |
| } catch (error: any) { | |
| console.error(`Failed to encrypt ${label}`); | |
| 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) { | |
| console.error(`|ERR| Failed to decrypt ${label}: ${error.message}`); | |
| throw new Error('Failed to decrypt config'); | |
| } | |
| } | |
| function isValueEncrypted(value: string | undefined): boolean { | |
| return value ? /^E-[0-9a-fA-F]{32}-[0-9a-fA-F]+$/.test(value) : false; | |
| } | |
| const rootUrl = (req: Request) => | |
| `${req.protocol}://${req.hostname}${req.hostname === 'localhost' ? `:${Settings.PORT}` : ''}`; | |