prod: доска board, nginx, WhiteboardIframe EXCALIDRAW_URL
Deploy to Production / deploy-production (push) Successful in 26s
Details
Deploy to Production / deploy-production (push) Successful in 26s
Details
This commit is contained in:
parent
d722ff49bd
commit
083fd4d826
|
|
@ -68,6 +68,13 @@ class ProfileViewSet(viewsets.ViewSet):
|
|||
GET /api/users/profile/me/
|
||||
"""
|
||||
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})
|
||||
|
||||
# Добавляем дополнительную информацию
|
||||
|
|
@ -374,6 +381,13 @@ class ProfileViewSet(viewsets.ViewSet):
|
|||
|
||||
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:
|
||||
avatar_value = request.data.get('avatar')
|
||||
|
|
|
|||
|
|
@ -160,10 +160,20 @@ class RegisterView(generics.CreateAPIView):
|
|||
serializer.is_valid(raise_exception=True)
|
||||
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)
|
||||
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)
|
||||
send_verification_email_task.delay(user.id, verification_token)
|
||||
|
|
|
|||
|
|
@ -31,17 +31,20 @@ class LiveKitService:
|
|||
def get_server_url(request=None) -> str:
|
||||
"""
|
||||
Публичный 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()
|
||||
if url:
|
||||
return url
|
||||
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'
|
||||
host = request.get_host().split(':')[0]
|
||||
port = request.get_port() or (443 if request.is_secure() else 80)
|
||||
# Только если запрос через nginx (порт 80/443)
|
||||
if (request.is_secure() and port == 443) or (not request.is_secure() and port == 80):
|
||||
host = (request.META.get('HTTP_X_FORWARDED_HOST') or request.get_host()).split(':')[0].strip()
|
||||
if host and not host.startswith('127.0.0.1') and host != 'localhost':
|
||||
return f"{scheme}://{host}/livekit"
|
||||
return 'ws://127.0.0.1:7880'
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ services:
|
|||
user: "0:0"
|
||||
env_file: .env
|
||||
# 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:
|
||||
- DEBUG=${DEBUG:-True}
|
||||
- SECRET_KEY=dev_secret_key
|
||||
|
|
@ -63,6 +63,8 @@ services:
|
|||
- EMAIL_TIMEOUT=${EMAIL_TIMEOUT:-10}
|
||||
# Ссылки в письмах (сброс пароля, подтверждение, приглашения) — без localhost
|
||||
- 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_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
- TELEGRAM_USE_WEBHOOK=${TELEGRAM_USE_WEBHOOK:-False}
|
||||
|
|
@ -181,13 +183,15 @@ services:
|
|||
networks:
|
||||
- dev_network
|
||||
|
||||
# Видеоуроки: хост nginx (api.uchill.online) проксирует /livekit на 7880. Dev на том же хосте — 7890.
|
||||
# LIVEKIT_KEYS — строго один ключ в формате "key: secret" (пробел после двоеточия). В .env задайте одну строку: LIVEKIT_KEYS=APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
container_name: platform_prod_livekit
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
- LIVEKIT_KEYS=${LIVEKIT_API_KEY:-APIKeyPlatform2024Secret}:${LIVEKIT_API_SECRET:-ThisIsAVerySecureSecretKeyForPlatform2024VideoConf}
|
||||
# Одна строка "key: secret" (пробел после двоеточия). В кавычках, чтобы YAML не воспринял двоеточие как ключ.
|
||||
- "LIVEKIT_KEYS=APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf"
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881"
|
||||
|
|
@ -221,6 +225,9 @@ services:
|
|||
- WATCHPACK_POLLING=true
|
||||
- HOSTNAME=0.0.0.0
|
||||
- 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:
|
||||
- "3010:3000"
|
||||
volumes:
|
||||
|
|
@ -247,6 +254,9 @@ services:
|
|||
dockerfile: Dockerfile
|
||||
container_name: platform_prod_excalidraw
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# basePath в next.config.js: иначе /_next/ запросы уходят на основной фронт и доска пустая
|
||||
- NEXT_PUBLIC_BASE_PATH=/excalidraw
|
||||
ports:
|
||||
- "3004:3001"
|
||||
networks:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ const nextConfig = {
|
|||
// Минимальная конфигурация для Excalidraw
|
||||
reactStrictMode: false,
|
||||
|
||||
// Прокси nginx: app.uchill.online/excalidraw/ → 3004. Без basePath запросы к /_next/ уходили бы на основной фронт.
|
||||
basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? '',
|
||||
|
||||
// Не редиректить /excalidraw/ ↔ /excalidraw (иначе цикл с nginx/iframe)
|
||||
trailingSlash: true,
|
||||
|
||||
// Отключаем SSR полностью
|
||||
output: 'standalone',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { AuthRedirect } from '@/components/auth/AuthRedirect';
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AuthRedirect>
|
||||
<div
|
||||
data-no-nav
|
||||
style={{
|
||||
|
|
@ -56,5 +59,6 @@ export default function AuthLayout({
|
|||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</AuthRedirect>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -733,19 +733,6 @@ export default function StudentsPage() {
|
|||
}}>
|
||||
<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
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
@ -38,16 +38,28 @@ export function WhiteboardIframe({
|
|||
const token = tokenProp ?? localStorage.getItem('access_token') ?? '';
|
||||
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 excalidrawHost = `${window.location.protocol}//${window.location.hostname}:3001`;
|
||||
|
||||
const url = new URL('/', excalidrawHost);
|
||||
// Вариант 1: отдельный URL доски (как LiveKit) — если app.uchill.online отдаёт один Next без nginx по путям
|
||||
const excalidrawBaseUrl = (process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '').trim().replace(/\/?$/, '');
|
||||
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('apiUrl', apiUrl);
|
||||
if (token) url.searchParams.set('token', token);
|
||||
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');
|
||||
iframe.src = url.toString();
|
||||
iframe.src = iframeSrc;
|
||||
iframe.style.cssText = 'width:100%;height:100%;border:none;display:block';
|
||||
iframe.title = 'Интерактивная доска (Excalidraw)';
|
||||
iframe.setAttribute('allow', 'camera; microphone; fullscreen');
|
||||
|
|
@ -93,12 +105,14 @@ export function WhiteboardIframe({
|
|||
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current?.contentWindow || !createdRef.current) return;
|
||||
const token = tokenProp ?? localStorage.getItem('access_token') ?? '';
|
||||
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 excalidrawHost = `${window.location.protocol}//${window.location.hostname}:3001`;
|
||||
const url = new URL('/', excalidrawHost);
|
||||
|
||||
const excalidrawBaseUrl = (process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '').trim().replace(/\/?$/, '');
|
||||
const targetOrigin = excalidrawBaseUrl.startsWith('http')
|
||||
? new URL(excalidrawBaseUrl).origin
|
||||
: (() => {
|
||||
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 => {
|
||||
if (!raw || typeof raw !== 'string') return raw;
|
||||
try {
|
||||
|
|
@ -108,11 +122,12 @@ export function WhiteboardIframe({
|
|||
return raw;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{ type: 'excalidraw-username', username: decodeName(username) },
|
||||
url.origin
|
||||
targetOrigin
|
||||
);
|
||||
} catch (_) {}
|
||||
}, [username]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
Loading…
Reference in New Issue