Files
app_umbrix/lib/marzban-api.ts
Umbrix Dev 33b20b979f 🔒 Аудит: безопасность, TypeScript, UI, BottomNav
Безопасность:
- proxy: белый список путей (только /sub/*), POST заблокирован
- console.log заменён на logger (утечки URL/данных)
- OnboardingFlow: убраны --tg-theme-* (не существуют в проекте)

TypeScript (0 ошибок):
- tsconfig target es5→es2017 (regex /u flag fix)
- layout.tsx: viewport перенесён в metadata (Next.js 13.5)
- telegram-webhook: fix text possibly undefined
- hooks/useTelegramWebApp: fix Object possibly undefined
- types/telegram: убрана дублирующая Window декларация

UI:
- BottomNav: новый компонент (Назад/Главная/Помощь)
- safe-area-bottom CSS класс добавлен в globals.css
- dashboard: spacer h-20, toast поднят над BottomNav
- OnboardingFlow: цены 149/249/350₽ (были 200/350/500₽)

Очистка:
- page_NEW.tsx удалён локально (не был в git)
2026-02-08 18:59:02 +03:00

337 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
if (typeof window === 'undefined') console.warn('API endpoint returned error:', response.status, response.statusText);
} catch (apiError) {
if (typeof window === 'undefined') 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) {
if (typeof window === 'undefined') 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();