337 lines
9.7 KiB
TypeScript
337 lines
9.7 KiB
TypeScript
// 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();
|