🔌 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