prod: доска board, nginx, WhiteboardIframe EXCALIDRAW_URL
Deploy to Production / deploy-production (push) Successful in 26s Details

This commit is contained in:
root 2026-02-13 04:30:56 +03:00
parent d722ff49bd
commit 083fd4d826
12 changed files with 1147 additions and 1053 deletions

View File

@ -68,6 +68,13 @@ class ProfileViewSet(viewsets.ViewSet):
GET /api/users/profile/me/ GET /api/users/profile/me/
""" """
user = request.user user = request.user
# Убедиться, что у пользователя есть 8-символьный код (для старых пользователей)
if not user.universal_code or len(user.universal_code) != 8:
try:
user.universal_code = user._generate_universal_code()
user.save(update_fields=['universal_code'])
except Exception:
pass
serializer = UserSerializer(user, context={'request': request}) serializer = UserSerializer(user, context={'request': request})
# Добавляем дополнительную информацию # Добавляем дополнительную информацию
@ -374,6 +381,13 @@ class ProfileViewSet(viewsets.ViewSet):
user = request.user user = request.user
# 8-символьный код: если нет — генерируем при обновлении профиля
if not user.universal_code or len(user.universal_code) != 8:
try:
user.universal_code = user._generate_universal_code()
except Exception:
pass
# Обработка удаления аватара # Обработка удаления аватара
if 'avatar' in request.data: if 'avatar' in request.data:
avatar_value = request.data.get('avatar') avatar_value = request.data.get('avatar')

View File

