Spaces:
Paused
Paused
| import { z } from 'zod'; | |
| import { config as appConfig } from '../config/index.js'; | |
| import { createLogger } from './index.js'; | |
| import { getSimpleTextHash } from './crypto.js'; | |
| import bytes from 'bytes'; | |
| /** | |
| * In-memory rate limiter for NZB fetches | |
| * Simple implementation that resets on server restart | |
| */ | |
| class NzbRateLimiter { | |
| private userCounts = new Map<string, { count: number; resetAt: number }>(); | |
| private cleanupInterval: ReturnType<typeof setInterval> | null = null; | |
| constructor() { | |
| // Cleanup old entries every 10 minutes | |
| this.cleanupInterval = setInterval(() => this.cleanup(), 600000); | |
| } | |
| /** Window resolved on-demand to avoid reading runtime config at module load. */ | |
| private get windowMs(): number { | |
| return appConfig.rateLimits.easynewsNzb.window * 1000; | |
| } | |
| /** | |
| * Check if a request is allowed under rate limits | |
| */ | |
| check( | |
| userKey: string, | |
| perUserLimit: number | |
| ): { allowed: boolean; reason?: string } { | |
| const now = Date.now(); | |
| // Reset user counter if window expired | |
| let userData = this.userCounts.get(userKey); | |
| if (!userData || userData.resetAt < now) { | |
| userData = { count: 0, resetAt: now + this.windowMs }; | |
| this.userCounts.set(userKey, userData); | |
| } | |
| if (userData.count >= perUserLimit) { | |
| return { allowed: false, reason: 'User NZB rate limit exceeded' }; | |
| } | |
| return { allowed: true }; | |
| } | |
| /** | |
| * Increment counters after successful request | |
| */ | |
| increment(userKey: string): void { | |
| const now = Date.now(); | |
| let userData = this.userCounts.get(userKey); | |
| if (!userData || userData.resetAt < now) { | |
| userData = { count: 1, resetAt: now + this.windowMs }; | |
| } else { | |
| userData.count++; | |
| } | |
| this.userCounts.set(userKey, userData); | |
| } | |
| private cleanup(): void { | |
| const now = Date.now(); | |
| for (const [key, data] of this.userCounts.entries()) { | |
| if (data.resetAt < now) { | |
| this.userCounts.delete(key); | |
| } | |
| } | |
| } | |
| destroy(): void { | |
| if (this.cleanupInterval) { | |
| clearInterval(this.cleanupInterval); | |
| this.cleanupInterval = null; | |
| } | |
| } | |
| } | |
| // Global rate limiter instance | |
| const nzbRateLimiter = new NzbRateLimiter(); | |
| /** | |
| * NZB Proxy Manager | |
| * | |
| * Provides unified rate limiting, URL generation, and validation for NZB proxying. | |
| */ | |
| export class NzbProxyManager { | |
| /** | |
| * Check if the public/generic NZB proxy is enabled | |
| */ | |
| static isPublicProxyEnabled(): boolean { | |
| return appConfig.nzbProxy.publicEnabled === true; | |
| } | |
| /** | |
| * Check if the Easynews NZB proxy is enabled | |
| */ | |
| static isEasynewsProxyEnabled(aiostreamsAuth?: { | |
| username: string; | |
| password: string; | |
| }): boolean { | |
| if ( | |
| aiostreamsAuth && | |
| this.isAuthorised(aiostreamsAuth.username, aiostreamsAuth.password) | |
| ) { | |
| return true; | |
| } | |
| return appConfig.nzbProxy.easynewsEnabled !== false; | |
| } | |
| /** | |
| * Check if a user has admin bypass (via AIOSTREAMS_AUTH) | |
| */ | |
| static isAuthorised(username: string, password: string): boolean { | |
| const authMap = appConfig.bootstrap.auth; | |
| if (!authMap || authMap.size === 0) return false; | |
| return authMap.get(username) === password; | |
| } | |
| /** | |
| * Generate a user key for rate limiting (hashed to avoid storing PII) | |
| */ | |
| static getUserKey(identifier: string): string { | |
| return getSimpleTextHash(identifier); | |
| } | |
| /** | |
| * Check rate limits for NZB fetch | |
| * | |
| * @param userKey - Hashed user identifier | |
| * @param aiostreamsAuth - Optional credentials to check for admin bypass | |
| * @returns Rate limit check result | |
| */ | |
| static checkRateLimit( | |
| userKey: string, | |
| aiostreamsAuth?: { username: string; password: string } | |
| ): { allowed: boolean; reason?: string; authorised?: boolean } { | |
| if ( | |
| aiostreamsAuth && | |
| this.isAuthorised(aiostreamsAuth.username, aiostreamsAuth.password) | |
| ) { | |
| return { allowed: true, authorised: true }; | |
| } | |
| const perUserLimit = appConfig.nzbProxy.rateLimitPerUser; | |
| return nzbRateLimiter.check(userKey, perUserLimit); | |
| } | |
| /** | |
| * Increment rate limit counters after successful fetch | |
| */ | |
| static incrementRateLimit(userKey: string): void { | |
| nzbRateLimiter.increment(userKey); | |
| } | |
| /** | |
| * Get the maximum allowed NZB size | |
| */ | |
| static getMaxSize(): number { | |
| return appConfig.nzbProxy.maxSize; | |
| } | |
| /** | |
| * Check if content size exceeds the limit | |
| */ | |
| static checkSizeLimit( | |
| size: number, | |
| aiostreamsAuth?: { username: string; password: string } | |
| ): { allowed: boolean; reason?: string } { | |
| if ( | |
| aiostreamsAuth && | |
| this.isAuthorised(aiostreamsAuth.username, aiostreamsAuth.password) | |
| ) { | |
| return { allowed: true }; | |
| } | |
| const maxSize = this.getMaxSize(); | |
| if (size > maxSize) { | |
| return { | |
| allowed: false, | |
| reason: `NZB size ${bytes(size)} exceeds limit of ${bytes(maxSize)}`, | |
| }; | |
| } | |
| return { allowed: true }; | |
| } | |
| } | |
| export default NzbProxyManager; | |