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/
|
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')
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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={{
|
||||||
|
|
|
||||||
|
|
@ -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 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 (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue