Files
AIOStreams/packages/core/src/utils/config.ts
T
2025-11-27 23:25:53 +00:00

1030 lines
30 KiB
TypeScript

import {
UserData,
UserDataSchema,
PresetObject,
Service,
Option,
StreamProxyConfig,
Group,
PresetMetadata,
} from '../db/schemas.js';
import { AIOStreams } from '../main.js';
import { Preset, PresetManager } from '../presets/index.js';
import { createProxy } from '../proxy/index.js';
import { TMDBMetadata } from '../metadata/tmdb.js';
import {
isEncrypted,
decryptString,
encryptString,
Env,
maskSensitiveInfo,
RPDB,
FeatureControl,
compileRegex,
constants,
} from './index.js';
import { z, ZodError } from 'zod';
import {
ExitConditionEvaluator,
GroupConditionEvaluator,
StreamSelector,
} from '../parser/streamExpression.js';
import { createLogger } from './logger.js';
import { TVDBMetadata } from '../metadata/tvdb.js';
const logger = createLogger('core');
export const formatZodError = (error: ZodError) => {
return z.prettifyError(error);
};
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.ALLDEBRID_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.DEBRIDER_SERVICE:
switch (credentialId) {
case 'apiKey':
return Env.DEFAULT_DEBRIDER_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.ALLDEBRID_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.DEBRIDER_SERVICE:
switch (credentialId) {
case 'apiKey':
return Env.FORCED_DEBRIDER_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,
intent: cred.intent,
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,
constraints: {
min: 1,
},
})),
},
])
) as typeof constants.SERVICE_DETAILS;
}
export interface ValidateConfigOptions {
skipErrorsFromAddonsOrProxies?: boolean;
decryptValues?: boolean;
increasedManifestTimeout?: boolean;
bypassManifestCache?: boolean;
}
export async function validateConfig(
data: any,
options?: ValidateConfigOptions
): Promise<UserData> {
const {
success,
data: config,
error,
} = UserDataSchema.safeParse(
removeInvalidPresetReferences(applyMigrations(data))
);
if (!success) {
throw new Error(formatZodError(error));
}
if (
Env.ADDON_PASSWORD.length > 0 &&
!Env.ADDON_PASSWORD.includes(config.addonPassword || '')
) {
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_STREAM_EXPRESSION_FILTERS,
],
'required stream expressions': [
config.requiredStreamExpressions,
Env.MAX_STREAM_EXPRESSION_FILTERS,
],
'preferred stream expressions': [
config.preferredStreamExpressions,
Env.MAX_STREAM_EXPRESSION_FILTERS,
],
'included stream expressions': [
config.includedStreamExpressions,
Env.MAX_STREAM_EXPRESSION_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 (!options?.skipErrorsFromAddonsOrProxies) {
throw error;
}
logger.warn(`Invalid preset ${preset.instanceId}: ${error}`);
}
}
}
if (config.groups?.groupings) {
for (const group of config.groups.groupings) {
await validateGroup(group);
}
}
if (config.dynamicAddonFetching?.enabled) {
try {
if (!config.dynamicAddonFetching.condition) {
throw new Error('Missing condition');
}
await ExitConditionEvaluator.testEvaluate(
config.dynamicAddonFetching.condition
);
} catch (error) {
throw new Error(`Invalid dynamic addon fetching condition: ${error}`);
}
}
// validate excluded filter condition
const streamExpressions = [
...(config.excludedStreamExpressions ?? []),
...(config.requiredStreamExpressions ?? []),
...(config.preferredStreamExpressions ?? []),
...(config.includedStreamExpressions ?? []),
];
for (const expression of streamExpressions) {
try {
await StreamSelector.testSelect(expression);
} catch (error) {
throw new Error(`Invalid stream expression: ${expression}: ${error}`);
}
}
if (config.services) {
config.services = config.services.map((service: Service) =>
validateService(service, options?.decryptValues)
);
}
config.proxy = await validateProxy(
config,
options?.skipErrorsFromAddonsOrProxies,
options?.decryptValues
);
if (config.rpdbApiKey) {
try {
const rpdb = new RPDB(config.rpdbApiKey);
await rpdb.validateApiKey();
} catch (error) {
if (!options?.skipErrorsFromAddonsOrProxies) {
throw new Error(`Invalid RPDB API key: ${error}`);
}
logger.warn(`Invalid RPDB API key: ${error}`);
}
}
const tmdbAuth =
config.tmdbApiKey ||
config.tmdbAccessToken ||
Env.TMDB_API_KEY ||
Env.TMDB_ACCESS_TOKEN;
const needTmdb =
config.titleMatching?.enabled ||
config.yearMatching?.enabled ||
config.digitalReleaseFilter;
if (needTmdb && !tmdbAuth) {
throw new Error(
'A TMDB API key or access token is required for the following features: title matching, year matching, and digital release filter'
);
}
// validate tmdb auth if it is needed or if it is provided in the config.
if (needTmdb || config.tmdbAccessToken || config.tmdbApiKey) {
try {
const tmdb = new TMDBMetadata({
accessToken: config.tmdbAccessToken,
apiKey: config.tmdbApiKey,
});
await tmdb.validateAuthorisation();
} catch (error) {
if (!options?.skipErrorsFromAddonsOrProxies) {
throw new Error(
`Failed to validate TMDB API Key/Access Token: ${error instanceof Error ? error.message : String(error)}`
);
}
logger.warn(error instanceof Error ? error.message : String(error));
}
}
if (config.tvdbApiKey) {
try {
const tvdb = new TVDBMetadata({
apiKey: config.tvdbApiKey,
});
await tvdb.validateApiKey();
} catch (error) {
if (!options?.skipErrorsFromAddonsOrProxies) {
throw new Error(`Invalid TVDB API key: ${error}`);
}
logger.warn(`Invalid TVDB API key: ${error}`);
}
}
if (FeatureControl.disabledServices.size > 0) {
for (const service of config.services ?? []) {
if (FeatureControl.disabledServices.has(service.id)) {
service.enabled = false;
}
}
}
await validateRegexes(config, options?.skipErrorsFromAddonsOrProxies);
await new AIOStreams(ensureDecrypted(config), {
skipFailedAddons: options?.skipErrorsFromAddonsOrProxies ?? false,
increasedManifestTimeout: options?.increasedManifestTimeout ?? false,
bypassManifestCache: options?.bypassManifestCache ?? false,
}).initialise();
return config;
}
function removeInvalidPresetReferences(config: UserData) {
// remove references to non-existent presets in options:
const existingPresetIds = config.presets?.map((preset) => preset.instanceId);
if (config.proxy) {
config.proxy.proxiedAddons = config.proxy.proxiedAddons?.filter((addon) =>
existingPresetIds?.includes(addon)
);
}
if (config.yearMatching) {
config.yearMatching.addons = config.yearMatching.addons?.filter((addon) =>
existingPresetIds?.includes(addon)
);
}
if (config.titleMatching) {
config.titleMatching.addons = config.titleMatching.addons?.filter((addon) =>
existingPresetIds?.includes(addon)
);
}
if (config.seasonEpisodeMatching) {
config.seasonEpisodeMatching.addons =
config.seasonEpisodeMatching.addons?.filter((addon) =>
existingPresetIds?.includes(addon)
);
}
if (config.groups?.groupings) {
config.groups.groupings = config.groups.groupings.map((group) => ({
...group,
addons: group.addons?.filter((addon) =>
existingPresetIds?.includes(addon)
),
}));
}
return config;
}
export function applyMigrations(config: any): UserData {
if (
config.deduplicator &&
typeof config.deduplicator.multiGroupBehaviour === 'string'
) {
switch (config.deduplicator.multiGroupBehaviour as string) {
case 'remove_uncached':
config.deduplicator.multiGroupBehaviour = 'aggressive';
break;
case 'remove_uncached_same_service':
config.deduplicator.multiGroupBehaviour = 'conservative';
break;
case 'remove_nothing':
config.deduplicator.multiGroupBehaviour = 'keep_all';
break;
}
}
if (config.titleMatching?.matchYear) {
config.yearMatching = {
enabled: true,
tolerance: config.titleMatching.yearTolerance
? config.titleMatching.yearTolerance
: 1,
requestTypes: config.titleMatching.requestTypes ?? [],
addons: config.titleMatching.addons ?? [],
};
delete config.titleMatching.matchYear;
}
if (Array.isArray(config.groups)) {
config.groups = {
enabled: config.disableGroups ? false : true,
groupings: config.groups,
behaviour: 'parallel',
};
}
if (config.showStatistics || config.statisticsPosition) {
config.statistics = {
enabled: config.showStatistics ?? false,
position: config.statisticsPosition ?? 'bottom',
statsToShow: ['addon', 'filter'],
...(config.statistics ?? {}),
};
delete config.showStatistics;
delete config.statisticsPosition;
}
const migrateHOSBS = (
type: 'preferred' | 'required' | 'excluded' | 'included'
) => {
if (Array.isArray(config[type + 'Encodes'])) {
config[type + 'Encodes'] = config[type + 'Encodes'].filter(
(encode: string) => {
if (encode === 'H-OU' || encode === 'H-SBS') {
// add H-OU and H-SBS to visual tags if in encodes.
config[type + 'VisualTags'] = [
...(config[type + 'VisualTags'] ?? []),
encode,
];
// filter out H-OU and H-SBS from encodes
return false;
}
return true;
}
);
}
};
migrateHOSBS('preferred');
migrateHOSBS('required');
migrateHOSBS('excluded');
migrateHOSBS('included');
// migrate comparisons of queryType to 'anime' to 'anime.series' or 'anime.movie'
const migrateAnimeQueryTypeInExpression = (expr?: string) => {
if (typeof expr !== 'string') return expr as any;
// Replace equality comparisons
let updated = expr.replace(
/queryType\s*==\s*(["'])anime\1/g,
"(queryType == 'anime.series' or queryType == 'anime.movie')"
);
updated = updated.replace(
/(["'])anime\1\s*==\s*queryType/g,
"(queryType == 'anime.series' or queryType == 'anime.movie')"
);
// Replace inequality comparisons
updated = updated.replace(
/queryType\s*!=\s*(["'])anime\1/g,
"(queryType != 'anime.series' and queryType != 'anime.movie')"
);
updated = updated.replace(
/(["'])anime\1\s*!=\s*queryType/g,
"(queryType != 'anime.series' and queryType != 'anime.movie')"
);
return updated;
};
const expressionLists = [
'excludedStreamExpressions',
'requiredStreamExpressions',
'includedStreamExpressions',
'preferredStreamExpressions',
] as const;
for (const key of expressionLists) {
if (Array.isArray((config as any)[key])) {
(config as any)[key] = (config as any)[key].map((expr: unknown) =>
typeof expr === 'string'
? migrateAnimeQueryTypeInExpression(expr)
: expr
);
}
}
if (config.dynamicAddonFetching?.condition) {
config.dynamicAddonFetching.condition = migrateAnimeQueryTypeInExpression(
config.dynamicAddonFetching.condition
);
}
if (config.groups?.groupings) {
config.groups.groupings = config.groups.groupings.map((group: any) => ({
...group,
condition: migrateAnimeQueryTypeInExpression(group.condition),
}));
}
return config;
}
async function validateRegexes(config: UserData, skipErrors: boolean = false) {
const excludedRegexes = config.excludedRegexPatterns;
const includedRegexes = config.includedRegexPatterns;
const requiredRegexes = config.requiredRegexPatterns;
const preferredRegexes = config.preferredRegexPatterns;
const regexAllowed = await FeatureControl.isRegexAllowed(config);
const regexes = [
...(excludedRegexes ?? []),
...(includedRegexes ?? []),
...(requiredRegexes ?? []),
...(preferredRegexes ?? []).map((regex) => regex.pattern),
];
if (!regexAllowed && regexes.length > 0) {
const allowedPatterns = (await FeatureControl.allowedRegexPatterns())
.patterns;
const allowedRegexes = regexes.filter((regex) =>
allowedPatterns.includes(regex)
);
if (allowedRegexes.length === 0) {
if (!skipErrors) {
throw new Error(
'You do not have permission to use regex filters, please remove them from your config'
);
}
return;
}
if (allowedRegexes.length !== regexes.length) {
if (!skipErrors) {
throw new Error(
`You are only permitted to use specific regex patterns, you have ${regexes.length - allowedRegexes.length} / ${regexes.length} regexes that are not allowed. Please remove them from your config.`
);
}
return;
}
}
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;
decryptedConfig.proxy.publicUrl = decryptedConfig.proxy.publicUrl
? tryDecrypt(decryptedConfig.proxy.publicUrl, 'proxy public 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 as PresetMetadata;
const optionMetas = presetMeta.OPTIONS;
if (presetMeta.DISABLED) {
throw new Error(
`Addon '${presetMeta.NAME}' is disabled: ${presetMeta.DISABLED.reason}`
);
}
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 (typeof value === 'string' && value === 'undefined') {
value = undefined;
}
const forcedValue =
option.forced !== undefined && option.forced !== null
? option.forced
: undefined;
if (forcedValue !== undefined) {
value = forcedValue;
}
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.constraints?.max && value.length > option.constraints.max) {
throw new Error(
`Option ${option.id} must be at most ${option.constraints.max} items, got ${value.length}`
);
}
if (option.constraints?.min && value.length < option.constraints.min) {
throw new Error(
`Option ${option.id} must be at least ${option.constraints.min} items, got ${value.length}`
);
}
return 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' || option.type === 'password') {
if (typeof value !== 'string') {
throw new Error(
`Option ${option.id} must be a string, got ${typeof value}: ${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 (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 (forcedValue !== undefined) {
value = forcedValue;
}
if (typeof value !== 'string') {
throw new Error(
`Option ${option.id} must be a string, got ${typeof value}`
);
}
}
return value;
}
async function validateProxy(
config: UserData,
skipProxyErrors: boolean = false,
decryptCredentials: boolean = false
): Promise<StreamProxyConfig> {
// apply forced values if they exist
const proxy = config.proxy ?? {};
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);
let forcedPublicUrl: string | undefined;
if (
proxy.url &&
(Env.FORCE_PUBLIC_PROXY_HOST !== undefined ||
Env.FORCE_PUBLIC_PROXY_PROTOCOL !== undefined ||
Env.FORCE_PUBLIC_PROXY_PORT !== undefined)
) {
const proxyUrl = new URL(
isEncrypted(proxy.url) ? decryptString(proxy.url).data || '' : proxy.url
);
const port = Env.FORCE_PUBLIC_PROXY_PORT ?? proxyUrl.port;
forcedPublicUrl = `${Env.FORCE_PUBLIC_PROXY_PROTOCOL ?? proxyUrl.protocol}://${Env.FORCE_PUBLIC_PROXY_HOST ?? proxyUrl.hostname}${port ? `:${port}` : ''}`;
}
forcedPublicUrl = Env.FORCE_PROXY_PUBLIC_URL ?? forcedPublicUrl;
proxy.publicUrl = forcedPublicUrl
? (encryptString(forcedPublicUrl).data ?? undefined)
: (proxy.publicUrl ?? 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.id === constants.BUILTIN_SERVICE) {
proxy.url = Env.BASE_URL;
}
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;
}
if (proxy.publicUrl && isEncrypted(proxy.publicUrl) && decryptCredentials) {
const { success, data, error } = decryptString(proxy.publicUrl);
if (!success) {
throw new Error(
`Proxy public URL for ${proxy.id} is encrypted but failed to decrypt: ${error}`
);
}
proxy.publicUrl = data;
}
// use decrypted proxy config for validation.
const ProxyService = createProxy(ensureDecrypted(config).proxy ?? {});
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;
}