ваше сообщение коммита

This commit is contained in:
2025-07-09 01:18:58 +03:00
parent c18b674364
commit 81dced1f11
54 changed files with 15732 additions and 214 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -141,11 +141,33 @@ services:
curl -X POST http://ollama:11434/api/pull -d '{\"name\":\"${OLLAMA_MODEL:-qwen2.5:7b}\"}' -H 'Content-Type: application/json' curl -X POST http://ollama:11434/api/pull -d '{\"name\":\"${OLLAMA_MODEL:-qwen2.5:7b}\"}' -H 'Content-Type: application/json'
echo 'Done!' echo 'Done!'
" "
ssh-tunnel-frontend:
image: alpine:latest
container_name: ssh-tunnel-frontend
volumes:
- ./id_rsa:/key:ro
command: >
sh -c "apk add --no-cache openssh && ssh -i /key -o StrictHostKeyChecking=no -N -R 0.0.0.0:9000:host.docker.internal:5173 root@185.221.214.140"
restart: unless-stopped
# network_mode: host <-- Эту строку удаляем
extra_hosts: # <-- Эту секцию добавляем
- "host.docker.internal:host-gateway"
ssh-tunnel-backend:
image: alpine:latest
container_name: ssh-tunnel-backend
volumes:
- ./id_rsa:/key:ro
command: >
sh -c "apk add --no-cache openssh && ssh -i /key -o StrictHostKeyChecking=no -N -R 0.0.0.0:8000:host.docker.internal:8000 root@185.221.214.140"
restart: unless-stopped
# network_mode: host <-- Эту строку удаляем
extra_hosts: # <-- Эту секцию добавляем
- "host.docker.internal:host-gateway"
volumes: volumes:
postgres_data: null postgres_data: null
ollama_data: null ollama_data: null
backend_node_modules: null backend_node_modules: null
frontend_node_modules: null frontend_node_modules: null
vector_search_data: null vector_search_data: null

80
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,80 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Оптимизация производительности
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Сжатие
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml+rss
application/atom+xml
image/svg+xml;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Кеширование статических файлов
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary Accept-Encoding;
}
# Основные файлы HTML
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# API прокси (если нужно)
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket прокси
location /ws {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Безопасность
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
}
}

View File

@@ -1,18 +1,8 @@
import axios from 'axios'; import axios from 'axios';
// Определяем baseURL в зависимости от окружения
const getBaseUrl = () => {
// В браузере используем localhost
if (typeof window !== 'undefined' && window.location.hostname === 'localhost') {
return 'http://localhost:8000';
}
// В других случаях используем переменную окружения
return import.meta.env.VITE_API_URL || '';
};
// Создаем экземпляр axios с базовым URL // Создаем экземпляр axios с базовым URL
const api = axios.create({ const api = axios.create({
baseURL: getBaseUrl(), baseURL: '/api',
withCredentials: true, withCredentials: true,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -29,17 +19,27 @@ api.interceptors.request.use(
(error) => Promise.reject(error) (error) => Promise.reject(error)
); );
// Добавляем перехватчик для обработки ответов // Добавляем перехватчик ответов для обработки ошибок
api.interceptors.response.use( axios.interceptors.response.use(
(response) => { (response) => {
console.log('Response from server:', response.data); // Проверяем, что ответ действительно JSON
if (response.headers['content-type'] &&
!response.headers['content-type'].includes('application/json')) {
console.warn('Server returned non-JSON response:', response.headers['content-type']);
// Если это HTML, значит, запрос ушёл не туда
if (response.headers['content-type'].includes('text/html')) {
throw new Error('Server returned HTML instead of JSON. Check API endpoint.');
}
}
return response; return response;
}, },
async (error) => { (error) => {
// Проверяем, что это действительно ошибка авторизации // Если ошибка содержит HTML в response
if (error.response?.status === 401) { if (error.response && error.response.data &&
// Перенаправляем на страницу логина typeof error.response.data === 'string' &&
window.location.href = '/login'; error.response.data.includes('<!DOCTYPE')) {
console.error('API Error: Server returned HTML instead of JSON');
error.message = 'Ошибка: сервер вернул HTML вместо JSON. Проверьте подключение к API.';
} }
return Promise.reject(error); return Promise.reject(error);
} }
@@ -48,7 +48,7 @@ api.interceptors.response.use(
// Пример функции для отправки гостевого сообщения на сервер // Пример функции для отправки гостевого сообщения на сервер
const sendGuestMessageToServer = async (messageText) => { const sendGuestMessageToServer = async (messageText) => {
try { try {
await axios.post('/api/chat/guest-message', { await axios.post('/chat/guest-message', {
message: messageText, message: messageText,
// language: userLanguage.value, // TODO: Реализовать получение языка пользователя // language: userLanguage.value, // TODO: Реализовать получение языка пользователя
}); });

View File

@@ -55,4 +55,14 @@ a {
a:hover { a:hover {
text-decoration: underline; /* Подчеркивание при наведении */ text-decoration: underline; /* Подчеркивание при наведении */
}
html, body {
width: 100vw !important;
max-width: 100vw !important;
min-width: 0 !important;
overflow-x: hidden !important;
box-sizing: border-box !important;
margin: 0 !important;
padding: 0 !important;
} }

View File

@@ -145,7 +145,7 @@ const handleWalletAuth = async () => {
const disconnectWallet = async () => { const disconnectWallet = async () => {
console.log('[BaseLayout] Выполняется выход из системы...'); console.log('[BaseLayout] Выполняется выход из системы...');
try { try {
await api.post('/api/auth/logout'); await api.post('/auth/logout');
showSuccessMessage('Вы успешно вышли из системы'); showSuccessMessage('Вы успешно вышли из системы');
removeFromStorage('guestMessages'); removeFromStorage('guestMessages');
removeFromStorage('hasUserSentMessage'); removeFromStorage('hasUserSentMessage');

View File

@@ -488,6 +488,33 @@ async function handleAiReply() {
</script> </script>
<style scoped> <style scoped>
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
max-height: 100vh;
min-height: 0;
position: relative;
}
.chat-messages {
flex: 1 1 auto;
overflow-y: auto;
position: relative;
padding-bottom: 8px;
}
.chat-input {
position: relative;
width: 100%;
margin-bottom: 12px;
margin-top: 8px;
left: 0;
right: 0;
border-radius: 12px 12px 0 0;
box-shadow: 0 -2px 8px rgba(0,0,0,0.04);
}
.chat-container { .chat-container {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -714,33 +741,61 @@ async function handleAiReply() {
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.chat-container { .chat-input {
margin: var(--spacing-xs) auto; position: fixed !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
z-index: 1000 !important;
margin: 0 !important;
border-radius: 12px 12px 0 0 !important;
} }
.chat-messages { .chat-messages {
padding: var(--spacing-sm); padding-bottom: 70px !important; /* чтобы сообщения не перекрывались input */
max-height: calc(100vh - 70px) !important;
overflow-y: auto !important;
} }
}
.input-area {
gap: 4px; @media (max-width: 600px) {
.chat-input {
position: fixed !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
z-index: 1000 !important;
margin: 0 !important;
border-radius: 12px 12px 0 0 !important;
padding: 8px 12px !important;
background: #f8f8f8 !important;
border-top: 1px solid #eee !important;
box-sizing: border-box !important;
} }
.chat-messages {
.chat-icon-btn { padding-bottom: 70px !important; /* чтобы сообщения не перекрывались input */
width: 28px; max-height: calc(100vh - 70px) !important;
height: 28px; overflow-y: auto !important;
box-sizing: border-box !important;
} }
.chat-container {
.chat-icon-btn svg { padding: 0 !important;
width: 18px; margin: 0 !important;
height: 18px; height: 100vh !important;
} max-height: 100vh !important;
min-height: 0 !important;
.preview-item { box-sizing: border-box !important;
font-size: var(--font-size-xs);
} }
} }
.input-area textarea {
flex: 1 1 0%;
min-width: 0;
width: 100%;
box-sizing: border-box;
}
.ai-spinner { .ai-spinner {
animation: ai-spin 1s linear infinite; animation: ai-spin 1s linear infinite;
} }

View File

@@ -182,11 +182,25 @@ onBeforeUnmount(() => {
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.header-text { .title {
display: none; /* Hide text on very small screens */ font-size: 1em;
text-align: left;
word-break: break-word;
}
.subtitle {
font-size: 0.7em;
text-align: left;
word-break: break-word;
} }
.header-content { .header-content {
justify-content: flex-end; /* Align button to the right */ flex-direction: row;
align-items: center;
}
.header-text {
flex: 1;
min-width: 0;
text-align: left;
width: auto;
} }
} }

View File

@@ -127,7 +127,7 @@ async function submitImport() {
return obj; return obj;
}); });
try { try {
const resp = await fetch('/api/users/import', { const resp = await fetch('/users/import', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(contacts) body: JSON.stringify(contacts)

View File

@@ -58,9 +58,9 @@ async function save() {
return; return;
} }
if (props.rule && props.rule.id) { if (props.rule && props.rule.id) {
await axios.put(`/api/settings/ai-assistant-rules/${props.rule.id}`, { name: name.value, description: description.value, rules }); await axios.put(`/settings/ai-assistant-rules/${props.rule.id}`, { name: name.value, description: description.value, rules });
} else { } else {
await axios.post('/api/settings/ai-assistant-rules', { name: name.value, description: description.value, rules }); await axios.post('/settings/ai-assistant-rules', { name: name.value, description: description.value, rules });
} }
emit('close', true); emit('close', true);
} }

View File

@@ -118,7 +118,7 @@ const getRagResultClass = () => {
const refreshStatus = async () => { const refreshStatus = async () => {
loading.value = true; loading.value = true;
try { try {
const response = await axios.get('/api/monitoring'); const response = await axios.get('/monitoring');
monitoringData.value = response.data; monitoringData.value = response.data;
lastUpdate.value = new Date().toLocaleString('ru-RU'); lastUpdate.value = new Date().toLocaleString('ru-RU');
} catch (e) { } catch (e) {
@@ -133,7 +133,7 @@ const testRAG = async () => {
ragTesting.value = true; ragTesting.value = true;
ragResult.value = null; ragResult.value = null;
try { try {
const response = await axios.post('/api/rag/answer', { const response = await axios.post('/rag/answer', {
tableId: 28, tableId: 28,
question: ragQuestion.value, question: ragQuestion.value,
userTags: [], userTags: [],

View File

@@ -57,7 +57,7 @@ const emit = defineEmits(['close', 'success']);
try { try {
isLoading.value = true; isLoading.value = true;
error.value = ''; error.value = '';
const response = await axios.post('/api/auth/email/request', { const response = await axios.post('/auth/email/request', {
email: email.value, email: email.value,
}); });
if (response.data.success) { if (response.data.success) {
@@ -76,7 +76,7 @@ const emit = defineEmits(['close', 'success']);
try { try {
isLoading.value = true; isLoading.value = true;
error.value = ''; error.value = '';
const response = await axios.post('/api/auth/email/verify-code', { const response = await axios.post('/auth/email/verify-code', {
email: email.value, email: email.value,
code: code.value, code: code.value,
}); });

View File

@@ -18,7 +18,7 @@ const updateIdentities = async () => {
if (!isAuthenticated.value || !userId.value) return; if (!isAuthenticated.value || !userId.value) return;
try { try {
const response = await axios.get('/api/auth/identities'); const response = await axios.get('/auth/identities');
if (response.data.success) { if (response.data.success) {
// Фильтруем идентификаторы: убираем гостевые и оставляем только уникальные // Фильтруем идентификаторы: убираем гостевые и оставляем только уникальные
const filteredIdentities = response.data.identities const filteredIdentities = response.data.identities
@@ -71,7 +71,7 @@ const stopIdentitiesPolling = () => {
const checkTokenBalances = async (address) => { const checkTokenBalances = async (address) => {
try { try {
const response = await axios.get(`/api/auth/check-tokens/${address}`); const response = await axios.get(`/auth/check-tokens/${address}`);
if (response.data.success) { if (response.data.success) {
tokenBalances.value = response.data.balances; tokenBalances.value = response.data.balances;
return response.data.balances; return response.data.balances;
@@ -194,7 +194,7 @@ const linkMessages = async () => {
/* Удаляем ненужный вызов /* Удаляем ненужный вызов
try { try {
// Отправляем запрос на связывание сообщений // Отправляем запрос на связывание сообщений
const response = await axios.post('/api/auth/link-guest-messages', identifiersData); const response = await axios.post('/auth/link-guest-messages', identifiersData);
if (response.data.success) { if (response.data.success) {
console.log('Messages linked successfully:', response.data); console.log('Messages linked successfully:', response.data);
@@ -257,7 +257,7 @@ const linkMessages = async () => {
const checkAuth = async () => { const checkAuth = async () => {
try { try {
const response = await axios.get('/api/auth/check'); const response = await axios.get('/auth/check');
console.log('Auth check response:', response.data); console.log('Auth check response:', response.data);
const wasAuthenticated = isAuthenticated.value; const wasAuthenticated = isAuthenticated.value;
@@ -315,7 +315,7 @@ const checkAuth = async () => {
const disconnect = async () => { const disconnect = async () => {
try { try {
// Удаляем все идентификаторы перед выходом // Удаляем все идентификаторы перед выходом
await axios.post('/api/auth/logout'); await axios.post('/auth/logout');
// Обновляем состояние в памяти // Обновляем состояние в памяти
updateAuth({ updateAuth({
@@ -423,7 +423,7 @@ const updateConnectionDisplay = (isConnected, authType, authData = {}) => {
* @returns {Promise<Object>} - Результат операции * @returns {Promise<Object>} - Результат операции
*/ */
const linkIdentity = async (type, value) => { const linkIdentity = async (type, value) => {
const response = await axios.post('/api/link', { const response = await axios.post('/link', {
type, type,
value, value,
}); });
@@ -437,7 +437,7 @@ const linkIdentity = async (type, value) => {
* @returns {Promise<Object>} - Результат операции * @returns {Promise<Object>} - Результат операции
*/ */
const deleteIdentity = async (provider, providerId) => { const deleteIdentity = async (provider, providerId) => {
const response = await axios.delete(`/api/${provider}/${encodeURIComponent(providerId)}`); const response = await axios.delete(`/${provider}/${encodeURIComponent(providerId)}`);
return response.data; return response.data;
}; };

View File

@@ -47,7 +47,7 @@ export function useAuthFlow(options = {}) {
telegramAuth.value.isLoading = true; telegramAuth.value.isLoading = true;
telegramAuth.value.error = ''; telegramAuth.value.error = '';
try { try {
const response = await api.post('/api/auth/telegram/init'); const response = await api.post('/auth/telegram/init');
if (response.data.success) { if (response.data.success) {
telegramAuth.value.verificationCode = response.data.verificationCode; telegramAuth.value.verificationCode = response.data.verificationCode;
telegramAuth.value.botLink = response.data.botLink; telegramAuth.value.botLink = response.data.botLink;
@@ -130,7 +130,7 @@ export function useAuthFlow(options = {}) {
emailAuth.value.isLoading = true; emailAuth.value.isLoading = true;
try { try {
const response = await api.post('/api/auth/email/init', { email: emailAuth.value.email }); const response = await api.post('/auth/email/init', { email: emailAuth.value.email });
if (response.data.success) { if (response.data.success) {
emailAuth.value.verificationEmail = emailAuth.value.email; // Сохраняем email emailAuth.value.verificationEmail = emailAuth.value.email; // Сохраняем email
emailAuth.value.showForm = false; emailAuth.value.showForm = false;
@@ -161,7 +161,7 @@ export function useAuthFlow(options = {}) {
emailAuth.value.isVerifying = true; emailAuth.value.isVerifying = true;
try { try {
const response = await api.post('/api/auth/email/verify-code', { const response = await api.post('/auth/email/verify-code', {
email: emailAuth.value.verificationEmail, email: emailAuth.value.verificationEmail,
code: emailAuth.value.verificationCode, code: emailAuth.value.verificationCode,
}); });

View File

@@ -14,7 +14,7 @@ export default function useBlockchainNetworks() {
const fetchNetworks = async () => { const fetchNetworks = async () => {
loadingNetworks.value = true; loadingNetworks.value = true;
try { try {
const { data } = await axios.get('/api/settings/rpc'); const { data } = await axios.get('/settings/rpc');
const networksArr = data.data || []; const networksArr = data.data || [];
networks.value = networksArr.map(n => ({ networks.value = networksArr.map(n => ({
value: n.network_id, value: n.network_id,
@@ -163,7 +163,7 @@ export default function useBlockchainNetworks() {
try { try {
// Формируем запрос на бэкенд для проверки RPC // Формируем запрос на бэкенд для проверки RPC
const response = await axios.post('/api/settings/rpc-test', { const response = await axios.post('/settings/rpc-test', {
networkId, networkId,
rpcUrl rpcUrl
}); });

View File

@@ -80,7 +80,7 @@ export function useChat(auth) {
let totalMessages = -1; let totalMessages = -1;
if (initial || messageLoading.value.offset === 0) { if (initial || messageLoading.value.offset === 0) {
try { try {
const countResponse = await api.get('/api/chat/history', { params: { count_only: true } }); const countResponse = await api.get('/chat/history', { params: { count_only: true } });
if (!countResponse.data.success) throw new Error('Не удалось получить количество сообщений'); if (!countResponse.data.success) throw new Error('Не удалось получить количество сообщений');
totalMessages = countResponse.data.total || countResponse.data.count || 0; totalMessages = countResponse.data.total || countResponse.data.count || 0;
console.log(`[useChat] Всего сообщений в истории: ${totalMessages}`); console.log(`[useChat] Всего сообщений в истории: ${totalMessages}`);
@@ -97,7 +97,7 @@ export function useChat(auth) {
console.log(`[useChat] Рассчитано начальное смещение: ${effectiveOffset}`); console.log(`[useChat] Рассчитано начальное смещение: ${effectiveOffset}`);
} }
const response = await api.get('/api/chat/history', { const response = await api.get('/chat/history', {
params: { params: {
offset: effectiveOffset, offset: effectiveOffset,
limit: messageLoading.value.limit, limit: messageLoading.value.limit,
@@ -225,14 +225,14 @@ export function useChat(auth) {
}); });
} }
let apiUrl = '/api/chat/message'; let apiUrl = '/chat/message';
if (isGuestMessage) { if (isGuestMessage) {
if (!guestId.value) { if (!guestId.value) {
guestId.value = initGuestId(); guestId.value = initGuestId();
setToStorage('guestId', guestId.value); setToStorage('guestId', guestId.value);
} }
formData.append('guestId', guestId.value); formData.append('guestId', guestId.value);
apiUrl = '/api/chat/guest-message'; apiUrl = '/chat/guest-message';
} }
const response = await api.post(apiUrl, formData, { const response = await api.post(apiUrl, formData, {
@@ -352,7 +352,7 @@ export function useChat(auth) {
const linkGuestMessagesAfterAuth = async () => { const linkGuestMessagesAfterAuth = async () => {
if (!guestId.value) return; if (!guestId.value) return;
try { try {
const response = await api.post('/api/chat/process-guest', { guestId: guestId.value }); const response = await api.post('/chat/process-guest', { guestId: guestId.value });
if (response.data.success && response.data.conversationId) { if (response.data.success && response.data.conversationId) {
// Можно сразу загрузить историю по этому диалогу, если нужно // Можно сразу загрузить историю по этому диалогу, если нужно
await loadMessages({ initial: true }); await loadMessages({ initial: true });

View File

@@ -42,7 +42,7 @@ export function useContactsAndMessagesWebSocket() {
async function fetchContactsReadStatus() { async function fetchContactsReadStatus() {
try { try {
const { data } = await axios.get('/api/users/read-contacts-status'); const { data } = await axios.get('/users/read-contacts-status');
readContacts.value = data || []; readContacts.value = data || [];
} catch (e) { } catch (e) {
readContacts.value = []; readContacts.value = [];
@@ -60,7 +60,7 @@ export function useContactsAndMessagesWebSocket() {
async function markContactAsRead(contactId) { async function markContactAsRead(contactId) {
try { try {
await axios.post('/api/users/mark-contact-read', { contactId }); await axios.post('/users/mark-contact-read', { contactId });
if (!readContacts.value.includes(contactId)) { if (!readContacts.value.includes(contactId)) {
readContacts.value.push(contactId); readContacts.value.push(contactId);
updateNewContacts(); updateNewContacts();
@@ -70,7 +70,7 @@ export function useContactsAndMessagesWebSocket() {
async function fetchReadStatus() { async function fetchReadStatus() {
try { try {
const { data } = await axios.get('/api/messages/read-status'); const { data } = await axios.get('/messages/read-status');
lastReadMessageDate.value = data || {}; lastReadMessageDate.value = data || {};
} catch (e) { } catch (e) {
lastReadMessageDate.value = {}; lastReadMessageDate.value = {};
@@ -97,7 +97,7 @@ export function useContactsAndMessagesWebSocket() {
const maxDate = Math.max(...userMessages.map(m => new Date(m.created_at).getTime())); const maxDate = Math.max(...userMessages.map(m => new Date(m.created_at).getTime()));
const maxDateISO = new Date(maxDate).toISOString(); const maxDateISO = new Date(maxDate).toISOString();
try { try {
await axios.post('/api/messages/mark-read', { userId, lastReadAt: maxDateISO }); await axios.post('/messages/mark-read', { userId, lastReadAt: maxDateISO });
lastReadMessageDate.value[userId] = maxDateISO; lastReadMessageDate.value[userId] = maxDateISO;
} catch (e) {} } catch (e) {}
} }
@@ -114,7 +114,8 @@ export function useContactsAndMessagesWebSocket() {
} }
function setupWebSocket() { function setupWebSocket() {
ws = new WebSocket('ws://localhost:8000'); const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${wsProtocol}://${window.location.host}/ws`);
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.type === 'contacts-updated') { if (data.type === 'contacts-updated') {

View File

@@ -9,12 +9,7 @@ import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css'; import 'element-plus/dist/index.css';
// Настройка axios // Настройка axios
// В Docker контейнере localhost:8000 не работает, поэтому используем явное значение axios.defaults.baseURL = '/api';
const apiUrl =
window.location.hostname === 'localhost'
? 'http://localhost:8000'
: 'http://dapp-backend:8000'; // имя контейнера backend
axios.defaults.baseURL = apiUrl;
axios.defaults.withCredentials = true; axios.defaults.withCredentials = true;
// Создаем и монтируем приложение Vue // Создаем и монтируем приложение Vue
@@ -33,7 +28,7 @@ app.use(ElementPlus);
// ]).catch(err => console.error('Failed to load API mocks:', err)); // ]).catch(err => console.error('Failed to load API mocks:', err));
// } // }
console.log('API URL:', apiUrl); console.log('API URL:', axios.defaults.baseURL);
console.log('main.js: Starting application with router'); console.log('main.js: Starting application with router');
app.mount('#app'); app.mount('#app');

View File

@@ -53,6 +53,11 @@ const routes = [
name: 'settings-interface', name: 'settings-interface',
component: SettingsInterfaceView, component: SettingsInterfaceView,
}, },
{
path: 'webssh',
name: 'settings-webssh',
component: () => import('../views/settings/WebSshSettingsView.vue'),
},
{ {
path: 'telegram', path: 'telegram',
@@ -170,7 +175,7 @@ router.beforeEach(async (to, from, next) => {
// Проверяем аутентификацию, если маршрут требует авторизации // Проверяем аутентификацию, если маршрут требует авторизации
if (to.matched.some((record) => record.meta.requiresAuth)) { if (to.matched.some((record) => record.meta.requiresAuth)) {
try { try {
const response = await axios.get('/api/auth/check'); const response = await axios.get('/auth/check');
if (response.data.authenticated) { if (response.data.authenticated) {
next(); next();
} else { } else {

View File

@@ -2,19 +2,19 @@ import api from '../api/axios';
export default { export default {
async getContacts() { async getContacts() {
const res = await api.get('/api/users'); const res = await api.get('/users');
if (res.data && res.data.success) { if (res.data && res.data.success) {
return res.data.contacts; return res.data.contacts;
} }
return []; return [];
}, },
async updateContact(id, data) { async updateContact(id, data) {
const res = await api.patch(`/api/users/${id}`, data); const res = await api.patch(`/users/${id}`, data);
return res.data; return res.data;
}, },
async deleteContact(id) { async deleteContact(id) {
try { try {
const res = await api.delete(`/api/users/${id}`); const res = await api.delete(`/users/${id}`);
console.log('Ответ на удаление контакта:', res.status, res.data); console.log('Ответ на удаление контакта:', res.status, res.data);
return res.data; return res.data;
} catch (err) { } catch (err) {
@@ -23,18 +23,18 @@ export default {
} }
}, },
async getContactById(id) { async getContactById(id) {
const res = await api.get(`/api/users/${id}`); const res = await api.get(`/users/${id}`);
if (res.data && res.data.id) { if (res.data && res.data.id) {
return res.data; return res.data;
} }
return null; return null;
}, },
async blockContact(id) { async blockContact(id) {
const res = await api.patch(`/api/users/${id}/block`); const res = await api.patch(`/users/${id}/block`);
return res.data; return res.data;
}, },
async unblockContact(id) { async unblockContact(id) {
const res = await api.patch(`/api/users/${id}/unblock`); const res = await api.patch(`/users/${id}/unblock`);
return res.data; return res.data;
} }
}; };

View File

@@ -10,7 +10,7 @@ class DLEService {
*/ */
async getDefaultSettings() { async getDefaultSettings() {
try { try {
const response = await api.get('/api/dle/settings'); const response = await api.get('/dle/settings');
return response.data.data; return response.data.data;
} catch (error) { } catch (error) {
console.error('Ошибка при получении настроек DLE:', error); console.error('Ошибка при получении настроек DLE:', error);
@@ -25,7 +25,7 @@ class DLEService {
*/ */
async createDLE(dleParams) { async createDLE(dleParams) {
try { try {
const response = await api.post('/api/dle', dleParams); const response = await api.post('/dle', dleParams);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Ошибка при создании DLE:', error); console.error('Ошибка при создании DLE:', error);
@@ -39,7 +39,7 @@ class DLEService {
*/ */
async getAllDLEs() { async getAllDLEs() {
try { try {
const response = await api.get('/api/dle'); const response = await api.get('/dle');
// Проверяем и нормализуем поля isicCodes для всех DLE // Проверяем и нормализуем поля isicCodes для всех DLE
if (response.data.data && Array.isArray(response.data.data)) { if (response.data.data && Array.isArray(response.data.data)) {
@@ -65,7 +65,7 @@ class DLEService {
*/ */
async deleteDLE(tokenAddress) { async deleteDLE(tokenAddress) {
try { try {
const response = await api.delete(`/api/dle/${tokenAddress}`); const response = await api.delete(`/dle/${tokenAddress}`);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Ошибка при удалении DLE:', error); console.error('Ошибка при удалении DLE:', error);
@@ -80,7 +80,7 @@ class DLEService {
*/ */
async deleteEmptyDLE(fileName) { async deleteEmptyDLE(fileName) {
try { try {
const response = await api.delete(`/api/dle/empty/${fileName}`); const response = await api.delete(`/dle/empty/${fileName}`);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Ошибка при удалении пустого DLE:', error); console.error('Ошибка при удалении пустого DLE:', error);

View File

@@ -3,7 +3,7 @@ import axios from 'axios';
export default { export default {
async getMessagesByUserId(userId) { async getMessagesByUserId(userId) {
if (!userId) return []; if (!userId) return [];
const { data } = await axios.get(`/api/messages?userId=${userId}`); const { data } = await axios.get(`/messages?userId=${userId}`);
return data; return data;
}, },
async sendMessage({ conversationId, message, attachments = [], toUserId }) { async sendMessage({ conversationId, message, attachments = [], toUserId }) {
@@ -14,7 +14,7 @@ export default {
attachments.forEach(file => { attachments.forEach(file => {
formData.append('attachments', file); formData.append('attachments', file);
}); });
const { data } = await axios.post('/api/chat/message', formData, { const { data } = await axios.post('/chat/message', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
withCredentials: true withCredentials: true
}); });
@@ -22,20 +22,20 @@ export default {
}, },
async getMessagesByConversationId(conversationId) { async getMessagesByConversationId(conversationId) {
if (!conversationId) return []; if (!conversationId) return [];
const { data } = await axios.get(`/api/messages?conversationId=${conversationId}`); const { data } = await axios.get(`/messages?conversationId=${conversationId}`);
return data; return data;
}, },
async getConversationByUserId(userId) { async getConversationByUserId(userId) {
if (!userId) return null; if (!userId) return null;
const { data } = await axios.get(`/api/messages/conversations?userId=${userId}`); const { data } = await axios.get(`/messages/conversations?userId=${userId}`);
return data; return data;
}, },
async generateAiDraft(conversationId, messages, language = 'auto') { async generateAiDraft(conversationId, messages, language = 'auto') {
const { data } = await axios.post('/api/chat/ai-draft', { conversationId, messages, language }); const { data } = await axios.post('/chat/ai-draft', { conversationId, messages, language });
return data; return data;
}, },
async broadcastMessage({ userId, message }) { async broadcastMessage({ userId, message }) {
const { data } = await axios.post('/api/messages/broadcast', { const { data } = await axios.post('/messages/broadcast', {
user_id: userId, user_id: userId,
content: message content: message
}, { }, {
@@ -46,6 +46,6 @@ export default {
}; };
export async function getAllMessages() { export async function getAllMessages() {
const { data } = await axios.get('/api/messages'); const { data } = await axios.get('/messages');
return data; return data;
} }

View File

@@ -1,6 +1,6 @@
import api from '../api/axios'; import api from '../api/axios';
const tablesApi = '/api/tables'; const tablesApi = '/tables';
export default { export default {
async getTables() { async getTables() {

View File

@@ -3,7 +3,7 @@ import api from '../api/axios';
// Получение балансов токенов // Получение балансов токенов
export const fetchTokenBalances = async (address = null) => { export const fetchTokenBalances = async (address = null) => {
try { try {
let url = '/api/tokens/balances'; let url = '/tokens/balances';
if (address) { if (address) {
url += `?address=${encodeURIComponent(address)}`; url += `?address=${encodeURIComponent(address)}`;
console.log(`Fetching token balances for specific address: ${address}`); console.log(`Fetching token balances for specific address: ${address}`);

View File

@@ -28,7 +28,7 @@ export async function connectWithWallet() {
// Запрашиваем nonce с сервера // Запрашиваем nonce с сервера
console.log('Requesting nonce...'); console.log('Requesting nonce...');
const nonceResponse = await axios.get(`/api/auth/nonce?address=${address}`); const nonceResponse = await axios.get(`/auth/nonce?address=${address}`);
const nonce = nonceResponse.data.nonce; const nonce = nonceResponse.data.nonce;
console.log('Got nonce:', nonce); console.log('Got nonce:', nonce);
@@ -62,7 +62,7 @@ export async function connectWithWallet() {
// Отправляем подпись на сервер для верификации // Отправляем подпись на сервер для верификации
console.log('Sending verification request...'); console.log('Sending verification request...');
const verificationResponse = await axios.post('/api/auth/verify', { const verificationResponse = await axios.post('/auth/verify', {
message, message,
signature, signature,
address, address,

View File

@@ -0,0 +1,475 @@
/**
* Сервис для управления WEB SSH туннелем
* Взаимодействует с локальным агентом на порту 12345
*/
const LOCAL_AGENT_URL = 'http://localhost:12345';
class WebSshService {
constructor() {
this.isAgentRunning = false;
this.connectionStatus = {
connected: false,
domain: null,
tunnelId: null
};
}
/**
* Проверка доступности локального агента
*/
async checkAgentStatus() {
try {
const response = await fetch(`${LOCAL_AGENT_URL}/health`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
this.isAgentRunning = true;
return { running: true };
}
this.isAgentRunning = false;
return { running: false };
} catch (error) {
console.error('Агент не доступен:', error);
this.isAgentRunning = false;
return { running: false, error: error.message };
}
}
/**
* Автоматическая установка и запуск агента
*/
async installAndStartAgent() {
try {
// Сначала проверяем, может агент уже запущен
const status = await this.checkAgentStatus();
if (status.running) {
return { success: true, message: 'Агент уже запущен' };
}
// Пытаемся запустить агент через системный вызов
const response = await fetch(`${LOCAL_AGENT_URL}/install`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
action: 'install_and_start'
})
});
if (response.ok) {
const result = await response.json();
this.isAgentRunning = true;
return { success: true, message: 'Агент успешно установлен и запущен' };
} else {
// Если агент не отвечает, пытаемся скачать и установить его
return await this.downloadAndInstallAgent();
}
} catch (error) {
console.error('Ошибка при установке агента:', error);
return await this.downloadAndInstallAgent();
}
}
/**
* Скачивание и установка агента
*/
async downloadAndInstallAgent() {
try {
// Создаем скрипт для скачивания и установки агента
const installScript = `
#!/bin/bash
# Создаем директорию для агента
mkdir -p ~/.webssh-agent
cd ~/.webssh-agent
# Скачиваем агент (пока создаем локально)
cat > agent.js << 'EOF'
${this.getAgentCode()}
EOF
# Скачиваем package.json
cat > package.json << 'EOF'
{
"name": "webssh-agent",
"version": "1.0.0",
"description": "Local SSH tunnel agent",
"main": "agent.js",
"scripts": {
"start": "node agent.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"ssh2": "^1.14.0",
"node-ssh": "^13.1.0"
}
}
EOF
# Устанавливаем зависимости
npm install
# Запускаем агент в фоне
nohup node agent.js > agent.log 2>&1 &
echo "Агент установлен и запущен"
`;
// Создаем Blob со скриптом
const blob = new Blob([installScript], { type: 'application/x-sh' });
const url = URL.createObjectURL(blob);
// Создаем ссылку для скачивания
const a = document.createElement('a');
a.href = url;
a.download = 'install-webssh-agent.sh';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
return {
success: false,
message: 'Скачайте и запустите скрипт install-webssh-agent.sh для установки агента',
requiresManualInstall: true
};
} catch (error) {
console.error('Ошибка при создании установочного скрипта:', error);
return {
success: false,
message: 'Ошибка при подготовке установки агента',
error: error.message
};
}
}
/**
* Создание SSH туннеля
*/
async createTunnel(config) {
try {
// Проверяем, что агент запущен
const agentStatus = await this.checkAgentStatus();
if (!agentStatus.running) {
// Пытаемся установить и запустить агент
const installResult = await this.installAndStartAgent();
if (!installResult.success) {
return installResult;
}
}
// Отправляем конфигурацию туннеля агенту
const response = await fetch(`${LOCAL_AGENT_URL}/tunnel/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
domain: config.domain,
email: config.email,
sshHost: config.sshHost,
sshUser: config.sshUser,
sshKey: config.sshKey,
localPort: config.localPort || 5173,
serverPort: config.serverPort || 9000,
sshPort: config.sshPort || 22
})
});
if (response.ok) {
const result = await response.json();
if (result.success) {
this.connectionStatus = {
connected: true,
domain: config.domain,
tunnelId: result.tunnelId
};
}
return result;
} else {
const error = await response.json();
return {
success: false,
message: error.message || 'Ошибка при создании туннеля'
};
}
} catch (error) {
console.error('Ошибка при создании туннеля:', error);
return {
success: false,
message: `Ошибка подключения к агенту: ${error.message}`
};
}
}
/**
* Отключение туннеля
*/
async disconnectTunnel() {
try {
const response = await fetch(`${LOCAL_AGENT_URL}/tunnel/disconnect`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
tunnelId: this.connectionStatus.tunnelId
})
});
if (response.ok) {
const result = await response.json();
if (result.success) {
this.connectionStatus = {
connected: false,
domain: null,
tunnelId: null
};
}
return result;
} else {
const error = await response.json();
return {
success: false,
message: error.message || 'Ошибка при отключении туннеля'
};
}
} catch (error) {
console.error('Ошибка при отключении туннеля:', error);
return {
success: false,
message: `Ошибка подключения к агенту: ${error.message}`
};
}
}
/**
* Получение статуса подключения
*/
async getStatus() {
try {
const response = await fetch(`${LOCAL_AGENT_URL}/tunnel/status`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
const result = await response.json();
this.connectionStatus = result;
return result;
} else {
return {
connected: false,
domain: null,
tunnelId: null
};
}
} catch (error) {
console.error('Ошибка при получении статуса:', error);
return {
connected: false,
domain: null,
tunnelId: null
};
}
}
/**
* Получение кода агента для установки
*/
getAgentCode() {
return `
const express = require('express');
const cors = require('cors');
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const { NodeSSH } = require('node-ssh');
const app = express();
const PORT = 12345;
// Middleware
app.use(cors());
app.use(express.json());
// Состояние туннеля
let tunnelState = {
connected: false,
domain: null,
tunnelId: null,
sshProcess: null
};
// Здоровье агента
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Создание туннеля
app.post('/tunnel/create', async (req, res) => {
try {
const { domain, email, sshHost, sshUser, sshKey, localPort, serverPort, sshPort } = req.body;
console.log('Создание туннеля для домена:', domain);
// Сохраняем SSH ключ во временный файл
const keyPath = path.join(__dirname, 'temp_ssh_key');
fs.writeFileSync(keyPath, sshKey, { mode: 0o600 });
// Подключаемся к серверу и настраиваем NGINX
const ssh = new NodeSSH();
await ssh.connect({
host: sshHost,
username: sshUser,
privateKey: sshKey,
port: sshPort
});
// Установка NGINX и certbot
await ssh.execCommand('apt-get update && apt-get install -y nginx certbot python3-certbot-nginx');
// Создание конфигурации NGINX
const nginxConfig = \`
server {
listen 80;
server_name \${domain};
location / {
proxy_pass http://localhost:\${serverPort};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
\`;
await ssh.execCommand(\`echo '\${nginxConfig}' > /etc/nginx/sites-available/\${domain}\`);
await ssh.execCommand(\`ln -sf /etc/nginx/sites-available/\${domain} /etc/nginx/sites-enabled/\`);
await ssh.execCommand('nginx -t && systemctl reload nginx');
// Получение SSL сертификата
await ssh.execCommand(\`certbot --nginx -d \${domain} --non-interactive --agree-tos --email \${email}\`);
ssh.dispose();
// Создание SSH туннеля
const tunnelId = Date.now().toString();
const sshArgs = [
'-i', keyPath,
'-p', sshPort.toString(),
'-R', \`\${serverPort}:localhost:\${localPort}\`,
'-N',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
\`\${sshUser}@\${sshHost}\`
];
const sshProcess = spawn('ssh', sshArgs);
sshProcess.on('error', (error) => {
console.error('SSH процесс ошибка:', error);
});
sshProcess.on('close', (code) => {
console.log('SSH процесс завершен с кодом:', code);
tunnelState.connected = false;
});
// Обновляем состояние
tunnelState = {
connected: true,
domain,
tunnelId,
sshProcess
};
res.json({
success: true,
message: 'Туннель успешно создан',
tunnelId,
domain
});
} catch (error) {
console.error('Ошибка создания туннеля:', error);
res.status(500).json({
success: false,
message: error.message
});
}
});
// Отключение туннеля
app.post('/tunnel/disconnect', (req, res) => {
try {
if (tunnelState.sshProcess) {
tunnelState.sshProcess.kill();
}
tunnelState = {
connected: false,
domain: null,
tunnelId: null,
sshProcess: null
};
res.json({
success: true,
message: 'Туннель отключен'
});
} catch (error) {
console.error('Ошибка отключения туннеля:', error);
res.status(500).json({
success: false,
message: error.message
});
}
});
// Статус туннеля
app.get('/tunnel/status', (req, res) => {
res.json({
connected: tunnelState.connected,
domain: tunnelState.domain,
tunnelId: tunnelState.tunnelId
});
});
// Запуск сервера
app.listen(PORT, 'localhost', () => {
console.log(\`WebSSH Agent запущен на порту \${PORT}\`);
});
`;
}
}
// Создаем композабл для использования в компонентах
export function useWebSshService() {
const service = new WebSshService();
return {
checkAgentStatus: () => service.checkAgentStatus(),
installAndStartAgent: () => service.installAndStartAgent(),
createTunnel: (config) => service.createTunnel(config),
disconnectTunnel: () => service.disconnectTunnel(),
getStatus: () => service.getStatus()
};
}
export default WebSshService;

86
frontend/src/utils/api.js Normal file
View File

@@ -0,0 +1,86 @@
/**
* Безопасный fetch с проверкой JSON-ответа
* @param {string} url - URL для запроса
* @param {object} options - Опции для fetch
* @returns {Promise<any>} - Распарсенный JSON или ошибка
*/
export async function safeFetch(url, options = {}) {
try {
const response = await fetch(url, options);
// Проверяем статус ответа
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Проверяем Content-Type
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
// Если это HTML, значит, запрос ушёл не на API
if (contentType && contentType.includes('text/html')) {
throw new Error('Сервер вернул HTML вместо JSON. Проверьте путь к API.');
}
throw new Error(`Неожиданный Content-Type: ${contentType}`);
}
// Парсим JSON
const data = await response.json();
return data;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* Обёртка для GET-запросов
* @param {string} url - URL для запроса
* @returns {Promise<any>} - Распарсенный JSON
*/
export async function apiGet(url) {
return safeFetch(url);
}
/**
* Обёртка для POST-запросов
* @param {string} url - URL для запроса
* @param {object} data - Данные для отправки
* @returns {Promise<any>} - Распарсенный JSON
*/
export async function apiPost(url, data) {
return safeFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
}
/**
* Обёртка для PUT-запросов
* @param {string} url - URL для запроса
* @param {object} data - Данные для отправки
* @returns {Promise<any>} - Распарсенный JSON
*/
export async function apiPut(url, data) {
return safeFetch(url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
}
/**
* Обёртка для DELETE-запросов
* @param {string} url - URL для запроса
* @returns {Promise<any>} - Распарсенный JSON
*/
export async function apiDelete(url) {
return safeFetch(url, {
method: 'DELETE',
});
}

View File

@@ -37,7 +37,7 @@ export const connectWallet = async () => {
// Запрашиваем nonce с сервера // Запрашиваем nonce с сервера
console.log('Requesting nonce...'); console.log('Requesting nonce...');
const nonceResponse = await axios.get(`/api/auth/nonce?address=${normalizedAddress}`); const nonceResponse = await axios.get(`/auth/nonce?address=${normalizedAddress}`);
const nonce = nonceResponse.data.nonce; const nonce = nonceResponse.data.nonce;
console.log('Got nonce:', nonce); console.log('Got nonce:', nonce);
@@ -88,7 +88,7 @@ export const connectWallet = async () => {
// Отправляем верификацию на сервер // Отправляем верификацию на сервер
console.log('Sending verification request...'); console.log('Sending verification request...');
const verifyResponse = await axios.post('/api/auth/verify', { const verifyResponse = await axios.post('/auth/verify', {
address: normalizedAddress, address: normalizedAddress,
signature, signature,
nonce, nonce,

View File

@@ -70,7 +70,8 @@ let ws = null;
function connectWebSocket() { function connectWebSocket() {
if (ws) ws.close(); if (ws) ws.close();
ws = new WebSocket('ws://localhost:8000'); const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${wsProtocol}://${window.location.host}/ws`);
ws.onopen = () => { ws.onopen = () => {
console.log('[CRM] WebSocket соединение установлено'); console.log('[CRM] WebSocket соединение установлено');
}; };

View File

@@ -40,8 +40,7 @@ async function reloadDleList() {
} }
onMounted(async () => { onMounted(async () => {
dleList.value = await dleService.getAllDLEs() || []; await reloadDleList();
onMounted(reloadDleList);
}); });
</script> </script>

View File

@@ -27,8 +27,6 @@
@send-message="handleSendMessage" @send-message="handleSendMessage"
@load-more="loadMessages" @load-more="loadMessages"
/> />
<!-- Можно добавить заглушку или пояснение -->
<div class="empty-table-placeholder">Вы видите только свои сообщения. Данные других пользователей недоступны.</div>
</template> </template>
</BaseLayout> </BaseLayout>
</template> </template>

View File

@@ -87,33 +87,33 @@ const filteredEmbeddingModels = computed(() => {
return embeddingModels.value.filter(m => m.provider === selectedLLM.value.provider); return embeddingModels.value.filter(m => m.provider === selectedLLM.value.provider);
}); });
async function loadUserTables() { async function loadUserTables() {
const { data } = await axios.get('/api/tables'); const { data } = await axios.get('/tables');
userTables.value = Array.isArray(data) ? data : []; userTables.value = Array.isArray(data) ? data : [];
} }
async function loadRules() { async function loadRules() {
const { data } = await axios.get('/api/settings/ai-assistant-rules'); const { data } = await axios.get('/settings/ai-assistant-rules');
rulesList.value = data.rules || []; rulesList.value = data.rules || [];
} }
async function loadSettings() { async function loadSettings() {
const { data } = await axios.get('/api/settings/ai-assistant'); const { data } = await axios.get('/settings/ai-assistant');
if (data.success && data.settings) { if (data.success && data.settings) {
settings.value = data.settings; settings.value = data.settings;
} }
} }
async function loadTelegramBots() { async function loadTelegramBots() {
const { data } = await axios.get('/api/settings/telegram-settings/list'); const { data } = await axios.get('/settings/telegram-settings/list');
telegramBots.value = data.items || []; telegramBots.value = data.items || [];
} }
async function loadEmailList() { async function loadEmailList() {
const { data } = await axios.get('/api/settings/email-settings/list'); const { data } = await axios.get('/settings/email-settings/list');
emailList.value = data.items || []; emailList.value = data.items || [];
} }
async function loadLLMModels() { async function loadLLMModels() {
const { data } = await axios.get('/api/settings/llm-models'); const { data } = await axios.get('/settings/llm-models');
llmModels.value = data.models || []; llmModels.value = data.models || [];
} }
async function loadEmbeddingModels() { async function loadEmbeddingModels() {
const { data } = await axios.get('/api/settings/embedding-models'); const { data } = await axios.get('/settings/embedding-models');
embeddingModels.value = data.models || []; embeddingModels.value = data.models || [];
} }
onMounted(() => { onMounted(() => {
@@ -126,7 +126,7 @@ onMounted(() => {
loadEmbeddingModels(); loadEmbeddingModels();
}); });
async function saveSettings() { async function saveSettings() {
await axios.put('/api/settings/ai-assistant', settings.value); await axios.put('/settings/ai-assistant', settings.value);
goBack(); goBack();
} }
function openRuleEditor(ruleId = null) { function openRuleEditor(ruleId = null) {
@@ -139,7 +139,7 @@ function openRuleEditor(ruleId = null) {
} }
async function deleteRule(ruleId) { async function deleteRule(ruleId) {
if (!confirm('Удалить этот набор правил?')) return; if (!confirm('Удалить этот набор правил?')) return;
await axios.delete(`/api/settings/ai-assistant-rules/${ruleId}`); await axios.delete(`/settings/ai-assistant-rules/${ruleId}`);
await loadRules(); await loadRules();
if (settings.value.rules_id === ruleId) settings.value.rules_id = null; if (settings.value.rules_id === ruleId) settings.value.rules_id = null;
} }

View File

@@ -63,7 +63,7 @@ const editMode = ref(false);
const loadDbSettings = async () => { const loadDbSettings = async () => {
try { try {
const res = await api.get('/api/settings/db-settings'); const res = await api.get('/settings/db-settings');
if (res.data.success) { if (res.data.success) {
const s = res.data.settings; const s = res.data.settings;
form.dbHost = s.db_host; form.dbHost = s.db_host;
@@ -85,7 +85,7 @@ onMounted(async () => {
const saveDbSettings = async () => { const saveDbSettings = async () => {
try { try {
await api.put('/api/settings/db-settings', { await api.put('/settings/db-settings', {
db_host: form.dbHost, db_host: form.dbHost,
db_port: form.dbPort, db_port: form.dbPort,
db_name: form.dbName, db_name: form.dbName,

View File

@@ -74,7 +74,7 @@ const editMode = ref(false);
const loadEmailSettings = async () => { const loadEmailSettings = async () => {
try { try {
const res = await api.get('/api/settings/email-settings'); const res = await api.get('/settings/email-settings');
if (res.data.success) { if (res.data.success) {
const s = res.data.settings; const s = res.data.settings;
form.smtpHost = s.smtp_host; form.smtpHost = s.smtp_host;
@@ -98,7 +98,7 @@ onMounted(async () => {
const saveEmailSettings = async () => { const saveEmailSettings = async () => {
try { try {
await api.put('/api/settings/email-settings', { await api.put('/settings/email-settings', {
smtp_host: form.smtpHost, smtp_host: form.smtpHost,
smtp_port: form.smtpPort, smtp_port: form.smtpPort,
smtp_user: form.smtpUser, smtp_user: form.smtpUser,

View File

@@ -45,7 +45,7 @@ const editMode = ref(false);
const loadTelegramSettings = async () => { const loadTelegramSettings = async () => {
try { try {
const res = await api.get('/api/settings/telegram-settings'); const res = await api.get('/settings/telegram-settings');
if (res.data.success) { if (res.data.success) {
const s = res.data.settings; const s = res.data.settings;
form.botToken = s.bot_token || ''; form.botToken = s.bot_token || '';
@@ -64,7 +64,7 @@ onMounted(async () => {
const saveTelegramSettings = async () => { const saveTelegramSettings = async () => {
try { try {
await api.put('/api/telegram-settings', { await api.put('/telegram-settings', {
bot_token: form.botToken, bot_token: form.botToken,
bot_username: form.botUsername bot_username: form.botUsername
}); });

View File

@@ -70,7 +70,7 @@ const saveError = ref('');
async function loadSettings() { async function loadSettings() {
try { try {
const { data } = await axios.get(`/api/settings/ai-settings/${props.provider}`); const { data } = await axios.get(`/settings/ai-settings/${props.provider}`);
if (data.settings) { if (data.settings) {
apiKey.value = data.settings.api_key || ''; apiKey.value = data.settings.api_key || '';
baseUrl.value = data.settings.base_url || ''; baseUrl.value = data.settings.base_url || '';
@@ -91,7 +91,7 @@ async function loadSettings() {
async function loadModels() { async function loadModels() {
try { try {
const { data } = await axios.get(`/api/settings/ai-settings/${props.provider}/models`); const { data } = await axios.get(`/settings/ai-settings/${props.provider}/models`);
models.value = data.models || []; models.value = data.models || [];
if (!selectedModel.value && models.value.length) { if (!selectedModel.value && models.value.length) {
const first = models.value[0]; const first = models.value[0];
@@ -104,7 +104,7 @@ async function loadModels() {
async function loadEmbeddingModels() { async function loadEmbeddingModels() {
try { try {
const { data } = await axios.get(`/api/settings/ai-settings/${props.provider}/models`); const { data } = await axios.get(`/settings/ai-settings/${props.provider}/models`);
embeddingModels.value = (data.models || []).filter(m => { embeddingModels.value = (data.models || []).filter(m => {
const name = m.id || m.name || m; const name = m.id || m.name || m;
return name && name.toLowerCase().includes('embed'); return name && name.toLowerCase().includes('embed');
@@ -123,7 +123,7 @@ async function onVerify() {
verifyStatus.value = null; verifyStatus.value = null;
verifyError.value = ''; verifyError.value = '';
try { try {
const { data } = await axios.post(`/api/settings/ai-settings/${props.provider}/verify`, { const { data } = await axios.post(`/settings/ai-settings/${props.provider}/verify`, {
api_key: apiKey.value, api_key: apiKey.value,
base_url: baseUrl.value, base_url: baseUrl.value,
}); });
@@ -144,7 +144,7 @@ async function onSave() {
saveStatus.value = null; saveStatus.value = null;
saveError.value = ''; saveError.value = '';
try { try {
await axios.put(`/api/settings/ai-settings/${props.provider}`, { await axios.put(`/settings/ai-settings/${props.provider}`, {
api_key: apiKey.value, api_key: apiKey.value,
base_url: baseUrl.value, base_url: baseUrl.value,
selected_model: selectedModel.value, selected_model: selectedModel.value,
@@ -161,7 +161,7 @@ async function onSave() {
} }
async function onDelete() { async function onDelete() {
await axios.delete(`/api/settings/ai-settings/${props.provider}`); await axios.delete(`/settings/ai-settings/${props.provider}`);
apiKey.value = ''; apiKey.value = '';
baseUrl.value = ''; baseUrl.value = '';
selectedModel.value = ''; selectedModel.value = '';

View File

@@ -93,7 +93,7 @@ async function addToken() {
return; return;
} }
try { try {
await api.post('/api/settings/auth-token', { await api.post('/settings/auth-token', {
...newToken, ...newToken,
minBalance: Number(newToken.minBalance) || 0 minBalance: Number(newToken.minBalance) || 0
}); });

View File

@@ -446,7 +446,7 @@ const fetchIsicCodes = async (params = {}, optionsRef, loadingRef) => {
console.debug(`[BlockchainSettingsView] Fetching ISIC codes with params: ${queryParams}`); console.debug(`[BlockchainSettingsView] Fetching ISIC codes with params: ${queryParams}`);
// Убедитесь, что базовый URL настроен правильно (например, через axios interceptors или .env) // Убедитесь, что базовый URL настроен правильно (например, через axios interceptors или .env)
const response = await axios.get(`/api/isic/codes?${queryParams}`); const response = await axios.get(`/isic/codes?${queryParams}`);
if (response.data && Array.isArray(response.data.codes)) { if (response.data && Array.isArray(response.data.codes)) {
optionsRef.value = response.data.codes.map(code => ({ optionsRef.value = response.data.codes.map(code => ({
@@ -716,7 +716,7 @@ const fetchAddressByZipcode = async () => {
} }
console.log(`[FetchByZipcode] Querying backend proxy for Nominatim with: ${params.toString()}`); console.log(`[FetchByZipcode] Querying backend proxy for Nominatim with: ${params.toString()}`);
const response = await axios.get(`/api/geocoding/nominatim-search?${params.toString()}`); const response = await axios.get(`/geocoding/nominatim-search?${params.toString()}`);
if (response.data && response.data.length > 0) { if (response.data && response.data.length > 0) {
const bestMatch = response.data[0]; const bestMatch = response.data[0];
@@ -818,7 +818,7 @@ const verifyAddress = async () => {
console.log(`[VerifyAddress] Querying backend proxy for Nominatim with: ${params.toString()}`); console.log(`[VerifyAddress] Querying backend proxy for Nominatim with: ${params.toString()}`);
// Запрос теперь идет на ваш бэкенд-прокси // Запрос теперь идет на ваш бэкенд-прокси
const response = await axios.get(`/api/geocoding/nominatim-search?${params.toString()}`); const response = await axios.get(`/geocoding/nominatim-search?${params.toString()}`);
// Ответ от бэкенд-прокси должен иметь ту же структуру, что и прямой ответ от Nominatim // Ответ от бэкенд-прокси должен иметь ту же структуру, что и прямой ответ от Nominatim
if (response.data && Array.isArray(response.data)) { // Проверяем, что это массив (как отвечает Nominatim) if (response.data && Array.isArray(response.data)) { // Проверяем, что это массив (как отвечает Nominatim)
@@ -901,7 +901,7 @@ const toggleShowDeployerKey = () => {
// Функция загрузки настроек RPC с сервера // Функция загрузки настроек RPC с сервера
const loadRpcSettings = async () => { const loadRpcSettings = async () => {
try { try {
const response = await axios.get('/api/settings/rpc'); const response = await axios.get('/settings/rpc');
console.log('Ответ сервера на /api/settings/rpc:', response.data); console.log('Ответ сервера на /api/settings/rpc:', response.data);
if (response.data && response.data.success) { if (response.data && response.data.success) {
securitySettings.rpcConfigs = (response.data.data || []).map(rpc => ({ securitySettings.rpcConfigs = (response.data.data || []).map(rpc => ({
@@ -921,7 +921,7 @@ const loadRpcSettings = async () => {
const saveRpcSettings = async () => { const saveRpcSettings = async () => {
try { try {
console.log('Отправляемые RPC:', securitySettings.rpcConfigs); console.log('Отправляемые RPC:', securitySettings.rpcConfigs);
const response = await axios.post('/api/settings/rpc', { const response = await axios.post('/settings/rpc', {
rpcConfigs: JSON.parse(JSON.stringify(securitySettings.rpcConfigs)) rpcConfigs: JSON.parse(JSON.stringify(securitySettings.rpcConfigs))
}); });

View File

@@ -73,7 +73,7 @@ async function addRpc() {
return; return;
} }
try { try {
await api.post('/api/settings/rpc', { networkId, rpcUrl, chainId }); await api.post('/settings/rpc', { networkId, rpcUrl, chainId });
emit('update'); // сигнал родителю перезагрузить список emit('update'); // сигнал родителю перезагрузить список
resetNetworkEntry(); resetNetworkEntry();
} catch (e) { } catch (e) {

View File

@@ -138,7 +138,7 @@ const loadSettings = async () => {
isLoading.value = true; isLoading.value = true;
try { try {
// Загрузка RPC конфигураций // Загрузка RPC конфигураций
const rpcResponse = await api.get('/api/settings/rpc'); const rpcResponse = await api.get('/settings/rpc');
if (rpcResponse.data && rpcResponse.data.success) { if (rpcResponse.data && rpcResponse.data.success) {
securitySettings.rpcConfigs = (rpcResponse.data.data || []).map(rpc => ({ securitySettings.rpcConfigs = (rpcResponse.data.data || []).map(rpc => ({
networkId: rpc.network_id, networkId: rpc.network_id,
@@ -149,7 +149,7 @@ const loadSettings = async () => {
} }
// Загрузка токенов для аутентификации // Загрузка токенов для аутентификации
const authResponse = await api.get('/api/settings/auth-tokens'); const authResponse = await api.get('/settings/auth-tokens');
if (authResponse.data && authResponse.data.success) { if (authResponse.data && authResponse.data.success) {
securitySettings.authTokens = (authResponse.data.data || []).map(token => ({ securitySettings.authTokens = (authResponse.data.data || []).map(token => ({
name: token.name, name: token.name,
@@ -195,12 +195,12 @@ const saveSecuritySettings = async () => {
console.log('[SecuritySettingsView] Отправка настроек на сервер:', settingsData); console.log('[SecuritySettingsView] Отправка настроек на сервер:', settingsData);
// Отправка RPC конфигураций // Отправка RPC конфигураций
const rpcResponse = await api.post('/api/settings/rpc', { const rpcResponse = await api.post('/settings/rpc', {
rpcConfigs: settingsData.rpcConfigs rpcConfigs: settingsData.rpcConfigs
}); });
// Отправка токенов для аутентификации // Отправка токенов для аутентификации
const authResponse = await api.post('/api/settings/auth-tokens', { const authResponse = await api.post('/settings/auth-tokens', {
authTokens: settingsData.authTokens authTokens: settingsData.authTokens
}); });

View File

@@ -20,6 +20,11 @@
<p>Настройки внешнего вида, локализации и пользовательского опыта.</p> <p>Настройки внешнего вида, локализации и пользовательского опыта.</p>
<button class="details-btn" @click="$router.push('/settings/interface')">Подробнее</button> <button class="details-btn" @click="$router.push('/settings/interface')">Подробнее</button>
</div> </div>
<div class="main-block">
<h3>WEB SSH</h3>
<p>Автоматическая публикация приложения в интернете через SSH-туннель.</p>
<button class="details-btn" @click="$router.push('/settings/webssh')">Подробнее</button>
</div>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,703 @@
<template>
<div class="web-ssh-settings">
<div class="settings-header">
<h2>WEB SSH Туннель</h2>
<p>Автоматическая публикация локального приложения в интернете через SSH-туннель и NGINX</p>
</div>
<div v-if="!agentAvailable" class="agent-instruction-block">
<h3>Установка локального агента</h3>
<ol>
<li>
<b>Windows:</b><br>
Откройте <b>PowerShell</b> или <b>Командную строку</b> и выполните:
<div class="copy-block" @click="copyToClipboard('wsl')">
<pre><code>wsl</code></pre>
<span class="copy-icon">
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="5" y="7" width="9" height="9" rx="2" stroke="#888" stroke-width="1.5"/><rect x="7" y="4" width="9" height="9" rx="2" stroke="#888" stroke-width="1.5"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M5 11.5L9 15L15 7" stroke="#27ae60" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</div>
Затем в открывшемся терминале WSL выполните:
<div class="copy-block" @click="copyToClipboard('cd ~/DApp-for-Business\nsudo bash webssh-agent/install.sh')">
<pre><code>cd ~/DApp-for-Business
sudo bash webssh-agent/install.sh</code></pre>
<span class="copy-icon">
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="5" y="7" width="9" height="9" rx="2" stroke="#888" stroke-width="1.5"/><rect x="7" y="4" width="9" height="9" rx="2" stroke="#888" stroke-width="1.5"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M5 11.5L9 15L15 7" stroke="#27ae60" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</div>
</li>
<li>
<b>Linux:</b><br>
Откройте терминал и выполните:
<div class="copy-block" @click="copyToClipboard('cd ~/DApp-for-Business\nsudo bash webssh-agent/install.sh')">
<pre><code>cd ~/DApp-for-Business
sudo bash webssh-agent/install.sh</code></pre>
<span class="copy-icon">
<svg v-if="!copied" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><rect x="5" y="7" width="9" height="9" rx="2" stroke="#888" stroke-width="1.5"/><rect x="7" y="4" width="9" height="9" rx="2" stroke="#888" stroke-width="1.5"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M5 11.5L9 15L15 7" stroke="#27ae60" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</div>
</li>
</ol>
<button @click="checkAgent" class="check-btn">Проверить</button>
<div v-if="copied" class="copied-indicator">Скопировано!</div>
</div>
<!-- Форма публикации всегда доступна -->
<div>
<!-- Статус подключения -->
<div class="connection-status">
<div class="status-indicator" :class="{ 'active': isConnected, 'inactive': !isConnected }"></div>
<span class="status-text">{{ connectionStatus }}</span>
<button v-if="isConnected" @click="disconnectTunnel" class="disconnect-btn">Отключить</button>
</div>
<!-- Форма настроек -->
<form @submit.prevent="handleSubmit" class="tunnel-form">
<div class="form-section">
<h3>Настройки домена</h3>
<div class="form-group">
<label for="domain">Домен *</label>
<input
id="domain"
v-model="form.domain"
type="text"
placeholder="example.com"
required
:disabled="isConnected"
/>
</div>
<div class="form-group">
<label for="email">Email для SSL *</label>
<input
id="email"
v-model="form.email"
type="email"
placeholder="admin@example.com"
required
:disabled="isConnected"
/>
</div>
</div>
<div class="form-section">
<h3>Настройки SSH сервера</h3>
<div class="form-group">
<label for="sshHost">SSH Host/IP *</label>
<input
id="sshHost"
v-model="form.sshHost"
type="text"
placeholder="192.168.1.100 или server.example.com"
required
:disabled="isConnected"
/>
</div>
<div class="form-group">
<label for="sshUser">SSH Пользователь *</label>
<input
id="sshUser"
v-model="form.sshUser"
type="text"
placeholder="root"
required
:disabled="isConnected"
/>
</div>
<div class="form-group">
<label for="sshKey">SSH Приватный ключ *</label>
<textarea
id="sshKey"
v-model="form.sshKey"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----"
rows="6"
required
:disabled="isConnected"
></textarea>
</div>
</div>
<div class="form-section advanced-section">
<h3>Дополнительные настройки</h3>
<div class="form-row">
<div class="form-group">
<label for="localPort">Локальный порт</label>
<input
id="localPort"
v-model="form.localPort"
type="number"
min="1"
max="65535"
:disabled="isConnected"
/>
</div>
<div class="form-group">
<label for="serverPort">Порт сервера</label>
<input
id="serverPort"
v-model="form.serverPort"
type="number"
min="1"
max="65535"
:disabled="isConnected"
/>
</div>
<div class="form-group">
<label for="sshPort">SSH порт</label>
<input
id="sshPort"
v-model="form.sshPort"
type="number"
min="1"
max="65535"
:disabled="isConnected"
/>
</div>
</div>
</div>
<div class="form-actions">
<button
type="submit"
:disabled="isLoading || isConnected"
class="publish-btn"
>
{{ isLoading ? 'Настройка...' : 'Опубликовать' }}
</button>
<button
type="button"
@click="resetForm"
:disabled="isLoading || isConnected"
class="reset-btn"
>
Сбросить
</button>
</div>
</form>
<!-- Лог операций -->
<div class="operation-log" v-if="logs.length > 0">
<h3>Лог операций</h3>
<div class="log-container">
<div
v-for="(log, index) in logs"
:key="index"
class="log-entry"
:class="log.type"
>
<span class="log-time">{{ formatTime(log.timestamp) }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue';
import { useWebSshService } from '../../services/webSshService';
const webSshService = useWebSshService();
const agentAvailable = ref(false);
// Реактивные данные
const isLoading = ref(false);
const isConnected = ref(false);
const connectionStatus = ref('Не подключено');
const logs = ref([]);
// Форма
const form = reactive({
domain: '',
email: '',
sshHost: '',
sshUser: '',
sshKey: '',
localPort: 5173,
serverPort: 9000,
sshPort: 22
});
const copied = ref(false);
let copyTimeout = null;
function copyToClipboard(text) {
navigator.clipboard.writeText(text.replace(/\\n/g, '\n')).then(() => {
copied.value = true;
clearTimeout(copyTimeout);
copyTimeout = setTimeout(() => copied.value = false, 1200);
});
}
function validatePrivateKey(key) {
if (!key) return false;
const trimmed = key.trim();
if (!trimmed.startsWith('-----BEGIN OPENSSH PRIVATE KEY-----')) {
console.error('Ключ не начинается с -----BEGIN OPENSSH PRIVATE KEY-----');
return false;
}
if (!trimmed.endsWith('-----END OPENSSH PRIVATE KEY-----')) {
console.error('Ключ не заканчивается на -----END OPENSSH PRIVATE KEY-----');
return false;
}
if (trimmed.split('\n').length < 3) {
console.error('Ключ слишком короткий или не содержит переносов строк');
return false;
}
return true;
}
// Методы
const handleSubmit = async () => {
if (!validateForm()) return;
// Дополнительная валидация приватного ключа
if (!validatePrivateKey(form.sshKey)) {
addLog('error', 'Проверьте формат приватного ключа!');
return;
}
// Логирование ключа (только для отладки!)
console.log('SSH ключ (начало):', form.sshKey.slice(0, 40));
console.log('SSH ключ (конец):', form.sshKey.slice(-40));
console.log('Длина ключа:', form.sshKey.length);
// Логирование отправляемых данных (без самого ключа)
console.log('Данные для агента:', {
...form,
sshKey: form.sshKey ? `[скрыто, длина: ${form.sshKey.length}]` : 'нет ключа'
});
isLoading.value = true;
addLog('info', 'Запуск публикации...');
try {
// Публикация через агента
const result = await webSshService.createTunnel(form);
if (result.success) {
isConnected.value = true;
connectionStatus.value = `Подключено к ${form.domain}`;
addLog('success', 'SSH туннель успешно создан и настроен');
addLog('info', `Ваше приложение доступно по адресу: https://${form.domain}`);
} else {
addLog('error', result.message || 'Ошибка при создании туннеля');
}
} catch (error) {
addLog('error', `Ошибка: ${error.message}`);
} finally {
isLoading.value = false;
}
};
const disconnectTunnel = async () => {
isLoading.value = true;
addLog('info', 'Отключаю SSH туннель...');
try {
const result = await webSshService.disconnectTunnel();
if (result.success) {
isConnected.value = false;
connectionStatus.value = 'Не подключено';
addLog('success', 'SSH туннель отключен');
} else {
addLog('error', result.message || 'Ошибка при отключении туннеля');
}
} catch (error) {
addLog('error', `Ошибка: ${error.message}`);
} finally {
isLoading.value = false;
}
};
const validateForm = () => {
if (!form.domain || !form.email || !form.sshHost || !form.sshUser || !form.sshKey) {
addLog('error', 'Заполните все обязательные поля');
return false;
}
if (!form.email.includes('@')) {
addLog('error', 'Введите корректный email');
return false;
}
if (!form.sshKey.includes('-----BEGIN') || !form.sshKey.includes('-----END')) {
addLog('error', 'SSH ключ должен быть в формате OpenSSH');
return false;
}
return true;
};
const resetForm = () => {
Object.assign(form, {
domain: '',
email: '',
sshHost: '',
sshUser: '',
sshKey: '',
localPort: 5173,
serverPort: 9000,
sshPort: 22
});
logs.value = [];
};
const addLog = (type, message) => {
logs.value.push({
type,
message,
timestamp: new Date()
});
};
const formatTime = (timestamp) => {
return timestamp.toLocaleTimeString();
};
const checkConnectionStatus = async () => {
try {
const status = await webSshService.getStatus();
isConnected.value = status.connected;
connectionStatus.value = status.connected
? `Подключено к ${status.domain}`
: 'Не подключено';
} catch (error) {
console.error('Ошибка проверки статуса:', error);
}
};
const checkAgent = async () => {
const status = await webSshService.checkAgentStatus();
agentAvailable.value = status.running;
};
// Жизненный цикл
onMounted(() => {
checkAgent();
checkConnectionStatus();
// Проверяем статус каждые 30 секунд
const interval = setInterval(checkConnectionStatus, 30000);
onUnmounted(() => {
clearInterval(interval);
});
});
</script>
<style scoped>
.web-ssh-settings {
max-width: 800px;
margin: 0 auto;
}
.settings-header {
margin-bottom: 2rem;
}
.settings-header h2 {
color: var(--color-primary);
margin-bottom: 0.5rem;
}
.settings-header p {
color: var(--color-text-secondary);
font-size: 0.95rem;
}
.connection-status {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--color-background);
border-radius: 8px;
margin-bottom: 2rem;
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
transition: background-color 0.3s;
}
.status-indicator.active {
background-color: var(--color-success);
}
.status-indicator.inactive {
background-color: var(--color-text-secondary);
}
.status-text {
font-weight: 500;
flex: 1;
}
.disconnect-btn {
background: var(--color-danger);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.disconnect-btn:hover {
background: var(--color-danger-dark);
}
.tunnel-form {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
padding: 2rem;
margin-bottom: 2rem;
}
.form-section {
margin-bottom: 2rem;
}
.form-section h3 {
color: var(--color-primary);
margin-bottom: 1rem;
font-size: 1.1rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--color-text);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: 0.95rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--color-primary);
}
.form-group input:disabled,
.form-group textarea:disabled {
background-color: var(--color-background);
cursor: not-allowed;
}
.form-group textarea {
resize: vertical;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
}
.advanced-section {
border-top: 1px solid var(--color-border);
padding-top: 2rem;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
}
.publish-btn {
background: var(--color-primary);
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: background-color 0.2s;
}
.publish-btn:hover:not(:disabled) {
background: var(--color-primary-dark);
}
.publish-btn:disabled {
background: var(--color-text-secondary);
cursor: not-allowed;
}
.reset-btn {
background: var(--color-background);
color: var(--color-text);
border: 1px solid var(--color-border);
padding: 0.75rem 2rem;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
.reset-btn:hover:not(:disabled) {
background: var(--color-border);
}
.reset-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.operation-log {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
padding: 2rem;
}
.operation-log h3 {
color: var(--color-primary);
margin-bottom: 1rem;
font-size: 1.1rem;
}
.log-container {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 1rem;
background: var(--color-background);
}
.log-entry {
display: flex;
gap: 1rem;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.log-entry:last-child {
margin-bottom: 0;
}
.log-time {
color: var(--color-text-secondary);
font-family: 'Courier New', monospace;
min-width: 80px;
}
.log-message {
flex: 1;
}
.log-entry.info .log-message {
color: var(--color-text);
}
.log-entry.success .log-message {
color: var(--color-success);
}
.log-entry.error .log-message {
color: var(--color-danger);
}
/* Адаптивный дизайн */
@media (max-width: 768px) {
.form-row {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
.connection-status {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
.agent-instruction-block {
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 8px;
padding: 1.5rem 2rem;
margin-bottom: 2rem;
text-align: left;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.check-btn {
margin-top: 1rem;
background: var(--color-primary);
color: white;
border: none;
padding: 0.5rem 1.5rem;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
}
pre {
background: #f5f5f5;
border-radius: 4px;
padding: 0.5em 1em;
margin: 0.5em 0;
font-size: 1em;
user-select: all;
white-space: pre-line;
}
.copy-block {
display: flex;
align-items: center;
cursor: pointer;
margin-bottom: 0.5em;
}
.copy-icon {
margin-left: 0.5em;
font-size: 1.2em;
color: #888;
transition: color 0.2s;
display: flex;
align-items: center;
}
.copy-block:hover .copy-icon svg {
stroke: var(--color-primary);
}
.copied-indicator {
color: var(--color-success, #27ae60);
font-weight: 500;
margin-top: 0.5em;
text-align: right;
}
</style>

View File

@@ -21,7 +21,7 @@ const router = useRouter();
const { isAdmin } = useAuthContext(); const { isAdmin } = useAuthContext();
async function remove() { async function remove() {
await axios.delete(`/api/tables/${$route.params.id}`); await axios.delete(`/tables/${$route.params.id}`);
router.push({ name: 'tables-list' }); router.push({ name: 'tables-list' });
} }
function cancel() { function cancel() {

View File

@@ -32,7 +32,7 @@ const description = ref('');
const isRagSourceId = ref(2); const isRagSourceId = ref(2);
onMounted(async () => { onMounted(async () => {
const { data } = await axios.get(`/api/tables/${$route.params.id}`); const { data } = await axios.get(`/tables/${$route.params.id}`);
name.value = data.name; name.value = data.name;
description.value = data.description; description.value = data.description;
isRagSourceId.value = data.is_rag_source_id || 2; isRagSourceId.value = data.is_rag_source_id || 2;

1
id_rsa.pub Normal file
View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEm8Ak7N1CiXWsq38p1g2OSUqnOXJ4/pXS1JtA53wkXP alex@AlexPC

View File

@@ -1,70 +0,0 @@
# План настройки и тестирования поиска по таблице RAG
## 1. Подготовка таблицы RAG
- [x] Убедиться, что таблица RAG создана и содержит пары "вопрос-ответ".
- [x] Добавить несколько тестовых записей через UI или напрямую в базу данных.
## 2. Настройка провайдера эмбеддингов
- [x] В настройках ассистента выбрать нужного провайдера (OpenAI, Ollama и др.).
- [x] Ввести API-ключ и Base URL (например, для OpenAI: https://api.openai.com/v1).
- [x] Сохранить настройки.
## 3. Проверка настроек ассистента
- [x] Убедиться, что выбран актуальный ID таблицы RAG.
- [x] Проверить выбранного провайдера эмбеддингов.
- [x] Установить порог релевантности (например, 0.95).
## 4. Проверка backend-логики
- [x] Проверить, что в backend (например, в ragService.js) реализован поиск по RAG с использованием выбранного провайдера эмбеддингов.
- [x] Убедиться, что используется актуальный ID таблицы и динамический выбор провайдера.
- [x] Проверить возможность изменения порога релевантности.
## 5. Тестирование через UI
- [ ] Отправить ассистенту вопрос, который есть в RAG-таблице — убедиться, что ответ возвращается из базы.
- [ ] Отправить вопрос, которого нет в таблице — убедиться, что ассистент либо не отвечает, либо использует LLM (по настройкам).
## 6. Проверка логов backend
- [ ] Проверить логи на наличие сообщений о поиске по RAG, найденных совпадениях и выбранном провайдере эмбеддингов.
- [ ] В случае ошибок — проанализировать и устранить их.
## 7. Тестирование через API (опционально)
- [ ] Использовать Postman/curl для отправки запросов напрямую к backend.
- [ ] Пример запроса:
```http
POST /api/chat/message
{
"userId": 137,
"message": "Как зовут?"
}
```
## 8. Автоматизация тестирования (по желанию)
- [ ] Написать автотесты (например, на Mocha/Jest), которые будут отправлять вопросы и сверять ответы с ожидаемыми из RAG.
## 9. Рекомендации
- [ ] Для тестов использовать уникальные, простые вопросы и ответы.
- [ ] После каждого изменения настроек проводить тестовые запросы.
- [ ] Добавить в UI индикатор источника ответа (из базы или сгенерирован).
---
## Этапы внедрения сервиса векторного поиска (под ключ)
- [x] 1. Проектирование и создание структуры Python-сервиса (FastAPI + FAISS)
- [x] 2. Реализация REST API: /upsert, /search, /delete, /rebuild, /health
- [x] 3. Интеграция с Ollama для генерации эмбеддингов
- [x] 4. Dockerfile и docker-compose для сервиса
- [ ] 5. Интеграция Node.js backend с новым сервисом (HTTP-клиент)
- [ ] 6. Перенос логики поиска из ragService.js на новый сервис
- [ ] 7. Тестирование интеграции (ручное и через API)
- [ ] 8. Документация по запуску и использованию
- [ ] 9. Финальное тестирование через UI и API
- [ ] 10. Передача проекта заказчику
---
**Прогресс:**
- Сервис векторного поиска реализован, поддерживает кэширование, интеграцию с Ollama, все основные REST API.
- Следующий этап — интеграция с Node.js backend и перенос логики поиска.
**Если потребуется пример кода или помощь с конкретной реализацией — обращайтесь!**

293
md/WEBSSH_SETUP_GUIDE.md Normal file
View File

@@ -0,0 +1,293 @@
# Руководство по настройке WEB SSH
## Обзор
Система WEB SSH позволяет автоматически публиковать локальное Vue.js приложение в интернете через SSH-туннель и NGINX на удаленном сервере.
## Архитектура
```
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Vue.js Frontend │───▶│ WebSSH Agent │───▶│ VPS Server │
│ (localhost:5173) │ │ (localhost:12345) │ │ (ваш сервер) │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
│ │
│ ▼
│ ┌─────────────────────┐
│ │ NGINX + SSL │
│ │ (порт 80/443) │
│ └─────────────────────┘
│ │
│ ▼
│ ┌─────────────────────┐
└─────────────▶│ SSH Reverse Tunnel │
│ (порт 9000) │
└─────────────────────┘
```
## Требования
### Локальная машина
- ✅ Node.js 16+
- ✅ SSH клиент
- ✅ Docker и Docker Compose
- ✅ Yarn
### Удаленный сервер (VPS)
- ✅ Ubuntu/Debian или CentOS/RHEL
- ✅ SSH доступ с правами sudo/root
- ✅ Открытые порты 80, 443, 22
- ✅ Домен, указывающий на IP сервера
## Установка и настройка
### 1. Запуск основного приложения
```bash
# Клонируйте проект
git clone <your-repo-url>
cd DApp-for-Business
# Запустите приложение
docker-compose up -d
# Убедитесь, что фронтенд доступен
curl http://localhost:5173
```
### 2. Установка WebSSH Agent
```bash
# Перейдите в директорию агента
cd webssh-agent
# Установите зависимости
npm install
# Запустите агент
npm start
# или в фоновом режиме:
nohup node agent.js > agent.log 2>&1 &
```
### 3. Проверка работы агента
```bash
# Проверьте статус агента
curl http://localhost:12345/health
# Ожидаемый ответ:
# {"status":"ok","timestamp":"2025-07-06T18:08:57.789Z","version":"1.0.0","tunnelConnected":false}
```
## Использование
### 1. Подготовка SSH ключей
```bash
# Создайте SSH ключ (если еще нет)
ssh-keygen -t rsa -b 4096 -C "webssh@yourdomain.com"
# Скопируйте публичный ключ на сервер
ssh-copy-id -i ~/.ssh/id_rsa.pub user@your-server.com
# Проверьте подключение
ssh -i ~/.ssh/id_rsa user@your-server.com
```
### 2. Настройка через веб-интерфейс
1. Откройте приложение: `http://localhost:5173`
2. Перейдите в **Настройки****WEB SSH**
3. Заполните форму:
#### Обязательные поля:
- **Домен**: `example.com` (ваш домен)
- **Email для SSL**: `admin@example.com` (для Let's Encrypt)
- **SSH Host/IP**: `192.168.1.100` или `server.example.com`
- **SSH Пользователь**: `root` или ваш пользователь
- **SSH Приватный ключ**: содержимое файла `~/.ssh/id_rsa`
#### Дополнительные настройки (заполняются автоматически):
- **Локальный порт**: `5173` (порт Vue.js приложения)
- **Порт сервера**: `9000` (порт для SSH туннеля)
- **SSH порт**: `22` (стандартный SSH порт)
4. Нажмите **"Опубликовать"**
### 3. Процесс публикации
После нажатия "Опубликовать" агент автоматически:
1. ✅ Подключается к вашему серверу по SSH
2. ✅ Устанавливает NGINX и certbot (если не установлены)
3. ✅ Создает конфигурацию NGINX для вашего домена
4. ✅ Перезапускает NGINX
5. ✅ Получает SSL сертификат через Let's Encrypt
6. ✅ Создает SSH reverse-туннель
7. ✅ Ваше приложение становится доступным по адресу `https://yourdomain.com`
## Управление туннелем
### Через веб-интерфейс
- **Статус**: отображается в верхней части страницы
- **Отключить**: кнопка "Отключить" при активном туннеле
- **Логи**: отображаются в нижней части страницы
### Через API
```bash
# Статус туннеля
curl http://localhost:12345/tunnel/status
# Создание туннеля
curl -X POST http://localhost:12345/tunnel/create \
-H "Content-Type: application/json" \
-d '{
"domain": "example.com",
"email": "admin@example.com",
"sshHost": "192.168.1.100",
"sshUser": "root",
"sshKey": "-----BEGIN OPENSSH PRIVATE KEY-----\n...",
"localPort": 5173,
"serverPort": 9000,
"sshPort": 22
}'
# Отключение туннеля
curl -X POST http://localhost:12345/tunnel/disconnect
```
## Безопасность
### Локальная безопасность
- ✅ Агент принимает соединения только с localhost
- ✅ SSH ключи хранятся временно и удаляются после отключения
-Все операции логируются
### Серверная безопасность
- ✅ Используется HTTPS с автоматическими SSL сертификатами
- ✅ NGINX настроен с безопасными заголовками
- ✅ SSH туннель использует приватные ключи
## Устранение неполадок
### Агент не запускается
```bash
# Проверьте порт
netstat -tulpn | grep 12345
# Проверьте логи
tail -f webssh-agent/agent.log
# Убейте процесс и перезапустите
pkill -f "node agent.js"
nohup node agent.js > agent.log 2>&1 &
```
### SSH соединение не устанавливается
```bash
# Проверьте SSH ключ
ssh -i ~/.ssh/id_rsa user@server
# Проверьте доступность сервера
ping your-server.com
# Проверьте SSH порт
telnet your-server.com 22
```
### NGINX не настраивается
```bash
# На сервере проверьте NGINX
sudo nginx -t
sudo systemctl status nginx
# Проверьте логи
sudo tail -f /var/log/nginx/error.log
```
### SSL сертификат не получается
```bash
# Проверьте DNS
nslookup your-domain.com
# Проверьте доступность домена
curl -I http://your-domain.com
# На сервере проверьте certbot
sudo certbot certificates
sudo tail -f /var/log/letsencrypt/letsencrypt.log
```
### Туннель не работает
```bash
# Проверьте процесс SSH
ps aux | grep ssh
# Проверьте порты на сервере
sudo netstat -tulpn | grep 9000
# Проверьте логи агента
tail -f webssh-agent/agent.log
```
## Примеры использования
### Разработка
```bash
# Запустите локальное приложение
docker-compose up -d
# Запустите агент
cd webssh-agent && npm start
# Настройте туннель через веб-интерфейс
# Ваше приложение доступно по https://yourdomain.com
```
### Демонстрация клиентам
```bash
# Быстрая публикация для демо
# 1. Заполните форму в веб-интерфейсе
# 2. Нажмите "Опубликовать"
# 3. Отправьте клиенту ссылку https://yourdomain.com
```
### Тестирование на мобильных устройствах
```bash
# Опубликуйте приложение
# Теперь можете тестировать на любых устройствах
# через https://yourdomain.com
```
## Файловая структура
```
DApp-for-Business/
├── webssh-agent/ # Локальный агент
│ ├── agent.js # Основной файл агента
│ ├── package.json # Зависимости
│ ├── install.sh # Установочный скрипт
│ └── README.md # Документация агента
├── frontend/ # Vue.js приложение
│ ├── src/
│ │ ├── views/settings/
│ │ │ └── WebSshSettingsView.vue # Страница настроек
│ │ └── services/
│ │ └── webSshService.js # Сервис для работы с агентом
│ └── ...
└── docker-compose.yml # Конфигурация Docker
```
## Поддержка
Если у вас возникли проблемы:
1. Проверьте этот документ
2. Посмотрите логи агента: `tail -f webssh-agent/agent.log`
3. Проверьте статус: `curl http://localhost:12345/health`
4. Создайте Issue в репозитории проекта
## Лицензия
MIT License

111
md/WEB_SSH_TUNNEL_PLAN.md Normal file
View File

@@ -0,0 +1,111 @@
# Автоматизация публикации локального приложения через SSH-туннель и NGINX
## Описание задачи
Необходимо реализовать функционал, позволяющий пользователю локального веб-приложения в один клик опубликовать своё приложение в интернете по собственному домену. Для этого используется внешний сервер (VPS) с белым IP и доменом, на котором автоматически настраиваются:
- Установка и настройка NGINX для проксирования домена на SSH-туннель
- Выпуск и установка SSL-сертификата (Let's Encrypt)
- SSH reverse-туннель с сервера на локальное приложение пользователя
- (Опционально) Автоматическая установка NGINX и certbot на сервере по SSH силами локального агента
Пользователь заполняет форму с необходимыми данными, после чего система автоматически выполняет все шаги по настройке инфраструктуры.
---
## Архитектура решения
1. **Локальное приложение** работает в Docker-контейнере на ПК пользователя (порт 5173 проброшен наружу).
2. **Локальный агент** (Node.js-приложение) устанавливается на ПК пользователя и позволяет фронтенду запускать команды (например, поднять SSH-туннель, а также настраивать NGINX/SSL на сервере по SSH) в один клик.
3. **Агент по SSH подключается к серверу (VPS)** и:
- Устанавливает NGINX и certbot (если не установлены)
- Создаёт или обновляет конфиг NGINX для указанного домена (проксирует на порт 9000)
- Перезапускает NGINX
- Выпускает SSL-сертификат через certbot
- Проверяет доступность домена по HTTPS
- Запускает SSH reverse-туннель (9000:localhost:5173)
4. **NGINX** на сервере проксирует домен на порт туннеля (9000).
---
## Необходимые данные для формы
- Домен (например, myapp.example.com)
- Host/IP сервера
- Пользователь SSH
- SSH-ключ (или пароль)
- E-mail для SSL (Let's Encrypt)
**Поля "Локальный порт приложения", "Порт на сервере для туннеля" и "Порт SSH" скрыты и всегда используются значения по умолчанию:**
- Локальный порт: 5173
- Порт на сервере: 9000
- Порт SSH: 22
---
## UX-поток с локальным агентом (финальный порядок)
1. Пользователь заходит в локальное веб-приложение и заполняет форму публикации (домен, SSH и т.д.).
2. Нажимает кнопку "Опубликовать".
3. Агент автоматически скачивается, устанавливается и запускается (без дополнительных действий пользователя).
4. После запуска агента фронтенд отправляет параметры публикации на локальный агент (порты и порт SSH подставляются автоматически).
5. Агент по SSH подключается к серверу и:
- Устанавливает NGINX и certbot (если не установлены)
- Создаёт или обновляет конфиг NGINX для домена
- Перезапускает NGINX
- Выпускает SSL-сертификат через certbot
- Запускает SSH reverse-туннель (9000:localhost:5173, порт SSH всегда 22)
6. После успешного запуска туннеля приложение становится доступно по домену из интернета.
---
## План выполнения
### 1. Фронтенд
- [ ] Добавить на страницу настроек интерфейса блок "WEB SSH" с кнопкой "Подробнее"
- [ ] Создать отдельную страницу
- [ ] Реализовать отправку формы на локальный агент (поля с портами и портом SSH скрыты, используются значения по умолчанию)
- [ ] После успешной настройки — отобразить пользователю статус публикации с ссылкой на домен
### 2. Локальный агент
- [ ] Реализовать Node.js-приложение (Web SSH Agent), слушающее локальный порт
- [ ] API: запуск/остановка SSH-туннеля, статус, логирование
- [ ] Автоматическая установка и настройка NGINX, выпуск SSL-сертификата на сервере по SSH
- [ ] Безопасность: принимать команды только с localhost, авторизация по токену
- [ ] Инструкция по установке для пользователя (Windows, Mac, Linux)
- [ ] (Опционально) Автообновление агента
### 3. Бэкенд (опционально)
- [ ] Реализовать API для логирования, аудита, хранения истории публикаций (если требуется)
- [ ] Возвращать статус выполнения и сообщения об ошибках (если используется)
### 4. Инфраструктура/DevOps
- [ ] Проверить, что на сервере открыт порт 9000 для туннеля
- [ ] Проверить, что домен указывает на сервер
- [ ] Проверить, что на сервере установлен NGINX и certbot
- [ ] (Опционально) Добавить systemd unit для автозапуска туннеля
---
## Важные замечания
- Для полной автоматизации публикации в один клик требуется локальный агент, который запускает команды на ПК пользователя и может настраивать сервер по SSH.
- В браузере без агента можно только сгенерировать команду для ручного запуска SSH-туннеля.
- Все действия на сервере должны выполняться только для авторизованных пользователей (лучше — только для админов)
- Необходимо реализовать валидацию всех полей формы
- **Используется именно проброшенный наружу порт из Docker (5173)**
- **Порт SSH всегда 1024 (по умолчанию), пользователь не видит это поле**
---
приложение остаётся локально — туннель обязателен
---
## Пример команды для SSH-туннеля
```bash
ssh -i /path/to/key -p 22 -R 9000:localhost:5173 user@ваш-сервер
```
---
## С локальным агентом публикация действительно становится "в один клик"!

134
nginx-optimized.conf Normal file
View File

@@ -0,0 +1,134 @@
server {
server_name hb3-accelerator.com www.hb3-accelerator.com;
# Включаем сжатие
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml+rss
application/atom+xml
image/svg+xml;
# Кеширование статических файлов
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
proxy_pass http://localhost:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary Accept-Encoding;
# Для WebSocket/HMR:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Основные страницы
location / {
proxy_pass http://localhost:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Оптимизация для быстрой загрузки
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
# Таймауты
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# Для WebSocket/HMR:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Для WebSocket соединений
location /ws {
proxy_pass http://localhost:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Таймауты для WebSocket
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 300s;
}
# API endpoints
location /api/ {
proxy_pass http://localhost:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Оптимизация для API
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
# Таймауты
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
# Для WebSocket:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# SSL настройки
listen 443 ssl http2; # Включаем HTTP/2
ssl_certificate /etc/letsencrypt/live/hb3-accelerator.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hb3-accelerator.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Дополнительные оптимизации SSL (только если не конфликтуют)
ssl_session_cache shared:SSL:10m;
ssl_stapling on;
ssl_stapling_verify on;
# Безопасность
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
}
# HTTP to HTTPS redirect
server {
if ($host = hb3-accelerator.com) {
return 301 https://$host$request_uri;
}
listen 80;
server_name hb3-accelerator.com www.hb3-accelerator.com;
return 404;
}

359
webssh-agent/agent.js Normal file
View File

@@ -0,0 +1,359 @@
const express = require('express');
const cors = require('cors');
const { spawn } = require('child_process');
const fs = require('fs-extra');
const path = require('path');
const chalk = require('chalk');
const app = express();
const PORT = 12345;
// Middleware
app.use(cors({
origin: ['http://localhost:5173', 'http://localhost:8000'],
credentials: true
}));
app.use(express.json({ limit: '10mb' }));
// Состояние туннеля
let tunnelState = {
connected: false,
domain: null,
tunnelId: null,
sshProcess: null,
config: null
};
// Логирование
const log = {
info: (message) => console.log(chalk.blue('[INFO]'), message),
success: (message) => console.log(chalk.green('[SUCCESS]'), message),
error: (message) => console.log(chalk.red('[ERROR]'), message),
warn: (message) => console.log(chalk.yellow('[WARN]'), message)
};
// Проверка здоровья агента
app.get('/health', (req, res) => {
log.info('Health check requested');
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
version: '1.0.0',
tunnelConnected: tunnelState.connected
});
});
// Создание туннеля
app.post('/tunnel/create', async (req, res) => {
try {
const { domain, email, sshHost, sshUser, sshKey, localPort, serverPort, sshPort } = req.body;
log.info(`Создание туннеля для домена: ${domain}`);
// Валидация входных данных
if (!domain || !email || !sshHost || !sshUser || !sshKey) {
return res.status(400).json({
success: false,
message: 'Все обязательные поля должны быть заполнены'
});
}
// Если туннель уже подключен, отключаем его
if (tunnelState.connected) {
log.warn('Отключаем существующий туннель');
await disconnectTunnel();
}
// Создаем временную директорию для SSH ключей
const tempDir = path.join(__dirname, 'temp');
await fs.ensureDir(tempDir);
// Сохраняем SSH ключ во временный файл
const keyPath = path.join(tempDir, `ssh_key_${Date.now()}`);
let normalizedKey = sshKey
.replace(/[ \t]+$/gm, '') // убираем пробелы и табы в конце каждой строки
.replace(/\r\n/g, '\n'); // нормализуем переносы строк
// Гарантируем, что после END нет ничего, кроме перевода строки
normalizedKey = normalizedKey.replace(
/(-----END OPENSSH PRIVATE KEY-----)[^\n]*$/m,
'$1\n'
);
await fs.writeFile(keyPath, normalizedKey, { mode: 0o600 });
log.info('SSH ключ сохранен, подключаемся к серверу...');
// Функция для выполнения SSH команд
const execSshCommand = (command) => {
return new Promise((resolve, reject) => {
const sshArgs = [
'-i', keyPath,
'-p', (sshPort || 22).toString(),
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
`${sshUser}@${sshHost}`,
command
];
const sshProcess = spawn('ssh', sshArgs);
let stdout = '';
let stderr = '';
sshProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
sshProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
sshProcess.on('close', (code) => {
resolve({ code, stdout, stderr });
});
sshProcess.on('error', (error) => {
reject(error);
});
});
};
log.info('Подключение к серверу установлено');
// Проверяем и устанавливаем NGINX и certbot
log.info('Проверка и установка NGINX...');
const installResult = await execSshCommand('which nginx || (apt-get update && apt-get install -y nginx certbot python3-certbot-nginx)');
if (installResult.code !== 0) {
log.error('Ошибка установки NGINX: ' + installResult.stderr);
}
// Создание конфигурации NGINX
const nginxConfig = `
server {
listen 80;
server_name ${domain};
location / {
proxy_pass http://localhost:${serverPort || 9000};
proxy_set_header Host \\$host;
proxy_set_header X-Real-IP \\$remote_addr;
proxy_set_header X-Forwarded-For \\$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \\$scheme;
proxy_set_header Upgrade \\$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_cache_bypass \\$http_upgrade;
}
}
`;
log.info('Создание конфигурации NGINX...');
await execSshCommand(`echo '${nginxConfig}' > /etc/nginx/sites-available/${domain}`);
await execSshCommand(`ln -sf /etc/nginx/sites-available/${domain} /etc/nginx/sites-enabled/`);
// Проверка и перезагрузка NGINX
const nginxTestResult = await execSshCommand('nginx -t');
if (nginxTestResult.code !== 0) {
log.error('Ошибка конфигурации NGINX: ' + nginxTestResult.stderr);
throw new Error('Ошибка конфигурации NGINX: ' + nginxTestResult.stderr);
}
await execSshCommand('systemctl reload nginx');
log.success('NGINX настроен и перезагружен');
// Получение SSL сертификата
log.info('Получение SSL сертификата...');
const certbotResult = await execSshCommand(
`certbot --nginx -d ${domain} --non-interactive --agree-tos --email ${email} --redirect`
);
if (certbotResult.code !== 0) {
log.warn('Предупреждение при получении SSL: ' + certbotResult.stderr);
// Продолжаем, даже если SSL не получен
} else {
log.success('SSL сертификат получен');
}
// Создание SSH туннеля
log.info('Создание SSH туннеля...');
const tunnelId = Date.now().toString();
const sshArgs = [
'-i', keyPath,
'-p', (sshPort || 22).toString(),
'-R', `${serverPort || 9000}:localhost:${localPort || 5173}`,
'-N',
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'ServerAliveInterval=60',
'-o', 'ServerAliveCountMax=3',
`${sshUser}@${sshHost}`
];
const sshProcess = spawn('ssh', sshArgs);
sshProcess.stdout.on('data', (data) => {
log.info('SSH stdout: ' + data.toString());
});
sshProcess.stderr.on('data', (data) => {
log.warn('SSH stderr: ' + data.toString());
});
sshProcess.on('error', (error) => {
log.error('SSH процесс ошибка: ' + error.message);
tunnelState.connected = false;
});
sshProcess.on('close', (code) => {
log.info(`SSH процесс завершен с кодом: ${code}`);
tunnelState.connected = false;
});
// Даем время на установку соединения
await new Promise(resolve => setTimeout(resolve, 3000));
// Обновляем состояние
tunnelState = {
connected: true,
domain,
tunnelId,
sshProcess,
config: { domain, email, sshHost, sshUser, localPort, serverPort, sshPort, keyPath }
};
log.success(`Туннель успешно создан для домена: ${domain}`);
res.json({
success: true,
message: 'Туннель успешно создан',
tunnelId,
domain,
url: `https://${domain}`
});
} catch (error) {
log.error('Ошибка создания туннеля: ' + error.message);
res.status(500).json({
success: false,
message: error.message
});
}
});
// Отключение туннеля
app.post('/tunnel/disconnect', async (req, res) => {
try {
const result = await disconnectTunnel();
res.json(result);
} catch (error) {
log.error('Ошибка отключения туннеля: ' + error.message);
res.status(500).json({
success: false,
message: error.message
});
}
});
// Функция отключения туннеля
async function disconnectTunnel() {
try {
if (tunnelState.sshProcess) {
tunnelState.sshProcess.kill('SIGTERM');
// Ждем завершения процесса
await new Promise((resolve) => {
tunnelState.sshProcess.on('close', resolve);
setTimeout(resolve, 5000); // Таймаут 5 сек
});
}
// Удаляем временный SSH ключ
if (tunnelState.config && tunnelState.config.keyPath) {
try {
await fs.remove(tunnelState.config.keyPath);
} catch (err) {
log.warn('Не удалось удалить временный SSH ключ: ' + err.message);
}
}
const previousDomain = tunnelState.domain;
tunnelState = {
connected: false,
domain: null,
tunnelId: null,
sshProcess: null,
config: null
};
log.success('Туннель отключен');
return {
success: true,
message: `Туннель для домена ${previousDomain} отключен`
};
} catch (error) {
log.error('Ошибка при отключении туннеля: ' + error.message);
return {
success: false,
message: error.message
};
}
}
// Статус туннеля
app.get('/tunnel/status', (req, res) => {
res.json({
connected: tunnelState.connected,
domain: tunnelState.domain,
tunnelId: tunnelState.tunnelId,
timestamp: new Date().toISOString()
});
});
// Получение логов
app.get('/tunnel/logs', (req, res) => {
// В реальной реализации здесь можно вернуть логи из файла
res.json({
logs: [
{
timestamp: new Date().toISOString(),
level: 'info',
message: 'Агент запущен и готов к работе'
}
]
});
});
// Graceful shutdown
process.on('SIGTERM', async () => {
log.info('Получен сигнал SIGTERM, завершаем работу...');
if (tunnelState.connected) {
await disconnectTunnel();
}
process.exit(0);
});
process.on('SIGINT', async () => {
log.info('Получен сигнал SIGINT, завершаем работу...');
if (tunnelState.connected) {
await disconnectTunnel();
}
process.exit(0);
});
// Запуск сервера
app.listen(PORT, 'localhost', () => {
log.success(`WebSSH Agent запущен на порту ${PORT}`);
log.info('Агент готов к приему команд от фронтенда');
});
// Обработка необработанных ошибок
process.on('uncaughtException', (error) => {
log.error('Необработанная ошибка: ' + error.message);
console.error(error);
});
process.on('unhandledRejection', (reason, promise) => {
log.error('Необработанное отклонение промиса: ' + reason);
console.error(reason);
});

1237
webssh-agent/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
webssh-agent/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "webssh-agent",
"version": "1.0.0",
"description": "Local SSH tunnel agent for automatic web publishing",
"main": "agent.js",
"scripts": {
"start": "node agent.js",
"dev": "nodemon agent.js",
"install-service": "node install-service.js"
},
"keywords": [
"ssh",
"tunnel",
"nginx",
"ssl",
"web",
"publishing"
],
"author": "DApp Business",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"fs-extra": "^11.1.1",
"chalk": "^4.1.2"
},
"devDependencies": {
"nodemon": "^3.0.1"
},
"engines": {
"node": ">=16.0.0"
}
}