aiostreams / packages /addon /src /server.ts
f4b404's picture
Sync from GitHub via hub-sync
4345f70 verified
Raw
History Blame Contribute Delete
14.1 kB
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}` : ''}`;