@ -160,10 +160,20 @@ class RegisterView(generics.CreateAPIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
user = serializer.save() user = serializer.save()
# Генерируем токен для подтверждения email и сохраняем одним запросом # 8-символьный код пользователя: генерируем при регистрации, если ещё нет
update_fields = []
if not user.universal_code or len(user.universal_code) != 8:
try:
user.universal_code = user._generate_universal_code()
update_fields.append('universal_code')
except Exception:
pass
# Токен для подтверждения email
verification_token = secrets.token_urlsafe(32) verification_token = secrets.token_urlsafe(32)
user.email_verification_token = verification_token user.email_verification_token = verification_token
user.save(update_fields=['email_verification_token']) update_fields.append('email_verification_token')
user.save(update_fields=update_fields)
# Отправляем email подтверждения (асинхронно через Celery) # Отправляем email подтверждения (асинхронно через Celery)
send_verification_email_task.delay(user.id, verification_token) send_verification_email_task.delay(user.id, verification_token)

View File

@ -31,17 +31,20 @@ class LiveKitService:
def get_server_url(request=None) -> str: def get_server_url(request=None) -> str:
""" """
Публичный URL LiveKit для фронтенда. Публичный URL LiveKit для фронтенда.
Приоритет: LIVEKIT_PUBLIC_URL > по request (порт 80/443 = nginx) > dev fallback. Приоритет: LIVEKIT_PUBLIC_URL > по request (Host + X-Forwarded-Proto за nginx) > dev fallback.
""" """
url = getattr(settings, 'LIVEKIT_PUBLIC_URL', '').strip() url = getattr(settings, 'LIVEKIT_PUBLIC_URL', '').strip()
if url: if url:
return url return url
if request: if request:
# За nginx: X-Forwarded-Proto и Host надёжнее request.is_secure()/get_port()
proto = request.META.get('HTTP_X_FORWARDED_PROTO', '').strip().lower()
if proto in ('https', 'http'):
scheme = 'wss' if proto == 'https' else 'ws'
else:
scheme = 'wss' if request.is_secure() else 'ws' scheme = 'wss' if request.is_secure() else 'ws'
host = request.get_host().split(':')[0] host = (request.META.get('HTTP_X_FORWARDED_HOST') or request.get_host()).split(':')[0].strip()
port = request.get_port() or (443 if request.is_secure() else 80) if host and not host.startswith('127.0.0.1') and host != 'localhost':
# Только если запрос через nginx (порт 80/443)
if (request.is_secure() and port == 443) or (not request.is_secure() and port == 80):
return f"{scheme}://{host}/livekit" return f"{scheme}://{host}/livekit"
return 'ws://127.0.0.1:7880' return 'ws://127.0.0.1:7880'

View File

@ -42,7 +42,7 @@ services:
user: "0:0" user: "0:0"
env_file: .env env_file: .env
# Daphne (ASGI): HTTP + WebSocket (/ws/notifications/, /ws/chat/, /ws/board/ и т.д.) # Daphne (ASGI): HTTP + WebSocket (/ws/notifications/, /ws/chat/, /ws/board/ и т.д.)
command: sh -c "python manage.py migrate && daphne -b 0.0.0.0 -p 8000 config.asgi:application" command: sh -c "python manage.py migrate && python manage.py init_subjects && daphne -b 0.0.0.0 -p 8000 config.asgi:application"
environment: environment:
- DEBUG=${DEBUG:-True} - DEBUG=${DEBUG:-True}
- SECRET_KEY=dev_secret_key - SECRET_KEY=dev_secret_key
@ -63,6 +63,8 @@ services:
- EMAIL_TIMEOUT=${EMAIL_TIMEOUT:-10} - EMAIL_TIMEOUT=${EMAIL_TIMEOUT:-10}
# Ссылки в письмах (сброс пароля, подтверждение, приглашения) — без localhost # Ссылки в письмах (сброс пароля, подтверждение, приглашения) — без localhost
- FRONTEND_URL=${FRONTEND_URL:-https://app.uchill.online} - FRONTEND_URL=${FRONTEND_URL:-https://app.uchill.online}
# LiveKit: публичный URL для браузера (обязательно в prod — иначе клиент идёт на 127.0.0.1)
- LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL:-wss://api.uchill.online/livekit}
# Telegram бот (профиль: bot-info, привязка аккаунта) # Telegram бот (профиль: bot-info, привязка аккаунта)
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- TELEGRAM_USE_WEBHOOK=${TELEGRAM_USE_WEBHOOK:-False} - TELEGRAM_USE_WEBHOOK=${TELEGRAM_USE_WEBHOOK:-False}
@ -181,13 +183,15 @@ services:
networks: networks:
- dev_network - dev_network
# Видеоуроки: хост nginx (api.uchill.online) проксирует /livekit на 7880. Dev на том же хосте — 7890.
# LIVEKIT_KEYS — строго один ключ в формате "key: secret" (пробел после двоеточия). В .env задайте одну строку: LIVEKIT_KEYS=APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf
livekit: livekit:
image: livekit/livekit-server:latest image: livekit/livekit-server:latest
container_name: platform_prod_livekit container_name: platform_prod_livekit
restart: unless-stopped restart: unless-stopped
env_file: .env
environment: environment:
- LIVEKIT_KEYS=${LIVEKIT_API_KEY:-APIKeyPlatform2024Secret}:${LIVEKIT_API_SECRET:-ThisIsAVerySecureSecretKeyForPlatform2024VideoConf} # Одна строка "key: secret" (пробел после двоеточия). В кавычках, чтобы YAML не воспринял двоеточие как ключ.
- "LIVEKIT_KEYS=APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf"
ports: ports:
- "7880:7880" - "7880:7880"
- "7881:7881" - "7881:7881"
@ -221,6 +225,9 @@ services:
- WATCHPACK_POLLING=true - WATCHPACK_POLLING=true
- HOSTNAME=0.0.0.0 - HOSTNAME=0.0.0.0
- CHOKIDAR_USEPOLLING=true - CHOKIDAR_USEPOLLING=true
# Доска: поддомен board.uchill.online (прокси nginx на 3004) или путь на том же домене
- NEXT_PUBLIC_EXCALIDRAW_URL=${NEXT_PUBLIC_EXCALIDRAW_URL:-}
- NEXT_PUBLIC_EXCALIDRAW_PATH=${NEXT_PUBLIC_EXCALIDRAW_PATH:-/excalidraw}
ports: ports:
- "3010:3000" - "3010:3000"
volumes: volumes:
@ -247,6 +254,9 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: platform_prod_excalidraw container_name: platform_prod_excalidraw
restart: unless-stopped restart: unless-stopped
environment:
# basePath в next.config.js: иначе /_next/ запросы уходят на основной фронт и доска пустая
- NEXT_PUBLIC_BASE_PATH=/excalidraw
ports: ports:
- "3004:3001" - "3004:3001"
networks: networks:

View File

@ -3,6 +3,12 @@ const nextConfig = {
// Минимальная конфигурация для Excalidraw // Минимальная конфигурация для Excalidraw
reactStrictMode: false, reactStrictMode: false,
// Прокси nginx: app.uchill.online/excalidraw/ → 3004. Без basePath запросы к /_next/ уходили бы на основной фронт.
basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? '',
// Не редиректить /excalidraw/ ↔ /excalidraw (иначе цикл с nginx/iframe)
trailingSlash: true,
// Отключаем SSR полностью // Отключаем SSR полностью
output: 'standalone', output: 'standalone',
}; };

View File

@ -1,9 +1,12 @@
import { AuthRedirect } from '@/components/auth/AuthRedirect';
export default function AuthLayout({ export default function AuthLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<AuthRedirect>
<div <div
data-no-nav data-no-nav
style={{ style={{
@ -56,5 +59,6 @@ export default function AuthLayout({
{children} {children}
</div> </div>
</div> </div>
</AuthRedirect>
); );
} }

View File

@ -733,19 +733,6 @@ export default function StudentsPage() {
}}> }}>
<div>Загрузка студентов...</div> <div>Загрузка студентов...</div>
</div> </div>
) : filteredStudents.length === 0 ? (
<md-elevated-card style={{
padding: '40px',
borderRadius: '20px',
textAlign: 'center'
}}>
<p style={{
fontSize: '16px',
color: 'var(--md-sys-color-on-surface-variant)'
}}>
Нет студентов
</p>
</md-elevated-card>
) : ( ) : (
<div <div
style={{ style={{

View File

@ -0,0 +1,45 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
/**
* Если пользователь авторизован редирект на дашборд.
* Страницы логина/регистрации и т.д. не должны быть доступны авторизованным.
*/
export function AuthRedirect({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { user, loading } = useAuth();
useEffect(() => {
if (loading) return;
if (user) {
router.replace('/dashboard');
}
}, [user, loading, router]);
if (loading) {
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fff',
}}
>
<div style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }}>
Загрузка...
</div>
</div>
);
}
if (user) {
return null; // редирект уже идёт
}
return <>{children}</>;
}

View File

@ -38,16 +38,28 @@ export function WhiteboardIframe({
const token = tokenProp ?? localStorage.getItem('access_token') ?? ''; const token = tokenProp ?? localStorage.getItem('access_token') ?? '';
const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8123/api'; const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8123/api';
const apiUrl = apiBase.replace(/\/api\/?$/, '') || 'http://127.0.0.1:8123'; const apiUrl = apiBase.replace(/\/api\/?$/, '') || 'http://127.0.0.1:8123';
const excalidrawHost = `${window.location.protocol}//${window.location.hostname}:3001`; // Вариант 1: отдельный URL доски (как LiveKit) — если app.uchill.online отдаёт один Next без nginx по путям
const excalidrawBaseUrl = (process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '').trim().replace(/\/?$/, '');
const url = new URL('/', excalidrawHost); const url: URL = excalidrawBaseUrl
? new URL(excalidrawBaseUrl.startsWith('http') ? excalidrawBaseUrl + '/' : `${window.location.origin}/${excalidrawBaseUrl}/`)
: (() => {
const path = process.env.NEXT_PUBLIC_EXCALIDRAW_PATH || '';
if (path) {
return new URL((path.startsWith('/') ? path : '/' + path) + '/', window.location.origin);
}
return new URL('/', `${window.location.protocol}//${window.location.hostname}:${process.env.NEXT_PUBLIC_EXCALIDRAW_PORT || '3001'}`);
})();
url.searchParams.set('boardId', boardId); url.searchParams.set('boardId', boardId);
url.searchParams.set('apiUrl', apiUrl); url.searchParams.set('apiUrl', apiUrl);
if (token) url.searchParams.set('token', token); if (token) url.searchParams.set('token', token);
if (isMentor) url.searchParams.set('isMentor', '1'); if (isMentor) url.searchParams.set('isMentor', '1');
const iframeSrc = excalidrawBaseUrl && excalidrawBaseUrl.startsWith('http')
? url.toString()
: `${url.origin}${url.pathname.replace(/\/?$/, '/')}?${url.searchParams.toString()}`;
const iframe = document.createElement('iframe'); const iframe = document.createElement('iframe');
iframe.src = url.toString(); iframe.src = iframeSrc;
iframe.style.cssText = 'width:100%;height:100%;border:none;display:block'; iframe.style.cssText = 'width:100%;height:100%;border:none;display:block';
iframe.title = 'Интерактивная доска (Excalidraw)'; iframe.title = 'Интерактивная доска (Excalidraw)';
iframe.setAttribute('allow', 'camera; microphone; fullscreen'); iframe.setAttribute('allow', 'camera; microphone; fullscreen');
@ -93,12 +105,14 @@ export function WhiteboardIframe({
useEffect(() => { useEffect(() => {
if (!iframeRef.current?.contentWindow || !createdRef.current) return; if (!iframeRef.current?.contentWindow || !createdRef.current) return;
const token = tokenProp ?? localStorage.getItem('access_token') ?? ''; const excalidrawBaseUrl = (process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '').trim().replace(/\/?$/, '');
const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8123/api'; const targetOrigin = excalidrawBaseUrl.startsWith('http')
const apiUrl = apiBase.replace(/\/api\/?$/, '') || 'http://127.0.0.1:8123'; ? new URL(excalidrawBaseUrl).origin
const excalidrawHost = `${window.location.protocol}//${window.location.hostname}:3001`; : (() => {
const url = new URL('/', excalidrawHost); const path = process.env.NEXT_PUBLIC_EXCALIDRAW_PATH || '';
if (path) return window.location.origin;
return `${window.location.protocol}//${window.location.hostname}:${process.env.NEXT_PUBLIC_EXCALIDRAW_PORT || '3001'}`;
})();
const decodeName = (raw: string): string => { const decodeName = (raw: string): string => {
if (!raw || typeof raw !== 'string') return raw; if (!raw || typeof raw !== 'string') return raw;
try { try {
@ -108,11 +122,12 @@ export function WhiteboardIframe({
return raw; return raw;
} }
}; };
try {
iframeRef.current.contentWindow.postMessage( iframeRef.current.contentWindow.postMessage(
{ type: 'excalidraw-username', username: decodeName(username) }, { type: 'excalidraw-username', username: decodeName(username) },
url.origin targetOrigin
); );
} catch (_) {}
}, [username]); }, [username]);
return ( return (