brunner56's picture
implement app
0bfe2e3
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<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)) {
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}` : ''}`;