Безопасность: - 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)
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
// API endpoint для создания нового пользователя VPN
|
||
// POST /api/create-user
|
||
// Принимает: { planType, period, locationIds, telegramId, telegramUsername, firstName, lastName }
|
||
// Возвращает: { success: true, token, username, subscriptionUrl }
|
||
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { randomUUID } from 'crypto'; // Криптографически безопасная генерация UUID
|
||
import { MARZBAN_PANEL_URL, getSubscriptionUrl } from '@/lib/constants';
|
||
import { logger } from '@/lib/logger';
|
||
import type { PlanType, CreateUserRequest, CreateUserResponse } from '@/types/marzban';
|
||
|
||
type Period = '1month' | '3months' | '6months' | '1year';
|
||
|
||
const MARZBAN_API = MARZBAN_PANEL_URL;
|
||
const ADMIN_USERNAME = process.env.MARZBAN_ADMIN_USERNAME;
|
||
const ADMIN_PASSWORD = process.env.MARZBAN_ADMIN_PASSWORD;
|
||
|
||
if (!ADMIN_USERNAME || !ADMIN_PASSWORD) {
|
||
throw new Error('MARZBAN_ADMIN_USERNAME and MARZBAN_ADMIN_PASSWORD must be set in .env');
|
||
}
|
||
|
||
export async function POST(request: NextRequest) {
|
||
try {
|
||
const {
|
||
planType,
|
||
period = '1month',
|
||
locationIds = [],
|
||
telegramId,
|
||
telegramUsername,
|
||
firstName,
|
||
lastName,
|
||
referrerId
|
||
} = await request.json();
|
||
|
||
logger.debug('📥 Received data:', {
|
||
planType,
|
||
period,
|
||
locationIds,
|
||
telegramId,
|
||
telegramUsername,
|
||
firstName,
|
||
lastName,
|
||
referrerId
|
||
});
|
||
|
||
// 1. Получаем токен админа
|
||
const tokenResponse = await fetch(`${MARZBAN_API}/api/admin/token`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
},
|
||
body: `grant_type=password&username=${ADMIN_USERNAME}&password=${ADMIN_PASSWORD}`,
|
||
});
|
||
|
||
if (!tokenResponse.ok) {
|
||
throw new Error('Failed to get admin token');
|
||
}
|
||
|
||
const { access_token } = await tokenResponse.json();
|
||
|
||
// 2. Определяем параметры тарифа с учетом периода
|
||
const planConfig = getPlanConfig(planType, period as Period);
|
||
|
||
// 3. Генерируем уникальное имя пользователя (приоритет: @username > firstName_ID > userID > random)
|
||
// ВАЖНО: Marzban принимает только a-z, 0-9 и подчеркивания, БЕЗ @
|
||
let username: string;
|
||
if (telegramUsername) {
|
||
// Есть @username в Telegram - используем БЕЗ @
|
||
username = telegramUsername.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
||
} else if (firstName && telegramId) {
|
||
// Нет username, используем имя + ID
|
||
const cleanName = firstName.toLowerCase().replace(/[^a-z0-9]/g, '_');
|
||
username = `${cleanName}_${telegramId}`;
|
||
} else if (telegramId) {
|
||
// Только ID
|
||
username = `user_${telegramId}`;
|
||
} else {
|
||
// Для тестирования вне Telegram
|
||
username = `user_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||
}
|
||
|
||
logger.debug('✅ Generated username:', username);
|
||
logger.debug('📊 Telegram data:', { telegramId, telegramUsername, firstName, lastName });
|
||
|
||
// 4. Определяем inbounds на основе выбранных локаций
|
||
let userInbounds = {
|
||
vless: ['VLESS TCP', 'VLESS Reality'],
|
||
vmess: ['VMess WS'],
|
||
trojan: ['Trojan TCP']
|
||
};
|
||
|
||
// Если locationIds указаны - получаем inbounds для выбранных нод
|
||
if (locationIds && locationIds.length > 0) {
|
||
try {
|
||
// Получить список нод
|
||
const nodesResponse = await fetch(`${MARZBAN_API}/api/nodes`, {
|
||
headers: { 'Authorization': `Bearer ${access_token}` },
|
||
});
|
||
|
||
if (nodesResponse.ok) {
|
||
const allNodes = await nodesResponse.json();
|
||
const selectedNodes = allNodes.filter((node: any) => locationIds.includes(node.id));
|
||
|
||
logger.debug(`📍 Selected ${selectedNodes.length} nodes:`, selectedNodes.map((n: any) => n.name));
|
||
|
||
// Получить inbounds
|
||
const inboundsResponse = await fetch(`${MARZBAN_API}/api/inbounds`, {
|
||
headers: { 'Authorization': `Bearer ${access_token}` },
|
||
});
|
||
|
||
if (inboundsResponse.ok) {
|
||
const allInbounds = await inboundsResponse.json();
|
||
|
||
// Создать mapping inbounds для выбранных нод
|
||
userInbounds = {
|
||
vless: [],
|
||
vmess: [],
|
||
trojan: []
|
||
};
|
||
|
||
// Группируем inbounds по протоколу
|
||
for (const node of selectedNodes) {
|
||
const nodeInbounds = allInbounds.filter((inbound: any) => {
|
||
// Ищем inbounds, которые содержат имя ноды в теге
|
||
const inboundTag = (inbound.tag || '').toLowerCase();
|
||
const nodeName = (node.name || '').toLowerCase();
|
||
return inboundTag.includes(nodeName) || inboundTag.includes(`node${node.id}`);
|
||
});
|
||
|
||
for (const inbound of nodeInbounds) {
|
||
const protocol = (inbound.protocol || '').toLowerCase();
|
||
if (protocol.includes('vless') && !userInbounds.vless.includes(inbound.tag)) {
|
||
userInbounds.vless.push(inbound.tag);
|
||
} else if (protocol.includes('vmess') && !userInbounds.vmess.includes(inbound.tag)) {
|
||
userInbounds.vmess.push(inbound.tag);
|
||
} else if (protocol.includes('trojan') && !userInbounds.trojan.includes(inbound.tag)) {
|
||
userInbounds.trojan.push(inbound.tag);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Если не нашли inbounds - используем дефолтные
|
||
if (userInbounds.vless.length === 0 && userInbounds.vmess.length === 0 && userInbounds.trojan.length === 0) {
|
||
logger.warn('⚠️ No inbounds found for selected nodes, using defaults');
|
||
userInbounds = {
|
||
vless: ['VLESS TCP', 'VLESS Reality'],
|
||
vmess: ['VMess WS'],
|
||
trojan: ['Trojan TCP']
|
||
};
|
||
} else {
|
||
logger.debug('✅ Mapped inbounds:', userInbounds);
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error('❌ Failed to fetch nodes/inbounds, using defaults:', error);
|
||
// Используем дефолтные inbounds при ошибке
|
||
}
|
||
}
|
||
|
||
// 5. Создаем пользователя в Marzban
|
||
const createUserResponse = await fetch(`${MARZBAN_API}/api/user`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${access_token}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
username: username,
|
||
status: 'active',
|
||
expire: planConfig.expireTimestamp,
|
||
data_limit: planConfig.dataLimitBytes,
|
||
data_limit_reset_strategy: 'no_reset',
|
||
proxies: {
|
||
vmess: {
|
||
id: randomUUID() // Криптографически безопасный UUID
|
||
},
|
||
vless: {},
|
||
trojan: {}
|
||
},
|
||
inbounds: userInbounds, // <-- ИСПОЛЬЗЕМ КАСТОМНЫЕ INBOUNDS
|
||
note: `Тариф: ${planConfig.name}, Период: ${period}, Локации: ${locationIds.length > 0 ? `#${locationIds.join(',')}` : 'Все'}, TG: ${telegramId || 'N/A'}, Создан: ${new Date().toISOString()}`,
|
||
}),
|
||
});
|
||
|
||
if (!createUserResponse.ok) {
|
||
const errorText = await createUserResponse.text();
|
||
throw new Error(`Failed to create user: ${errorText}`);
|
||
}
|
||
|
||
const userData = await createUserResponse.json();
|
||
|
||
// 6. Получаем subscription token
|
||
const subscriptionToken = userData.subscription_url?.split('/sub/')[1]?.replace(/\/$/, '') || username;
|
||
|
||
// 7. Track referral if referrerId provided
|
||
if (referrerId) {
|
||
try {
|
||
await fetch(`${request.nextUrl.origin}/api/referral/track`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
username: username,
|
||
referrer_username: referrerId,
|
||
}),
|
||
});
|
||
logger.debug('✅ Referral tracked:', { username, referrer: referrerId });
|
||
} catch (refError) {
|
||
logger.error('❌ Referral tracking failed:', refError);
|
||
// Don't fail user creation if referral tracking fails
|
||
}
|
||
}
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
token: subscriptionToken,
|
||
username: username,
|
||
expiresAt: new Date(planConfig.expireTimestamp * 1000).toISOString(),
|
||
subscriptionUrl: getSubscriptionUrl(subscriptionToken),
|
||
});
|
||
|
||
} catch (error) {
|
||
logger.error('Create user error:', error);
|
||
return NextResponse.json(
|
||
{
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Unknown error'
|
||
},
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
|
||
function getPlanConfig(planType: PlanType, period: Period = '1month') {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
|
||
// Базовые параметры тарифов
|
||
const basePlans = {
|
||
trial: {
|
||
name: '7 дней бесплатно',
|
||
dataLimitGB: 0, // безлимит
|
||
durationDays: 7,
|
||
},
|
||
start: {
|
||
name: 'Базовый',
|
||
dataLimitGB: 50,
|
||
durationDays: 30,
|
||
},
|
||
plus: {
|
||
name: 'Расширенный',
|
||
dataLimitGB: 299,
|
||
durationDays: 30,
|
||
},
|
||
max: {
|
||
name: 'Премиум',
|
||
dataLimitGB: 0, // безлимит
|
||
durationDays: 30,
|
||
},
|
||
};
|
||
|
||
// Множители для периодов
|
||
const periodMultipliers: Record<Period, number> = {
|
||
'1month': 1,
|
||
'3months': 3,
|
||
'6months': 6,
|
||
'1year': 12,
|
||
};
|
||
|
||
const plan = basePlans[planType as keyof typeof basePlans] || basePlans.trial;
|
||
const multiplier = periodMultipliers[period] || 1;
|
||
|
||
// Расчет итоговой длительности
|
||
const totalDays = plan.durationDays * multiplier;
|
||
|
||
return {
|
||
name: plan.name,
|
||
expireTimestamp: now + (totalDays * 24 * 60 * 60),
|
||
dataLimitBytes: plan.dataLimitGB > 0 ? plan.dataLimitGB * 1024 * 1024 * 1024 : 0,
|
||
periodDays: totalDays,
|
||
};
|
||
}
|