From 8269d044a28cff5912d8874d5450bdef21628fb0 Mon Sep 17 00:00:00 2001 From: David Bruno Vontor Date: Thu, 28 May 2026 17:23:04 +0200 Subject: [PATCH] chat ws with pfp is working --- .gitignore | 4 +- README.md | 33 ++++++++ backend/Dockerfile | 11 ++- backend/social/chat/consumers.py | 11 ++- backend/social/chat/serializers.py | 17 ++++ backend/social/chat/views.py | 3 +- backend/vontor_cz/asgi.py | 10 ++- backend/vontor_cz/middleware.py | 83 +++++++++++++++++++ backend/vontor_cz/settings.py | 5 -- docker-compose.yml | 2 + frontend/.env.example | 1 + frontend/nginx/nginx.conf | 15 ++++ .../src/api/generated/private/models/index.ts | 1 + .../api/generated/private/models/message.ts | 4 +- .../generated/private/models/messageSender.ts | 12 +++ .../private/models/patchedMessage.ts | 4 +- .../src/api/generated/public/models/index.ts | 1 + .../api/generated/public/models/message.ts | 4 +- .../generated/public/models/messageSender.ts | 12 +++ .../generated/public/models/patchedMessage.ts | 4 +- frontend/src/api/social/ws.ts | 22 ++--- .../social/chat/CreateChatModal.tsx | 50 ++++++----- .../src/components/social/chat/Message.tsx | 7 +- frontend/src/hooks/useChatSocket.ts | 4 +- frontend/src/index.css | 36 +++++--- .../src/pages/social/chat/ChatRoomPage.tsx | 2 +- frontend/src/utils/mediaUrl.ts | 24 +++--- frontend/vite.config.ts | 7 +- 28 files changed, 299 insertions(+), 90 deletions(-) create mode 100644 backend/vontor_cz/middleware.py create mode 100644 frontend/src/api/generated/private/models/messageSender.ts create mode 100644 frontend/src/api/generated/public/models/messageSender.ts diff --git a/.gitignore b/.gitignore index f9a718d..dec841e 100644 --- a/.gitignore +++ b/.gitignore @@ -84,4 +84,6 @@ frontend/.env.*.orig .env venv -.venv \ No newline at end of file +.venv + +certs \ No newline at end of file diff --git a/README.md b/README.md index e69de29..beb616d 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,33 @@ +# vontor.cz + +Czech e-marketplace — Django backend + React frontend. + +## Requirements + +- Docker Desktop +- Node.js 22+ (frontend dev) +- Python 3.12+ (backend dev) + +## Running locally + +```sh +docker-compose up +``` + +Frontend dev server: + +```sh +cd frontend && npm install && npm run dev +``` + +## Docker — corporate / custom CA certificates + +If your network uses SSL inspection (corporate proxy, Tailscale, etc.) the Docker build will fail with SSL errors when fetching packages. + +Fix: export your Windows Root CA store into the build context once: + +```powershell +New-Item -Force -ItemType Directory backend\certs | Out-Null; $s = [Security.Cryptography.X509Certificates.X509Store]::new("Root","LocalMachine"); $s.Open("ReadOnly"); $s.Certificates | % { "-----BEGIN CERTIFICATE-----`n" + [Convert]::ToBase64String($_.RawData,"InsertLineBreaks") + "`n-----END CERTIFICATE-----" } | Out-File -Encoding ascii backend\certs\windows-ca-bundle.crt; $s.Close() +``` + +The file is git-ignored. Re-run the command whenever your CA store changes. diff --git a/backend/Dockerfile b/backend/Dockerfile index e119d9d..23cf431 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,11 +2,14 @@ FROM python:3.12-slim WORKDIR /app +# Trust Windows/corporate root CAs before any network operations +COPY certs/windows-ca-bundle.crt /usr/local/share/ca-certificates/windows-ca-bundle.crt +RUN update-ca-certificates + # Install system dependencies including Node.js for yt-dlp JavaScript runtime -RUN apt update && apt install -y \ +RUN apt-get update && apt-get install -y --no-install-recommends \ weasyprint \ libcairo2 \ - pango1.0-tools \ libpango-1.0-0 \ libgobject-2.0-0 \ ffmpeg \ @@ -14,9 +17,9 @@ RUN apt update && apt install -y \ curl \ libmagic1 \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ - && apt install -y nodejs \ + && apt-get install -y --no-install-recommends nodejs \ && update-ca-certificates \ - && apt clean \ + && apt-get clean \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/backend/social/chat/consumers.py b/backend/social/chat/consumers.py index a6507d6..04c27be 100644 --- a/backend/social/chat/consumers.py +++ b/backend/social/chat/consumers.py @@ -51,7 +51,9 @@ class ChatConsumer(AsyncWebsocketConsumer): "type": "chat.message", "message_id": message.id, "message": message.content, + "sender_id": user.id, "sender": user.username, + "sender_avatar": user.avatar.url if user.avatar else None, }) elif msg_type == "new_reply_chat_message": @@ -66,7 +68,9 @@ class ChatConsumer(AsyncWebsocketConsumer): "message_id": message.id, "message": message.content, "reply_to_id": data.get("reply_to_id"), + "sender_id": user.id, "sender": user.username, + "sender_avatar": user.avatar.url if user.avatar else None, }) elif msg_type == "reaction": @@ -116,7 +120,9 @@ class ChatConsumer(AsyncWebsocketConsumer): "type": "new_chat_message", "message_id": event["message_id"], "message": event["message"], + "sender_id": event["sender_id"], "sender": event["sender"], + "sender_avatar": event["sender_avatar"], })) async def reply_chat_message(self, event): @@ -125,7 +131,9 @@ class ChatConsumer(AsyncWebsocketConsumer): "message_id": event["message_id"], "message": event["message"], "reply_to_id": event["reply_to_id"], + "sender_id": event["sender_id"], "sender": event["sender"], + "sender_avatar": event["sender_avatar"], })) async def edit_message(self, event): @@ -178,7 +186,8 @@ class ChatConsumer(AsyncWebsocketConsumer): @database_sync_to_async def _is_chat_member(chat_id, user): - return Chat.objects.filter(pk=chat_id, members=user).exists() + from django.db.models import Q + return Chat.objects.filter(Q(pk=chat_id), Q(members=user) | Q(owner=user)).exists() @database_sync_to_async diff --git a/backend/social/chat/serializers.py b/backend/social/chat/serializers.py index c49b045..4705b1f 100644 --- a/backend/social/chat/serializers.py +++ b/backend/social/chat/serializers.py @@ -1,8 +1,24 @@ +from django.contrib.auth import get_user_model from rest_framework import serializers from drf_spectacular.utils import extend_schema_field from .models import Chat, ChatReadStatus, Message, MessageFile, MessageHistory, MessageReaction +class MessageSenderSerializer(serializers.ModelSerializer): + avatar = serializers.SerializerMethodField() + + def get_avatar(self, obj): + from django.conf import settings + if obj.avatar: + return settings.MEDIA_URL + obj.avatar.name + return None + + class Meta: + model = get_user_model() + fields = ['id', 'username', 'avatar'] + read_only_fields = ['id', 'username'] + + class MessageFileSerializer(serializers.ModelSerializer): class Meta: model = MessageFile @@ -25,6 +41,7 @@ class MessageHistorySerializer(serializers.ModelSerializer): class MessageSerializer(serializers.ModelSerializer): + sender = MessageSenderSerializer(read_only=True) media_files = MessageFileSerializer(many=True, read_only=True) reactions = MessageReactionSerializer(many=True, read_only=True) diff --git a/backend/social/chat/views.py b/backend/social/chat/views.py index cbadbe3..64fab61 100644 --- a/backend/social/chat/views.py +++ b/backend/social/chat/views.py @@ -1,6 +1,7 @@ from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.contrib.auth import get_user_model +from django.db.models import Q from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError @@ -49,7 +50,7 @@ class ChatViewSet(viewsets.ModelViewSet): user = self.request.user if user.is_superuser: return Chat.objects.all() - return Chat.objects.filter(members=user) + return Chat.objects.filter(Q(members=user) | Q(owner=user)).distinct() def perform_create(self, serializer): serializer.save(owner=self.request.user) diff --git a/backend/vontor_cz/asgi.py b/backend/vontor_cz/asgi.py index ea430fc..1399313 100644 --- a/backend/vontor_cz/asgi.py +++ b/backend/vontor_cz/asgi.py @@ -16,15 +16,17 @@ from django.core.asgi import get_asgi_application django_asgi_app = get_asgi_application() from channels.routing import ProtocolTypeRouter, URLRouter -from channels.auth import AuthMiddlewareStack -import social.chat.routing +from social.chat.routing import websocket_urlpatterns as social_ws from thirdparty.downloader.routing import websocket_urlpatterns as downloader_ws +from vontor_cz.middleware import JWTAuthMiddleware + +websocket_urlpatterns = downloader_ws + social_ws application = ProtocolTypeRouter({ "http": django_asgi_app, - "websocket": AuthMiddlewareStack( + "websocket": JWTAuthMiddleware( URLRouter( - downloader_ws + social.chat.routing.websocket_urlpatterns + websocket_urlpatterns ) ), }) diff --git a/backend/vontor_cz/middleware.py b/backend/vontor_cz/middleware.py new file mode 100644 index 0000000..9f5ec03 --- /dev/null +++ b/backend/vontor_cz/middleware.py @@ -0,0 +1,83 @@ +import logging + +from channels.db import database_sync_to_async +from channels.middleware import BaseMiddleware +from django.contrib.auth.models import AnonymousUser +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +from rest_framework_simplejwt.tokens import AccessToken + +logger = logging.getLogger(__name__) + + +# ---------- WEBSOCKET JWT AUTH VALIDATION ----------- + +@database_sync_to_async +def get_user_from_token(token_string): + """ + Validate JWT token and return user + """ + try: + # Validate the token + access_token = AccessToken(token_string) + + # Get user ID from token + user_id = access_token.get('user_id') + + if not user_id: + return AnonymousUser() + + # Import here to avoid circular imports + from account.models import CustomUser + + # Get user from database + try: + user = CustomUser.objects.get(id=user_id) + return user + except CustomUser.DoesNotExist: + return AnonymousUser() + + except (TokenError, InvalidToken, Exception) as e: + logger.warning(f"JWT validation failed in websocket: {e}") + return AnonymousUser() + + +class JWTAuthMiddleware(BaseMiddleware): + """ + Custom middleware to authenticate WebSocket connections using JWT from cookies. + Replaces AuthMiddlewareStack / CSRF validation for WebSocket routes. + """ + + async def __call__(self, scope, receive, send): + # Get headers from scope + headers = dict(scope.get('headers', [])) + + # Extract cookies from headers + cookie_header = headers.get(b'cookie', b'').decode('utf-8') + + logger.info(f"[WS] cookie header present: {bool(cookie_header)} | keys: {[c.split('=')[0].strip() for c in cookie_header.split('; ') if '=' in c]}") + + # Parse cookies + cookies = {} + if cookie_header: + for cookie in cookie_header.split('; '): + if '=' in cookie: + key, value = cookie.split('=', 1) + cookies[key.strip()] = value + + # Get access_token from cookies + token = cookies.get('access_token') + + logger.info(f"[WS] token found: {bool(token)}") + + # Authenticate user + if token: + scope['user'] = await get_user_from_token(token) + else: + scope['user'] = AnonymousUser() + + logger.info(f"[WS] authenticated as: {scope['user']} | is_authenticated: {getattr(scope['user'], 'is_authenticated', False)}") + + return await super().__call__(scope, receive, send) + + +# ---------- END | WEBSOCKET JWT AUTH VALIDATION ----------- diff --git a/backend/vontor_cz/settings.py b/backend/vontor_cz/settings.py index 45cd825..b7c83ce 100644 --- a/backend/vontor_cz/settings.py +++ b/backend/vontor_cz/settings.py @@ -306,12 +306,8 @@ else: REST_FRAMEWORK = { "DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel 'DEFAULT_AUTHENTICATION_CLASSES': ( - # In DEBUG keep Session + JWT + your cookie class for convenience - 'rest_framework.authentication.SessionAuthentication', 'rest_framework_simplejwt.authentication.JWTAuthentication', 'account.tokens.CookieJWTAuthentication', - ) if DEBUG else ( - 'account.tokens.CookieJWTAuthentication', ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.AllowAny', @@ -427,7 +423,6 @@ MIDDLEWARE = [ 'silk.middleware.SilkyMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', diff --git a/docker-compose.yml b/docker-compose.yml index 7565f9b..b2e4ebb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,6 +91,8 @@ services: nginx: #web server, reverse proxy, serves static files container_name: nginx-vontor-cz + profiles: + - production build: context: ./frontend dockerfile: Dockerfile.prod diff --git a/frontend/.env.example b/frontend/.env.example index 1a6940c..6c8de13 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,5 +1,6 @@ # Base URL of the Django backend (must include /api/ if your axios baseURL expects it). VITE_BACKEND_URL="http://localhost:8000/api/" +VITE_BACKEND_WS_URL="ws://localhost:8000/" # Optional override for the WebSocket base. If unset, derived from VITE_BACKEND_URL # (the `/api` suffix is stripped automatically; only the host is used). diff --git a/frontend/nginx/nginx.conf b/frontend/nginx/nginx.conf index ffe449d..496def7 100644 --- a/frontend/nginx/nginx.conf +++ b/frontend/nginx/nginx.conf @@ -43,6 +43,21 @@ http { alias /app/media/; } + # ------------------------- + # WebSocket proxy + # ------------------------- + 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; + proxy_read_timeout 86400; + } + # Same-origin proxy for API -> avoids CORS and allows cookies location /api { return 301 /api/; diff --git a/frontend/src/api/generated/private/models/index.ts b/frontend/src/api/generated/private/models/index.ts index 7ca62d1..6939f94 100644 --- a/frontend/src/api/generated/private/models/index.ts +++ b/frontend/src/api/generated/private/models/index.ts @@ -67,6 +67,7 @@ export * from "./message"; export * from "./messageFile"; export * from "./messageReaction"; export * from "./messageSend"; +export * from "./messageSender"; export * from "./orderCarrier"; export * from "./orderCreate"; export * from "./orderItemCreate"; diff --git a/frontend/src/api/generated/private/models/message.ts b/frontend/src/api/generated/private/models/message.ts index a8f6185..21ae00b 100644 --- a/frontend/src/api/generated/private/models/message.ts +++ b/frontend/src/api/generated/private/models/message.ts @@ -5,12 +5,12 @@ */ import type { MessageFile } from "./messageFile"; import type { MessageReaction } from "./messageReaction"; +import type { MessageSender } from "./messageSender"; export interface Message { readonly id: number; readonly chat: number; - /** @nullable */ - readonly sender: number | null; + readonly sender: MessageSender; /** @nullable */ readonly reply_to: number | null; content?: string; diff --git a/frontend/src/api/generated/private/models/messageSender.ts b/frontend/src/api/generated/private/models/messageSender.ts new file mode 100644 index 0000000..df284da --- /dev/null +++ b/frontend/src/api/generated/private/models/messageSender.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export interface MessageSender { + readonly id: number; + /** Požadováno. 150 znaků nebo méně. Pouze písmena, číslice a znaky @/./+/-/_. */ + readonly username: string; + readonly avatar: string; +} diff --git a/frontend/src/api/generated/private/models/patchedMessage.ts b/frontend/src/api/generated/private/models/patchedMessage.ts index 2abd023..bb75e9d 100644 --- a/frontend/src/api/generated/private/models/patchedMessage.ts +++ b/frontend/src/api/generated/private/models/patchedMessage.ts @@ -5,12 +5,12 @@ */ import type { MessageFile } from "./messageFile"; import type { MessageReaction } from "./messageReaction"; +import type { MessageSender } from "./messageSender"; export interface PatchedMessage { readonly id?: number; readonly chat?: number; - /** @nullable */ - readonly sender?: number | null; + readonly sender?: MessageSender; /** @nullable */ readonly reply_to?: number | null; content?: string; diff --git a/frontend/src/api/generated/public/models/index.ts b/frontend/src/api/generated/public/models/index.ts index 15699f1..0378a13 100644 --- a/frontend/src/api/generated/public/models/index.ts +++ b/frontend/src/api/generated/public/models/index.ts @@ -49,6 +49,7 @@ export * from "./message"; export * from "./messageFile"; export * from "./messageReaction"; export * from "./messageSend"; +export * from "./messageSender"; export * from "./orderCarrier"; export * from "./orderCreate"; export * from "./orderItemCreate"; diff --git a/frontend/src/api/generated/public/models/message.ts b/frontend/src/api/generated/public/models/message.ts index a8f6185..21ae00b 100644 --- a/frontend/src/api/generated/public/models/message.ts +++ b/frontend/src/api/generated/public/models/message.ts @@ -5,12 +5,12 @@ */ import type { MessageFile } from "./messageFile"; import type { MessageReaction } from "./messageReaction"; +import type { MessageSender } from "./messageSender"; export interface Message { readonly id: number; readonly chat: number; - /** @nullable */ - readonly sender: number | null; + readonly sender: MessageSender; /** @nullable */ readonly reply_to: number | null; content?: string; diff --git a/frontend/src/api/generated/public/models/messageSender.ts b/frontend/src/api/generated/public/models/messageSender.ts new file mode 100644 index 0000000..df284da --- /dev/null +++ b/frontend/src/api/generated/public/models/messageSender.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.8.0 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.0 + */ + +export interface MessageSender { + readonly id: number; + /** Požadováno. 150 znaků nebo méně. Pouze písmena, číslice a znaky @/./+/-/_. */ + readonly username: string; + readonly avatar: string; +} diff --git a/frontend/src/api/generated/public/models/patchedMessage.ts b/frontend/src/api/generated/public/models/patchedMessage.ts index 2abd023..bb75e9d 100644 --- a/frontend/src/api/generated/public/models/patchedMessage.ts +++ b/frontend/src/api/generated/public/models/patchedMessage.ts @@ -5,12 +5,12 @@ */ import type { MessageFile } from "./messageFile"; import type { MessageReaction } from "./messageReaction"; +import type { MessageSender } from "./messageSender"; export interface PatchedMessage { readonly id?: number; readonly chat?: number; - /** @nullable */ - readonly sender?: number | null; + readonly sender?: MessageSender; /** @nullable */ readonly reply_to?: number | null; content?: string; diff --git a/frontend/src/api/social/ws.ts b/frontend/src/api/social/ws.ts index ad51a2f..9fab3ce 100644 --- a/frontend/src/api/social/ws.ts +++ b/frontend/src/api/social/ws.ts @@ -1,21 +1,21 @@ /** - * Derives the WebSocket base URL from env. Mirrors the BE Channels routing, - * which lives behind the same host as the REST API. + * Derives the WebSocket base URL. * - * Set `VITE_WS_URL` to override (e.g. wss://example.com); otherwise we flip the - * scheme of `VITE_BACKEND_URL`. + * Priority: + * 1. `VITE_WS_URL` env var — explicit override (e.g. wss://example.com) + * 2. Current page origin — uses window.location so the request goes through + * whatever proxy is in front (Vite dev proxy or nginx in production). + * This avoids hardcoding the backend port and bypassing the proxy. */ export function getChatSocketUrl(chatId: number | string): string { - const explicit = import.meta.env.VITE_WS_URL as string | undefined; + const explicit = import.meta.env.VITE_BACKEND_WS_URL as string | undefined; if (explicit) { return `${stripTrailing(explicit)}/ws/chat/${chatId}/`; } - const backend = (import.meta.env.VITE_BACKEND_URL as string | undefined) - ?? "http://localhost:8000"; - const wsBase = backend.replace(/^http/, "ws"); - // WS endpoints live at the host root (/ws/chat//), not under /api/. - const hostOnly = wsBase.replace(/\/api\/?$/, ""); - return `${stripTrailing(hostOnly)}/ws/chat/${chatId}/`; + + // Derive scheme from page protocol, host from page host (preserves dev port) + const wsScheme = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${wsScheme}//${window.location.host}/ws/chat/${chatId}/`; } function stripTrailing(s: string): string { diff --git a/frontend/src/components/social/chat/CreateChatModal.tsx b/frontend/src/components/social/chat/CreateChatModal.tsx index 0ee543b..8a2b5df 100644 --- a/frontend/src/components/social/chat/CreateChatModal.tsx +++ b/frontend/src/components/social/chat/CreateChatModal.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { FiX, FiSearch, FiCheck } from "react-icons/fi"; +import { FiX, FiSearch, FiCheck, FiUser, FiUsers } from "react-icons/fi"; import { useQueryClient } from "@tanstack/react-query"; import { useApiSocialChatsCreate, @@ -156,7 +156,7 @@ export default function CreateChatModal({ open, onClose }: Props) { /> {/* Dialog */} -
+
{/* Header */}

