Compare commits

3 Commits
1 ... master

25 changed files with 1393 additions and 1217 deletions

29
DEPLOY-SERVER.md Normal file
View File

@@ -0,0 +1,29 @@
# Развёртывание на сервере (с прокси)
Чтобы сайт и Strapi работали без 404, на порту 85 должен слушать **прокси**, а не frontend.
## Что должно быть в стеке
- **iieasy_proxy** — единственный контейнер с портом **85:80** (nginx, раздаёт / → frontend, /api, /admin и т.д. → strapi).
- **iieasy_backend** (Strapi) — **без** проброса портов наружу.
- **iieasy_frontend** — **без** проброса портов наружу.
Если в Portainer/Docker видишь только frontend (85) и strapi (1340) и нет proxy — запросы к `/api` идут во frontend и дают 404.
## Как исправить на сервере
1. Убедись, что в каталоге проекта есть:
- `docker-compose.yml` (в нём описан сервис **proxy** и у strapi/frontend нет `ports`).
- Папка `nginx/conf.d/` с файлом `default.conf`.
2. Останови и удали текущий стек (в Portainer: Stack → iieasy → Remove. Или в терминале в каталоге с compose: `docker-compose down`).
3. Запусти заново из каталога, где лежит этот `docker-compose.yml`:
```bash
docker-compose up -d
```
Или в Portainer: загрузи/вставь актуальный `docker-compose.yml` и нажми Deploy.
4. Проверь контейнеры: должен быть **iieasy_proxy** с портом 85, **iieasy_backend** и **iieasy_frontend** без опубликованных портов.
После этого открывай сайт по `http://хост:85` — запросы к `/api` и `/admin` пойдут через прокси в Strapi, 404 пропадёт.

View File

@@ -1,6 +1,9 @@
# Этап 1: Сборка # Этап 1: Сборка
FROM node:20-alpine AS build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
# Пустой VITE_STRAPI_URL при сборке за прокси — запросы идут на тот же origin (порт 85)
ARG VITE_STRAPI_URL=
ENV VITE_STRAPI_URL=$VITE_STRAPI_URL
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install RUN npm install
COPY . . COPY . .

View File

@@ -12,3 +12,13 @@ This contains everything you need to run your app locally.
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key 2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app: 3. Run the app:
`npm run dev` `npm run dev`
## Docker (один открытый порт)
Запуск через `docker-compose up -d` из папки с `docker-compose.yml`:
- **Снаружи открыт только порт 85.** Сайт и API доступны по `http://хост:85` (главная, `/api/*`, `/admin`, `/uploads`).
- Порты Strapi и фронта наружу не пробрасываются; доступ к ним только через Nginx-прокси.
- Ollama снаружи не открыт; к нему обращается только Strapi по внутренней сети (`OLLAMA_URL` в `.env` Strapi).
Для деплоя без прокси (например, на другой машине с прямым доступом к Strapi) соберите фронт с `VITE_STRAPI_URL=http://...` и при необходимости откройте порты вручную.

View File

