diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx
index 8ce644a..29b7269 100644
--- a/client/src/components/Layout.tsx
+++ b/client/src/components/Layout.tsx
@@ -1,6 +1,6 @@
-import {
- Toolbar, Drawer, List, ListItem,
- ListItemButton, ListItemIcon, ListItemText, Box, useMediaQuery, useTheme
+import {
+ Toolbar, Drawer, List, ListItem,
+ ListItemButton, ListItemIcon, ListItemText, Box, useMediaQuery, useTheme
} from '@mui/material';
import { People, Settings, Dns, SwapHoriz } from '@mui/icons-material';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
@@ -8,6 +8,8 @@ import { useState } from 'react';
import Header from './Header';
import Footer from './Footer';
+import SecurityWarning from './SecurityWarning';
+import { useSecureConnection } from '../utils/useSecureConnection';
const drawerWidth = 240;
@@ -17,6 +19,7 @@ export default function Layout() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [mobileOpen, setMobileOpen] = useState(false);
+ const { isSecure } = useSecureConnection();
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
@@ -81,6 +84,7 @@ export default function Layout() {
}}
>
+ {!isSecure && }
diff --git a/client/src/components/SecurityWarning.tsx b/client/src/components/SecurityWarning.tsx
new file mode 100644
index 0000000..65de83a
--- /dev/null
+++ b/client/src/components/SecurityWarning.tsx
@@ -0,0 +1,114 @@
+import { Alert, AlertTitle, Box, Button, Collapse, IconButton, Snackbar, useMediaQuery, useTheme } from '@mui/material';
+import { Close, ContentCopy } from '@mui/icons-material';
+import { useState } from 'react';
+
+const INSTALL_COMMAND = 'bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)';
+
+export default function SecurityWarning() {
+ const [copied, setCopied] = useState(false);
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(INSTALL_COMMAND);
+ setCopied(true);
+ } catch {
+ // Fallback для старых браузеров
+ const textArea = document.createElement('textarea');
+ textArea.value = INSTALL_COMMAND;
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+ setCopied(true);
+ }
+ };
+
+ return (
+ <>
+
+
+
+ 3DP-MANAGER работает в небезопасном режиме (HTTP)
+
+
+ Не вводите реальные пароли от 3x-ui панели и не меняйте пароль администратора в режиме работы по HTTP!{' '}
+ Для безопасной работы переустановите 3DP-MANAGER с SSL-сертификатами. Все ваши настройки сохранятся.
+
+
+
+ {INSTALL_COMMAND}
+
+ }
+ onClick={handleCopy}
+ sx={{
+ color: 'inherit',
+ borderColor: 'currentColor',
+ whiteSpace: 'nowrap',
+ flexShrink: 0,
+ '&:hover': {
+ backgroundColor: 'rgba(255, 255, 255, 0.2)',
+ },
+ minWidth: { xs: 'auto', sm: '140px' },
+ px: { xs: 1, sm: 2 },
+ }}
+ >
+ {isMobile ? : 'Копировать'}
+
+
+
+
+
+ setCopied(false)}
+ message="Скопировано"
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
+ action={
+ setCopied(false)}>
+
+
+ }
+ />
+ >
+ );
+}
diff --git a/client/src/utils/useSecureConnection.ts b/client/src/utils/useSecureConnection.ts
new file mode 100644
index 0000000..01c3b82
--- /dev/null
+++ b/client/src/utils/useSecureConnection.ts
@@ -0,0 +1,18 @@
+import { useMemo } from 'react';
+
+/**
+ * Хук для определения безопасного соединения (HTTPS)
+ * @returns {isSecure: boolean} - true если соединение по HTTPS
+ */
+export function useSecureConnection() {
+ const isSecure = useMemo(() => {
+ // Проверка в браузере
+ if (typeof window !== 'undefined' && window.location) {
+ return window.location.protocol === 'https:';
+ }
+ // SSR fallback - считаем небезопасным
+ return false;
+ }, []);
+
+ return { isSecure };
+}
diff --git a/client/test/components/SecurityWarning.spec.tsx b/client/test/components/SecurityWarning.spec.tsx
new file mode 100644
index 0000000..019dc47
--- /dev/null
+++ b/client/test/components/SecurityWarning.spec.tsx
@@ -0,0 +1,175 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import SecurityWarning from '../../src/components/SecurityWarning'
+
+// Мок для navigator.clipboard
+const mockWriteText = vi.fn()
+Object.assign(navigator, {
+ clipboard: {
+ writeText: mockWriteText,
+ },
+})
+
+// Мок для document.execCommand (fallback для старых браузеров)
+const mockExecCommand = vi.fn()
+beforeEach(() => {
+ vi.clearAllMocks()
+ document.execCommand = mockExecCommand
+})
+
+describe('SecurityWarning', () => {
+ const INSTALL_COMMAND = 'bash <(curl -fsSL https://raw.githubusercontent.com/denpiligrim/3dp-manager/main/install.sh)'
+
+ const renderWarning = () => {
+ return render()
+ }
+
+ describe('рендеринг', () => {
+ it('должен рендериться с заголовком предупреждения', () => {
+ renderWarning()
+ expect(
+ screen.getByText(/3DP-MANAGER работает в небезопасном режиме/i)
+ ).toBeInTheDocument()
+ })
+
+ it('должен отображать предупреждение о паролях', () => {
+ renderWarning()
+ expect(
+ screen.getByText(/Не вводите реальные пароли от 3x-ui панели/i)
+ ).toBeInTheDocument()
+ })
+
+ it('должен отображать инструкцию по переустановке', () => {
+ renderWarning()
+ expect(
+ screen.getByText(/Для безопасной работы переустановите 3DP-MANAGER с SSL-сертификатами/i)
+ ).toBeInTheDocument()
+ })
+
+ it('должен отображать команду установки', () => {
+ renderWarning()
+ expect(screen.getByText(INSTALL_COMMAND)).toBeInTheDocument()
+ })
+
+ it('должен отображать кнопку копирования команды', () => {
+ renderWarning()
+ expect(screen.getByText('Копировать')).toBeInTheDocument()
+ })
+
+ it('должен отображать иконку копирования', () => {
+ renderWarning()
+ expect(screen.getByTestId('icon-ContentCopy')).toBeInTheDocument()
+ })
+
+ it('должен иметь семантически правильный Alert', () => {
+ renderWarning()
+ const alert = screen.getByRole('alert')
+ expect(alert).toBeInTheDocument()
+ })
+ })
+
+ describe('копирование команды', () => {
+ it('должен копировать команду в буфер обмена при клике', async () => {
+ mockWriteText.mockResolvedValue(undefined)
+
+ renderWarning()
+ const copyButton = screen.getByText('Копировать')
+ fireEvent.click(copyButton)
+
+ await waitFor(() => {
+ expect(mockWriteText).toHaveBeenCalledWith(INSTALL_COMMAND)
+ })
+ })
+
+ it('должен показывать уведомление "Скопировано" после копирования', async () => {
+ mockWriteText.mockResolvedValue(undefined)
+
+ renderWarning()
+ const copyButton = screen.getByText('Копировать')
+ fireEvent.click(copyButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Скопировано')).toBeInTheDocument()
+ })
+ })
+
+ it('должен использовать fallback при отсутствии navigator.clipboard', async () => {
+ // Мок ошибки clipboard API
+ mockWriteText.mockRejectedValue(new Error('Not supported'))
+
+ renderWarning()
+ const copyButton = screen.getByText('Копировать')
+ fireEvent.click(copyButton)
+
+ await waitFor(() => {
+ expect(mockExecCommand).toHaveBeenCalledWith('copy')
+ })
+ })
+
+ it('должен показывать уведомление "Скопировано" при использовании fallback', async () => {
+ mockWriteText.mockRejectedValue(new Error('Not supported'))
+ mockExecCommand.mockReturnValue(true)
+
+ renderWarning()
+ const copyButton = screen.getByText('Копировать')
+ fireEvent.click(copyButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Скопировано')).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('Snackbar уведомление', () => {
+ it('должен показывать уведомление после копирования', async () => {
+ mockWriteText.mockResolvedValue(undefined)
+
+ renderWarning()
+ const copyButton = screen.getByText('Копировать')
+ fireEvent.click(copyButton)
+
+ await waitFor(() => {
+ expect(screen.getByText('Скопировано')).toBeInTheDocument()
+ })
+ })
+
+ it('должен закрывать уведомление при клике на кнопку закрытия', async () => {
+ mockWriteText.mockResolvedValue(undefined)
+
+ renderWarning()
+ const copyButton = screen.getByText('Копировать')
+ fireEvent.click(copyButton)
+
+ // Ждём появления уведомления
+ await waitFor(() => {
+ expect(screen.getByText('Скопировано')).toBeInTheDocument()
+ })
+
+ // Находим кнопку закрытия по иконке Close
+ const closeButton = screen.getByTestId('icon-Close').closest('button')
+ if (closeButton) {
+ fireEvent.click(closeButton)
+ }
+
+ // Уведомление закрыто
+ await waitFor(() => {
+ expect(screen.queryByText('Скопировано')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('стили компонента', () => {
+ it('должен иметь variant="filled"', () => {
+ renderWarning()
+ const alert = screen.getByRole('alert')
+ // Проверяем что Alert имеет filled стиль (проверка через классы MUI)
+ expect(alert).toHaveClass('MuiAlert-filledWarning')
+ })
+
+ it('должен отображать код в monospace шрифте', () => {
+ renderWarning()
+ const codeBlock = screen.getByText(INSTALL_COMMAND)
+ expect(codeBlock.tagName).toBe('CODE')
+ })
+ })
+})
diff --git a/client/test/utils/useSecureConnection.spec.ts b/client/test/utils/useSecureConnection.spec.ts
new file mode 100644
index 0000000..35d4f90
--- /dev/null
+++ b/client/test/utils/useSecureConnection.spec.ts
@@ -0,0 +1,91 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { renderHook } from '@testing-library/react'
+import { useSecureConnection } from '../../src/utils/useSecureConnection'
+
+describe('useSecureConnection', () => {
+ const originalLocation = window.location
+
+ beforeEach(() => {
+ // Очищаем моки перед каждым тестом
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ // Восстанавливаем оригинальный location
+ Object.defineProperty(window, 'location', {
+ value: originalLocation,
+ writable: true,
+ configurable: true,
+ })
+ })
+
+ describe('при HTTPS соединении', () => {
+ it('должен возвращать isSecure: true когда protocol https:', () => {
+ // Мок для HTTPS
+ Object.defineProperty(window, 'location', {
+ value: {
+ protocol: 'https:',
+ },
+ writable: true,
+ configurable: true,
+ })
+
+ const { result } = renderHook(() => useSecureConnection())
+
+ expect(result.current.isSecure).toBe(true)
+ })
+ })
+
+ describe('при HTTP соединении', () => {
+ it('должен возвращать isSecure: false когда protocol http:', () => {
+ // Мок для HTTP
+ Object.defineProperty(window, 'location', {
+ value: {
+ protocol: 'http:',
+ },
+ writable: true,
+ configurable: true,
+ })
+
+ const { result } = renderHook(() => useSecureConnection())
+
+ expect(result.current.isSecure).toBe(false)
+ })
+ })
+
+ describe('при localhost', () => {
+ it('должен возвращать isSecure: false для localhost без HTTPS', () => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ protocol: 'http:',
+ hostname: 'localhost',
+ },
+ writable: true,
+ configurable: true,
+ })
+
+ const { result } = renderHook(() => useSecureConnection())
+
+ expect(result.current.isSecure).toBe(false)
+ })
+ })
+
+ describe('мемозация', () => {
+ it('должен возвращать одно и то же значение при повторных рендерах', () => {
+ Object.defineProperty(window, 'location', {
+ value: {
+ protocol: 'https:',
+ },
+ writable: true,
+ configurable: true,
+ })
+
+ const { result, rerender } = renderHook(() => useSecureConnection())
+ const firstValue = result.current.isSecure
+
+ rerender()
+
+ expect(result.current.isSecure).toBe(firstValue)
+ })
+ })
+})
diff --git a/server/Dockerfile b/server/Dockerfile
index 651c986..60020ed 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -4,7 +4,7 @@ WORKDIR /app
COPY package*.json ./
-RUN npm ci
+RUN npm ci --legacy-peer-deps
COPY . .
@@ -32,7 +32,7 @@ FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
-RUN npm ci --only=production
+RUN npm ci --only=production --legacy-peer-deps
# Runtime tools required for checker/scanner integration scripts.
RUN apk add --no-cache \