@@ -172,25 +172,28 @@ export default function CreateChatModal({ open, onClose }: Props) {

- {/* Tab switcher */} -
- {(["DM", "GROUP"] as ChatTab[]).map((type) => ( - - ))} + {/* Type switch */} +
+
+ {(["DM", "GROUP"] as ChatTab[]).map((type, i) => ( + + ))} +
{/* Form */} @@ -259,7 +262,10 @@ export default function CreateChatModal({ open, onClose }: Props) { {/* Dropdown */} {showDropdown && debouncedQuery.length >= 2 && ( -
+
{usersLoading && (
diff --git a/frontend/src/components/social/chat/Message.tsx b/frontend/src/components/social/chat/Message.tsx index 853dbf1..fc60951 100644 --- a/frontend/src/components/social/chat/Message.tsx +++ b/frontend/src/components/social/chat/Message.tsx @@ -19,7 +19,8 @@ interface Props { export default function Message({ message, chat, onReply, onReact }: Props) { const { t } = useTranslation("social"); const { user } = useAuth(); - const isOwn = user?.id != null && message.sender === user.id; + const sender = message.sender as { id: number; username: string; avatar: string | null } | null; + const isOwn = user?.id != null && sender?.id === user.id; async function handleDelete() { if (!confirm("Smazat zprávu?")) return; @@ -31,11 +32,11 @@ export default function Message({ message, chat, onReply, onReact }: Props) { return (
- +
{ - try { - return new URL(import.meta.env.VITE_BACKEND_URL ?? "http://localhost:8000").origin; - } catch { - return "http://localhost:8000"; - } -})(); - +/** + * Normalises a media URL so it always resolves through the current origin + * (Vite dev proxy → backend, or nginx in production). + * + * - Full URLs (http/https): strip the origin, keep only the path. + * - Relative paths without a leading slash: add one. + * - blob: / data: URLs: returned unchanged. + * - null / undefined / empty: returns null. + */ export function mediaUrl(src: string | null | undefined): string | null { if (!src) return null; if (src.startsWith("blob:") || src.startsWith("data:")) return src; try { + // Full URL — strip origin so the request goes through the proxy/nginx const url = new URL(src); - if (url.origin === BACKEND_ORIGIN) return url.pathname + url.search; + return url.pathname + (url.search || ""); } catch { - // already a relative path — return as-is + // Already a relative path + return src.startsWith("/") ? src : `/${src}`; } - return src; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e137a42..59dd166 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -42,11 +42,12 @@ export default defineConfig(({ mode }) => { target: backendUrl, changeOrigin: true, }, - '/ws': { - target: backendUrl, + /*'/ws': { + target: 'ws://localhost:8000/', ws: true, changeOrigin: true, - }, + rewriteWsOrigin: true, + },*/ }, }, };