/** * Camera Scanner Hook with BarcodeDetector and ZXing fallback */ import { useCallback, useEffect, useRef, useState } from 'react'; import { BrowserCodeReader, type IScannerControls } from '@zxing/browser'; import { doc, onSnapshot } from 'firebase/firestore'; import { db } from '../../lib/firebase'; import { captureScannerError, addScannerBreadcrumb, setScannerContext, captureScannerPerformance } from '../../lib/sentry'; import { QRDebounceManager } from './DebounceManager'; import { ScannerRateLimiter, DeviceAbuseTracker } from './RateLimiter'; import type { ScannerState, AbusePreventionConfig } from './types'; interface ScannerHookOptions { onScan: (qr: string) => void; enabled: boolean; torchEnabled?: boolean; soundEnabled?: boolean; vibrationEnabled?: boolean; eventId?: string; organizationId?: string; abusePreventionConfig?: Partial; } export function useScanner({ onScan, enabled, torchEnabled = false, soundEnabled = true, vibrationEnabled = true, eventId, organizationId, abusePreventionConfig = {}, }: ScannerHookOptions) { const videoRef = useRef(null); const canvasRef = useRef(null); const streamRef = useRef(null); const scannerRef = useRef(null); const lastScanRef = useRef<{ qr: string; time: number } | null>(null); const codeReaderRef = useRef(null); const sessionIdRef = useRef(''); const deviceIdRef = useRef(''); const rateLimiterRef = useRef(null); const abuseTrackerRef = useRef(null); const debounceManagerRef = useRef(null); const [state, setState] = useState({ isScanning: false, cameraPermission: 'prompt', torchSupported: false, torchEnabled: false, eventLoading: false, scanningEnabled: true, // Default to enabled until we load event data rateLimitStatus: 'ok', }); // Fetch event data and listen for scanning control changes useEffect(() => { if (!eventId) { return; } setState(prev => ({ ...prev, eventLoading: true })); // Set up real-time listener for event document const eventDocRef = doc(db, 'events', eventId); const unsubscribe = onSnapshot( eventDocRef, (docSnapshot) => { if (docSnapshot.exists()) { const eventData = docSnapshot.data(); const scanningEnabled = eventData.scanningEnabled !== false; // Default to true if not set setState(prev => ({ ...prev, eventLoading: false, scanningEnabled, eventData: { id: docSnapshot.id, title: eventData.title || 'Event', scanningEnabled, }, })); addScannerBreadcrumb('Event data loaded', { eventId, scanningEnabled, eventTitle: eventData.title, }); } else { // Event doesn't exist, disable scanning setState(prev => ({ ...prev, eventLoading: false, scanningEnabled: false, eventData: undefined, })); addScannerBreadcrumb('Event not found', { eventId, }); } }, (error) => { captureScannerError(error, { operation: 'event_data_fetch', sessionId: sessionIdRef.current, eventId, additionalData: { errorCode: error.code, errorMessage: error.message, }, }); setState(prev => ({ ...prev, eventLoading: false, scanningEnabled: false, })); } ); return () => { unsubscribe(); }; }, [eventId]); // Initialize scanner session tracking and abuse prevention useEffect(() => { // Get or create session ID let sessionId = sessionStorage.getItem('scanner_session_id'); if (!sessionId) { sessionId = `session_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; sessionStorage.setItem('scanner_session_id', sessionId); } sessionIdRef.current = sessionId; // Get or create device ID let deviceId = localStorage.getItem('scanner_device_id'); if (!deviceId) { deviceId = `device_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; localStorage.setItem('scanner_device_id', deviceId); } deviceIdRef.current = deviceId; // Initialize abuse prevention systems const config = { rateLimitEnabled: true, maxScansPerSecond: 8, debounceTimeMs: 2000, deviceTrackingEnabled: true, ticketStatusCheckEnabled: true, ...abusePreventionConfig, }; if (config.rateLimitEnabled) { rateLimiterRef.current = new ScannerRateLimiter({ maxScansPerSecond: config.maxScansPerSecond, }); } if (config.deviceTrackingEnabled) { abuseTrackerRef.current = new DeviceAbuseTracker(); } debounceManagerRef.current = new QRDebounceManager({ debounceTimeMs: config.debounceTimeMs, }); // Set Sentry context setScannerContext({ sessionId, deviceId, eventId, organizationId, }); addScannerBreadcrumb('Scanner initialized with abuse prevention', { sessionId, deviceId: deviceId.split('_')[1]?.substring(0, 8), eventId, abusePreventionEnabled: { rateLimitEnabled: config.rateLimitEnabled, deviceTrackingEnabled: config.deviceTrackingEnabled, maxScansPerSecond: config.maxScansPerSecond, }, }); }, [eventId, organizationId, abusePreventionConfig]); // Initialize scanner const initializeScanner = useCallback(async () => { if (!enabled || !videoRef.current || state.isScanning) {return;} const startTime = performance.now(); addScannerBreadcrumb('Camera initialization started'); try { setState(prev => ({ ...prev, cameraPermission: 'loading' })); // Request camera permission const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', // Use back camera width: { ideal: 1280 }, height: { ideal: 720 }, }, }); streamRef.current = stream; videoRef.current.srcObject = stream; // Check torch support const track = stream.getVideoTracks()[0]; const capabilities = track.getCapabilities(); const torchSupported = 'torch' in capabilities; setState(prev => ({ ...prev, isScanning: true, cameraPermission: 'granted', torchSupported, })); const initDuration = performance.now() - startTime; captureScannerPerformance('camera_initialization', initDuration, { torchSupported, sessionId: sessionIdRef.current, }); addScannerBreadcrumb('Camera initialized successfully', { duration: initDuration, torchSupported, }); // Start scanning await startScanning(); } catch (error) { const initDuration = performance.now() - startTime; captureScannerError(error as Error, { operation: 'camera_initialization', sessionId: sessionIdRef.current, deviceId: deviceIdRef.current, eventId, additionalData: { duration: initDuration, enabled, hasVideoElement: !!videoRef.current, }, }); console.error('Failed to initialize camera:', error); setState(prev => ({ ...prev, cameraPermission: 'denied', isScanning: false, })); } }, [enabled, state.isScanning, eventId]); // Start scanning with BarcodeDetector or ZXing fallback const startScanning = useCallback(async () => { if (!videoRef.current || !canvasRef.current) {return;} // Try native BarcodeDetector first if ('BarcodeDetector' in window && (window as any).BarcodeDetector) { try { const barcodeDetector = new (window as any).BarcodeDetector({ formats: ['qr_code', 'code_128', 'code_39'], }); const scanLoop = async () => { if (!state.isScanning || !videoRef.current || !canvasRef.current) {return;} try { const canvas = canvasRef.current; const context = canvas.getContext('2d'); if (!context) {return;} // Draw video frame to canvas canvas.width = videoRef.current.videoWidth; canvas.height = videoRef.current.videoHeight; context.drawImage(videoRef.current, 0, 0); // Detect barcodes const barcodes = await barcodeDetector.detect(canvas); if (barcodes.length > 0) { const qr = barcodes[0].rawValue; handleScanResult(qr); } } catch (error) { captureScannerError(error as Error, { operation: 'barcode_detection', sessionId: sessionIdRef.current, deviceId: deviceIdRef.current, eventId, additionalData: { method: 'BarcodeDetector', hasCanvas: !!canvasRef.current, hasVideo: !!videoRef.current, }, }); console.error('Barcode detection error:', error); } // Continue scanning if (state.isScanning) { requestAnimationFrame(scanLoop); } }; requestAnimationFrame(scanLoop); return; } catch (error) { addScannerBreadcrumb('BarcodeDetector failed, falling back to ZXing', { error: (error as Error).message, }); console.warn('BarcodeDetector failed, falling back to ZXing:', error); } } // Fallback to ZXing try { const codeReader = new BrowserCodeReader(); codeReaderRef.current = codeReader; const controls = await codeReader.decodeFromVideoDevice( undefined, // Auto-select device videoRef.current, (result, error) => { if (result) { handleScanResult(result.getText()); } if (error && error.name !== 'NotFoundException') { captureScannerError(error, { operation: 'zxing_scan', sessionId: sessionIdRef.current, deviceId: deviceIdRef.current, eventId, additionalData: { method: 'ZXing', errorName: error.name, }, }); console.error('ZXing scan error:', error); } } ); scannerRef.current = controls; } catch (error) { captureScannerError(error as Error, { operation: 'zxing_initialization', sessionId: sessionIdRef.current, deviceId: deviceIdRef.current, eventId, additionalData: { method: 'ZXing', }, }); console.error('ZXing initialization failed:', error); } }, [state.isScanning]); // Handle scan result with comprehensive abuse prevention const handleScanResult = useCallback((qr: string) => { const now = Date.now(); // Check if scanning is enabled for this event if (!state.scanningEnabled) { addScannerBreadcrumb('Scan attempt blocked - scanning disabled by admin', { qr_masked: `${qr.substring(0, 4)}***`, eventId, scanningEnabled: state.scanningEnabled, }); // Harsh vibration for blocked scan if (vibrationEnabled && 'vibrate' in navigator) { navigator.vibrate([200, 100, 200, 100, 200]); // Long error pattern } return; } // Device-level abuse check if (abuseTrackerRef.current) { const abuseStatus = abuseTrackerRef.current.checkAbuseStatus(now); if (abuseStatus.isAbusive) { setState(prev => ({ ...prev, rateLimitStatus: 'blocked', deviceBlocked: true, deviceBlockMessage: abuseStatus.message, rateLimitCountdown: abuseStatus.backoffTime, })); addScannerBreadcrumb('Scan blocked - device abuse detected', { qr_masked: `${qr.substring(0, 4) }***`, backoffTime: abuseStatus.backoffTime, deviceStats: abuseTrackerRef.current.getStats(), }); // Harsh vibration for blocked scan if (vibrationEnabled && 'vibrate' in navigator) { navigator.vibrate([200, 100, 200, 100, 200]); // Long error pattern } return; } } // Rate limiting check if (rateLimiterRef.current) { const rateCheck = rateLimiterRef.current.checkRate(now); if (!rateCheck.allowed) { setState(prev => ({ ...prev, rateLimitStatus: 'blocked', rateLimitMessage: rateCheck.message, rateLimitCountdown: rateCheck.waitTime, })); // Record violation for device abuse tracking if (abuseTrackerRef.current) { abuseTrackerRef.current.recordViolation(now); } addScannerBreadcrumb('Scan blocked - rate limit exceeded', { qr_masked: `${qr.substring(0, 4) }***`, currentRate: rateCheck.currentRate, waitTime: rateCheck.waitTime, rateLimiterStats: rateLimiterRef.current.getStats(now), }); // Double vibration for rate limit if (vibrationEnabled && 'vibrate' in navigator) { navigator.vibrate([100, 100, 100]); // Double vibration pattern } return; } // Warn if approaching rate limit if (rateCheck.currentRate > 6) { // Warning at 75% of limit setState(prev => ({ ...prev, rateLimitStatus: 'warning', rateLimitMessage: 'Slow down - approaching scan limit', })); } else { setState(prev => ({ ...prev, rateLimitStatus: 'ok', rateLimitMessage: undefined, rateLimitCountdown: undefined, })); } } // Enhanced debouncing check if (debounceManagerRef.current) { const debounceCheck = debounceManagerRef.current.checkScan(qr, deviceIdRef.current, now); if (!debounceCheck.allowed) { setState(prev => ({ ...prev, debounceActive: true, debounceCountdown: debounceCheck.remainingTime, })); addScannerBreadcrumb('QR scan debounced (enhanced duplicate check)', { qr_masked: `${qr.substring(0, 4) }***`, remainingTime: debounceCheck.remainingTime, lastScanTime: debounceCheck.lastScanTime, }); // Triple vibration for debounced scan if (vibrationEnabled && 'vibrate' in navigator) { navigator.vibrate([50, 50, 50, 50, 50]); // Quick triple pattern } return; } setState(prev => ({ ...prev, debounceActive: false, debounceCountdown: undefined, })); } // All checks passed - process the scan lastScanRef.current = { qr, time: now }; // Record successful scan in abuse prevention systems if (rateLimiterRef.current) { rateLimiterRef.current.recordScan(now); } if (debounceManagerRef.current) { debounceManagerRef.current.recordScan(qr, deviceIdRef.current, now); } setState(prev => ({ ...prev, lastScannedQR: qr, lastScanTime: now, rateLimitStatus: 'ok', deviceBlocked: false, })); addScannerBreadcrumb('QR code successfully scanned with abuse checks', { qr_masked: `${qr.substring(0, 4) }***`, sessionId: sessionIdRef.current, timestamp: new Date(now).toISOString(), rateLimitStats: rateLimiterRef.current?.getStats(now), debounceStats: debounceManagerRef.current?.getStats(now), }); // Success haptic feedback if (vibrationEnabled && 'vibrate' in navigator) { navigator.vibrate(100); // Short vibration for success } // Audio feedback if (soundEnabled) { playSuccessSound(); } onScan(qr); }, [onScan, soundEnabled, vibrationEnabled, state.scanningEnabled, eventId]); // Play success sound const playSuccessSound = useCallback(() => { try { const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)(); const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); oscillator.connect(gainNode); gainNode.connect(audioContext.destination); oscillator.frequency.value = 800; // 800 Hz beep gainNode.gain.value = 0.1; oscillator.start(); oscillator.stop(audioContext.currentTime + 0.1); // 100ms beep } catch (error) { // Don't send audio errors to Sentry as they're not critical addScannerBreadcrumb('Audio feedback failed', { error: (error as Error).message, }); console.warn('Audio feedback failed:', error); } }, []); // Toggle torch const toggleTorch = useCallback(async () => { if (!state.torchSupported || !streamRef.current) {return;} try { const track = streamRef.current.getVideoTracks()[0]; const newTorchState = !state.torchEnabled; await track.applyConstraints({ advanced: [{ torch: newTorchState }] as any, }); setState(prev => ({ ...prev, torchEnabled: newTorchState })); } catch (error) { captureScannerError(error as Error, { operation: 'torch_toggle', sessionId: sessionIdRef.current, deviceId: deviceIdRef.current, eventId, additionalData: { torchSupported: state.torchSupported, currentTorchState: state.torchEnabled, hasStream: !!streamRef.current, }, }); console.error('Failed to toggle torch:', error); } }, [state.torchSupported, state.torchEnabled]); // Stop scanning const stopScanning = useCallback(() => { if (scannerRef.current) { scannerRef.current.stop(); scannerRef.current = null; } if (codeReaderRef.current) { codeReaderRef.current.reset(); codeReaderRef.current = null; } if (streamRef.current) { streamRef.current.getTracks().forEach(track => track.stop()); streamRef.current = null; } if (videoRef.current) { videoRef.current.srcObject = null; } setState(prev => ({ ...prev, isScanning: false, torchEnabled: false, })); }, []); // Auto-enable torch in dark environments useEffect(() => { if (!torchEnabled || !state.torchSupported || state.torchEnabled) {return;} // Simple ambient light detection using video luminance const checkAmbientLight = () => { if (!videoRef.current || !canvasRef.current) {return;} const canvas = canvasRef.current; const context = canvas.getContext('2d'); if (!context) {return;} canvas.width = videoRef.current.videoWidth; canvas.height = videoRef.current.videoHeight; context.drawImage(videoRef.current, 0, 0); const imageData = context.getImageData(0, 0, canvas.width, canvas.height); const {data} = imageData; let brightness = 0; for (let i = 0; i < data.length; i += 4) { brightness += (data[i] + data[i + 1] + data[i + 2]) / 3; } brightness = brightness / (data.length / 4); // If very dark (brightness < 30), auto-enable torch if (brightness < 30 && !state.torchEnabled) { toggleTorch(); } }; const interval = setInterval(checkAmbientLight, 5000); // Check every 5 seconds return () => clearInterval(interval); }, [torchEnabled, state.torchSupported, state.torchEnabled, toggleTorch]); // Initialize when enabled useEffect(() => { if (enabled && !state.isScanning) { initializeScanner(); } else if (!enabled && state.isScanning) { stopScanning(); } }, [enabled, initializeScanner, stopScanning, state.isScanning]); // Update abuse prevention countdowns useEffect(() => { let countdownInterval: NodeJS.Timeout | null = null; const hasActiveCountdown = state.rateLimitCountdown || state.debounceCountdown; if (hasActiveCountdown) { countdownInterval = setInterval(() => { const now = Date.now(); setState(prev => { const updates: Partial = {}; // Update rate limit countdown if (prev.rateLimitCountdown && prev.rateLimitCountdown > 0) { const newCountdown = Math.max(0, prev.rateLimitCountdown - 100); updates.rateLimitCountdown = newCountdown; if (newCountdown === 0) { updates.rateLimitStatus = 'ok'; updates.rateLimitMessage = undefined; updates.deviceBlocked = false; updates.deviceBlockMessage = undefined; } } // Update debounce countdown if (prev.debounceCountdown && prev.debounceCountdown > 0) { const newCountdown = Math.max(0, prev.debounceCountdown - 100); updates.debounceCountdown = newCountdown; if (newCountdown === 0) { updates.debounceActive = false; } } return { ...prev, ...updates }; }); }, 100); // Update every 100ms for smooth countdown } return () => { if (countdownInterval) { clearInterval(countdownInterval); } }; }, [state.rateLimitCountdown, state.debounceCountdown]); // Cleanup on unmount useEffect(() => () => { stopScanning(); }, [stopScanning]); return { videoRef, canvasRef, state, toggleTorch, stopScanning, restart: initializeScanner, }; }