brunner56's picture
implement app
0bfe2e3
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<UserData> {
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<string>();
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<StreamProxyConfig> {
// 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;
}