@@ -1,6 +1,7 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { SECTION_IDS } from '../constants'; import { SECTION_IDS } from '../constants';
import { SparklesIcon } from './icons'; // Using an icon for principles import { SparklesIcon } from './icons';
import { fetchUploadFiles, STRAPI_URL } from '../strapiService';
const principlesData = [ const principlesData = [
{ {
@@ -57,7 +58,35 @@ const PrincipleItem: React.FC<{ title: string; children: React.ReactNode; index:
); );
const ABOUT_IMAGE_ALT: [string, string][] = [
['Команда iiEasy за работой в современном офисе в Уфе, обсуждает проект на фоне доски с диаграммами и кодом. Атмосфера сфокусированной совместной работы.', 'Команда iiEasy в Уфе: от идеи к реализации.'],
['Крупный план экрана с кодом или дашбордом предиктивной аналитики. На заднем плане — сфокусированный взгляд разработчика, отражающийся в мониторе.', 'Глубокая техническая работа — основа наших решений.'],
['Групповой снимок команды iiEasy. Разнообразные лица, уверенные и дружелюбные улыбки. Неформальная, но профессиональная обстановка.', 'Наша команда — наш главный актив.'],
['Панорамный вид на Уфу с акцентом на современные здания (например, Конгресс-холл Торатау), символизирующий связь компании с городом и ее нацеленность на будущее развитие региона.', 'Нацелены на будущее развитие нашего региона.'],
];
const PLACEHOLDER_IMAGES = [
'https://picsum.photos/seed/iieasy-team-work/1200/800',
'https://picsum.photos/seed/iieasy-code/1200/800',
'https://picsum.photos/seed/iieasy-team-group/1200/800',
'https://picsum.photos/seed/iieasy-ufa-view/1200/800',
];
const AboutUsSection: React.FC = () => { const AboutUsSection: React.FC = () => {
const [aboutImages, setAboutImages] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
const files = await fetchUploadFiles();
if (cancelled) return;
const sorted = [...files].sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
const urls = sorted.slice(0, 4).map((f) => (f.url.startsWith('http') ? f.url : `${STRAPI_URL}${f.url}`));
setAboutImages(urls);
})();
return () => { cancelled = true; };
}, []);
useEffect(() => { useEffect(() => {
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
@@ -68,16 +97,14 @@ const AboutUsSection: React.FC = () => {
} }
}); });
}, },
{ { threshold: 0.1 }
threshold: 0.1,
}
); );
const elements = document.querySelectorAll('.scroll-animate'); const elements = document.querySelectorAll('.scroll-animate');
elements.forEach((el) => observer.observe(el)); elements.forEach((el) => observer.observe(el));
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, [aboutImages]);
const getImageUrl = (index: number) => aboutImages[index] ?? PLACEHOLDER_IMAGES[index];
return ( return (
<section <section
@@ -100,8 +127,8 @@ const AboutUsSection: React.FC = () => {
</div> </div>
<figure className="my-12 md:my-16 scroll-animate" style={{ transitionDelay: '200ms' }}> <figure className="my-12 md:my-16 scroll-animate" style={{ transitionDelay: '200ms' }}>
<img src="https://picsum.photos/seed/iieasy-team-work/1200/800" alt="Команда iiEasy за работой в современном офисе в Уфе, обсуждает проект на фоне доски с диаграммами и кодом. Атмосфера сфокусированной совместной работы." className="rounded-lg shadow-xl aspect-video object-cover"/> <img src={getImageUrl(0)} alt={ABOUT_IMAGE_ALT[0][0]} className="rounded-lg shadow-xl aspect-video object-cover"/>
<figcaption className="text-center text-sm text-slate-500 mt-3 italic">Команда iiEasy в Уфе: от идеи к реализации.</figcaption> <figcaption className="text-center text-sm text-slate-500 mt-3 italic">{ABOUT_IMAGE_ALT[0][1]}</figcaption>
</figure> </figure>
{/* Mission */} {/* Mission */}
@@ -130,8 +157,8 @@ const AboutUsSection: React.FC = () => {
</div> </div>
<figure className="my-12 md:my-16 scroll-animate" style={{ transitionDelay: '200ms' }}> <figure className="my-12 md:my-16 scroll-animate" style={{ transitionDelay: '200ms' }}>
<img src="https://picsum.photos/seed/iieasy-code/1200/800" alt="Крупный план экрана с кодом или дашбордом предиктивной аналитики. На заднем плане — сфокусированный взгляд разработчика, отражающийся в мониторе." className="rounded-lg shadow-xl aspect-video object-cover"/> <img src={getImageUrl(1)} alt={ABOUT_IMAGE_ALT[1][0]} className="rounded-lg shadow-xl aspect-video object-cover"/>
<figcaption className="text-center text-sm text-slate-500 mt-3 italic">Глубокая техническая работа основа наших решений.</figcaption> <figcaption className="text-center text-sm text-slate-500 mt-3 italic">{ABOUT_IMAGE_ALT[1][1]}</figcaption>
</figure> </figure>
{/* Team */} {/* Team */}
@@ -143,8 +170,8 @@ const AboutUsSection: React.FC = () => {
</div> </div>
<figure className="my-12 md:my-16 scroll-animate" style={{ transitionDelay: '200ms' }}> <figure className="my-12 md:my-16 scroll-animate" style={{ transitionDelay: '200ms' }}>
<img src="https://picsum.photos/seed/iieasy-team-group/1200/800" alt="Групповой снимок команды iiEasy. Разнообразные лица, уверенные и дружелюбные улыбки. Неформальная, но профессиональная обстановка." className="rounded-lg shadow-xl aspect-video object-cover"/> <img src={getImageUrl(2)} alt={ABOUT_IMAGE_ALT[2][0]} className="rounded-lg shadow-xl aspect-video object-cover"/>
<figcaption className="text-center text-sm text-slate-500 mt-3 italic">Наша команда наш главный актив.</figcaption> <figcaption className="text-center text-sm text-slate-500 mt-3 italic">{ABOUT_IMAGE_ALT[2][1]}</figcaption>
</figure> </figure>
{/* Principles */} {/* Principles */}
@@ -160,8 +187,8 @@ const AboutUsSection: React.FC = () => {
</div> </div>
<figure className="mt-12 md:my-16 scroll-animate" style={{ transitionDelay: '200ms' }}> <figure className="mt-12 md:my-16 scroll-animate" style={{ transitionDelay: '200ms' }}>
<img src="https://picsum.photos/seed/iieasy-ufa-view/1200/800" alt="Панорамный вид на Уфу с акцентом на современные здания (например, Конгресс-холл Торатау), символизирующий связь компании с городом и ее нацеленность на будущее развитие региона." className="rounded-lg shadow-xl aspect-video object-cover"/> <img src={getImageUrl(3)} alt={ABOUT_IMAGE_ALT[3][0]} className="rounded-lg shadow-xl aspect-video object-cover"/>
<figcaption className="text-center text-sm text-slate-500 mt-3 italic">Нацелены на будущее развитие нашего региона.</figcaption> <figcaption className="text-center text-sm text-slate-500 mt-3 italic">{ABOUT_IMAGE_ALT[3][1]}</figcaption>
</figure> </figure>
</div> </div>
</section> </section>

View File

@@ -5,6 +5,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { CurrentView } from '../types'; import { CurrentView } from '../types';
import { NAVIGABLE_VIEWS } from '../constants'; import { NAVIGABLE_VIEWS } from '../constants';
import { ArrowUpIcon } from './icons'; import { ArrowUpIcon } from './icons';
import { STRAPI_URL } from '../strapiService';
const PLACEHOLDERS = [ const PLACEHOLDERS = [
"Создайте мне сайт для моего бизнеса", "Создайте мне сайт для моего бизнеса",
@@ -81,32 +82,35 @@ const ChatInput: React.FC<ChatInputProps> = ({ setCurrentView, isSticky = false,
Your entire response must be only the JSON object, with no other text, explanation, or markdown formatting.`; Your entire response must be only the JSON object, with no other text, explanation, or markdown formatting.`;
try { try {
const response = await fetch('https://ai.iieasy.ru/v1/chat/completions', { // Запрос через прокси Strapi (/api/ollama/chat) — один origin с фронтом, нет CORS и работает из любой сети
const response = await fetch(`${STRAPI_URL}/api/ollama/chat`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: 'local-model', model: 'gemma3n:e4b',
messages: [ messages: [
{ role: 'system', content: systemInstruction }, { role: 'system', content: systemInstruction },
{ role: 'user', content: prompt } { role: 'user', content: prompt }
], ],
temperature: 0.1, stream: false,
}), }),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Ошибка сети: ${response.status} ${response.statusText}. Убедитесь, что сервер LM Studio запущен.`); throw new Error(`Ошибка сети: ${response.status} ${response.statusText}. Убедитесь, что бэкенд и Ollama доступны.`);
} }
const data = await response.json(); const data = await response.json();
if (data.error) { if (data.error) {
throw new Error(`LM Studio вернула ошибку: ${data.error.message}`); throw new Error(`Сервер ИИ вернул ошибку: ${typeof data.error === 'string' ? data.error : data.error.message || 'неизвестная ошибка'}`);
} }
const responseJsonString = data.choices?.[0]?.message?.content; // Ответ Ollama в /api/chat (без стриминга):
// { message: { role: 'assistant', content: '...' }, ... }
const responseJsonString = data.message?.content;
if (!responseJsonString) { if (!responseJsonString) {
throw new Error("LM Studio вернула пустой ответ."); throw new Error("Сервер ИИ вернул пустой ответ.");
} }
// More robust JSON extraction // More robust JSON extraction
@@ -127,8 +131,8 @@ const ChatInput: React.FC<ChatInputProps> = ({ setCurrentView, isSticky = false,
setError("К сожалению, я не смог понять ваш запрос. Попробуйте переформулировать."); setError("К сожалению, я не смог понять ваш запрос. Попробуйте переформулировать.");
} }
} catch (err: any) { } catch (err: any) {
console.error("Ошибка при обращении к LM Studio:", err); console.error("Ошибка при обращении к серверу ИИ (Ollama):", err);
setError(err.message || "Произошла ошибка. Убедитесь, что сервер LM Studio запущен и модель загружена. Пожалуйста, попробуйте еще раз."); setError(err.message || "Произошла ошибка. Убедитесь, что бэкенд и сервер Ollama доступны. Пожалуйста, попробуйте еще раз.");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@@ -586,7 +586,7 @@
sections: [ sections: [
{ {
heading: "1. Общие положения", heading: "1. Общие положения",
content: "Настоящие Правила использования (далее «Правила») регулируют отношения между ООО \"ИЗИ ГРУПП\" (далее «Сервис») и Пользователем сети Интернет (далее «Пользователь») возникающие при использовании сайта iieasy.example.com (далее «Сайт»). Пользователь обязан полностью ознакомиться с настоящими Правилами до момента начала использования Сайта. Использование Сайта Пользователем означает полное и безоговорочное принятие Пользователем настоящих Правил в соответствии со ст. 438 Гражданского кодекса Российской Федерации." content: "Настоящие Правила использования (далее «Правила») регулируют отношения между ООО \"ИЗИ ГРУПП\" (далее «Сервис») и Пользователем сети Интернет (далее «Пользователь») возникающие при использовании сайта iieasy.ru (далее «Сайт»). Пользователь обязан полностью ознакомиться с настоящими Правилами до момента начала использования Сайта. Использование Сайта Пользователем означает полное и безоговорочное принятие Пользователем настоящих Правил в соответствии со ст. 438 Гражданского кодекса Российской Федерации."
}, },
{ {
heading: "2. Интеллектуальная собственность", heading: "2. Интеллектуальная собственность",
@@ -611,7 +611,7 @@
sections: [ sections: [
{ {
heading: "1. Введение", heading: "1. Введение",
content: "Настоящая Политика конфиденциальности персональных данных (далее «Политика») действует в отношении всей информации, которую ООО \"ИЗИ ГРУПП\" (далее «Компания») может получить о Пользователе во время использования сайта iieasy.example.com (далее «Сайт»). Использование Сайта означает безоговорочное согласие Пользователя с настоящей Политикой и указанными в ней условиями обработки его персональной информации; в случае несогласия с этими условиями Пользователь должен воздержаться от использования Сайта." content: "Настоящая Политика конфиденциальности персональных данных (далее «Политика») действует в отношении всей информации, которую ООО \"ИЗИ ГРУПП\" (далее «Компания») может получить о Пользователе во время использования сайта iieasy.ru (далее «Сайт»). Использование Сайта означает безоговорочное согласие Пользователя с настоящей Политикой и указанными в ней условиями обработки его персональной информации; в случае несогласия с этими условиями Пользователь должен воздержаться от использования Сайта."
}, },
{ {
heading: "2. Какие данные мы собираем", heading: "2. Какие данные мы собираем",

View File

@@ -1,15 +1,31 @@
# Снаружи открыт только порт 85 (прокси). Сайт и API по http://хост:85
# Strapi и фронт доступны только внутри Docker-сети через прокси.
version: '3.8' version: '3.8'
services: services:
# БЭКЕНД: Strapi (находится в подпапке iiEasy) # ПРОКСИ: единственная точка входа (порт 85)
proxy:
container_name: iieasy_proxy
image: nginx:stable-alpine
restart: always
ports:
- "85:80"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- frontend
- strapi
# БЭКЕНД: Strapi (порт наружу не пробрасывается, доступ через proxy)
strapi: strapi:
container_name: iieasy_backend container_name: iieasy_backend
build: build:
context: ./iiEasy context: ./iiEasy
dockerfile: Dockerfile dockerfile: Dockerfile
args:
STRAPI_ADMIN_BACKEND_URL: "https://iieasy.ru"
restart: always restart: always
ports:
- "1340:1340"
# Пробрасываем папку с загрузками, чтобы картинки не удалились при обновлении контейнера # Пробрасываем папку с загрузками, чтобы картинки не удалились при обновлении контейнера
volumes: volumes:
- ./iiEasy/public/uploads:/opt/app/public/uploads - ./iiEasy/public/uploads:/opt/app/public/uploads
@@ -19,15 +35,15 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
# ФРОНТЕНД: Сайт с Tailwind (находится в текущей папке) # ФРОНТЕНД: статика (порт наружу не пробрасывается, доступ через proxy)
frontend: frontend:
container_name: iieasy_frontend container_name: iieasy_frontend
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
VITE_STRAPI_URL: ""
restart: always restart: always
ports:
- "85:80" # Твой запрос: заходим через 85 порт
depends_on: depends_on:
- strapi - strapi

BIN
frontend-image.tar Normal file

Binary file not shown.

View File

@@ -6,3 +6,7 @@ ADMIN_JWT_SECRET=tobemodified
TRANSFER_TOKEN_SALT=tobemodified TRANSFER_TOKEN_SALT=tobemodified
JWT_SECRET=tobemodified JWT_SECRET=tobemodified
ENCRYPTION_KEY=tobemodified ENCRYPTION_KEY=tobemodified
# Ollama (прокси /api/ollama/chat). Сервер должен быть доступен с хоста, где запущен Strapi.
OLLAMA_URL=http://192.168.88.160:11434
OLLAMA_MODEL=gemma3n:e4b

View File

@@ -26,6 +26,10 @@ RUN npm ci --omit=dev
# Копируем весь код проекта # Копируем весь код проекта
COPY . . COPY . .
# Публичный URL для админки (запросы из браузера идут на этот origin)
ARG STRAPI_ADMIN_BACKEND_URL
ENV STRAPI_ADMIN_BACKEND_URL=${STRAPI_ADMIN_BACKEND_URL}
# Собираем админку Strapi # Собираем админку Strapi
ENV NODE_ENV=production ENV NODE_ENV=production
RUN npm run build RUN npm run build

View File

@@ -1,9 +1,36 @@
export default ({ env }) => ({ export default ({ env }) => {
// #region agent log
const apiTokenSaltValue = env('API_TOKEN_SALT');
const apiTokenSaltFinal = apiTokenSaltValue || 'dev-api-token-salt-change-in-production';
fetch('http://localhost:7246/ingest/f7a9af46-2b22-4f00-8298-4ecbb46f59d7', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: `log_${Date.now()}_apiTokenSalt`,
timestamp: Date.now(),
location: 'config/admin.ts',
message: 'Check API_TOKEN_SALT presence',
runId: 'post-fix',
hypothesisId: 'H2',
data: {
hasApiTokenSaltEnv: !!process.env.API_TOKEN_SALT,
envReturnsLength: (apiTokenSaltValue || '').length,
envReturnsDefined: !!apiTokenSaltValue,
saltUsedLength: apiTokenSaltFinal.length,
},
}),
}).catch(() => {});
// #endregion
return {
auth: { auth: {
secret: env('ADMIN_JWT_SECRET'), // Стратегия: если переменная окружения не задана, используем devсекрет по умолчанию,
// чтобы Strapi не падал с ошибкой Missing admin.auth.secret.
// В проде обязательно переопредели ADMIN_JWT_SECRET в .env!
secret: env('ADMIN_JWT_SECRET', 'change-me-admin-jwt-secret-dev-only'),
}, },
apiToken: { apiToken: {
salt: env('API_TOKEN_SALT'), salt: apiTokenSaltFinal,
}, },
transfer: { transfer: {
token: { token: {
@@ -17,4 +44,5 @@ export default ({ env }) => ({
nps: env.bool('FLAG_NPS', true), nps: env.bool('FLAG_NPS', true),
promoteEE: env.bool('FLAG_PROMOTE_EE', true), promoteEE: env.bool('FLAG_PROMOTE_EE', true),
}, },
}); };
};

View File

@@ -2,7 +2,13 @@ export default [
'strapi::logger', 'strapi::logger',
'strapi::errors', 'strapi::errors',
'strapi::security', 'strapi::security',
'strapi::cors', {
name: 'strapi::cors',
config: {
origin: '*', // разрешить любой origin (строка; true не поддерживается — вызывает originList.split)
headers: ['Content-Type', 'Authorization'],
},
},
'strapi::poweredBy', 'strapi::poweredBy',
'strapi::query', 'strapi::query',
'strapi::body', 'strapi::body',

View File

@@ -1,7 +1,11 @@
export default ({ env }) => ({ export default ({ env }) => ({
host: env('HOST', '0.0.0.0'), // Явно 0.0.0.0: иначе в некоторых сценариях Strapi может взять host из defaultConfig (localhost).
// В консоли по-прежнему показывается http://localhost:PORT — это только для открытия в браузере, bind идёт на 0.0.0.0.
host: env('HOST', '0.0.0.0') || '0.0.0.0',
port: env.int('PORT', 1340), port: env.int('PORT', 1340),
app: { app: {
keys: env.array('APP_KEYS'), // Если APP_KEYS не заданы в .env, используем devзначения по умолчанию,
// чтобы middleware strapi::session не падал. В проде ОБЯЗАТЕЛЬНО переопредели APP_KEYS!
keys: env.array('APP_KEYS', ['devKeyA-change-me', 'devKeyB-change-me']),
}, },
}); });

2092
iiEasy/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
/**
* Прокси к Ollama: запросы из браузера идут на тот же origin (Strapi), CORS не нужен.
* Strapi дергает Ollama по внутренней сети (OLLAMA_URL).
*/
export default {
async chat(ctx) {
const ollamaBase = process.env.OLLAMA_URL || 'http://192.168.88.160:11434';
const model = process.env.OLLAMA_MODEL || 'gemma3n:e4b';
const url = `${ollamaBase.replace(/\/$/, '')}/api/chat`;
const body = ctx.request.body as Record<string, unknown>;
const payload = {
model: body?.model ?? model,
messages: body?.messages ?? [],
stream: body?.stream ?? false,
};
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
ctx.status = response.status;
ctx.body = data;
ctx.set('Content-Type', 'application/json');
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Ошибка прокси к Ollama';
ctx.status = 502;
ctx.body = { error: message };
ctx.set('Content-Type', 'application/json');
}
},
};

View File

@@ -0,0 +1,22 @@
/**
* Прокси-маршрут для Ollama: POST /api/ollama/chat
* Браузер вызывает тот же origin, что и Strapi — CORS не мешает, работает из любой сети.
*/
import type { Core } from '@strapi/strapi';
const config: Core.RouterConfig = {
type: 'content-api',
routes: [
{
method: 'POST',
path: '/ollama/chat', // полный путь: /api + /ollama/chat = /api/ollama/chat
handler: 'api::ollama.ollama.chat',
config: {
auth: false,
},
},
],
};
export default config;

View File

@@ -1073,6 +1073,7 @@ export interface PluginUploadFile extends Struct.CollectionTypeSchema {
createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> &
Schema.Attribute.Private; Schema.Attribute.Private;
ext: Schema.Attribute.String; ext: Schema.Attribute.String;
focalPoint: Schema.Attribute.JSON;
folder: Schema.Attribute.Relation<'manyToOne', 'plugin::upload.folder'> & folder: Schema.Attribute.Relation<'manyToOne', 'plugin::upload.folder'> &
Schema.Attribute.Private; Schema.Attribute.Private;
folderPath: Schema.Attribute.String & folderPath: Schema.Attribute.String &

View File

@@ -285,8 +285,8 @@
"addressCountry": "RU" "addressCountry": "RU"
}, },
"email": "hello@iieasy.ru", "email": "hello@iieasy.ru",
"url": "https://www.iieasy.example.com", "url": "https://iieasy.ru",
"image": "https://www.iieasy.example.com/logo.png", "image": "https://iieasy.ru/logo.png",
"telephone": "+7 963 890 8700", "telephone": "+7 963 890 8700",
"priceRange": "$$", "priceRange": "$$",
"areaServed": { "areaServed": {

84
nginx/conf.d/default.conf Normal file
View File

@@ -0,0 +1,84 @@
# Обратный прокси: единственная точка входа. Снаружи открыт только порт 85.
# /api, /uploads, /admin -> Strapi (внутренний strapi:1340)
# / -> фронт (frontend:80)
server {
listen 80;
server_name _;
client_max_body_size 200M;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# Strapi API
location /api {
proxy_pass http://strapi:1337;
proxy_http_version 1.1;
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;
}
# Strapi Upload plugin API (/api/upload уже под /api; на всякий случай явно)
location /upload {
proxy_pass http://strapi:1337;
proxy_http_version 1.1;
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;
client_max_body_size 200M;
}
# Strapi uploads (медиа-файлы)
location /uploads {
proxy_pass http://strapi:1337;
proxy_http_version 1.1;
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;
}
# Strapi admin panel
location /admin {
proxy_pass http://strapi:1337;
proxy_http_version 1.1;
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;
}
# Strapi Content Manager API (/content-manager/init и др.)
location /content-manager {
proxy_pass http://strapi:1337;
proxy_http_version 1.1;
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;
}
# Strapi Content-Type Builder API (/content-type-builder/schema и др.)
location /content-type-builder {
proxy_pass http://strapi:1337;
proxy_http_version 1.1;
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;
}
# Фронт: статический сайт
location / {
proxy_pass http://frontend:80;
proxy_http_version 1.1;
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;
}
}

47
package-lock.json generated
View File

@@ -14,6 +14,8 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"vite": "^6.2.0" "vite": "^6.2.0"
} }
@@ -745,10 +747,47 @@
"integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==", "integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.5", "version": "0.25.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
@@ -852,6 +891,7 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -893,6 +933,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -1004,9 +1045,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.5", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -16,6 +16,8 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"vite": "^6.2.0" "vite": "^6.2.0"
} }

BIN
strapi-data.tar.gz Normal file

Binary file not shown.

BIN
strapi-image.tar Normal file

Binary file not shown.

View File

@@ -1,10 +1,20 @@
/// <reference types="vite/client" />
import { Post, NewsArticle, ResearchPaper, BusinessStory, ServiceItemData, ClientLogo, BusinessCourse, StudentProgram, AcceleratorProject, InvestmentProject, Vacancy, GalleryItem } from './types'; import { Post, NewsArticle, ResearchPaper, BusinessStory, ServiceItemData, ClientLogo, BusinessCourse, StudentProgram, AcceleratorProject, InvestmentProject, Vacancy, GalleryItem } from './types';
const STRAPI_URL = 'https://next.iieasy.ru'; /**
const API_TOKEN = '91344ae88ae3e496f72d6ae9c157a3e929c3078b6269b57d3751d81026880cca6e7dcec074bffd77c8fe853dce9308f8d7dd494cf7b8c804a2875f0f4c24c0419cee196adf247d01cd5ddd5893d325bff34ad236febe0b508114ec906b12423e658d7210be94f2fcc37184334e11065538531c6d935de5becf81f2a48fdd6318'; * Базовый URL Strapi API.
* - В dev: пустая строка — запросы идут через Vite proxy на Strapi.
* - В prod за прокси: задайте VITE_STRAPI_URL="" при сборке — запросы на тот же origin (порт 85).
* - В prod без прокси: задайте VITE_STRAPI_URL=http://... при сборке; иначе используется запасной URL.
*/
const _url = import.meta.env?.VITE_STRAPI_URL;
export const STRAPI_URL =
import.meta.env?.DEV
? ''
: typeof _url === 'string'
? _url
: 'http://192.168.88.121:1337';
const API_TOKEN = '9e4de071544acf9ca7a9bc2b01c2fdc913c1e92e930b6c04193a00cb71a27787c19fd546e4fcebee6ed34b284015506a6eb650cea99291149328f78b4020c5028337e3dcd45f50ca379944b0a4a8276bb7f9127aa816a8b35eb68a7beef54d52a0eeea84991776165347039c20fa8b447df6c4ff653a82d4684da9db9f7716db';
interface StrapiResponse<T> { interface StrapiResponse<T> {
data: StrapiDataItem<T>[]; data: StrapiDataItem<T>[];
@@ -441,3 +451,40 @@ export const fetchStudentPrograms = (): Promise<StudentProgram[]> => fetchData('
export const fetchAcceleratorProjects = (): Promise<AcceleratorProject[]> => fetchData('accelerator-projects', transformAcceleratorProject); export const fetchAcceleratorProjects = (): Promise<AcceleratorProject[]> => fetchData('accelerator-projects', transformAcceleratorProject);
export const fetchInvestmentProjects = (): Promise<InvestmentProject[]> => fetchData('investment-opportunities', transformInvestmentProject); export const fetchInvestmentProjects = (): Promise<InvestmentProject[]> => fetchData('investment-opportunities', transformInvestmentProject);
export const fetchVacancies = (): Promise<Vacancy[]> => fetchData('vacancies', transformVacancy); export const fetchVacancies = (): Promise<Vacancy[]> => fetchData('vacancies', transformVacancy);
/** Список файлов из Strapi Media Library (для страницы «О нас» и др.).
* В Strapi: Settings → API Tokens → роль токена должна иметь Plugins → Upload → find (доступ к медиа). */
export interface StrapiUploadFile {
url: string;
name?: string;
id?: number;
}
function parseUploadItem(item: any): StrapiUploadFile | null {
const attrs = item?.attributes ?? item;
const url = typeof attrs?.url === 'string' ? attrs.url : typeof item?.url === 'string' ? item.url : '';
if (!url) return null;
const name = attrs?.name ?? attrs?.fileName ?? item?.name ?? '';
return { id: item?.id ?? attrs?.id, url, name };
}
/** Папки в Content API недоступны — запрос без фильтра folder. */
export async function fetchUploadFiles(): Promise<StrapiUploadFile[]> {
try {
const response = await fetch(`${STRAPI_URL}/api/upload/files`, {
headers: { Authorization: `Bearer ${API_TOKEN}` },
});
if (!response.ok) {
console.warn('fetchUploadFiles:', response.status, await response.text());
return [];
}
const json = await response.json();
const list = Array.isArray(json) ? json : json?.data;
if (!Array.isArray(list)) return [];
const files = list.map((item: any) => parseUploadItem(item)).filter(Boolean) as StrapiUploadFile[];
return files.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
} catch (e: any) {
console.warn('fetchUploadFiles error:', e?.message);
return [];
}
}

View File

@@ -7,6 +7,7 @@ const __dirname = path.dirname(__filename);
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', ''); const env = loadEnv(mode, '.', '');
const strapiTarget = env.VITE_STRAPI_PROXY_TARGET || 'http://localhost:1337';
return { return {
define: { define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
@@ -17,14 +18,15 @@ export default defineConfig(({ mode }) => {
'@': path.resolve(__dirname, '.'), '@': path.resolve(__dirname, '.'),
}, },
}, },
// --- ДОБАВЬ ЭТОТ БЛОК ---
server: { server: {
host: '0.0.0.0', // Чтобы слушал все интерфейсы host: '0.0.0.0',
port: 5173, // Твой порт port: 5173,
allowedHosts: ['iieasy.ru', allowedHosts: ['iieasy.ru', 'n8n.iieasy.ru'],
'next.iieasy.ru' // Прокси к Strapi: в dev запросы на /api и /uploads идут на Strapi без CORS и без хардкода IP
], // Разрешаем твой домен proxy: {
'/api': { target: strapiTarget, changeOrigin: true },
'/uploads': { target: strapiTarget, changeOrigin: true },
},
}, },
// ------------------------
}; };
}); });