diff --git a/client/src/components/Footer.tsx b/client/src/components/Footer.tsx index 912ed68..2bc8c9a 100644 --- a/client/src/components/Footer.tsx +++ b/client/src/components/Footer.tsx @@ -1,17 +1,14 @@ -import React from 'react'; -import { Box, Container, Grid, Typography, IconButton, Link, Stack, useTheme } from '@mui/material'; -import { GitHub, YouTube, Telegram, Article } from '@mui/icons-material'; +import { Box, Container, Grid, IconButton, Link, Stack } from '@mui/material'; +import { GitHub, YouTube, Telegram } from '@mui/icons-material'; export default function Footer() { - const theme = useTheme(); - return ( theme.palette.mode === 'light' ? theme.palette.grey[200] @@ -21,18 +18,15 @@ export default function Footer() { - {/* Логотип и копирайт */} Logo - - {/* Ссылка на документацию */} - {/* Социальные иконки */} diff --git a/client/src/pages/SubscriptionsPage.tsx b/client/src/pages/SubscriptionsPage.tsx index 32056dc..38ace11 100644 --- a/client/src/pages/SubscriptionsPage.tsx +++ b/client/src/pages/SubscriptionsPage.tsx @@ -1,10 +1,15 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Box, Button, Typography, Paper, Table, TableBody, TableCell, TableHead, TableRow, IconButton, Dialog, DialogTitle, - DialogContent, TextField, DialogActions + DialogContent, TextField, DialogActions, + FormControl, + Select, + InputAdornment, + MenuItem, + type SelectChangeEvent } from '@mui/material'; -import { Delete, Add, Link as LinkIcon, Refresh, QrCode, Share, OpenInNew, CopyAll, ContentCopy } from '@mui/icons-material'; +import { Delete, Add, Link as LinkIcon, Refresh, OpenInNew, ContentCopy, Dns, Router } from '@mui/icons-material'; import api from '../api'; interface Subscription { @@ -14,12 +19,20 @@ interface Subscription { inbounds: any[]; } +interface Tunnel { + id: number; + name: string; + ip: string; +} + export default function SubscriptionsPage() { const [subs, setSubs] = useState([]); const [open, setOpen] = useState(false); const [name, setName] = useState(''); + const [tunnels, setTunnels] = useState([]); + + const [selectedServer, setSelectedServer] = useState('main'); - // Для модалки со ссылками const [linksOpen, setLinksOpen] = useState(false); const [currentLinks, setCurrentLinks] = useState([]); @@ -28,6 +41,8 @@ export default function SubscriptionsPage() { const loadSubs = async () => { const { data } = await api.get('/subscriptions'); setSubs(data); + const tunnelsRes = await api.get('/tunnels'); + setTunnels(tunnelsRes.data); }; const handleCreate = async () => { @@ -54,16 +69,49 @@ export default function SubscriptionsPage() { setLinksOpen(true); }; + const handleServerChange = (event: SelectChangeEvent) => { + setSelectedServer(event.target.value as string); + }; + return ( Подписки + {tunnels.length > 0 && ( + + + + )} + @@ -83,14 +131,14 @@ export default function SubscriptionsPage() { navigator.clipboard.writeText(`http://localhost:3000/bus/${sub.uuid}`)} + onClick={() => navigator.clipboard.writeText(selectedServer === 'main' ? `http://localhost:3000/bus/${sub.uuid}` : `http://localhost:3000/bus/${sub.uuid}/${selectedServer}`)} title="Копировать ссылку" > window.open(`http://localhost:3000/bus/${sub.uuid}`, '_blank')} + onClick={() => window.open(selectedServer === 'main' ? `http://localhost:3000/bus/${sub.uuid}` : `http://localhost:3000/bus/${sub.uuid}/${selectedServer}`, '_blank')} title="Открыть подписку" > diff --git a/client/src/pages/TunnelsPage.tsx b/client/src/pages/TunnelsPage.tsx index f68f895..075e1bb 100644 --- a/client/src/pages/TunnelsPage.tsx +++ b/client/src/pages/TunnelsPage.tsx @@ -70,7 +70,7 @@ export default function TunnelsPage() { return ( - Редирект серверы (Туннели) + Relay серверы @@ -101,16 +101,18 @@ export default function TunnelsPage() { } - + {!t.isInstalled && ( + + )} handleDelete(t.id)}> diff --git a/client/src/theme.ts b/client/src/theme.ts index 9bb56de..f719783 100644 --- a/client/src/theme.ts +++ b/client/src/theme.ts @@ -1,30 +1,27 @@ import type { PaletteMode } from '@mui/material'; -import { amber, deepOrange, grey } from '@mui/material/colors'; -// 1. Определение цветов для Светлой темы const lightPalette = { primary: { - main: '#2563eb', // Насыщенный синий (Tailwind Blue 600) + main: '#2563eb', light: '#60a5fa', dark: '#1e40af', }, secondary: { - main: '#7c3aed', // Фиолетовый + main: '#7c3aed', }, background: { - default: '#f3f4f6', // Светло-серый фон (не чисто белый) - paper: '#ffffff', // Карточки белые + default: '#f3f4f6', + paper: '#ffffff', }, text: { - primary: '#111827', // Почти черный - secondary: '#6b7280', // Серый текст + primary: '#111827', + secondary: '#6b7280', }, }; -// 2. Определение цветов для Темной темы const darkPalette = { primary: { - main: '#3b82f6', // Чуть светлее синий для контраста на темном + main: '#3b82f6', light: '#60a5fa', dark: '#1d4ed8', }, @@ -32,16 +29,15 @@ const darkPalette = { main: '#8b5cf6', }, background: { - default: '#0B0F19', // Глубокий темный (Deep Space), лучше чем #121212 - paper: '#111827', // Чуть светлее фона (Gray 900) + default: '#0B0F19', + paper: '#111827', }, text: { - primary: '#f9fafb', // Почти белый - secondary: '#9ca3af', // Светло-серый + primary: '#f9fafb', + secondary: '#9ca3af', }, }; -// 3. Функция генерации настроек export const getDesignTokens = (mode: PaletteMode) => ({ palette: { mode, @@ -56,15 +52,14 @@ export const getDesignTokens = (mode: PaletteMode) => ({ h5: { fontWeight: 600 }, h6: { fontWeight: 600 }, button: { - textTransform: 'none' as const, // Убираем CAPS LOCK на кнопках + textTransform: 'none' as const, fontWeight: 600, }, }, shape: { - borderRadius: 12, // Скругляем углы у всего (кнопки, карты) + borderRadius: 12, }, components: { - // Кастомизация глобальных стилей (скроллбар) MuiCssBaseline: { styleOverrides: { body: { @@ -87,7 +82,6 @@ export const getDesignTokens = (mode: PaletteMode) => ({ }, }, }, - // Кастомизация Кнопок MuiButton: { styleOverrides: { root: { @@ -104,11 +98,10 @@ export const getDesignTokens = (mode: PaletteMode) => ({ }, }, }, - // Кастомизация Карточек (Paper) MuiPaper: { styleOverrides: { root: { - backgroundImage: 'none', // Убираем осветление в темной теме (стандарт Material) + backgroundImage: 'none', }, elevation1: { boxShadow: mode === 'light' @@ -118,7 +111,6 @@ export const getDesignTokens = (mode: PaletteMode) => ({ }, }, }, - // Кастомизация Инпутов MuiOutlinedInput: { styleOverrides: { root: { @@ -131,12 +123,11 @@ export const getDesignTokens = (mode: PaletteMode) => ({ }, }, }, - // Кастомизация AppBar (Хедера) MuiAppBar: { styleOverrides: { root: { backgroundColor: mode === 'light' ? 'rgba(255, 255, 255, 0.8)' : 'rgba(17, 24, 39, 0.8)', - backdropFilter: 'blur(8px)', // Эффект стекла + backdropFilter: 'blur(8px)', borderBottom: `1px solid ${mode === 'light' ? '#e5e7eb' : '#374151'}`, boxShadow: 'none', color: mode === 'light' ? '#111827' : '#f9fafb', @@ -144,10 +135,12 @@ export const getDesignTokens = (mode: PaletteMode) => ({ }, }, MuiTableRow: { - root: { - "&:last-child td": { - borderBottom: 0, - }, + styleOverrides: { + root: { + "&:last-child td": { + borderBottom: 0, + }, + } } } }, diff --git a/server/src/client/client.controller.ts b/server/src/client/client.controller.ts index 312e5b6..2c85ebb 100644 --- a/server/src/client/client.controller.ts +++ b/server/src/client/client.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, HttpException, HttpStatus, Res, Req, Inject } from '@nestjs/common'; +import { Controller, Get, Param, HttpException, HttpStatus, Res, Req, Inject, Query } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import type { Response, Request } from 'express'; @@ -7,12 +7,15 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import type { Cache } from 'cache-manager'; import { Subscription } from '../subscriptions/entities/subscription.entity'; import { Public } from '../auth/public.decorator'; +import { Tunnel } from 'src/tunnels/entities/tunnel.entity'; -@Controller() // Убираем 'client', так как путь зададим явно +@Controller() export class ClientController { constructor( @InjectRepository(Subscription) private subRepo: Repository, + @InjectRepository(Tunnel) + private tunnelRepo: Repository, @Inject(CACHE_MANAGER) private cacheManager: Cache ) { } @@ -130,4 +133,154 @@ export class ClientController { res.send(html); } } + + @Public() + @Get('bus/:uuid/:tunnelId') + async getRelaySubscription( + @Param('uuid') uuid: string, + @Param('tunnelId') tunnelId: string, + @Query('format') format: string, + @Req() req: Request, + @Res() res: Response + ) { + const tunnel = await this.tunnelRepo.findOne({ where: { id: +tunnelId } }); + if (!tunnel) { + return res.status(HttpStatus.NOT_FOUND).send('Relay server not found'); + } + + const relayHost = tunnel.domain || tunnel.ip; + + const sub = await this.subRepo.findOne({ + where: { uuid }, + relations: ['inbounds'] + }); + + if (!sub || !sub.isEnabled) { + throw new HttpException('Subscription not found', HttpStatus.NOT_FOUND); + } + + let links = sub.inbounds + ?.map(i => i.link) + .filter(l => l && l.length > 0) || []; + + links = links.map(link => this.patchLink(link, relayHost)); + + const plainTextList = links.join('\n'); + const base64Config = Buffer.from(plainTextList).toString('base64'); + + const userAgent = req.headers['user-agent'] || ''; + const isBrowser = /Mozilla|Chrome|Safari|Firefox|Edge/.test(userAgent); + + if (!isBrowser) { + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.send(base64Config); + } else { + + const currentUrl = `${req.protocol}://${req.get('host')}/bus/${uuid}/${tunnelId}`; + + const cacheKey = `qr_${uuid}_${relayHost || 'direct'}`; + + let qrDataUrl = await this.cacheManager.get(cacheKey); + + if (!qrDataUrl) { + qrDataUrl = await QRCode.toDataURL(currentUrl, { width: 300, margin: 2 }); + + await this.cacheManager.set(cacheKey, qrDataUrl, 86400000); + } else { + console.log(`Взяли QR из кэша для ${uuid}`); + } + + // HTML шаблон + const html = ` + + + + + + ${sub.name} | 3DP-MANAGER + + + +
+

Ваша подписка

+

Отсканируйте QR-код в приложении Happ, v2RayTun или Streisand

+ +
+ QR Code +
+ + + + + +
Для автоматического обновления конфигов используйте эту ссылку
+ +
+ + + + + + `; + + res.setHeader('Content-Type', 'text/html'); + res.send(html); + } + } + + private patchLink(link: string, newHost: string): string { + if (link.startsWith('vmess://')) { + try { + const base64Part = link.substring(8); + const jsonStr = Buffer.from(base64Part, 'base64').toString('utf-8'); + const config = JSON.parse(jsonStr); + + config.add = newHost; + + const newJsonStr = JSON.stringify(config); + const newBase64 = Buffer.from(newJsonStr).toString('base64'); + return `vmess://${newBase64}`; + } catch (e) { + return link; + } + } else if (link.startsWith('vless://') || link.startsWith('trojan://')) { + return link.replace(/@.*?:/, `@${newHost}:`); + } else if (link.startsWith('ss://')) { + if (link.includes('@')) { + return link.replace(/@.*?:/, `@${newHost}:`); + } + return link; + } + + return link; + } } \ No newline at end of file diff --git a/server/src/client/client.module.ts b/server/src/client/client.module.ts index 4b09128..6b5a3b2 100644 --- a/server/src/client/client.module.ts +++ b/server/src/client/client.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ClientController } from './client.controller'; import { CacheModule } from '@nestjs/cache-manager'; import { Subscription } from '../subscriptions/entities/subscription.entity'; +import { Tunnel } from 'src/tunnels/entities/tunnel.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Subscription]), CacheModule.register()], + imports: [TypeOrmModule.forFeature([Subscription, Tunnel]), CacheModule.register()], controllers: [ClientController], }) export class ClientModule {} \ No newline at end of file diff --git a/server/src/main.ts b/server/src/main.ts index d3853e1..8b3b277 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -11,7 +11,10 @@ async function bootstrap() { app.enableCors(); app.setGlobalPrefix('api', { - exclude: [{ path: 'bus/:uuid', method: RequestMethod.GET }] + exclude: [ + { path: 'bus/:uuid', method: RequestMethod.GET }, + { path: 'bus/:uuid/:tunnelId', method: RequestMethod.GET }, + ] }); await app.listen(3000); diff --git a/server/src/tunnels/tunnels.service.ts b/server/src/tunnels/tunnels.service.ts index e1d8eb3..0635cb6 100644 --- a/server/src/tunnels/tunnels.service.ts +++ b/server/src/tunnels/tunnels.service.ts @@ -53,7 +53,7 @@ export class TunnelsService { // 3. Формируем команду // export ORIGIN_IP="1.2.3.4" && bash <(curl ...) - const command = `export ORIGIN_IP="${mainServerIp}" && bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/forwarding_install.sh)`; + const command = `export ORIGIN_IP="${mainServerIp}" && bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/dp-gui/forwarding_install.sh)`; try { // 4. Выполняем через SSH