feat: service cred env vars, better validation, handling of encrypted values

This commit is contained in:
Viren070
2025-05-29 15:05:12 +01:00
parent 13a20a7b61
commit 61e21cd803
18 changed files with 700 additions and 297 deletions
+16 -3
View File
@@ -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<typeof ServiceSchema>;
@@ -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),
})
),
}),
});
+21 -6
View File
@@ -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
);
+335 -26
View File
@@ -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<UserData> {
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<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 ?? 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;
}
+17 -28
View File
@@ -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,
+5 -2
View File
@@ -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);
+133 -71
View File
@@ -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',
}),
+1 -2
View File
@@ -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;
@@ -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<ServiceId | null>(null);
const [modalValues, setModalValues] = useState<Record<string, any>>({});
const [invalidServices, setInvalidServices] = useState<string[]>([]);
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<string, any> = {};
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() {
</div>
<div className="flex flex-1"></div>
</div>
{invalidServices.length > 0 && (
{invalidServices && invalidServices.length > 0 && (
<div className="mb-6">
<Alert
intent="alert"
@@ -225,7 +284,7 @@ function Content() {
</li>
) : (
userData.services?.map((service, idx) => {
const svcMeta = SERVICE_DETAILS[service.id];
const svcMeta = status.settings.services[service.id]!;
return (
<SortableServiceItem
key={service.id}
@@ -234,36 +293,6 @@ function Content() {
onEdit={() => 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 (
<li ref={setNodeRef} style={style}>
<div className="px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border flex gap-3 relative">
@@ -335,11 +367,16 @@ function SortableServiceItem({
</span>
</div>
<div className="flex items-center gap-3">
<Switch value={!!service.enabled} onValueChange={onToggleEnabled} />
<Switch
value={!!service.enabled}
onValueChange={onToggleEnabled}
disabled={disableEdit}
/>
<IconButton
icon={<FiSettings />}
intent="primary-outline"
onClick={onEdit}
disabled={disableEdit}
/>
</div>
</div>
@@ -364,8 +401,10 @@ function ServiceModal({
onSubmit: (v: Record<string, any>) => 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 (
<Modal
@@ -384,7 +423,7 @@ function ServiceModal({
<TemplateOption
key={opt.id}
option={opt}
value={values[opt.id]}
value={opt.forced || opt.default || values[opt.id]}
onChange={(v) => onChange({ ...values, [opt.id]: v })}
/>
))}
@@ -36,12 +36,34 @@ const TemplateOption: React.FC<TemplateOptionProps> = ({
required,
options,
constraints,
forced,
default: defaultValue,
emptyIsUndefined = false,
} = option;
const isDisabled = disabled;
const isDisabled = disabled || !!forced;
switch (type) {
case 'password':
return (
<div>
<TextInput
type="password"
label={name}
value={forced || defaultValue || value}
onValueChange={(value: string) =>
onChange(emptyIsUndefined ? value || undefined : value)
}
required={required}
disabled={isDisabled}
/>
{description && (
<div className="text-xs text-[--muted] mt-1">
<MarkdownLite>{description}</MarkdownLite>
</div>
)}
</div>
);
case 'string':
return (
<div>
+28 -12
View File
@@ -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;
};
+8 -10
View File
@@ -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));
}
}
};
+9 -5
View File
@@ -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({
@@ -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);
}
});
+2 -14
View File
@@ -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);
}
});
@@ -17,14 +17,13 @@ export default router;
const manifest = async (config?: UserData): Promise<Manifest> => {
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));
}
});
+2 -15
View File
@@ -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);
}
});
+1 -14
View File
@@ -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);
}
});
+2 -15
View File
@@ -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);
}
});