From cf19c6e6460598b4339e9bed32e12d901e37af61 Mon Sep 17 00:00:00 2001 From: Umbrix Dev Date: Wed, 4 Feb 2026 05:02:53 +0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=8C=20Marzban=20API=20Client:=20=D0=9F?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D1=8B=20=D1=81=20Marzban=20Panel=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/marzban-api.ts | 336 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 lib/marzban-api.ts diff --git a/lib/marzban-api.ts b/lib/marzban-api.ts new file mode 100644 index 0000000..7537f21 --- /dev/null +++ b/lib/marzban-api.ts @@ -0,0 +1,336 @@ +// 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; + } + + console.warn('API endpoint returned error:', response.status, response.statusText); + } catch (apiError) { + 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) { + 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();