diff --git a/.gitignore b/.gitignore index acbe4c0..bc10988 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,74 @@ # Dependencies node_modules/ +.pnp +.pnp.js + +# Testing +coverage/ # Build output +dist/ +build/ apps/server/dist/ apps/web/dist/ +*.tsbuildinfo # Environment files .env +.env.local +.env.development.local +.env.test.local +.env.production.local apps/server/.env +apps/web/.env -# Uploads (user data) +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +eslint-debug.log* +*.log + +# Editor/IDE +.idea/ +.vscode/ +*.swp +*.swo +*.sublime-project +*.sublime-workspace +.project +.settings/ +.classpath + +# OS files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Docker +.docker-compose.override.yml +.docker-compose.local.yml + +# Uploads and User Data +apps/server/uploads/* +!apps/server/uploads/.gitkeep apps/server/uploads/avatars/* !apps/server/uploads/avatars/.gitkeep # Deploy artifacts vortex-deploy/ vortex-deploy.tar.gz - -# Credentials +vortex-deploy.zip CREDENTIALS.txt - -# Security audit (personal) SECURITY_AUDIT.md -# OS files -Thumbs.db -.DS_Store - -# IDE -.idea/ -.vscode/ -*.swp -*.swo +# Misc +.cache/ +.eslintcache +.npm/ +.tmp/ +tmp/ diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 7482f55..2d38c79 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -59,8 +59,11 @@ app.use('/uploads', (req, res, next) => { const contentType = mime.lookup(filePath) || 'application/octet-stream'; res.setHeader('Content-Type', contentType); - // If encryption is enabled, try to decrypt - if (isEncryptionEnabled()) { + // Check if we should skip decryption (videos/audio always skipped for range support) + const isMedia = contentType.startsWith('video/') || contentType.startsWith('audio/'); + + // If encryption is enabled and NOT video/audio, try to decrypt + if (isEncryptionEnabled() && !isMedia) { const decrypted = decryptFileToBuffer(filePath); if (decrypted) { res.setHeader('Content-Length', decrypted.length); diff --git a/apps/server/src/shared.ts b/apps/server/src/shared.ts index 89799ba..a18fed1 100644 --- a/apps/server/src/shared.ts +++ b/apps/server/src/shared.ts @@ -135,7 +135,7 @@ export const uploadFile = multer({ cb(null, `${uuidv4()}${ext}`); }, }), - limits: { fileSize: 50 * 1024 * 1024 }, + limits: { fileSize: 200 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); if (BLOCKED_EXTENSIONS.has(ext)) { @@ -158,15 +158,20 @@ export function encryptUploadedFile(req: Request, _res: Response, next: NextFunc try { // Single file upload (req.file) if (req.file) { - encryptFileInPlace(req.file.path); + // Don't encrypt videos and audio as they need range support and are large + if (!req.file.mimetype.startsWith('video/') && !req.file.mimetype.startsWith('audio/')) { + encryptFileInPlace(req.file.path); + } } // Multiple files (req.files) — handle both array and field-keyed forms if (req.files) { const files = Array.isArray(req.files) ? req.files - : Object.values(req.files).flat(); + : Object.values(req.files).flat() as Express.Multer.File[]; for (const file of files) { - encryptFileInPlace(file.path); + if (!file.mimetype.startsWith('video/') && !file.mimetype.startsWith('audio/')) { + encryptFileInPlace(file.path); + } } } } catch (e) { diff --git a/apps/server/src/socket/index.ts b/apps/server/src/socket/index.ts index 34b8c06..95d11c6 100644 --- a/apps/server/src/socket/index.ts +++ b/apps/server/src/socket/index.ts @@ -779,6 +779,15 @@ export function setupSocket(io: Server) { } }); + socket.on('call_type_changed', (data: { targetUserId: string; callType: 'voice' | 'video' }) => { + const targetSockets = onlineUsers.get(data.targetUserId); + if (targetSockets) { + for (const sid of targetSockets) { + io.to(sid).emit('call_type_changed', { from: userId, callType: data.callType }); + } + } + }); + // ======= Group Conference Calls ======= // Query active group call status for a chat diff --git a/apps/web/src/components/CallModal.tsx b/apps/web/src/components/CallModal.tsx index 5b97de2..e0f665a 100644 --- a/apps/web/src/components/CallModal.tsx +++ b/apps/web/src/components/CallModal.tsx @@ -220,20 +220,26 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi remoteStreamRef.current = stream; // Check if remote has video tracks - // Include !t.muted so we detect when the remote stops sending - // (replaceTrack(null) or direction change causes muted=true) const checkVideo = () => { const videoTracks = stream.getVideoTracks(); + // hasRemoteVideo stays true if we have ANY live video track. const hasVideo = videoTracks.length > 0 && videoTracks.some( t => t.readyState === 'live' && t.enabled && !t.muted ); + console.log('[checkVideo] videoTracks:', videoTracks.map(t => `${t.label} state=${t.readyState} enabled=${t.enabled} muted=${t.muted}` ), '→ hasRemoteVideo:', hasVideo); + setHasRemoteVideo(hasVideo); + // Fallback: if we detect a live video track but signalling hasn't updated us, switch to video mode + if (hasVideo && callType !== 'video') { + console.log('[checkVideo] Auto-switching to video mode'); + setCallType('video'); + } }; - // Listen for unmute/mute on every video track (muted→unmuted when data starts flowing) + // Listen for unmute/mute/ended on video tracks const attachTrackListeners = (track: MediaStreamTrack) => { if (track.kind !== 'video') return; track.onunmute = checkVideo; @@ -245,6 +251,8 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi checkVideo(); if (remoteVideoRef.current) { + // Force re-assignment to ensure browser picks up new track + remoteVideoRef.current.srcObject = null; remoteVideoRef.current.srcObject = stream; } if (remoteAudioRef.current) { @@ -336,9 +344,11 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi // Add all tracks from the stream stream.getTracks().forEach(track => pc.addTrack(track, stream)); - // If this is a video call but we only have audio, add a recvonly video transceiver - // so we can receive video from the other side and potentially add video later - if (effectiveCallType === 'video' && stream.getVideoTracks().length === 0) { + // Always add a video transceiver (even for voice calls) + // This ensures we have a video m-line for future camera/screen share, + // making renegotiation much more stable across browsers. + const hasVideoTransceiver = pc.getTransceivers().some(t => t.receiver?.track?.kind === 'video'); + if (!hasVideoTransceiver) { pc.addTransceiver('video', { direction: 'recvonly' }); } @@ -429,17 +439,13 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi pc.addTrack(track, stream); }); - // If this is a video call but we only have audio, we need a recvonly video transceiver. - // After setRemoteDescription, one already exists from the offer — but only if the offer - // contained a video m-line. If it did, the transceiver is already recvonly. - // If there's no video transceiver at all and we want to receive video, add one. - if (incoming.callType === 'video' && !hasVideo) { - const hasVideoTransceiver = pc.getTransceivers().some( - t => t.receiver?.track?.kind === 'video' - ); - if (!hasVideoTransceiver) { - pc.addTransceiver('video', { direction: 'recvonly' }); - } + // Always ensure we have a video transceiver. + // After setRemoteDescription, one usually exists from the offer if it was a video call or if the caller added one. + const hasVideoTransceiver = pc.getTransceivers().some( + t => t.receiver?.track?.kind === 'video' + ); + if (!hasVideoTransceiver) { + pc.addTransceiver('video', { direction: 'recvonly' }); } console.log('[acceptCall] Transceivers after setup:', @@ -888,6 +894,8 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi } setIsVideoOff(true); setCallType('voice'); + const socket = getSocket(); + socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice' }); } else { // Turn on: re-enable existing track or get new camera const sender = findVideoSender(pc); @@ -937,6 +945,8 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi setIsVideoOff(false); setCallType('video'); + const socket = getSocket(); + socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video' }); } catch (err) { console.error('Could not start camera:', err); } @@ -1038,6 +1048,8 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi } setCallType('voice'); setIsVideoOff(false); + const socket = getSocket(); + socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice' }); } setIsScreenSharing(false); @@ -1165,11 +1177,15 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi }); } setCallType('voice'); + const socket = getSocket(); + socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice' }); } }; setIsScreenSharing(true); setCallType('video'); + const socket = getSocket(); + socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video' }); } catch (err) { console.error('Error starting screen share:', err); } @@ -1287,6 +1303,12 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi } }; + const onCallTypeChanged = (data: { from: string; callType: 'voice' | 'video' }) => { + if (data.from !== targetUserIdRef.current) return; + console.log('[onCallTypeChanged] New call type:', data.callType); + setCallType(data.callType); + }; + socket.on('call_answered', onCallAnswered); socket.on('ice_candidate', onIceCandidate); socket.on('call_ended', onCallEnded); @@ -1294,6 +1316,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi socket.on('call_unavailable', onCallUnavailable); socket.on('renegotiate', onRenegotiate); socket.on('renegotiate_answer', onRenegotiateAnswer); + socket.on('call_type_changed', onCallTypeChanged); return () => { socket.off('call_answered', onCallAnswered); @@ -1303,6 +1326,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi socket.off('call_unavailable', onCallUnavailable); socket.off('renegotiate', onRenegotiate); socket.off('renegotiate_answer', onRenegotiateAnswer); + socket.off('call_type_changed', onCallTypeChanged); }; }, [cleanup, scheduleClose]); @@ -1332,11 +1356,13 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi // Sync remote video ref with remote stream (only when srcObject actually changes) useEffect(() => { - if (!remoteVideoRef.current) return; - if (remoteStreamRef.current && remoteVideoRef.current.srcObject !== remoteStreamRef.current) { + if (!remoteVideoRef.current || !remoteStreamRef.current) return; + if (remoteVideoRef.current.srcObject !== remoteStreamRef.current) { + console.log('[useEffect] Syncing remote video srcObject'); remoteVideoRef.current.srcObject = remoteStreamRef.current; + remoteVideoRef.current.play().catch(() => { }); } - }); + }, [hasRemoteVideo, callType]); // Cleanup on unmount useEffect(() => { @@ -1523,24 +1549,25 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi className="relative bg-black w-full" style={{ aspectRatio: '16 / 9' }} > - {/* Remote video — fills container, preserves natural aspect ratio */} - {hasRemoteVideo ? ( +