brunner56's picture
implement app
0bfe2e3
import { AddonDetail, Config } from '@aiostreams/types';
import {
addonDetails,
isValueEncrypted,
parseAndDecryptString,
serviceDetails,
Settings,
unminifyConfig,
} from '@aiostreams/utils';
export const allowedFormatters = [
'gdrive',
'minimalistic-gdrive',
'torrentio',
'torbox',
'imposter',
'custom',
];
export const allowedLanguages = [
'Multi',
'English',
'Japanese',
'Chinese',
'Russian',
'Arabic',
'Portuguese',
'Spanish',
'French',
'German',
'Italian',
'Korean',
'Hindi',
'Bengali',
'Punjabi',
'Marathi',
'Gujarati',
'Tamil',
'Telugu',
'Kannada',
'Malayalam',
'Thai',
'Vietnamese',
'Indonesian',
'Turkish',
'Hebrew',
'Persian',
'Ukrainian',
'Greek',
'Lithuanian',
'Latvian',
'Estonian',
'Polish',
'Czech',
'Slovak',
'Hungarian',
'Romanian',
'Bulgarian',
'Serbian',
'Croatian',
'Slovenian',
'Dutch',
'Danish',
'Finnish',
'Swedish',
'Norwegian',
'Malay',
'Latino',
'Unknown',
'Dual Audio',
'Dubbed',
];
export function validateConfig(
config: Config,
environment: 'client' | 'server' = 'server'
): {
valid: boolean;
errorCode: string | null;
errorMessage: string | null;
} {
config = unminifyConfig(config);
const createResponse = (
valid: boolean,
errorCode: string | null,
errorMessage: string | null
) => {
return { valid, errorCode, errorMessage };
};
if (config.addons.length < 1) {
return createResponse(
false,
'noAddons',
'At least one addon must be selected'
);
}
if (config.addons.length > Settings.MAX_ADDONS) {
return createResponse(
false,
'tooManyAddons',
`You can only select a maximum of ${Settings.MAX_ADDONS} addons`
);
}
// check for apiKey if Settings.API_KEY is set
if (environment === 'server' && Settings.API_KEY) {
const { apiKey } = config;
if (!apiKey) {
return createResponse(
false,
'missingApiKey',
'The AIOStreams API key is required'
);
}
let decryptedApiKey = apiKey;
if (isValueEncrypted(apiKey)) {
const decryptionResult = parseAndDecryptString(apiKey);
if (decryptionResult === null) {
return createResponse(
false,
'decryptionFailed',
'Failed to decrypt the AIOStreams API key'
);
} else if (decryptionResult === '') {
return createResponse(
false,
'emptyDecryption',
'Decrypted API key is empty'
);
}
decryptedApiKey = decryptionResult;
}
if (decryptedApiKey !== Settings.API_KEY) {
return createResponse(
false,
'invalidApiKey',
'Invalid AIOStreams API key. Please use the one defined in your environment variables'
);
}
}
const duplicateAddons = config.addons.filter(
(addon, index) =>
config.addons.findIndex(
(a) =>
a.id === addon.id &&
JSON.stringify(a.options) === JSON.stringify(addon.options)
) !== index
);
if (duplicateAddons.length > 0) {
return createResponse(
false,
'duplicateAddons',
'Duplicate addons found. Please remove any duplicates'
);
}
for (const addon of config.addons) {
if (Settings.DISABLE_TORRENTIO && addon.id === 'torrentio') {
return createResponse(
false,
'torrentioDisabled',
Settings.DISABLE_TORRENTIO_MESSAGE
);
}
const details = addonDetails.find(
(detail: AddonDetail) => detail.id === addon.id
);
if (!details) {
return createResponse(
false,
'invalidAddon',
`Invalid addon: ${addon.id}`
);
}
if (details.requiresService) {
const supportedServices = details.supportedServices;
const isAtLeastOneServiceEnabled = config.services.some(
(service) => supportedServices.includes(service.id) && service.enabled
);
const isOverrideUrlSet = addon.options?.overrideUrl;
if (!isAtLeastOneServiceEnabled && !isOverrideUrlSet) {
return createResponse(
false,
'missingService',
`${addon.options?.name || details.name} requires at least one of the following services to be enabled: ${supportedServices
.map(
(service) =>
serviceDetails.find((detail) => detail.id === service)?.name ||
service
)
.join(', ')}`
);
}
}
if (details.options) {
for (const option of details.options) {
if (option.required && !addon.options[option.id]) {
return createResponse(
false,
'missingRequiredOption',
`Option ${option.label} is required for addon ${addon.id}`
);
}
if (
option.id.toLowerCase().includes('url') &&
addon.options[option.id] &&
((isValueEncrypted(addon.options[option.id]) &&
environment === 'server') ||
!isValueEncrypted(addon.options[option.id]))
) {
const url = parseAndDecryptString(addon.options[option.id] ?? '');
if (url === null) {
return createResponse(
false,
'decryptionFailed',
`Failed to decrypt URL for ${option.label}`
);
} else if (url === '') {
return createResponse(
false,
'emptyDecryption',
`Decrypted URL for ${option.label} is empty`
);
}
if (
Settings.DISABLE_TORRENTIO &&
url.match(/torrentio\.strem\.fun/) !== null
) {
// if torrentio is disabled, don't allow the user to set URLs with torrentio.strem.fun
return createResponse(
false,
'torrentioDisabled',
Settings.DISABLE_TORRENTIO_MESSAGE
);
} else if (
Settings.DISABLE_TORRENTIO &&
url.match(/stremthru\.elfhosted\.com/) !== null
) {
// if torrentio is disabled, we need to inspect the stremthru URL to see if it's using torrentio
try {
const parsedUrl = new URL(url);
// get the component before manifest.json
const pathComponents = parsedUrl.pathname.split('/');
if (pathComponents.includes('manifest.json')) {
const index = pathComponents.indexOf('manifest.json');
const componentBeforeManifest = pathComponents[index - 1];
// base64 decode the component before manifest.json
const decodedComponent = atob(componentBeforeManifest);
const stremthruData = JSON.parse(decodedComponent);
if (stremthruData?.manifest_url?.match(/torrentio.strem.fun/)) {
return createResponse(
false,
'torrentioDisabled',
Settings.DISABLE_TORRENTIO_MESSAGE
);
}
}
} catch (_) {
// ignore
}
} else {
try {
new URL(url);
} catch (_) {
return createResponse(
false,
'invalidUrl',
` Invalid URL for ${option.label}`
);
}
}
}
if (option.type === 'number' && addon.options[option.id]) {
const input = addon.options[option.id];
if (input !== undefined && !parseInt(input)) {
return createResponse(
false,
'invalidNumber',
`${option.label} must be a number`
);
} else if (input !== undefined) {
const value = parseInt(input);
const { min, max } = option.constraints || {};
if (
(min !== undefined && value < min) ||
(max !== undefined && value > max)
) {
return createResponse(
false,
'invalidNumber',
`${option.label} must be between ${min} and ${max}`
);
}
}
}
}
}
}
if (!allowedFormatters.includes(config.formatter)) {
if (config.formatter.startsWith('custom') && config.formatter.length > 7) {
const jsonString = config.formatter.slice(7);
const data = JSON.parse(jsonString);
if (!data.name || !data.description) {
return createResponse(
false,
'invalidCustomFormatter',
'Invalid custom formatter: name and description are required'
);
}
} else {
return createResponse(
false,
'invalidFormatter',
`Invalid formatter: ${config.formatter}`
);
}
}
for (const service of config.services) {
if (service.enabled) {
const serviceDetail = serviceDetails.find(
(detail) => detail.id === service.id
);
if (!serviceDetail) {
return createResponse(
false,
'invalidService',
`Invalid service: ${service.id}`
);
}
for (const credential of serviceDetail.credentials) {
if (!service.credentials[credential.id]) {
return createResponse(
false,
'missingCredential',
`${credential.label} is required for ${service.name}`
);
}
}
}
}
// need at least one visual tag, resolution, quality
if (
!config.visualTags.some((tag) => Object.values(tag)[0]) ||
!config.resolutions.some((resolution) => Object.values(resolution)[0]) ||
!config.qualities.some((quality) => Object.values(quality)[0])
) {
return createResponse(
false,
'noFilters',
'At least one visual tag, resolution, and quality must be selected'
);
}
for (const [min, max] of [
[config.minMovieSize, config.maxMovieSize],
[config.minEpisodeSize, config.maxEpisodeSize],
[config.minSize, config.maxSize],
]) {
if (min && max) {
if (min >= max) {
return createResponse(
false,
'invalidSizeRange',
"Your minimum size limit can't be greater than or equal to your maximum size limit"
);
}
}
}
if (config.maxResultsPerResolution && config.maxResultsPerResolution < 1) {
return createResponse(
false,
'invalidMaxResultsPerResolution',
'Max results per resolution must be greater than 0'
);
}
if (
config.mediaFlowConfig?.mediaFlowEnabled &&
config.stremThruConfig?.stremThruEnabled
) {
return createResponse(
false,
'multipleProxyServices',
'Multiple proxy services are not allowed'
);
}
if (config.mediaFlowConfig?.mediaFlowEnabled) {
if (!config.mediaFlowConfig.proxyUrl) {
return createResponse(
false,
'missingProxyUrl',
'Proxy URL is required if MediaFlow is enabled'
);
}
if (!config.mediaFlowConfig.apiPassword) {
return createResponse(
false,
'missingApiPassword',
'API Password is required if MediaFlow is enabled'
);
}
}
if (config.stremThruConfig?.stremThruEnabled) {
if (!config.stremThruConfig.url) {
return createResponse(
false,
'missingUrl',
'URL is required if Stremthru is enabled'
);
}
if (!config.stremThruConfig.credential) {
return createResponse(
false,
'missingCredential',
'Credential is required if StremThru is enabled'
);
}
}
if (
(config.excludeFilters?.length ?? 0) > Settings.MAX_KEYWORD_FILTERS ||
(config.strictIncludeFilters?.length ?? 0) > Settings.MAX_KEYWORD_FILTERS
) {
return createResponse(
false,
'tooManyFilters',
`You can only have a maximum of ${Settings.MAX_KEYWORD_FILTERS} filters`
);
}
const filters = [
...(config.excludeFilters || []),
...(config.strictIncludeFilters || []),
];
filters.forEach((filter) => {
if (filter.length > 20) {
return createResponse(
false,
'invalidFilter',
'One of your filters is too long'
);
}
if (!filter) {
return createResponse(
false,
'invalidFilter',
'Filters must not be empty'
);
}
});
if (config.regexFilters) {
if (!config.apiKey) {
return createResponse(
false,
'missingApiKey',
'Regex filtering requires an API key to be set'
);
}
if (config.regexFilters.excludePattern) {
try {
new RegExp(config.regexFilters.excludePattern);
} catch (e) {
return createResponse(
false,
'invalidExcludeRegex',
'Invalid exclude regex pattern'
);
}
}
if (config.regexFilters.includePattern) {
try {
new RegExp(config.regexFilters.includePattern);
} catch (e) {
return createResponse(
false,
'invalidIncludeRegex',
'Invalid include regex pattern'
);
}
}
}
if (config.regexSortPatterns) {
if (!config.apiKey) {
return createResponse(
false,
'missingApiKey',
'Regex sorting requires an API key to be set'
);
}
// Split the pattern by spaces and validate each one
const patterns = config.regexSortPatterns.split(/\s+/).filter(Boolean);
// Enforce an upper bound on the number of patterns
if (patterns.length > Settings.MAX_REGEX_SORT_PATTERNS) {
return createResponse(
false,
'tooManyRegexSortPatterns',
`You can specify at most ${Settings.MAX_REGEX_SORT_PATTERNS} regex sort patterns`
);
}
for (const pattern of patterns) {
const delimiter = '<::>';
const delimiterIndex = pattern.indexOf(delimiter);
let name: string = 'Unamed';
let regexPattern = pattern;
if (delimiterIndex !== -1) {
name = pattern.slice(0, delimiterIndex).replace(/_/g, ' ');
regexPattern = pattern.slice(delimiterIndex + delimiter.length);
}
try {
new RegExp(regexPattern);
} catch (e) {
return createResponse(
false,
'invalidRegexSortPattern',
`Invalid regex sort pattern: ${name ? `"${name}" ` : ''}${regexPattern}`
);
}
}
}
return createResponse(true, null, null);
}