mirror of
https://github.com/Viren070/AIOStreams.git
synced 2025-12-01 23:14:04 +01:00
feat: add built-in proxy
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { ParsedId } from '../../utils/id-parser.js';
|
||||
import { constants, createLogger } from '../../utils/index.js';
|
||||
import { constants, createLogger, Env } from '../../utils/index.js';
|
||||
import { Torrent, NZB } from '../../debrid/index.js';
|
||||
import { SearchMetadata } from '../base/debrid.js';
|
||||
import { createHash } from 'crypto';
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
NabAddonConfigSchema,
|
||||
NabAddonConfig,
|
||||
} from '../base/nab/addon.js';
|
||||
import { BuiltinProxy, createProxy } from '../../proxy/index.js';
|
||||
|
||||
const logger = createLogger('newznab');
|
||||
|
||||
@@ -19,15 +20,20 @@ class NewznabApi extends BaseNabApi<'newznab'> {
|
||||
}
|
||||
}
|
||||
|
||||
export const NewznabAddonConfigSchema = NabAddonConfigSchema.extend({
|
||||
proxyAuth: z.string(),
|
||||
});
|
||||
export type NewznabAddonConfig = z.infer<typeof NewznabAddonConfigSchema>;
|
||||
|
||||
// Addon class
|
||||
export class NewznabAddon extends BaseNabAddon<NabAddonConfig, NewznabApi> {
|
||||
export class NewznabAddon extends BaseNabAddon<NewznabAddonConfig, NewznabApi> {
|
||||
readonly name = 'Newznab';
|
||||
readonly version = '1.0.0';
|
||||
readonly id = 'newznab';
|
||||
readonly logger = logger;
|
||||
readonly api: NewznabApi;
|
||||
constructor(userData: NabAddonConfig, clientIp?: string) {
|
||||
super(userData, NabAddonConfigSchema, clientIp);
|
||||
constructor(userData: NewznabAddonConfig, clientIp?: string) {
|
||||
super(userData, NewznabAddonConfigSchema, clientIp);
|
||||
if (
|
||||
!userData.services.find((s) => s.id === constants.TORBOX_SERVICE) ||
|
||||
userData.services.length > 1
|
||||
@@ -75,6 +81,32 @@ export class NewznabAddon extends BaseNabAddon<NabAddonConfig, NewznabApi> {
|
||||
type: 'usenet',
|
||||
});
|
||||
}
|
||||
|
||||
if (this.userData.proxyAuth) {
|
||||
try {
|
||||
BuiltinProxy.validateAuth(this.userData.proxyAuth);
|
||||
} catch (error) {
|
||||
throw new Error('Invalid AIOStreams Proxy Auth Credentials');
|
||||
}
|
||||
const proxy = createProxy({
|
||||
id: constants.BUILTIN_SERVICE,
|
||||
url: Env.BASE_URL,
|
||||
credentials: this.userData.proxyAuth,
|
||||
});
|
||||
const urlsToProxy = nzbs.map((nzb) => nzb.nzb);
|
||||
const proxiedUrls = await proxy.generateUrls(
|
||||
urlsToProxy.map((url) => ({
|
||||
url,
|
||||
filename: url.split('/').pop(),
|
||||
}))
|
||||
);
|
||||
if (!proxiedUrls) {
|
||||
throw new Error('Failed to proxy NZBs');
|
||||
}
|
||||
for (let i = 0; i < nzbs.length; i++) {
|
||||
nzbs[i].nzb = proxiedUrls[i];
|
||||
}
|
||||
}
|
||||
return nzbs;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from './parser/index.js';
|
||||
export * from './formatters/index.js';
|
||||
export * from './transformers/index.js';
|
||||
export * from './debrid/index.js';
|
||||
export * from './proxy/index.js';
|
||||
export {
|
||||
TorBoxSearchAddon,
|
||||
GDriveAddon,
|
||||
|
||||
@@ -38,6 +38,14 @@ export class NewznabPreset extends BuiltinAddonPreset {
|
||||
required: false,
|
||||
default: '/api',
|
||||
},
|
||||
{
|
||||
id: 'proxyAuth',
|
||||
name: 'AIOStreams Proxy Auth',
|
||||
description:
|
||||
'If you want to proxy the NZBs through AIOStreams, provide a username:password pair from the `BUILTIN_PROXY_AUTH` environment variable.',
|
||||
type: 'password',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'timeout',
|
||||
name: 'Timeout',
|
||||
@@ -150,6 +158,7 @@ export class NewznabPreset extends BuiltinAddonPreset {
|
||||
url: options.newznabUrl,
|
||||
apiPath: options.apiPath,
|
||||
apiKey: options.apiKey,
|
||||
proxyAuth: options.proxyAuth,
|
||||
forceQuerySearch: options.forceQuerySearch ?? false,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { StreamProxyConfig } from '../db/schemas.js';
|
||||
import { Cache, createLogger, maskSensitiveInfo, Env } from '../utils/index.js';
|
||||
import {
|
||||
Cache,
|
||||
createLogger,
|
||||
maskSensitiveInfo,
|
||||
Env,
|
||||
constants,
|
||||
} from '../utils/index.js';
|
||||
|
||||
const logger = createLogger('proxy');
|
||||
const cache = Cache.getInstance<string, string>('publicIp');
|
||||
@@ -14,7 +20,7 @@ export interface ProxyStream {
|
||||
}
|
||||
|
||||
type ValidatedStreamProxyConfig = StreamProxyConfig & {
|
||||
id: 'mediaflow' | 'stremthru';
|
||||
id: 'mediaflow' | 'stremthru' | 'builtin';
|
||||
url: string;
|
||||
credentials: string;
|
||||
};
|
||||
@@ -25,6 +31,9 @@ export abstract class BaseProxy {
|
||||
/^(10\.|127\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/;
|
||||
|
||||
constructor(config: StreamProxyConfig) {
|
||||
if (config.id === constants.BUILTIN_SERVICE) {
|
||||
config.url = Env.BASE_URL;
|
||||
}
|
||||
if (!config.id || !config.credentials || !config.url) {
|
||||
throw new Error('Proxy configuration is missing');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { BaseProxy, ProxyStream } from './base.js';
|
||||
import {
|
||||
createLogger,
|
||||
maskSensitiveInfo,
|
||||
Env,
|
||||
makeRequest,
|
||||
encryptString,
|
||||
Cache,
|
||||
} from '../utils/index.js';
|
||||
import path from 'path';
|
||||
|
||||
const logger = createLogger('builtin');
|
||||
|
||||
export class BuiltinProxy extends BaseProxy {
|
||||
public static validateAuth(auth: string): {
|
||||
username: string;
|
||||
password: string;
|
||||
admin: boolean;
|
||||
} {
|
||||
const [username, password] = auth.split(':');
|
||||
if (!username || !password) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
if (
|
||||
Env.BUILTIN_PROXY_AUTH?.has(username) &&
|
||||
Env.BUILTIN_PROXY_AUTH?.get(username) !== password
|
||||
) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
return {
|
||||
username,
|
||||
password,
|
||||
admin:
|
||||
Env.BUILTIN_PROXY_ADMINS && Env.BUILTIN_PROXY_ADMINS.length > 0
|
||||
? Env.BUILTIN_PROXY_ADMINS.includes(username)
|
||||
: true,
|
||||
};
|
||||
}
|
||||
|
||||
protected override generateProxyUrl(endpoint: string): URL {
|
||||
return new URL(endpoint);
|
||||
}
|
||||
|
||||
protected override getPublicIpEndpoint(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
protected override getPublicIpFromResponse(data: any): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected override getHeaders(): Record<string, string> {
|
||||
return {};
|
||||
}
|
||||
|
||||
public override async getPublicIp(): Promise<string | null> {
|
||||
BuiltinProxy.validateAuth(this.config.credentials);
|
||||
|
||||
const response = await makeRequest('https://checkip.amazonaws.com', {
|
||||
method: 'GET',
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
protected override async generateStreamUrls(
|
||||
streams: ProxyStream[]
|
||||
): Promise<string[] | null> {
|
||||
const auth = BuiltinProxy.validateAuth(this.config.credentials);
|
||||
return streams.map((stream) => {
|
||||
const encryptedAuth = encryptString(
|
||||
JSON.stringify({
|
||||
username: auth.username,
|
||||
password: auth.password,
|
||||
})
|
||||
);
|
||||
const encryptedData = encryptString(
|
||||
JSON.stringify({
|
||||
url: stream.url,
|
||||
filename: stream.filename,
|
||||
requestHeaders: stream.headers?.request,
|
||||
responseHeaders: stream.headers?.response,
|
||||
})
|
||||
);
|
||||
return `${Env.BASE_URL}/api/v1/proxy/${encryptedAuth.data}.${encryptedData.data}/${encodeURIComponent(stream.filename ?? '')}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class BuiltinProxyStats {
|
||||
private activeConnections = Cache.getInstance<
|
||||
string,
|
||||
{ ip: string; url: string; filename?: string; timestamp: number }[]
|
||||
>('bproxy:stats', 10000, 'sql');
|
||||
|
||||
constructor() {}
|
||||
|
||||
public async getAllActiveConnections(): Promise<
|
||||
Map<
|
||||
string,
|
||||
{ ip: string; url: string; filename?: string; timestamp: number }[]
|
||||
>
|
||||
> {
|
||||
const users = Env.BUILTIN_PROXY_AUTH?.keys();
|
||||
|
||||
// create a map of users and their active connections
|
||||
const connections = new Map<
|
||||
string,
|
||||
{ ip: string; url: string; filename?: string; timestamp: number }[]
|
||||
>();
|
||||
for (const user of users ?? []) {
|
||||
connections.set(user, await this.getActiveConnections(user));
|
||||
}
|
||||
return connections;
|
||||
}
|
||||
|
||||
public async getActiveConnections(
|
||||
user: string
|
||||
): Promise<
|
||||
{ ip: string; url: string; filename?: string; timestamp: number }[]
|
||||
> {
|
||||
return (await this.activeConnections.get(user)) ?? [];
|
||||
}
|
||||
|
||||
public async addActiveConnection(
|
||||
user: string,
|
||||
ip: string,
|
||||
url: string,
|
||||
timestamp: number,
|
||||
filename?: string
|
||||
) {
|
||||
logger.debug(`[${user}] Adding active connection`, {
|
||||
ip,
|
||||
url,
|
||||
filename,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
const existingConnections = (await this.activeConnections.get(user)) ?? [];
|
||||
const connectionKey = `${ip}:${url}`;
|
||||
|
||||
// Filter out any existing connections with the same IP+filename combination
|
||||
const filteredConnections = existingConnections.filter((conn) => {
|
||||
return `${conn.ip}:${conn.url}` !== connectionKey;
|
||||
});
|
||||
|
||||
// Add the new connection (which will be the most recent for this IP+filename)
|
||||
const updatedConnections = [
|
||||
...filteredConnections,
|
||||
{ ip, url, filename, timestamp },
|
||||
];
|
||||
|
||||
await this.activeConnections.set(user, updatedConnections, 1 * 60 * 60);
|
||||
}
|
||||
|
||||
public async removeActiveConnection(user: string, ip: string, url: string) {
|
||||
await this.activeConnections.set(
|
||||
user,
|
||||
((await this.activeConnections.get(user)) ?? []).filter(
|
||||
(connection) => connection.ip !== ip && connection.url !== url
|
||||
),
|
||||
24 * 60 * 60
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './base.js';
|
||||
export * from './builtin.js';
|
||||
export * from './mediaflow.js';
|
||||
export * from './stremthru.js';
|
||||
|
||||
@@ -7,6 +8,7 @@ import { BaseProxy } from './base.js';
|
||||
import { MediaFlowProxy } from './mediaflow.js';
|
||||
import { StremThruProxy } from './stremthru.js';
|
||||
import { StreamProxyConfig } from '../db/schemas.js';
|
||||
import { BuiltinProxy } from './builtin.js';
|
||||
|
||||
export function createProxy(config: StreamProxyConfig): BaseProxy {
|
||||
switch (config.id) {
|
||||
@@ -14,6 +16,8 @@ export function createProxy(config: StreamProxyConfig): BaseProxy {
|
||||
return new MediaFlowProxy(config);
|
||||
case constants.STREMTHRU_SERVICE:
|
||||
return new StremThruProxy(config);
|
||||
case constants.BUILTIN_SERVICE:
|
||||
return new BuiltinProxy(config);
|
||||
default:
|
||||
throw new Error(`Unknown proxy type: ${config.id}`);
|
||||
}
|
||||
|
||||
@@ -902,6 +902,9 @@ async function validateProxy(
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export enum ErrorCode {
|
||||
METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED',
|
||||
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
|
||||
BAD_REQUEST = 'BAD_REQUEST',
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
}
|
||||
|
||||
interface ErrorDetails {
|
||||
@@ -89,6 +90,10 @@ export const ErrorMap: Record<ErrorCode, ErrorDetails> = {
|
||||
statusCode: 400,
|
||||
message: 'Bad request',
|
||||
},
|
||||
[ErrorCode.UNAUTHORIZED]: {
|
||||
statusCode: 401,
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
};
|
||||
|
||||
export class APIError extends Error {
|
||||
@@ -231,8 +236,13 @@ export type BuiltinServiceId = (typeof BUILTIN_SUPPORTED_SERVICES)[number];
|
||||
|
||||
export const MEDIAFLOW_SERVICE = 'mediaflow' as const;
|
||||
export const STREMTHRU_SERVICE = 'stremthru' as const;
|
||||
export const BUILTIN_SERVICE = 'builtin' as const;
|
||||
|
||||
export const PROXY_SERVICES = [MEDIAFLOW_SERVICE, STREMTHRU_SERVICE] as const;
|
||||
export const PROXY_SERVICES = [
|
||||
MEDIAFLOW_SERVICE,
|
||||
STREMTHRU_SERVICE,
|
||||
BUILTIN_SERVICE,
|
||||
] as const;
|
||||
export type ProxyServiceId = (typeof PROXY_SERVICES)[number];
|
||||
|
||||
export const PROXY_SERVICE_DETAILS: Record<
|
||||
@@ -258,7 +268,14 @@ export const PROXY_SERVICE_DETAILS: Record<
|
||||
description:
|
||||
'[StremThru](https://github.com/MunifTanjim/stremthru) is a feature packed companion to Stremio which also offers a HTTP proxy, written in Go.',
|
||||
credentialDescription:
|
||||
'A valid credential for your StremThru instance, defined in the `STREMTHRU_PROXY_AUTH` environment variable.',
|
||||
'A valid username:password pair for your StremThru instance, defined in the `STREMTHRU_PROXY_AUTH` environment variable.',
|
||||
},
|
||||
[BUILTIN_SERVICE]: {
|
||||
id: BUILTIN_SERVICE,
|
||||
name: 'Builtin Proxy',
|
||||
description: 'A proxy service that is built into the core of AIOStreams',
|
||||
credentialDescription:
|
||||
'A valid username:password pair for this AIOStreams instance, defined in the `BUILTIN_PROXY_AUTH` environment variable.',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -200,6 +200,24 @@ const readonly = makeValidator((x) => {
|
||||
return x;
|
||||
});
|
||||
|
||||
const proxyAuth = makeValidator((x) => {
|
||||
if (typeof x !== 'string') {
|
||||
throw new EnvError('Proxy auth must be a string');
|
||||
}
|
||||
// comma separated list of username:password
|
||||
const userMap: Map<string, string> = new Map();
|
||||
x.split(',').forEach((x) => {
|
||||
const [username, password] = x.split(':');
|
||||
if (!username || !password) {
|
||||
throw new EnvError(
|
||||
'Proxy auth must be a comma separated list of username:password pairs'
|
||||
);
|
||||
}
|
||||
userMap.set(username, password);
|
||||
});
|
||||
return userMap;
|
||||
});
|
||||
|
||||
const boolOrList = makeValidator((x) => {
|
||||
if (typeof x !== 'string') {
|
||||
return undefined;
|
||||
@@ -1589,6 +1607,15 @@ export const Env = cleanEnv(process.env, {
|
||||
desc: 'Default AStream user agent',
|
||||
}),
|
||||
|
||||
BUILTIN_PROXY_AUTH: proxyAuth({
|
||||
default: undefined,
|
||||
desc: 'Builtin proxy auth',
|
||||
}),
|
||||
BUILTIN_PROXY_ADMINS: commaSeparated({
|
||||
default: undefined,
|
||||
desc: 'Comma separated list of admin usernames. If not set, all users are admins.',
|
||||
}),
|
||||
|
||||
BUILTIN_STREMTHRU_URL: url({
|
||||
default: 'https://stremthru.13377001.xyz',
|
||||
desc: 'Builtin StremThru URL',
|
||||
|
||||
@@ -138,7 +138,7 @@ export async function makeRequest(url: string, options: RequestOptions) {
|
||||
}
|
||||
|
||||
const proxyAgents = new Map<string, Dispatcher>();
|
||||
function getProxyAgent(proxyUrl: string): Dispatcher | undefined {
|
||||
export function getProxyAgent(proxyUrl: string): Dispatcher | undefined {
|
||||
if (!proxyUrl) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -163,7 +163,7 @@ function getProxyAgent(proxyUrl: string): Dispatcher | undefined {
|
||||
return proxyAgent;
|
||||
}
|
||||
|
||||
function shouldProxy(url: URL): {
|
||||
export function shouldProxy(url: URL): {
|
||||
useProxy: boolean;
|
||||
proxyIndex: number;
|
||||
} {
|
||||
|
||||
@@ -151,7 +151,11 @@ export const getTimeTakenSincePoint = (point: number) => {
|
||||
const duration = timeNow - point;
|
||||
if (duration < 1000) {
|
||||
return `${duration.toFixed(2)}ms`;
|
||||
} else {
|
||||
} else if (duration < 60000) {
|
||||
return `${(duration / 1000).toFixed(2)}s`;
|
||||
} else if (duration < 3600000) {
|
||||
return `${(duration / 60000).toFixed(0)}m`;
|
||||
} else {
|
||||
return `${(duration / 3600000).toFixed(0)}h`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -528,6 +528,34 @@ const logStartupInfo = () => {
|
||||
);
|
||||
});
|
||||
|
||||
logSection('BUILT-IN PROXY', '🔧', () => {
|
||||
if (Env.BUILTIN_PROXY_AUTH) {
|
||||
logKeyValue('Status:', '✅ Configured');
|
||||
const users = Array.from(Env.BUILTIN_PROXY_AUTH.keys());
|
||||
if (users.length === 0) {
|
||||
logKeyValue('Users:', '❌ None');
|
||||
} else {
|
||||
logKeyValue('Users:', '');
|
||||
for (const user of users) {
|
||||
const password = Env.BUILTIN_PROXY_AUTH.get(user);
|
||||
const masked =
|
||||
password && password.length > 0
|
||||
? '*'.repeat(Math.max(4, Math.min(password.length, 12)))
|
||||
: '❌ None';
|
||||
logKeyValue(` → ${user}:`, masked, ' ');
|
||||
}
|
||||
}
|
||||
logKeyValue(
|
||||
'Admins:',
|
||||
Env.BUILTIN_PROXY_ADMINS
|
||||
? `${Env.BUILTIN_PROXY_ADMINS.join(', ')}`
|
||||
: '⚠️ All users'
|
||||
);
|
||||
} else {
|
||||
logKeyValue('Status:', '❌ None');
|
||||
}
|
||||
});
|
||||
|
||||
logSection('BUILT-IN ADDONS', '🔧', () => {
|
||||
// Torznab
|
||||
logKeyValue('*znab:', '');
|
||||
|
||||
@@ -138,55 +138,59 @@ function Content() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<PasswordInput
|
||||
label="URL"
|
||||
value={userData.proxy?.url ?? ''}
|
||||
onValueChange={(v) => {
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
proxy: { ...prev.proxy, url: v },
|
||||
}));
|
||||
}}
|
||||
placeholder="Enter proxy URL"
|
||||
disabled={isUrlForced || !userData.proxy?.enabled}
|
||||
/>
|
||||
<p className="text-[--muted] text-sm">
|
||||
The URL of your hosted proxy service.
|
||||
</p>
|
||||
</div>
|
||||
{userData.proxy?.id !== 'builtin' && (
|
||||
<div className="space-y-2">
|
||||
<PasswordInput
|
||||
label="URL"
|
||||
value={userData.proxy?.url ?? ''}
|
||||
onValueChange={(v) => {
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
proxy: { ...prev.proxy, url: v },
|
||||
}));
|
||||
}}
|
||||
placeholder="Enter proxy URL"
|
||||
disabled={isUrlForced || !userData.proxy?.enabled}
|
||||
/>
|
||||
<p className="text-[--muted] text-sm">
|
||||
The URL of your hosted proxy service.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<PasswordInput
|
||||
label="Public URL (optional)"
|
||||
value={userData.proxy?.publicUrl ?? ''}
|
||||
onValueChange={(v) => {
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
proxy: { ...prev.proxy, publicUrl: v },
|
||||
}));
|
||||
}}
|
||||
placeholder="Enter proxy public URL"
|
||||
disabled={isPublicUrlForced || !userData.proxy?.enabled}
|
||||
/>
|
||||
<p className="text-[--muted] text-sm">
|
||||
The public URL of your hosted proxy service. Provide this only if
|
||||
you want to use a local URL for requests but a publicly accessible
|
||||
URL is needed for streams. e.g. setting http://
|
||||
{userData.proxy?.id
|
||||
? userData.proxy.id === 'stremthru'
|
||||
? 'stremthru:8080'
|
||||
: 'mediaflow-proxy:8888'
|
||||
: 'mediaflow-proxy:8888'}
|
||||
as the URL above but then using https://
|
||||
{userData.proxy?.id
|
||||
? userData.proxy.id === 'stremthru'
|
||||
? 'stremthru.yourdomain.com'
|
||||
: 'mediaflow-proxy.yourdomain.com'
|
||||
: 'mediaflow-proxy.yourdomain.com'}
|
||||
as the public URL.
|
||||
</p>
|
||||
</div>
|
||||
{userData.proxy?.id !== 'builtin' && (
|
||||
<div className="space-y-2">
|
||||
<PasswordInput
|
||||
label="Public URL (optional)"
|
||||
value={userData.proxy?.publicUrl ?? ''}
|
||||
onValueChange={(v) => {
|
||||
setUserData((prev) => ({
|
||||
...prev,
|
||||
proxy: { ...prev.proxy, publicUrl: v },
|
||||
}));
|
||||
}}
|
||||
placeholder="Enter proxy public URL"
|
||||
disabled={isPublicUrlForced || !userData.proxy?.enabled}
|
||||
/>
|
||||
<p className="text-[--muted] text-sm">
|
||||
The public URL of your hosted proxy service. Provide this only
|
||||
if you want to use a local URL for requests but a publicly
|
||||
accessible URL is needed for streams. e.g. setting http://
|
||||
{userData.proxy?.id
|
||||
? userData.proxy.id === 'stremthru'
|
||||
? 'stremthru:8080'
|
||||
: 'mediaflow-proxy:8888'
|
||||
: 'mediaflow-proxy:8888'}
|
||||
as the URL above but then using https://
|
||||
{userData.proxy?.id
|
||||
? userData.proxy.id === 'stremthru'
|
||||
? 'stremthru.yourdomain.com'
|
||||
: 'mediaflow-proxy.yourdomain.com'
|
||||
: 'mediaflow-proxy.yourdomain.com'}
|
||||
as the public URL.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<PasswordInput
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^8.0.1",
|
||||
"rate-limit-redis": "^4.2.2",
|
||||
"undici": "^7.13.0",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
debridApi,
|
||||
searchApi,
|
||||
animeApi,
|
||||
proxyApi,
|
||||
} from './routes/api/index.js';
|
||||
import {
|
||||
configure,
|
||||
@@ -94,6 +95,7 @@ if (Env.ENABLE_SEARCH_API) {
|
||||
apiRouter.use('/search', searchApi);
|
||||
}
|
||||
apiRouter.use('/anime', animeApi);
|
||||
apiRouter.use('/proxy', proxyApi);
|
||||
app.use(`/api/v${constants.API_VERSION}`, apiRouter);
|
||||
|
||||
// Stremio Routes
|
||||
|
||||
@@ -8,3 +8,4 @@ export { default as gdriveApi } from './gdrive.js';
|
||||
export { default as debridApi } from './debrid.js';
|
||||
export { default as searchApi } from './search.js';
|
||||
export { default as animeApi } from './anime.js';
|
||||
export { default as proxyApi } from './proxy.js';
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import {
|
||||
APIError,
|
||||
constants,
|
||||
createLogger,
|
||||
decryptString,
|
||||
Env,
|
||||
getProxyAgent,
|
||||
getTimeTakenSincePoint,
|
||||
shouldProxy,
|
||||
} from '@aiostreams/core';
|
||||
import { z } from 'zod';
|
||||
import { request, Dispatcher } from 'undici';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { createProxy, BuiltinProxyStats, BuiltinProxy } from '@aiostreams/core';
|
||||
|
||||
const logger = createLogger('server');
|
||||
const router: Router = Router();
|
||||
|
||||
// Create a singleton instance of BuiltinProxyStats
|
||||
const proxyStats = new BuiltinProxyStats();
|
||||
|
||||
export default router;
|
||||
|
||||
const ProxyAuthSchema = z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
const ProxyDataSchema = z.object({
|
||||
url: z.url(),
|
||||
// These are optional, as we'll be forwarding client headers
|
||||
requestHeaders: z.record(z.string(), z.string()).optional(),
|
||||
responseHeaders: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
// GET /stats endpoint to display proxy statistics
|
||||
router.get(
|
||||
'/stats',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
// only show stats to admin users
|
||||
try {
|
||||
const { auth: authQuery } = z
|
||||
.object({ auth: z.string() })
|
||||
.parse(req.query);
|
||||
const auth = BuiltinProxy.validateAuth(authQuery);
|
||||
if (!auth.admin) {
|
||||
throw new APIError(
|
||||
constants.ErrorCode.UNAUTHORIZED,
|
||||
undefined,
|
||||
'Invalid auth'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof APIError) {
|
||||
next(error);
|
||||
} else {
|
||||
next(
|
||||
new APIError(
|
||||
constants.ErrorCode.UNAUTHORIZED,
|
||||
undefined,
|
||||
'Invalid auth'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const allConnections = await proxyStats.getAllActiveConnections();
|
||||
|
||||
// Convert Map to a more JSON-friendly format
|
||||
const stats = {
|
||||
timestamp: new Date().toISOString(),
|
||||
totalUsers: allConnections.size,
|
||||
activeConnections: Object.fromEntries(
|
||||
Array.from(allConnections.entries()).map(([user, connections]) => [
|
||||
user,
|
||||
connections.map((conn) => ({
|
||||
...conn,
|
||||
timestamp: new Date(conn.timestamp).toISOString(),
|
||||
relativeTimestamp: `${getTimeTakenSincePoint(conn.timestamp)} ago`,
|
||||
})),
|
||||
])
|
||||
),
|
||||
summary: {
|
||||
totalActiveConnections: Array.from(allConnections.values()).reduce(
|
||||
(total, connections) => total + connections.length,
|
||||
0
|
||||
),
|
||||
usersWithActiveConnections: Array.from(
|
||||
allConnections.entries()
|
||||
).filter(([_, connections]) => connections.length > 0).length,
|
||||
},
|
||||
};
|
||||
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get proxy stats', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.all(
|
||||
'/:encryptedAuthAndData{/:filename}',
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const startTime = Date.now();
|
||||
const requestId = Math.random().toString(36).substring(7);
|
||||
let upstreamResponse: Dispatcher.ResponseData | undefined;
|
||||
let auth: { username: string; password: string } | undefined;
|
||||
let data: z.infer<typeof ProxyDataSchema> | undefined;
|
||||
let clientIp: string | undefined;
|
||||
|
||||
try {
|
||||
// decrypt and authenticate the request
|
||||
const { encryptedAuthAndData } = req.params;
|
||||
const [encryptedAuth, encryptedData] = encryptedAuthAndData.split('.');
|
||||
const filename = req.params.filename as string | undefined;
|
||||
|
||||
const { data: rawData } = decryptString(encryptedData);
|
||||
const { data: rawAuth } = decryptString(encryptedAuth);
|
||||
|
||||
if (!rawData || !rawAuth) {
|
||||
logger.error(`[${requestId}] Decryption failed`);
|
||||
throw new APIError(
|
||||
constants.ErrorCode.ENCRYPTION_ERROR,
|
||||
undefined,
|
||||
'Could not decrypt data or auth'
|
||||
);
|
||||
}
|
||||
|
||||
data = ProxyDataSchema.parse(JSON.parse(rawData));
|
||||
auth = ProxyAuthSchema.parse(JSON.parse(rawAuth));
|
||||
|
||||
if (
|
||||
!Env.BUILTIN_PROXY_AUTH?.has(auth.username) ||
|
||||
Env.BUILTIN_PROXY_AUTH?.get(auth.username) !== auth.password
|
||||
) {
|
||||
logger.warn(`[${requestId}] Authentication failed`, {
|
||||
username: auth.username,
|
||||
});
|
||||
throw new APIError(
|
||||
constants.ErrorCode.UNAUTHORIZED,
|
||||
undefined,
|
||||
'Invalid auth'
|
||||
);
|
||||
}
|
||||
|
||||
// Track the active connection
|
||||
clientIp = req.ip || req.connection.remoteAddress || 'unknown';
|
||||
const timestamp = Date.now();
|
||||
proxyStats.addActiveConnection(
|
||||
auth.username,
|
||||
clientIp,
|
||||
data.url,
|
||||
timestamp,
|
||||
filename
|
||||
);
|
||||
|
||||
// prepare and execute upstream request
|
||||
const { host, ...clientHeaders } = req.headers;
|
||||
|
||||
const isBodyRequest =
|
||||
req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH';
|
||||
|
||||
const upstreamStartTime = Date.now();
|
||||
const urlObj = new URL(data.url);
|
||||
if (Env.BASE_URL && urlObj.origin === Env.BASE_URL) {
|
||||
const internalUrl = new URL(Env.INTERNAL_URL);
|
||||
urlObj.protocol = internalUrl.protocol;
|
||||
urlObj.host = internalUrl.host;
|
||||
urlObj.port = internalUrl.port;
|
||||
}
|
||||
|
||||
if (Env.REQUEST_URL_MAPPINGS) {
|
||||
for (const [key, value] of Object.entries(Env.REQUEST_URL_MAPPINGS)) {
|
||||
if (urlObj.origin === key) {
|
||||
const mappedUrl = new URL(value);
|
||||
urlObj.protocol = mappedUrl.protocol;
|
||||
urlObj.host = mappedUrl.host;
|
||||
urlObj.port = mappedUrl.port;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const { useProxy, proxyIndex } = shouldProxy(urlObj);
|
||||
const proxyAgent = useProxy
|
||||
? getProxyAgent(Env.ADDON_PROXY![proxyIndex])
|
||||
: undefined;
|
||||
upstreamResponse = await request(data.url, {
|
||||
method: req.method as Dispatcher.HttpMethod,
|
||||
headers: { ...clientHeaders, ...data.requestHeaders },
|
||||
dispatcher: proxyAgent,
|
||||
body: isBodyRequest ? req : undefined,
|
||||
bodyTimeout: 0,
|
||||
headersTimeout: 0,
|
||||
});
|
||||
const upstreamDuration = getTimeTakenSincePoint(upstreamStartTime);
|
||||
|
||||
logger.debug(`[${requestId}] Serving upstream response`, {
|
||||
username: auth.username,
|
||||
targetUrl: data.url,
|
||||
statusCode: upstreamResponse.statusCode,
|
||||
upstreamDuration,
|
||||
});
|
||||
|
||||
// forward upstream response to client
|
||||
res.set(upstreamResponse.headers);
|
||||
if (data.responseHeaders) {
|
||||
res.set(data.responseHeaders);
|
||||
}
|
||||
res.status(upstreamResponse.statusCode);
|
||||
|
||||
if (req.method === 'HEAD') {
|
||||
res.end();
|
||||
} else {
|
||||
await pipeline(upstreamResponse.body, res);
|
||||
}
|
||||
logger.debug(`[${requestId}] Proxy connection closed`, {
|
||||
username: auth.username,
|
||||
});
|
||||
} catch (error) {
|
||||
const totalDuration = Date.now() - startTime;
|
||||
|
||||
// Remove the active connection tracking on error
|
||||
if (auth && clientIp && data) {
|
||||
proxyStats
|
||||
.removeActiveConnection(auth.username, clientIp, data.url)
|
||||
.catch((statsError) =>
|
||||
logger.warn(
|
||||
`[${requestId}] Failed to remove connection from stats on error`,
|
||||
{ error: statsError }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (upstreamResponse) {
|
||||
upstreamResponse.body.destroy();
|
||||
}
|
||||
|
||||
if (
|
||||
(error as NodeJS.ErrnoException)?.code !== 'ERR_STREAM_PREMATURE_CLOSE'
|
||||
) {
|
||||
logger.error(`[${requestId}] Proxy request failed`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
durationMs: totalDuration,
|
||||
upstreamStatusCode: upstreamResponse?.statusCode,
|
||||
});
|
||||
next(error);
|
||||
} else {
|
||||
logger.debug(`[${requestId}] Client disconnected (premature close)`, {
|
||||
durationMs: totalDuration,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
Generated
+3
@@ -295,6 +295,9 @@ importers:
|
||||
rate-limit-redis:
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2(express-rate-limit@8.0.1(express@5.1.0))
|
||||
undici:
|
||||
specifier: ^7.13.0
|
||||
version: 7.13.0
|
||||
zod:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5
|
||||
|
||||
Reference in New Issue
Block a user