From 61e21cd803981899b4e445c5058fb546db79096d Mon Sep 17 00:00:00 2001 From: Viren070 Date: Thu, 29 May 2025 15:05:12 +0100 Subject: [PATCH] feat: service cred env vars, better validation, handling of encrypted values --- packages/core/src/db/schemas.ts | 19 +- packages/core/src/db/users.ts | 27 +- packages/core/src/utils/config.ts | 361 ++++++++++++++++-- packages/core/src/utils/constants.ts | 45 +-- packages/core/src/utils/crypto.ts | 7 +- packages/core/src/utils/env.ts | 204 ++++++---- packages/core/src/utils/http.ts | 3 +- .../frontend/src/components/menu/services.tsx | 149 +++++--- .../src/components/shared/template-option.tsx | 24 +- packages/server/src/middlewares/errors.ts | 40 +- packages/server/src/middlewares/userData.ts | 18 +- packages/server/src/routes/api/status.ts | 14 +- .../server/src/routes/stremio/addonCatalog.ts | 17 +- packages/server/src/routes/stremio/catalog.ts | 16 +- .../server/src/routes/stremio/manifest.ts | 4 +- packages/server/src/routes/stremio/meta.ts | 17 +- packages/server/src/routes/stremio/stream.ts | 15 +- .../server/src/routes/stremio/subtitle.ts | 17 +- 18 files changed, 700 insertions(+), 297 deletions(-) diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts index 4f483a34..c51bf220 100644 --- a/packages/core/src/db/schemas.ts +++ b/packages/core/src/db/schemas.ts @@ -34,7 +34,7 @@ const StreamProxyConfig = z.object({ id: z.enum(constants.PROXY_SERVICES), url: z.string().url(), credentials: z.string().min(1), - publicIp: z.string().min(1).optional(), + publicIp: z.string().ip().optional(), proxiedAddons: z.array(z.string().min(1)), proxiedServices: z.array(z.string().min(1)), }); @@ -80,7 +80,7 @@ const SizeFilterOptions = z.object({ const ServiceSchema = z.object({ id: ServiceIds, enabled: z.boolean().optional(), - credentials: z.record(z.string().min(1), z.string().min(1)).optional(), + credentials: z.record(z.string().min(1), z.string().min(1)), }); export type Service = z.infer; @@ -152,6 +152,7 @@ const OptionDefinition = z.object({ emptyIsUndefined: z.boolean().optional(), type: z.enum([ 'string', + 'password', 'number', 'boolean', 'select', @@ -160,7 +161,8 @@ const OptionDefinition = z.object({ ]), required: z.boolean().optional(), default: z.any().optional(), - sensitive: z.boolean().optional(), + // sensitive: z.boolean().optional(), + forced: z.any().optional(), options: z .array( z.object({ @@ -586,6 +588,17 @@ const StatusResponseSchema = z.object({ excludedRegex: z.array(z.string()), }), presets: z.array(PresetMetadataSchema), + services: z.record( + z.enum(constants.SERVICES), + z.object({ + id: z.enum(constants.SERVICES), + name: z.string(), + shortName: z.string(), + knownNames: z.array(z.string()), + signUpText: z.string(), + credentials: z.array(OptionDefinition), + }) + ), }), }); diff --git a/packages/core/src/db/users.ts b/packages/core/src/db/users.ts index 2ecb30e3..8236838e 100644 --- a/packages/core/src/db/users.ts +++ b/packages/core/src/db/users.ts @@ -45,8 +45,9 @@ export class UserRepository { ); } + let validatedConfig: UserData; try { - await validateConfig(config); + validatedConfig = await validateConfig(config); } catch (error: any) { return Promise.reject( new APIError( @@ -61,7 +62,7 @@ export class UserRepository { config.uuid = uuid; const { encryptedConfig, salt: configSalt } = await this.encryptConfig( - config, + validatedConfig, password ); const hashedPassword = await getTextHash(password); @@ -139,11 +140,24 @@ export class UserRepository { result[0].config_salt ); - decryptedConfig.admin = + let validatedConfig: UserData; + try { + validatedConfig = await validateConfig(decryptedConfig); + } catch (error: any) { + return Promise.reject( + new APIError( + constants.ErrorCode.USER_INVALID_CONFIG, + undefined, + error.message + ) + ); + } + + validatedConfig.admin = Env.ADMIN_UUIDS?.split(',').some((u) => new RegExp(u).test(uuid)) ?? false; logger.info(`Retrieved configuration for user ${uuid}`); - return decryptedConfig; + return validatedConfig; } catch (error) { logger.error( `Error retrieving user ${uuid}: ${error instanceof Error ? error.message : String(error)}` @@ -172,8 +186,9 @@ export class UserRepository { ); } + let validatedConfig: UserData; try { - await validateConfig(config); + validatedConfig = await validateConfig(config); } catch (error: any) { await tx.rollback(); return Promise.reject( @@ -194,7 +209,7 @@ export class UserRepository { } const { encryptedConfig } = await this.encryptConfig( - config, + validatedConfig, password, currentUser.rows[0].config_salt ); diff --git a/packages/core/src/utils/config.ts b/packages/core/src/utils/config.ts index c4d1213f..066f3b68 100644 --- a/packages/core/src/utils/config.ts +++ b/packages/core/src/utils/config.ts @@ -4,11 +4,17 @@ import { PresetObject, Service, Option, + StreamProxyConfig, } from '../db/schemas'; import { AIOStreams } from '../main'; import { Preset, PresetManager } from '../presets'; -import { SERVICE_DETAILS } from './constants'; +import { createProxy } from '../proxy'; +import { constants } from '.'; +import { isEncrypted, decryptString, encryptString } from './crypto'; import { Env } from './env'; +import { createLogger } from './logger'; + +const logger = createLogger('core'); const formatZodError = (error: any) => { let message = ''; @@ -18,11 +24,219 @@ const formatZodError = (error: any) => { return message; }; +function getServiceCredentialDefault( + serviceId: constants.ServiceId, + credentialId: string +) { + // env mapping + logger.info(`Getting default credential for ${serviceId} ${credentialId}`); + switch (serviceId) { + case constants.REALDEBRID_SERVICE: + switch (credentialId) { + case 'apiKey': + logger.info( + `Default credential for ${serviceId} ${credentialId} is ${Env.DEFAULT_REALDEBRID_API_KEY}` + ); + 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).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( - config: any, + data: any, skipFailedAddons: boolean = false ): Promise { - const { success, data, error } = UserDataSchema.safeParse(config); + const { success, data: config, error } = UserDataSchema.safeParse(data); if (!success) { throw new Error(formatZodError(error)); } @@ -42,36 +256,50 @@ export async function validateConfig( } if (config.services) { - for (const service of config.services.filter((s: Service) => s.enabled)) { - validateService(service); - } + config.services = config.services.map((service: Service) => + validateService(service) + ); + } + + if (config.proxy) { + config.proxy = await validateProxy(config.proxy); } try { - await new AIOStreams(data, skipFailedAddons).initialise(); + await new AIOStreams(config, skipFailedAddons).initialise(); } catch (error: any) { throw new Error(error.message); } - return data; + return config; } -function validateService(service: Service) { - const serviceMeta = SERVICE_DETAILS?.[service.id]; +function validateService(service: Service): Service { + const serviceMeta = getEnvironmentServiceDetails()[service.id]; if (!serviceMeta) { throw new Error(`Service ${service.id} not found`); } - for (const credential of serviceMeta.credentials) { - try { - validateOption(credential, service.credentials?.[credential.id]); - } catch (error) { - throw new Error( - `The value for credential '${credential.name}' in service '${serviceMeta.name}' is invalid: ${error}` - ); + 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] + ); + } catch (error) { + throw new Error( + `The value for credential '${credential.name}' in service '${serviceMeta.name}' is invalid: ${error}` + ); + } } } + return service; } function validatePreset(preset: PresetObject) { @@ -85,7 +313,7 @@ function validatePreset(preset: PresetObject) { throw new Error(`Option ${optionId} not found in preset ${preset.id}`); } try { - validateOption(optionMeta, optionValue); + preset.options[optionId] = validateOption(optionMeta, optionValue); } catch (error) { throw new Error( `The value for option '${optionMeta.name}' in preset '${presetMeta.NAME}' is invalid: ${error}` @@ -94,44 +322,125 @@ function validatePreset(preset: PresetObject) { } } -function validateOption(option: Option, value: any) { +function validateOption(option: Option, value: any): any { if (option.type === 'multi-select') { if (!Array.isArray(value)) { - throw new Error(`Option ${option.id} must be an array`); + 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`); + 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`); + 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`); + throw new Error( + `Option ${option.id} must be a number, got ${typeof value}` + ); } } if (option.type === 'string') { if (typeof value !== 'string') { - throw new Error(`Option ${option.id} must be a string`); + throw new Error( + `Option ${option.id} must be a string, got ${typeof value}` + ); + } + } + + if (option.type === 'password') { + if (typeof value !== 'string') { + throw new Error( + `Option ${option.id} must be a string, got ${typeof value}` + ); + } + + if (option.forced) { + value = option.forced; + } + if (isEncrypted(value)) { + 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`); + throw new Error( + `Option ${option.id} must be a string, got ${typeof value}` + ); } } if (option.required && value === undefined) { - throw new Error(`Option ${option.id} is required `); + throw new Error(`Option ${option.id} is required, got ${value}`); } + + return value; +} + +async function validateProxy( + proxy: StreamProxyConfig +): 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 ?? proxy.url; + proxy.credentials = Env.FORCE_PROXY_CREDENTIALS ?? proxy.credentials; + proxy.publicIp = Env.FORCE_PROXY_PUBLIC_IP ?? proxy.publicIp; + proxy.proxiedAddons = Env.FORCE_PROXY_PROXIED_ADDONS ?? 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'); + } + + const ProxyService = createProxy(proxy); + + try { + proxy.publicIp || (await ProxyService.getPublicIp()); + } catch (error) { + throw new Error( + `Failed to get the public IP of the proxy service ${proxy.id}: ${error}` + ); + } + + if (isEncrypted(proxy.credentials)) { + 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; + } + } + return proxy; } diff --git a/packages/core/src/utils/constants.ts b/packages/core/src/utils/constants.ts index cd8410d6..92070543 100644 --- a/packages/core/src/utils/constants.ts +++ b/packages/core/src/utils/constants.ts @@ -248,9 +248,8 @@ const SERVICE_DETAILS: Record< name: 'API Key', description: 'The API key for the Real-Debrid service. Obtain it from [here](https://real-debrid.com/apitoken)', - type: 'string', + type: 'password', required: true, - sensitive: true, }, ], }, @@ -267,9 +266,8 @@ const SERVICE_DETAILS: Record< name: 'API Key', description: 'The API key for the All-Debrid service. Create one [here](https://alldebrid.com/apikeys)', - type: 'string', + type: 'password', required: true, - sensitive: true, }, ], }, @@ -286,9 +284,8 @@ const SERVICE_DETAILS: Record< name: 'API Key', description: 'Your Premiumize API key. Obtain it from [here](https://www.premiumize.me/account)', - type: 'string', + type: 'password', required: true, - sensitive: true, }, ], }, @@ -305,9 +302,8 @@ const SERVICE_DETAILS: Record< name: 'API Key', description: 'Your Debrid-Link API key. Obtain it from [here](https://debrid-link.com/webapp/apikey)', - type: 'string', + type: 'password', required: true, - sensitive: true, }, ], }, @@ -324,9 +320,8 @@ const SERVICE_DETAILS: Record< name: 'API Key', description: 'Your Torbox API key. Obtain it from [here](https://torbox.app/settings)', - type: 'string', + type: 'password', required: true, - sensitive: true, }, ], }, @@ -343,16 +338,15 @@ const SERVICE_DETAILS: Record< name: 'API Key', description: 'Your Offcloud API key. Obtain it from [here](https://offcloud.com/#/account) on the `API Key` tab. ', - type: 'string', + type: 'password', required: true, - sensitive: true, }, { id: 'email', name: 'Email', description: 'Your Offcloud email. (These credentials are necessary for some addons)', - type: 'string', + type: 'password', required: true, }, { @@ -360,7 +354,7 @@ const SERVICE_DETAILS: Record< name: 'Password', description: 'Your Offcloud password. (These credentials are necessary for some addons)', - type: 'string', + type: 'password', required: true, }, ], @@ -377,18 +371,16 @@ const SERVICE_DETAILS: Record< name: 'Client ID', description: 'Your put.io Client ID. Obtain it from [here](https://app.put.io/oauth)', - type: 'string', + type: 'password', required: true, - sensitive: true, }, { id: 'token', name: 'Token', description: 'Your put.io Token. Obtain it from [here](https://app.put.io/oauth)', - type: 'string', + type: 'password', required: true, - sensitive: true, }, ], }, @@ -404,16 +396,15 @@ const SERVICE_DETAILS: Record< id: 'username', name: 'Username', description: 'Your Easynews username', - type: 'string', + type: 'password', required: true, }, { id: 'password', name: 'Password', description: 'Your Easynews password', - type: 'string', + type: 'password', required: true, - sensitive: true, }, ], }, @@ -430,9 +421,8 @@ const SERVICE_DETAILS: Record< name: 'API Key', description: 'Your EasyDebrid API key. Obtain it from [here](https://paradise-cloud.com/dashboard/)', - type: 'string', + type: 'password', required: true, - sensitive: true, }, ], }, @@ -448,16 +438,15 @@ const SERVICE_DETAILS: Record< id: 'email', name: 'Email', description: 'Your PikPak email address', - type: 'string', + type: 'password', required: true, }, { id: 'password', name: 'Password', description: 'Your PikPak password', - type: 'string', + type: 'password', required: true, - sensitive: true, }, ], }, @@ -474,9 +463,8 @@ const SERVICE_DETAILS: Record< name: 'Encoded Token', description: 'Please authorise at MediaFusion and copy the token into here.', - type: 'string', + type: 'password', required: true, - sensitive: true, }, ], }, @@ -820,6 +808,7 @@ export { REALDEBRID_SERVICE, PREMIUMIZE_SERVICE, ALLEDEBRID_SERVICE, + DEBRIDLINK_SERVICE, TORBOX_SERVICE, EASYDEBRID_SERVICE, PUTIO_SERVICE, diff --git a/packages/core/src/utils/crypto.ts b/packages/core/src/utils/crypto.ts index e6cb7097..0e862be8 100644 --- a/packages/core/src/utils/crypto.ts +++ b/packages/core/src/utils/crypto.ts @@ -71,6 +71,9 @@ type ErrorResponse = { export type Response = SuccessResponse | ErrorResponse; +export function isEncrypted(data: string): boolean { + return data.startsWith('aioEncrypt:'); +} /** * Encrypts a string using AES-256-CBC encryption, returns a string in the format "iv:encrypted" where * iv and encrypted are url encoded. @@ -87,7 +90,7 @@ export function encryptString(data: string, secretKey?: Buffer): Response { const { iv, data: encrypted } = encryptData(secretKey, compressed); return { success: true, - data: `${encodeURIComponent(iv)}:${encodeURIComponent(encrypted)}`, + data: `aioEncrypt:${encodeURIComponent(iv)}:${encodeURIComponent(encrypted)}`, error: null, }; } catch (error: any) { @@ -111,7 +114,7 @@ export function decryptString(data: string, secretKey?: Buffer): Response { secretKey = Buffer.from(Env.SECRET_KEY, 'hex'); } try { - const [ivHex, encryptedHex] = data.split(':').map(decodeURIComponent); + const [_, ivHex, encryptedHex] = data.split(':').map(decodeURIComponent); const iv = Buffer.from(ivHex, 'base64'); const encrypted = Buffer.from(encryptedHex, 'base64'); const decrypted = decryptData(secretKey, encrypted, iv); diff --git a/packages/core/src/utils/env.ts b/packages/core/src/utils/env.ts index c1eca415..4585d8ca 100644 --- a/packages/core/src/utils/env.ts +++ b/packages/core/src/utils/env.ts @@ -216,14 +216,6 @@ export const Env = cleanEnv(process.env, { default: '', desc: 'API key for the addon, can be set to anything', }), - SHOW_DIE: bool({ - default: false, - desc: 'Show a game die emoji in streams for non-custom formats', - }), - DETERMINISTIC_ADDON_ID: bool({ - default: true, - desc: 'Deterministic addon ID', - }), DATABASE_URI: str({ default: 'sqlite://./data/db.sqlite', desc: 'Database URI for the addon', @@ -248,10 +240,6 @@ export const Env = cleanEnv(process.env, { default: undefined, desc: 'TMDB Read Access Token. Used for fetching metadata for the strict title matching option.', }), - DISABLE_CUSTOM_CONFIG_GENERATOR_ROUTE: bool({ - default: false, - desc: 'Disable custom config generator route', - }), // logging settings LOG_SENSITIVE_INFO: bool({ @@ -409,79 +397,153 @@ export const Env = cleanEnv(process.env, { desc: 'Default proxy proxied services', }), - // // MediaFlow settings - // FORCE_MEDIAFLOW_URL: url({ - // default: undefined, - // desc: 'Force MediaFlow URL', - // }), - // FORCE_MEDIAFLOW_API_PASSWORD: str({ - // default: undefined, - // desc: 'Force MediaFlow API password', - // }), - // FORCE_MEDIAFLOW_PUBLIC_IP: str({ - // default: undefined, - // desc: 'Force MediaFlow public IP', - // }), - // DEFAULT_MEDIAFLOW_URL: url({ - // default: '', - // desc: 'Default MediaFlow URL', - // }), - // DEFAULT_MEDIAFLOW_API_PASSWORD: str({ - // default: '', - // desc: 'Default MediaFlow API password', - // }), - // DEFAULT_MEDIAFLOW_PUBLIC_IP: str({ - // default: '', - // desc: 'Default MediaFlow public IP', - // }), - // MEDIAFLOW_IP_TIMEOUT: num({ - // default: 30000, - // desc: 'MediaFlow IP timeout', - // }), ENCRYPT_MEDIAFLOW_URLS: bool({ default: true, desc: 'Encrypt MediaFlow URLs', }), - // // StremThru settings - // FORCE_STREMTHRU_URL: url({ - // default: undefined, - // desc: 'Force StremThru URL', - // }), - // FORCE_STREMTHRU_CREDENTIAL: str({ - // default: undefined, - // desc: 'Force StremThru credential', - // }), - // FORCE_STREMTHRU_PUBLIC_IP: str({ - // default: undefined, - // desc: 'Force StremThru public IP', - // }), - // DEFAULT_STREMTHRU_URL: url({ - // default: '', - // desc: 'Default StremThru URL', - // }), - // DEFAULT_STREMTHRU_CREDENTIAL: str({ - // default: '', - // desc: 'Default StremThru credential', - // }), - // DEFAULT_STREMTHRU_PUBLIC_IP: str({ - // default: '', - // desc: 'Default StremThru public IP', - // }), - // STREMTHRU_TIMEOUT: num({ - // default: 30000, - // desc: 'StremThru timeout', - // }), ENCRYPT_STREMTHRU_URLS: bool({ default: true, desc: 'Encrypt StremThru URLs', }), + // service settings + DEFAULT_REALDEBRID_API_KEY: str({ + default: undefined, + desc: 'Default RealDebrid API key', + }), + DEFAULT_ALLDEBRID_API_KEY: str({ + default: undefined, + desc: 'Default AllDebrid API key', + }), + DEFAULT_PREMIUMIZE_API_KEY: str({ + default: undefined, + desc: 'Default Premiumize API key', + }), + DEFAULT_DEBRIDLINK_API_KEY: str({ + default: undefined, + desc: 'Default DebridLink API key', + }), + DEFAULT_TORBOX_API_KEY: str({ + default: undefined, + desc: 'Default Torbox API key', + }), + DEFAULT_OFFCLOUD_API_KEY: str({ + default: undefined, + desc: 'Default OffCloud API key', + }), + DEFAULT_OFFCLOUD_EMAIL: str({ + default: undefined, + desc: 'Default OffCloud email', + }), + DEFAULT_OFFCLOUD_PASSWORD: str({ + default: undefined, + desc: 'Default OffCloud password', + }), + DEFAULT_PUTIO_CLIENT_ID: str({ + default: undefined, + desc: 'Default Putio client id', + }), + DEFAULT_PUTIO_CLIENT_SECRET: str({ + default: undefined, + desc: 'Default Putio client secret', + }), + DEFAULT_EASYNEWS_USERNAME: str({ + default: undefined, + desc: 'Default EasyNews username', + }), + DEFAULT_EASYNEWS_PASSWORD: str({ + default: undefined, + desc: 'Default EasyNews password', + }), + DEFAULT_EASYDEBRID_API_KEY: str({ + default: undefined, + desc: 'Default EasyDebrid API key', + }), + DEFAULT_PIKPAK_EMAIL: str({ + default: undefined, + desc: 'Default PikPak email', + }), + DEFAULT_PIKPAK_PASSWORD: str({ + default: undefined, + desc: 'Default PikPak password', + }), + DEFAULT_SEEDR_ENCODED_TOKEN: str({ + default: undefined, + desc: 'Default Seedr encoded token', + }), + + // forced services + FORCED_REALDEBRID_API_KEY: str({ + default: undefined, + desc: 'Forced RealDebrid API key', + }), + FORCED_ALLDEBRID_API_KEY: str({ + default: undefined, + desc: 'Forced AllDebrid API key', + }), + FORCED_PREMIUMIZE_API_KEY: str({ + default: undefined, + desc: 'Forced Premiumize API key', + }), + FORCED_DEBRIDLINK_API_KEY: str({ + default: undefined, + desc: 'Forced DebridLink API key', + }), + FORCED_TORBOX_API_KEY: str({ + default: undefined, + desc: 'Forced Torbox API key', + }), + FORCED_OFFCLOUD_API_KEY: str({ + default: undefined, + desc: 'Forced OffCloud API key', + }), + FORCED_OFFCLOUD_EMAIL: str({ + default: undefined, + desc: 'Forced OffCloud email', + }), + FORCED_OFFCLOUD_PASSWORD: str({ + default: undefined, + desc: 'Forced OffCloud password', + }), + FORCED_PUTIO_CLIENT_ID: str({ + default: undefined, + desc: 'Forced Putio client id', + }), + FORCED_PUTIO_CLIENT_SECRET: str({ + default: undefined, + desc: 'Forced Putio client secret', + }), + FORCED_EASYNEWS_USERNAME: str({ + default: undefined, + desc: 'Forced EasyNews username', + }), + FORCED_EASYNEWS_PASSWORD: str({ + default: undefined, + desc: 'Forced EasyNews password', + }), + FORCED_EASYDEBRID_API_KEY: str({ + default: undefined, + desc: 'Forced EasyDebrid API key', + }), + FORCED_PIKPAK_EMAIL: str({ + default: undefined, + desc: 'Forced PikPak email', + }), + FORCED_PIKPAK_PASSWORD: str({ + default: undefined, + desc: 'Forced PikPak password', + }), + FORCED_SEEDR_ENCODED_TOKEN: str({ + default: undefined, + desc: 'Forced Seedr encoded token', + }), + COMET_URL: url({ default: 'https://comet.elfhosted.com/', desc: 'Comet URL', }), - COMET_INDEXERS: json({ + DEFAULT_COMET_INDEXERS: json({ default: ['dmm_public_hash_shares_only'], desc: 'Comet indexers', }), diff --git a/packages/core/src/utils/http.ts b/packages/core/src/utils/http.ts index 1e88845a..a2e0020a 100644 --- a/packages/core/src/utils/http.ts +++ b/packages/core/src/utils/http.ts @@ -9,9 +9,8 @@ export function makeUrlLogSafe(url: string) { // for each component of the path, if it is longer than 10 characters, mask it return url .split('/') - .filter((component) => !component.includes('.')) .map((component) => { - if (component.length > 10) { + if (component.length > 10 && !component.includes('.')) { return maskSensitiveInfo(component); } return component; diff --git a/packages/frontend/src/components/menu/services.tsx b/packages/frontend/src/components/menu/services.tsx index 8be040fa..3162e13f 100644 --- a/packages/frontend/src/components/menu/services.tsx +++ b/packages/frontend/src/components/menu/services.tsx @@ -2,7 +2,7 @@ import { useStatus } from '@/context/status'; import { PageWrapper } from '../shared/page-wrapper'; import { - SERVICE_DETAILS, + // SERVICE_DETAILS, ServiceId, } from '../../../../core/src/utils/constants'; import { useUserData } from '@/context/userData'; @@ -49,13 +49,13 @@ export function ServicesMenu() { // function Content() { - const status = useStatus(); + const { status } = useStatus(); + if (!status) return null; const { setUserData, userData } = useUserData(); const [modalOpen, setModalOpen] = useState(false); const [modalService, setModalService] = useState(null); const [modalValues, setModalValues] = useState>({}); - const [invalidServices, setInvalidServices] = useState([]); const [isDragging, setIsDragging] = useState(false); // DND logic @@ -97,11 +97,6 @@ function Content() { const newUserData = { ...prev }; newUserData.services = (newUserData.services ?? []).map((service) => { if (service.id === modalService) { - if (invalidServices.includes(SERVICE_DETAILS[service.id].name)) { - setInvalidServices((prev) => - prev.filter((a) => a !== SERVICE_DETAILS[service.id].name) - ); - } return { ...service, enabled: true, @@ -116,28 +111,79 @@ function Content() { }; useEffect(() => { - const allServiceIds = Object.keys(SERVICE_DETAILS); + const allServiceIds: ServiceId[] = Object.keys( + status.settings.services + ) as ServiceId[]; const currentServices = userData.services ?? []; - // Remove any services not in SERVICE_DETAILS - let filtered = currentServices.filter((s: { id: string }) => - allServiceIds.includes(s.id) - ); + + // Remove any services not in SERVICE_DETAILS and apply forced/default credentials + let filtered = currentServices + .filter((s) => allServiceIds.includes(s.id)) + .map((service) => { + const svcMeta = status.settings.services[service.id]!; + const updatedCredentials = { ...service.credentials }; + let hasChanges = false; + + svcMeta.credentials.forEach((cred) => { + // Always apply forced credentials, regardless of existing value + if (cred.forced) { + if (updatedCredentials[cred.id] !== cred.forced) { + updatedCredentials[cred.id] = cred.forced; + hasChanges = true; + } + } + // Only apply defaults for missing credentials + else if (!service.credentials?.[cred.id] && cred.default) { + updatedCredentials[cred.id] = cred.default; + hasChanges = true; + } + }); + + // Only create a new object if there were changes + return hasChanges + ? { + ...service, + credentials: updatedCredentials, + } + : service; + }); + // Add any missing services from SERVICE_DETAILS const missing = allServiceIds.filter( - (id) => !filtered.some((s: { id: string }) => s.id === id) + (id) => !filtered.some((s) => s.id === id) ); + if (missing.length > 0 || filtered.length !== currentServices.length) { - const toAdd = missing.map((id) => ({ - id, - enabled: false, - credentials: {}, - })); + const toAdd = missing.map((id) => { + const svcMeta = status.settings.services[id]!; + const credentials: Record = {}; + let enabled = false; + + // Apply forced/default credentials for new services + svcMeta.credentials.forEach((cred) => { + if (cred.forced) { + credentials[cred.id] = cred.forced; + // enable the service if it has forced credentials + enabled = true; + } else if (cred.default) { + credentials[cred.id] = cred.default; + enabled = true; + } + }); + + return { + id, + enabled, + credentials, + }; + }); + setUserData((prev: any) => ({ ...prev, services: [...filtered, ...toAdd], })); } - }, [userData.services, setUserData]); + }, [status.settings.services]); const sensors = useSensors( useSensor(PointerSensor), @@ -167,6 +213,19 @@ function Content() { }; }, [isDragging]); + const invalidServices = + userData.services + ?.filter((service) => { + const svcMeta = status.settings.services[service.id]; + if (!svcMeta) return false; + // Check if any required credential is missing + return ( + service.enabled && + svcMeta.credentials.some((cred) => !service.credentials?.[cred.id]) + ); + }) + .map((service) => status.settings.services[service.id]?.name) ?? []; + // Render return ( <> @@ -179,7 +238,7 @@ function Content() {
- {invalidServices.length > 0 && ( + {invalidServices && invalidServices.length > 0 && (
) : ( userData.services?.map((service, idx) => { - const svcMeta = SERVICE_DETAILS[service.id]; + const svcMeta = status.settings.services[service.id]!; return ( handleServiceClick(service.id)} onToggleEnabled={(v: boolean) => { setUserData((prev) => { - const existingService = prev.services?.find( - (s) => s.id === service.id - ); - if (!existingService) return prev; - const missingCredentials = - svcMeta.credentials.filter( - (cred) => - !existingService.credentials?.[cred.id] - ); - const invalidService = svcMeta.name; - if (missingCredentials.length > 0) { - if (v) { - if (!invalidServices.includes(invalidService)) { - setInvalidServices((prev) => [ - ...prev, - invalidService, - ]); - } - } else { - setInvalidServices((prev) => - prev.filter((a) => a !== invalidService) - ); - } - } else { - if (invalidServices.includes(invalidService)) { - setInvalidServices((prev) => - prev.filter((a) => a !== invalidService) - ); - } - } return { ...prev, services: (prev.services ?? []).map((s) => @@ -318,6 +347,9 @@ function SortableServiceItem({ transition, opacity: isDragging ? 0.5 : 1, }; + const disableEdit = meta.credentials.every((cred: any) => { + return cred.forced; + }); return (
  • @@ -335,11 +367,16 @@ function SortableServiceItem({
    - + } intent="primary-outline" onClick={onEdit} + disabled={disableEdit} />
  • @@ -364,8 +401,10 @@ function ServiceModal({ onSubmit: (v: Record) => void; onClose: () => void; }) { + const { status } = useStatus(); + if (!status) return null; if (!serviceId) return null; - const meta = SERVICE_DETAILS[serviceId]; + const meta = status.settings.services[serviceId]!; const credentials = meta.credentials || []; return ( onChange({ ...values, [opt.id]: v })} /> ))} diff --git a/packages/frontend/src/components/shared/template-option.tsx b/packages/frontend/src/components/shared/template-option.tsx index a8d2d831..c3972a6d 100644 --- a/packages/frontend/src/components/shared/template-option.tsx +++ b/packages/frontend/src/components/shared/template-option.tsx @@ -36,12 +36,34 @@ const TemplateOption: React.FC = ({ required, options, constraints, + forced, + default: defaultValue, emptyIsUndefined = false, } = option; - const isDisabled = disabled; + const isDisabled = disabled || !!forced; switch (type) { + case 'password': + return ( +
    + + onChange(emptyIsUndefined ? value || undefined : value) + } + required={required} + disabled={isDisabled} + /> + {description && ( +
    + {description} +
    + )} +
    + ); case 'string': return (
    diff --git a/packages/server/src/middlewares/errors.ts b/packages/server/src/middlewares/errors.ts index c90f087e..99e82ef1 100644 --- a/packages/server/src/middlewares/errors.ts +++ b/packages/server/src/middlewares/errors.ts @@ -24,18 +24,34 @@ export const errorMiddleware = ( error = err; } - res.status(error.statusCode).json( - createResponse( - { - success: false, - error: { - code: error.code, - message: error.message, - }, - }, - req.path, - true - ) + let stremioResponse = false; + const match = req.originalUrl.match( + /\/stremio(?:\/[^\/]+){0,2}\/(stream|catalog|subtitles|meta)/ ); + if (match) { + stremioResponse = true; + } + + res + .status( + stremioResponse + ? req.userData?.hideErrors + ? error.statusCode + : 200 + : error.statusCode + ) + .json( + createResponse( + { + success: false, + error: { + code: error.code, + message: error.message, + }, + }, + req.path, + true + ) + ); return; }; diff --git a/packages/server/src/middlewares/userData.ts b/packages/server/src/middlewares/userData.ts index 0908120f..58ec648d 100644 --- a/packages/server/src/middlewares/userData.ts +++ b/packages/server/src/middlewares/userData.ts @@ -7,7 +7,7 @@ import { decryptString, validateConfig, } from '@aiostreams/core'; -import { UserDataSchema, UserRepository } from '@aiostreams/core'; +import { UserDataSchema, UserRepository, UserData } from '@aiostreams/core'; const logger = createLogger('server'); @@ -74,19 +74,17 @@ export const userDataMiddleware = async ( next(new APIError(constants.ErrorCode.USER_INVALID_PASSWORD)); return; } - try { - validateConfig(decryptedConfig, true); - } catch (error) { - next(new APIError(constants.ErrorCode.USER_INVALID_CONFIG)); - return; - } - // Attach validated data to request req.userData = decryptedConfig; req.userData.ip = req.userIp; req.uuid = uuid; next(); - } catch (error) { - next(new APIError(constants.ErrorCode.USER_ERROR)); + } catch (error: any) { + logger.error(error.message); + if (error instanceof APIError) { + next(error); + } else { + next(new APIError(constants.ErrorCode.USER_ERROR)); + } } }; diff --git a/packages/server/src/routes/api/status.ts b/packages/server/src/routes/api/status.ts index bc1c960f..228fd26a 100644 --- a/packages/server/src/routes/api/status.ts +++ b/packages/server/src/routes/api/status.ts @@ -1,9 +1,12 @@ import { Router, Request, Response, NextFunction } from 'express'; -import { Env, PresetManager, UserRepository } from '@aiostreams/core'; -// import { PresetMetadata } from '@aiostreams/core/src/presets'; -import { PresetMetadata, StatusResponse } from '@aiostreams/core'; -import { APIError } from '@aiostreams/core/'; -import { constants, encryptString } from '@aiostreams/core'; +import { + Env, + getEnvironmentServiceDetails, + PresetManager, + UserRepository, +} from '@aiostreams/core'; +import { StatusResponse } from '@aiostreams/core'; +import { encryptString } from '@aiostreams/core'; const router = Router(); @@ -56,6 +59,7 @@ router.get('/', async (req: Request, res: Response) => { excludedRegex: Env.DEFAULT_EXCLUDED_REGEX_PATTERNS ?? null, }, presets: PresetManager.getPresetList(), + services: getEnvironmentServiceDetails(), }, }; res.status(200).json({ diff --git a/packages/server/src/routes/stremio/addonCatalog.ts b/packages/server/src/routes/stremio/addonCatalog.ts index d016dc43..231acfd1 100644 --- a/packages/server/src/routes/stremio/addonCatalog.ts +++ b/packages/server/src/routes/stremio/addonCatalog.ts @@ -6,7 +6,7 @@ import { createResponse } from '../../utils/responses'; const logger = createLogger('stremio/addonCatalog'); const router = Router(); -router.get('/:type/:id.json', async (req, res) => { +router.get('/:type/:id.json', async (req, res, next) => { if (!req.userData) { res.status(200).json( createResponse( @@ -65,20 +65,7 @@ router.get('/:type/:id.json', async (req, res) => { ) ); } catch (error) { - logger.error('Error processing addon catalog request', { error }); - res.status(200).json( - createResponse( - { - success: false, - error: { - code: constants.ErrorCode.INTERNAL_SERVER_ERROR, - message: error instanceof Error ? error.message : String(error), - }, - }, - req.originalUrl, - true - ) - ); + next(error); } }); diff --git a/packages/server/src/routes/stremio/catalog.ts b/packages/server/src/routes/stremio/catalog.ts index 31cc41c6..3b268bfa 100644 --- a/packages/server/src/routes/stremio/catalog.ts +++ b/packages/server/src/routes/stremio/catalog.ts @@ -7,7 +7,7 @@ const router = Router(); router.use(stremioCatalogRateLimiter); -router.get('/:type/:id/:extras?.json', async (req, res) => { +router.get('/:type/:id/:extras?.json', async (req, res, next) => { if (!req.userData) { res.status(200).json( createResponse( @@ -61,19 +61,7 @@ router.get('/:type/:id/:extras?.json', async (req, res) => { ) ); } catch (error) { - res.status(200).json( - createResponse( - { - success: false, - error: { - code: constants.ErrorCode.INTERNAL_SERVER_ERROR, - message: error instanceof Error ? error.message : String(error), - }, - }, - req.originalUrl, - true - ) - ); + next(error); } }); diff --git a/packages/server/src/routes/stremio/manifest.ts b/packages/server/src/routes/stremio/manifest.ts index 7aec051e..b0de2bca 100644 --- a/packages/server/src/routes/stremio/manifest.ts +++ b/packages/server/src/routes/stremio/manifest.ts @@ -17,14 +17,13 @@ export default router; const manifest = async (config?: UserData): Promise => { let addonId = Env.ADDON_ID; - if (config && Env.DETERMINISTIC_ADDON_ID) { + if (config) { addonId = addonId += `.${config.uuid?.substring(0, 12)}`; } let catalogs: Manifest['catalogs'] = []; let resources: Manifest['resources'] = []; if (config) { const aiostreams = new AIOStreams(config); - // wait till initialized await aiostreams.initialise(); @@ -65,7 +64,6 @@ router.get('/', async (req, res, next) => { res.status(200).json(await manifest(req.userData)); } catch (error) { logger.error(`Failed to generate manifest: ${error}`); - logger.verbose(JSON.stringify(req.userData, null, 2)); next(new APIError(constants.ErrorCode.INTERNAL_SERVER_ERROR)); } }); diff --git a/packages/server/src/routes/stremio/meta.ts b/packages/server/src/routes/stremio/meta.ts index 776ea8b6..8f251f74 100644 --- a/packages/server/src/routes/stremio/meta.ts +++ b/packages/server/src/routes/stremio/meta.ts @@ -6,7 +6,7 @@ import { createResponse } from '../../utils/responses'; const logger = createLogger('stremio/meta'); const router = Router(); -router.get('/:type/:id.json', async (req, res) => { +router.get('/:type/:id.json', async (req, res, next) => { if (!req.userData) { res.status(200).json( createResponse( @@ -61,20 +61,7 @@ router.get('/:type/:id.json', async (req, res) => { ) ); } catch (error) { - logger.error('Error processing meta request', { error }); - res.status(200).json( - createResponse( - { - success: false, - error: { - code: constants.ErrorCode.INTERNAL_SERVER_ERROR, - message: error instanceof Error ? error.message : String(error), - }, - }, - req.originalUrl, - true - ) - ); + next(error); } }); diff --git a/packages/server/src/routes/stremio/stream.ts b/packages/server/src/routes/stremio/stream.ts index 3573308a..62fbd1c9 100644 --- a/packages/server/src/routes/stremio/stream.ts +++ b/packages/server/src/routes/stremio/stream.ts @@ -55,20 +55,7 @@ router.get('/:type/:id.json', async (req, res, next) => { ) ); } catch (error) { - logger.error(error); - res.status(200).json( - createResponse( - { - success: false, - error: { - code: constants.ErrorCode.INTERNAL_SERVER_ERROR, - message: `An error occurred while fetching streams: ${error instanceof Error ? error.message : String(error)}`, - }, - }, - req.originalUrl, - true - ) - ); + next(error); } }); diff --git a/packages/server/src/routes/stremio/subtitle.ts b/packages/server/src/routes/stremio/subtitle.ts index 1004196e..2e83ad8a 100644 --- a/packages/server/src/routes/stremio/subtitle.ts +++ b/packages/server/src/routes/stremio/subtitle.ts @@ -6,7 +6,7 @@ import { createResponse } from '../../utils/responses'; const logger = createLogger('stremio/subtitle'); const router = Router(); -router.get('/:type/:id.json', async (req, res) => { +router.get('/:type/:id.json', async (req, res, next) => { if (!req.userData) { res.status(200).json( createResponse( @@ -59,20 +59,7 @@ router.get('/:type/:id.json', async (req, res) => { ) ); } catch (error) { - logger.error('Error processing subtitle request', { error }); - res.status(200).json( - createResponse( - { - success: false, - error: { - code: constants.ErrorCode.INTERNAL_SERVER_ERROR, - message: error instanceof Error ? error.message : String(error), - }, - }, - req.originalUrl, - true - ) - ); + next(error); } });