🔌 Marzban API Client: Полный клиент для работы с Marzban Panel API
This commit is contained in:
336
lib/marzban-api.ts
Normal file
336
lib/marzban-api.ts
Normal file
@@ -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<string, { data: any; timestamp: number }>;
|
||||
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<MarzbanUser> {
|
||||
// Проверяем кеш
|
||||
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[^>]*>([^<]+)<\/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<MarzbanUsageResponse> {
|
||||
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<string> {
|
||||
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();
|
||||
Reference in New Issue
Block a user