Files
app_umbrix/lib/marzban-api.ts

337 lines
9.7 KiB
TypeScript
Raw Normal View History

// 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();