import { UserData, UserDataSchema, PresetObject, Service, Option, StreamProxyConfig, Group, } from '../db/schemas'; import { AIOStreams } from '../main'; import { Preset, PresetManager } from '../presets'; import { createProxy } from '../proxy'; import { constants, TMDBMetadata } from '.'; import { isEncrypted, decryptString, encryptString } from './crypto'; import { Env } from './env'; import { createLogger, maskSensitiveInfo } from './logger'; import { ZodError } from 'zod'; import { GroupConditionEvaluator, StreamSelector, } from '../parser/streamExpression'; import { RPDB } from './rpdb'; import { FeatureControl } from './feature'; import { compileRegex } from './regex'; const logger = createLogger('core'); export const formatZodError = (error: ZodError) => { let errs = []; for (const issue of error.issues) { errs.push( `Invalid value for ${issue.path.join('.')}: ${issue.message}${ (issue as any).unionErrors ? `. Union checks performed:\n${(issue as any).unionErrors .map((issue: any) => `- ${formatZodError(issue)}`) .join('\n')}` : '' }` ); } return errs.join(' | '); }; function getServiceCredentialDefault( serviceId: constants.ServiceId, credentialId: string ) { // env mapping switch (serviceId) { case constants.REALDEBRID_SERVICE: switch (credentialId) { case 'apiKey': return Env.DEFAULT_REALDEBRID_API_KEY; } break; case constants.ALLEDEBRID_SERVICE: switch (credentialId) { case 'apiKey': return Env.DEFAULT_ALLDEBRID_API_KEY; } break; case constants.PREMIUMIZE_SERVICE: switch (credentialId) { case 'apiKey': return Env.DEFAULT_PREMIUMIZE_API_KEY; } break; case constants.DEBRIDLINK_SERVICE: switch (credentialId) { case 'apiKey': return Env.DEFAULT_DEBRIDLINK_API_KEY; } break; case constants.TORBOX_SERVICE: switch (credentialId) { case 'apiKey': return Env.DEFAULT_TORBOX_API_KEY; } break; case constants.EASYDEBRID_SERVICE: switch (credentialId) { case 'apiKey': return Env.DEFAULT_EASYDEBRID_API_KEY; } break; case constants.PUTIO_SERVICE: switch (credentialId) { case 'clientId': return Env.DEFAULT_PUTIO_CLIENT_ID; case 'clientSecret': return Env.DEFAULT_PUTIO_CLIENT_SECRET; } break; case constants.PIKPAK_SERVICE: switch (credentialId) { case 'email': return Env.DEFAULT_PIKPAK_EMAIL; case 'password': return Env.DEFAULT_PIKPAK_PASSWORD; } break; case constants.OFFCLOUD_SERVICE: switch (credentialId) { case 'apiKey': return Env.DEFAULT_OFFCLOUD_API_KEY; case 'email': return Env.DEFAULT_OFFCLOUD_EMAIL; case 'password': return Env.DEFAULT_OFFCLOUD_PASSWORD; } break; case constants.SEEDR_SERVICE: switch (credentialId) { case 'encodedToken': return Env.DEFAULT_SEEDR_ENCODED_TOKEN; } break; case constants.EASYNEWS_SERVICE: switch (credentialId) { case 'username': return Env.DEFAULT_EASYNEWS_USERNAME; case 'password': return Env.DEFAULT_EASYNEWS_PASSWORD; } break; default: return null; } } function getServiceCredentialForced( serviceId: constants.ServiceId, credentialId: string ) { // env mapping switch (serviceId) { case constants.REALDEBRID_SERVICE: switch (credentialId) { case 'apiKey': return Env.FORCED_REALDEBRID_API_KEY; } break; case constants.ALLEDEBRID_SERVICE: switch (credentialId) { case 'apiKey': return Env.FORCED_ALLDEBRID_API_KEY; } break; case constants.PREMIUMIZE_SERVICE: switch (credentialId) { case 'apiKey': return Env.FORCED_PREMIUMIZE_API_KEY; } break; case constants.DEBRIDLINK_SERVICE: switch (credentialId) { case 'apiKey': return Env.FORCED_DEBRIDLINK_API_KEY; } break; case constants.TORBOX_SERVICE: switch (credentialId) { case 'apiKey': return Env.FORCED_TORBOX_API_KEY; } break; case constants.EASYDEBRID_SERVICE: switch (credentialId) { case 'apiKey': return Env.FORCED_EASYDEBRID_API_KEY; } break; case constants.PUTIO_SERVICE: switch (credentialId) { case 'clientId': return Env.FORCED_PUTIO_CLIENT_ID; case 'clientSecret': return Env.FORCED_PUTIO_CLIENT_SECRET; } break; case constants.PIKPAK_SERVICE: switch (credentialId) { case 'email': return Env.FORCED_PIKPAK_EMAIL; case 'password': return Env.FORCED_PIKPAK_PASSWORD; } break; case constants.OFFCLOUD_SERVICE: switch (credentialId) { case 'apiKey': return Env.FORCED_OFFCLOUD_API_KEY; case 'email': return Env.FORCED_OFFCLOUD_EMAIL; case 'password': return Env.FORCED_OFFCLOUD_PASSWORD; } break; case constants.SEEDR_SERVICE: switch (credentialId) { case 'encodedToken': return Env.FORCED_SEEDR_ENCODED_TOKEN; } break; case constants.EASYNEWS_SERVICE: switch (credentialId) { case 'username': return Env.FORCED_EASYNEWS_USERNAME; case 'password': return Env.FORCED_EASYNEWS_PASSWORD; } break; default: return null; } } export function getEnvironmentServiceDetails(): typeof constants.SERVICE_DETAILS { return Object.fromEntries( Object.entries(constants.SERVICE_DETAILS) .filter(([id, _]) => !FeatureControl.disabledServices.has(id)) .map(([id, service]) => [ id as constants.ServiceId, { id: service.id, name: service.name, shortName: service.shortName, knownNames: service.knownNames, signUpText: service.signUpText, credentials: service.credentials.map((cred) => ({ id: cred.id, name: cred.name, description: cred.description, type: cred.type, required: cred.required, default: getServiceCredentialDefault(service.id, cred.id) ? encryptString(getServiceCredentialDefault(service.id, cred.id)!) .data : null, forced: getServiceCredentialForced(service.id, cred.id) ? encryptString(getServiceCredentialForced(service.id, cred.id)!) .data : null, })), }, ]) ) as typeof constants.SERVICE_DETAILS; } export async function validateConfig( data: any, skipErrorsFromAddonsOrProxies: boolean = false, decryptValues: boolean = false ): Promise { const { success, data: config, error } = UserDataSchema.safeParse(data); if (!success) { throw new Error(formatZodError(error)); } if (Env.ADDON_PASSWORD && config.addonPassword !== Env.ADDON_PASSWORD) { throw new Error( 'Invalid addon password. Please enter the value of the ADDON_PASSWORD environment variable ' ); } const validations = { 'excluded stream expressions': [ config.excludedStreamExpressions, Env.MAX_CONDITION_FILTERS, ], 'excluded keywords': [config.excludedKeywords, Env.MAX_KEYWORD_FILTERS], 'included keywords': [config.includedKeywords, Env.MAX_KEYWORD_FILTERS], 'required keywords': [config.requiredKeywords, Env.MAX_KEYWORD_FILTERS], 'preferred keywords': [config.preferredKeywords, Env.MAX_KEYWORD_FILTERS], groups: [config.groups, Env.MAX_GROUPS], }; for (const [name, [items, max]] of Object.entries(validations)) { if (items && max && (items as any[]).length > (max as number)) { throw new Error( `You have ${(items as any[]).length} ${name}, but the maximum is ${max}` ); } } // now, validate preset options and service credentials. if (config.presets) { // ensure uniqenesss of instanceIds const instanceIds = new Set(); for (const preset of config.presets) { if (preset.instanceId && instanceIds.has(preset.instanceId)) { throw new Error(`Preset instanceId ${preset.instanceId} is not unique`); } if (preset.instanceId.includes('.')) { throw new Error( `Preset instanceId ${preset.instanceId} cannot contain a dot` ); } instanceIds.add(preset.instanceId); try { validatePreset(preset); } catch (error) { if (!skipErrorsFromAddonsOrProxies) { throw error; } logger.warn(`Invalid preset ${preset.instanceId}: ${error}`); } } } if (config.groups) { for (const group of config.groups) { await validateGroup(group); } } // validate excluded filter condition if (config.excludedStreamExpressions) { for (const condition of config.excludedStreamExpressions) { try { await StreamSelector.testSelect(condition); } catch (error) { throw new Error(`Invalid excluded stream expression: ${error}`); } } } if (config.services) { config.services = config.services.map((service: Service) => validateService(service, decryptValues) ); } if (config.proxy) { const decryptedProxy = ensureDecrypted(config).proxy; if (decryptedProxy) { config.proxy = await validateProxy( config.proxy, decryptedProxy, skipErrorsFromAddonsOrProxies, decryptValues ); } } if (config.rpdbApiKey) { try { const rpdb = new RPDB(config.rpdbApiKey); await rpdb.validateApiKey(); } catch (error) { if (!skipErrorsFromAddonsOrProxies) { throw new Error(`Invalid RPDB API key: ${error}`); } logger.warn(`Invalid RPDB API key: ${error}`); } } if (config.titleMatching?.enabled === true) { try { const tmdb = new TMDBMetadata(config.tmdbAccessToken); await tmdb.validateAccessToken(); } catch (error) { if (!skipErrorsFromAddonsOrProxies) { throw new Error(`Invalid TMDB access token: ${error}`); } logger.warn(`Invalid TMDB access token: ${error}`); } } if (FeatureControl.disabledServices.size > 0) { for (const service of config.services ?? []) { if (FeatureControl.disabledServices.has(service.id)) { service.enabled = false; } } } await validateRegexes(config); await new AIOStreams( ensureDecrypted(config), skipErrorsFromAddonsOrProxies ).initialise(); return config; } async function validateRegexes(config: UserData) { const excludedRegexes = config.excludedRegexPatterns; const includedRegexes = config.includedRegexPatterns; const requiredRegexes = config.requiredRegexPatterns; const preferredRegexes = config.preferredRegexPatterns; const regexAllowed = FeatureControl.isRegexAllowed(config); if ( !regexAllowed && (excludedRegexes?.length || includedRegexes?.length || requiredRegexes?.length || preferredRegexes?.length) ) { throw new Error( 'You do not have permission to use regex filters, please remove them from your config' ); } const regexes = [ ...(excludedRegexes ?? []), ...(includedRegexes ?? []), ...(requiredRegexes ?? []), ...(preferredRegexes ?? []).map((regex) => regex.pattern), ]; await Promise.all( regexes.map(async (regex) => { try { await compileRegex(regex); } catch (error: any) { logger.error(`Invalid regex: ${regex}: ${error.message}`); throw new Error(`Invalid regex: ${regex}: ${error.message}`); } }) ); } function ensureDecrypted(config: UserData): UserData { const decryptedConfig: UserData = structuredClone(config); // Helper function to decrypt a value if needed const tryDecrypt = (value: any, context: string) => { if (!isEncrypted(value)) return value; const { success, data, error } = decryptString(value); if (!success) { throw new Error(`Failed to decrypt ${context}: ${error}`); } return data; }; // Decrypt service credentials for (const service of decryptedConfig.services ?? []) { if (!service.credentials) continue; for (const [credential, value] of Object.entries(service.credentials)) { service.credentials[credential] = tryDecrypt( value, `credential ${credential}` ); } } // Decrypt proxy config if (decryptedConfig.proxy) { decryptedConfig.proxy.credentials = decryptedConfig.proxy.credentials ? tryDecrypt(decryptedConfig.proxy.credentials, 'proxy credentials') : undefined; decryptedConfig.proxy.url = decryptedConfig.proxy.url ? tryDecrypt(decryptedConfig.proxy.url, 'proxy URL') : undefined; } return decryptedConfig; } function validateService( service: Service, decryptValues: boolean = false ): Service { const serviceMeta = getEnvironmentServiceDetails()[service.id]; if (!serviceMeta) { throw new Error(`Service ${service.id} not found`); } if (serviceMeta.credentials.every((cred) => cred.forced)) { service.enabled = true; } if (service.enabled) { for (const credential of serviceMeta.credentials) { try { service.credentials[credential.id] = validateOption( credential, service.credentials?.[credential.id], decryptValues ); } catch (error) { throw new Error( `The value for credential '${credential.name}' in service '${serviceMeta.name}' is invalid: ${error}` ); } } } return service; } function validatePreset(preset: PresetObject) { const presetMeta = PresetManager.fromId(preset.type).METADATA; const optionMetas = presetMeta.OPTIONS; for (const optionMeta of optionMetas) { const optionValue = preset.options[optionMeta.id]; try { preset.options[optionMeta.id] = validateOption(optionMeta, optionValue); } catch (error) { throw new Error( `The value for option '${optionMeta.name}' in preset '${presetMeta.NAME}' is invalid: ${error}` ); } } } async function validateGroup(group: Group) { if (!group) { return; } // each group must have at least one addon, and we must be able to parse the condition if (group.addons.length === 0) { throw new Error('Every group must have at least one addon'); } // we must be able to parse the condition let result; try { result = await GroupConditionEvaluator.testEvaluate(group.condition); } catch (error: any) { throw new Error( `Your group condition - '${group.condition}' - is invalid: ${error.message}` ); } if (typeof result !== 'boolean') { throw new Error( `Your group condition - '${group.condition}' - is invalid. Expected evaluation to a boolean, instead got '${typeof result}'` ); } } function validateOption( option: Option, value: any, decryptValues: boolean = false ): any { if (value === undefined) { if (option.required) { throw new Error(`Option ${option.id} is required, got ${value}`); } return value; } if (option.type === 'multi-select') { if (!Array.isArray(value)) { throw new Error( `Option ${option.id} must be an array, got ${typeof value}` ); } } if (option.type === 'select') { if (typeof value !== 'string') { throw new Error( `Option ${option.id} must be a string, got ${typeof value}` ); } } if (option.type === 'boolean') { if (typeof value !== 'boolean') { throw new Error( `Option ${option.id} must be a boolean, got ${typeof value}` ); } } if (option.type === 'number') { if (typeof value !== 'number') { throw new Error( `Option ${option.id} must be a number, got ${typeof value}` ); } if (option.constraints?.min && value < option.constraints.min) { throw new Error( `Option ${option.id} must be at least ${option.constraints.min}, got ${value}` ); } if (option.constraints?.max && value > option.constraints.max) { throw new Error( `Option ${option.id} must be at most ${option.constraints.max}, got ${value}` ); } } if (option.type === 'string') { if (typeof value !== 'string') { throw new Error( `Option ${option.id} must be a string, got ${typeof value}` ); } if (option.constraints?.min && value.length < option.constraints.min) { throw new Error( `Option ${option.id} must be at least ${option.constraints.min} characters, got ${value.length}` ); } if (option.constraints?.max && value.length > option.constraints.max) { throw new Error( `Option ${option.id} must be at most ${option.constraints.max} characters, got ${value.length}` ); } } if (option.type === 'password') { if (typeof value !== 'string') { throw new Error( `Option ${option.id} must be a string, got ${typeof value}` ); } if (option.forced) { // option.forced is already encrypted value = option.forced; } if (isEncrypted(value) && decryptValues) { const { success, data, error } = decryptString(value); if (!success) { throw new Error( `Option ${option.id} is encrypted but failed to decrypt: ${error}` ); } value = data; } } if (option.type === 'url') { if (typeof value !== 'string') { throw new Error( `Option ${option.id} must be a string, got ${typeof value}` ); } } return value; } async function validateProxy( proxy: StreamProxyConfig, decryptedProxy: StreamProxyConfig, skipProxyErrors: boolean = false, decryptCredentials: boolean = false ): Promise { // apply forced values if they exist proxy.enabled = Env.FORCE_PROXY_ENABLED ?? proxy.enabled; proxy.id = Env.FORCE_PROXY_ID ?? proxy.id; proxy.url = Env.FORCE_PROXY_URL ? (encryptString(Env.FORCE_PROXY_URL).data ?? undefined) : (proxy.url ?? undefined); proxy.credentials = Env.FORCE_PROXY_CREDENTIALS ? (encryptString(Env.FORCE_PROXY_CREDENTIALS).data ?? undefined) : (proxy.credentials ?? undefined); proxy.publicIp = Env.FORCE_PROXY_PUBLIC_IP ?? proxy.publicIp; proxy.proxiedAddons = Env.FORCE_PROXY_DISABLE_PROXIED_ADDONS ? undefined : proxy.proxiedAddons; proxy.proxiedServices = Env.FORCE_PROXY_PROXIED_SERVICES ?? proxy.proxiedServices; if (proxy.enabled) { if (!proxy.id) { throw new Error('Proxy ID is required'); } if (!proxy.url) { throw new Error('Proxy URL is required'); } if (!proxy.credentials) { throw new Error('Proxy credentials are required'); } if (isEncrypted(proxy.credentials) && decryptCredentials) { const { success, data, error } = decryptString(proxy.credentials); if (!success) { throw new Error( `Proxy credentials for ${proxy.id} are encrypted but failed to decrypt: ${error}` ); } proxy.credentials = data; } if (isEncrypted(proxy.url) && decryptCredentials) { const { success, data, error } = decryptString(proxy.url); if (!success) { throw new Error( `Proxy URL for ${proxy.id} is encrypted but failed to decrypt: ${error}` ); } proxy.url = data; } // use decrypted proxy config for validation. const ProxyService = createProxy(decryptedProxy); try { proxy.publicIp || (await ProxyService.getPublicIp()); } catch (error) { if (!skipProxyErrors) { logger.error( `Failed to get the public IP of the proxy service ${proxy.id} (${maskSensitiveInfo(proxy.url)}): ${error}` ); throw new Error( `Failed to get the public IP of the proxy service ${proxy.id}: ${error}` ); } } } return proxy; }