// Marzban API Client для Next.js // Безопасная интеграция без изменений на production сервере export interface MarzbanUser { username: string; status: 'active' | 'limited' | 'expired' | 'disabled'; used_traffic: number; // bytes data_limit: number | null; // bytes, null = unlimited expire: number | null; // Unix timestamp, null = unlimited sub_updated_at: string; // ISO 8601 datetime on_hold_expire_duration: number; on_hold_timeout: string | null; } export interface MarzbanUsage { date: string; // YYYY-MM-DD upload: number; // bytes download: number; // bytes total: number; // bytes } export interface MarzbanUsageResponse { username: string; usages: MarzbanUsage[]; } export interface SubscriptionUserInfo { upload: number; download: number; total: number; expire: number; } export class MarzbanApiClient { private baseUrl: string; private cache: Map; private readonly CACHE_TTL = 60000; // 1 минута constructor(baseUrl: string = 'https://umbrix2.3to3.sbs') { this.baseUrl = baseUrl; this.cache = new Map(); } /** * Get user subscription info from Marzban API * Falls back to HTML scraping if API fails * * @param token - Subscription token (base64 encoded) * @returns User subscription details */ async getUserInfo(token: string): Promise { // Проверяем кеш const cached = this.cache.get(`info_${token}`); if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { return cached.data; } try { // ✅ Try API first const response = await fetch(`${this.baseUrl}/sub/${token}/info`, { headers: { 'Accept': 'application/json', }, }); if (response.ok) { const data = await response.json(); this.cache.set(`info_${token}`, { data, timestamp: Date.now() }); return data; } if (typeof window === 'undefined') console.warn('API endpoint returned error:', response.status, response.statusText); } catch (apiError) { if (typeof window === 'undefined') console.warn('API failed, falling back to HTML scraping:', apiError); } // ❌ Fallback to HTML scraping (старый метод) try { const response = await fetch(`${this.baseUrl}/sub/${token}/`, { headers: { 'Accept': 'text/html', }, }); if (!response.ok) { throw new Error(`HTTP error ${response.status}`); } const html = await response.text(); const data = this.parseHtmlResponse(html); this.cache.set(`info_${token}`, { data, timestamp: Date.now() }); return data; } catch (fallbackError) { if (typeof window === 'undefined') console.error('Both API and HTML scraping failed:', fallbackError); throw new Error('Не удалось загрузить данные подписки'); } } /** * Parse HTML response (fallback method) * @private */ private parseHtmlResponse(html: string): MarzbanUser { // Username const usernameMatch = html.match(/]*>([^<]+)<\/h2>/); const username = usernameMatch ? usernameMatch[1].trim() : 'Unknown'; // Status const statusMatch = html.match(/badge--(\w+)/); let status: 'active' | 'limited' | 'expired' | 'disabled' = 'active'; if (statusMatch) { const statusClass = statusMatch[1]; if (statusClass === 'success' || statusClass === 'active') status = 'active'; else if (statusClass === 'warning' || statusClass === 'limited') status = 'limited'; else if (statusClass === 'error' || statusClass === 'expired') status = 'expired'; else status = 'disabled'; } // Traffic const trafficMatch = html.match(/(\d+(?:\.\d+)?)\s*GB\s*(?:\/|из)\s*(\d+)\s*GB/); const usedTrafficGB = trafficMatch ? parseFloat(trafficMatch[1]) : 0; const totalTrafficGB = trafficMatch ? parseFloat(trafficMatch[2]) : 0; const used_traffic = usedTrafficGB * 1024 * 1024 * 1024; const data_limit = totalTrafficGB > 0 ? totalTrafficGB * 1024 * 1024 * 1024 : null; // Expire date const expireMatch = html.match(/Действительна до[:\s]*([^<]+)/); let expire: number | null = null; if (expireMatch && expireMatch[1].trim() !== '∞') { const dateStr = expireMatch[1].trim(); const dateParts = dateStr.match(/(\d{2})\.(\d{2})\.(\d{2,4})/); if (dateParts) { const day = parseInt(dateParts[1]); const month = parseInt(dateParts[2]) - 1; const year = parseInt(dateParts[3]); const fullYear = year < 100 ? 2000 + year : year; expire = Math.floor(new Date(fullYear, month, day).getTime() / 1000); } } return { username, status, used_traffic, data_limit, expire, sub_updated_at: new Date().toISOString(), on_hold_expire_duration: 0, on_hold_timeout: null, }; } /** * Get user traffic usage history * * @param token - Subscription token * @param start - Start date (YYYY-MM-DD) optional * @param end - End date (YYYY-MM-DD) optional * @returns Usage statistics */ async getUserUsage( token: string, start?: string, end?: string ): Promise { const cacheKey = `usage_${token}_${start || ''}_${end || ''}`; const cached = this.cache.get(cacheKey); if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { return cached.data; } const params = new URLSearchParams(); if (start) params.append('start', start); if (end) params.append('end', end); const url = `${this.baseUrl}/sub/${token}/usage${ params.toString() ? '?' + params.toString() : '' }`; const response = await fetch(url, { headers: { 'Accept': 'application/json', }, }); if (!response.ok) { throw new Error(`Marzban API error: ${response.status} ${response.statusText}`); } const data = await response.json(); this.cache.set(cacheKey, { data, timestamp: Date.now() }); return data; } /** * Get subscription config for specific client type * * @param token - Subscription token * @param clientType - Client type (sing-box, clash-meta, v2ray, etc.) * @returns Config file (YAML/JSON/base64) */ async getSubscriptionConfig( token: string, clientType: 'sing-box' | 'clash-meta' | 'clash' | 'outline' | 'v2ray' | 'v2ray-json' ): Promise { const response = await fetch(`${this.baseUrl}/sub/${token}/${clientType}`, { headers: { 'Accept': clientType.includes('clash') ? 'text/yaml' : 'application/json', }, }); if (!response.ok) { throw new Error(`Marzban API error: ${response.status} ${response.statusText}`); } return response.text(); } /** * Parse subscription-userinfo header from response * * @param headers - Response headers from /sub/{token}/ endpoint * @returns Parsed user info */ parseSubscriptionUserInfo(headers: Headers): SubscriptionUserInfo { const userInfo = headers.get('subscription-userinfo'); if (!userInfo) { throw new Error('subscription-userinfo header not found'); } const parsed: any = {}; userInfo.split(';').forEach((pair) => { const [key, value] = pair.trim().split('='); parsed[key] = parseInt(value, 10); }); return { upload: parsed.upload || 0, download: parsed.download || 0, total: parsed.total || 0, expire: parsed.expire || 0, }; } /** * Format bytes to human-readable string */ formatBytes(bytes: number, decimals: number = 2): string { if (bytes === 0) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } /** * Format Unix timestamp to Russian date string */ formatExpireDate(timestamp: number | null): string { if (timestamp === null || timestamp === 0) { return '∞ Безлимитная'; } const date = new Date(timestamp * 1000); return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', }); } /** * Calculate days until expiration */ getDaysUntilExpire(timestamp: number | null): number | null { if (timestamp === null || timestamp === 0) { return null; // Unlimited } const now = Date.now() / 1000; const diff = timestamp - now; return Math.ceil(diff / (24 * 60 * 60)); } /** * Get status color class based on status and days remaining */ getStatusColor(status: string, daysRemaining: number | null): string { if (status === 'expired') { return 'text-red-500 bg-red-500/10 border-red-500/20'; } if (status === 'limited') { return 'text-yellow-500 bg-yellow-500/10 border-yellow-500/20'; } if (status === 'disabled') { return 'text-slate-400 bg-slate-500/10 border-slate-500/20'; } if (daysRemaining !== null && daysRemaining <= 7) { return 'text-yellow-500 bg-yellow-500/10 border-yellow-500/20'; } return 'text-green-500 bg-green-500/10 border-green-500/20'; } /** * Get status text in Russian */ getStatusText(status: string): string { switch (status) { case 'active': return 'Активна'; case 'limited': return 'Ограничена'; case 'expired': return 'Истекла'; case 'disabled': return 'Отключена'; default: return 'Неизвестно'; } } /** * Clear cache (useful for manual refresh) */ clearCache(): void { this.cache.clear(); } } // Singleton instance export const marzbanApi = new MarzbanApiClient();