This commit is contained in:
Den Piligrim
2026-01-30 10:13:13 +03:00
parent ba6db6edd5
commit 6db895bcc7
8 changed files with 254 additions and 61 deletions
+4 -11
View File
@@ -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 (
<Box
component="footer"
sx={{
py: 3,
px: 2,
mt: 'auto', // Ключевой стиль для прижатия к низу
mt: 'auto',
backgroundColor: (theme) =>
theme.palette.mode === 'light'
? theme.palette.grey[200]
@@ -21,18 +18,15 @@ export default function Footer() {
<Container maxWidth={false}>
<Grid container spacing={4} justifyContent="space-between" alignItems="center">
{/* Логотип и копирайт */}
<Grid size={{ xs: 12, sm: 4 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<img src="/img/logo.png" alt="Logo" width={32} height={32} style={{ marginRight: 14 }} />
</Stack>
</Grid>
{/* Ссылка на документацию */}
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: { xs: 'left', sm: 'center' } }}>
<Link
href="https://3dp-manager.com/docs/intro" // Ссылка на ваш репо или доку
href="https://3dp-manager.com/docs/intro"
target="_blank"
rel="noopener"
color="text.primary"
@@ -43,7 +37,6 @@ export default function Footer() {
</Link>
</Grid>
{/* Социальные иконки */}
<Grid size={{ xs: 12, sm: 4 }} sx={{ textAlign: { xs: 'left', sm: 'right' } }}>
<Stack direction="row" spacing={1} justifyContent={{ xs: 'flex-start', sm: 'flex-end' }}>
+54 -6
View File
@@ -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<Subscription[]>([]);
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
const [selectedServer, setSelectedServer] = useState<string>('main');
// Для модалки со ссылками
const [linksOpen, setLinksOpen] = useState(false);
const [currentLinks, setCurrentLinks] = useState<string[]>([]);
@@ -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 (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4">Подписки</Typography>
{tunnels.length > 0 && (
<FormControl variant='standard' size="small" sx={{ minWidth: 220, justifyContent: 'center' }}>
<Select
labelId="server-select-label"
value={selectedServer}
onChange={handleServerChange}
startAdornment={
<InputAdornment position="start">
{selectedServer === 'main' ? <Dns fontSize="small"/> : <Router fontSize="small"/>}
</InputAdornment>
}
>
<MenuItem value="main">
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>Основной сервер</Typography>
</Box>
</MenuItem>
{tunnels.map((t) => (
<MenuItem key={t.id} value={t.id.toString()}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t.name}</Typography>
</Box>
</MenuItem>
))}
</Select>
</FormControl>
)}
<Box>
<Button startIcon={<Refresh />} onClick={loadSubs} sx={{ mr: 1 }}>Обновить</Button>
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Создать</Button>
</Box>
</Box>
<Paper>
<Table>
<TableHead>
@@ -83,14 +131,14 @@ export default function SubscriptionsPage() {
<TableCell align="right">
<IconButton
color="primary"
onClick={() => 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="Копировать ссылку"
>
<ContentCopy />
</IconButton>
<IconButton
color="primary"
onClick={() => 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="Открыть подписку"
>
<OpenInNew />
+4 -2
View File
@@ -70,7 +70,7 @@ export default function TunnelsPage() {
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h4">Редирект серверы (Туннели)</Typography>
<Typography variant="h4">Relay серверы</Typography>
<Button variant="contained" startIcon={<Add />} onClick={() => setOpen(true)}>Добавить</Button>
</Box>
@@ -101,9 +101,10 @@ export default function TunnelsPage() {
}
</TableCell>
<TableCell align="right">
{!t.isInstalled && (
<Button
startIcon={loadingId === t.id ? <CircularProgress size={20} /> : <Terminal />}
disabled={loadingId !== null} // Блокируем всё, пока идет установка
disabled={loadingId !== null}
onClick={() => handleInstall(t.id)}
sx={{ mr: 1 }}
variant="outlined"
@@ -111,6 +112,7 @@ export default function TunnelsPage() {
>
{loadingId === t.id ? 'Установка...' : 'Установить'}
</Button>
)}
<IconButton color="inherit" onClick={() => handleDelete(t.id)}>
<Delete />
</IconButton>
+17 -24
View File
@@ -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,11 +135,13 @@ export const getDesignTokens = (mode: PaletteMode) => ({
},
},
MuiTableRow: {
styleOverrides: {
root: {
"&:last-child td": {
borderBottom: 0,
},
}
}
}
},
});
+155 -2
View File
@@ -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<Subscription>,
@InjectRepository(Tunnel)
private tunnelRepo: Repository<Tunnel>,
@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<string>(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 = `
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${sub.name} | 3DP-MANAGER</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f4f6f8; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
.card { background: white; padding: 2rem; border-radius: 16px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); text-align: center; max-width: 400px; width: 90%; }
h2 { margin-top: 0; color: #333; }
.qr-box { background: #fff; padding: 10px; border: 1px solid #eee; border-radius: 8px; display: inline-block; margin: 20px 0; }
.link-box { background: #f5f5f5; padding: 10px; border-radius: 6px; font-family: monospace; word-break: break-all; font-size: 12px; color: #666; margin-bottom: 20px; border: 1px solid #e0e0e0; }
button { background-color: #1976d2; color: white; border: none; padding: 12px 24px; border-radius: 8px; font-size: 16px; cursor: pointer; transition: background 0.2s; width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; }
button:hover { background-color: #1565c0; }
button:active { transform: scale(0.98); }
.note { margin-top: 20px; font-size: 12px; color: #999; }
#subscription-links { display: none; }
</style>
</head>
<body>
<div class="card">
<h2>Ваша подписка</h2>
<p style="color: #666;">Отсканируйте QR-код в приложении Happ, v2RayTun или Streisand</p>
<div class="qr-box">
<img src="${qrDataUrl}" alt="QR Code" />
</div>
<div class="link-box" id="link-text">${currentUrl}</div>
<button onclick="copyLink()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="white"><path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z"/></svg>
Копировать ссылку
</button>
<div class="note">Для автоматического обновления конфигов используйте эту ссылку</div>
</div>
<textarea id="subscription-links">${base64Config}</textarea>
<script>
function copyLink() {
const link = document.getElementById('link-text').innerText;
navigator.clipboard.writeText(link).then(() => {
const btn = document.querySelector('button');
const originalText = btn.innerHTML;
btn.innerHTML = 'Скопировано!';
btn.style.backgroundColor = '#2e7d32';
setTimeout(() => {
btn.innerHTML = originalText;
btn.style.backgroundColor = '#1976d2';
}, 2000);
});
}
</script>
</body>
</html>
`;
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;
}
}
+2 -1
View File
@@ -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 {}
+4 -1
View File
@@ -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);
+1 -1
View File
@@ -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