/** * FieldWitness Frontend JavaScript * Shared functionality across encode, decode, and generate pages */ const Stego = { // ======================================================================== // PASSWORD/PIN VISIBILITY TOGGLES // ======================================================================== initPasswordToggles() { document.querySelectorAll('[data-toggle-password]').forEach(btn => { btn.addEventListener('click', function() { const targetId = this.dataset.togglePassword; const input = document.getElementById(targetId); const icon = this.querySelector('i'); if (!input) return; if (input.type === 'password') { input.type = 'text'; icon?.classList.replace('bi-eye', 'bi-eye-slash'); } else { input.type = 'password'; icon?.classList.replace('bi-eye-slash', 'bi-eye'); } }); }); }, // ======================================================================== // RSA INPUT METHOD TOGGLE (File vs QR) // ======================================================================== initRsaMethodToggle() { const fileRadio = document.getElementById('rsaMethodFile'); const qrRadio = document.getElementById('rsaMethodQr'); const fileSection = document.getElementById('rsaFileSection'); const qrSection = document.getElementById('rsaQrSection'); if (!fileRadio || !qrRadio || !fileSection || !qrSection) return; const update = () => { const isFile = fileRadio.checked; fileSection.classList.toggle('d-none', !isFile); qrSection.classList.toggle('d-none', isFile); }; fileRadio.addEventListener('change', update); qrRadio.addEventListener('change', update); }, // ======================================================================== // DROP ZONES (Drag & Drop + Preview) // ======================================================================== initDropZones(options = {}) { document.querySelectorAll('.drop-zone').forEach(zone => { const input = zone.querySelector('input[type="file"]'); const label = zone.querySelector('.drop-zone-label'); const preview = zone.querySelector('.drop-zone-preview'); if (!input) return; // Check if this is a special zone type const isPayloadZone = zone.id === 'payloadDropZone'; const isCarrierZone = zone.id === 'carrierDropZone'; const isQrZone = zone.id === 'qrDropZone'; // Drag events ['dragenter', 'dragover'].forEach(evt => { zone.addEventListener(evt, e => { e.preventDefault(); zone.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach(evt => { zone.addEventListener(evt, e => { e.preventDefault(); zone.classList.remove('drag-over'); }); }); // Drop handler zone.addEventListener('drop', e => { if (e.dataTransfer.files.length) { input.files = e.dataTransfer.files; input.dispatchEvent(new Event('change')); } }); // Change handler for preview (skip payload and QR zones - they have special handling) if (!isPayloadZone && !isQrZone) { input.addEventListener('change', function() { if (this.files && this.files[0]) { const file = this.files[0]; if (file.type.startsWith('image/') && preview) { Stego.showImagePreview(file, preview, label, zone); } else if (file.type.startsWith('audio/') || !file.type.startsWith('image/')) { // Audio or non-image files: show file info instead of image preview Stego.showAudioFileInfo(file, zone); if (label) { label.classList.add('d-none'); } } } }); } // Make preview clickable to replace file if (preview) { preview.style.cursor = 'pointer'; preview.addEventListener('click', (e) => { e.stopPropagation(); input.click(); }); } // Make entire zone clickable (in case label/preview don't cover it) zone.addEventListener('click', (e) => { // Only trigger if not clicking directly on the input if (e.target !== input) { input.click(); } }); }); }, showImagePreview(file, previewEl, labelEl, zone = null) { if (!file.type.startsWith('image/')) return; const isScanContainer = zone && zone.classList.contains('scan-container'); const isPixelContainer = zone && zone.classList.contains('pixel-container'); const reader = new FileReader(); reader.onload = e => { if (previewEl) { previewEl.src = e.target.result; previewEl.classList.remove('d-none'); } // For scan/pixel containers, hide the label entirely (filename will appear in data panel) if (labelEl) { if (isScanContainer || isPixelContainer) { labelEl.classList.add('d-none'); } else { labelEl.textContent = ''; const icon = document.createElement('i'); icon.className = 'bi bi-check-circle text-success me-1'; labelEl.appendChild(icon); labelEl.appendChild(document.createTextNode(file.name)); } } // Trigger appropriate animation if (isScanContainer) { Stego.triggerScanAnimation(zone, file); } else if (isPixelContainer) { Stego.triggerPixelReveal(zone, file); } }; reader.readAsDataURL(file); }, /** * Format audio file info for display in drop zones (v4.3.0) */ showAudioFileInfo(file, zone) { const filenameEl = zone.querySelector('.pixel-data-filename span, .scan-data-filename span'); const sizeEl = zone.querySelector('.pixel-data-value, .scan-data-value'); if (filenameEl) filenameEl.textContent = file.name; if (sizeEl) { const kb = file.size / 1024; sizeEl.textContent = kb >= 1024 ? (kb / 1024).toFixed(1) + ' MB' : kb.toFixed(1) + ' KB'; } zone.classList.add('has-file'); }, // ======================================================================== // REFERENCE PHOTO SCAN ANIMATION // ======================================================================== triggerScanAnimation(container, file, duration = 700) { // Reset any previous state container.classList.remove('scan-complete'); container.classList.add('scanning'); const preview = container.querySelector('.drop-zone-preview'); // Create hash blocks for the "hashing" visual effect const createHashBlocks = () => { // Remove old hash blocks const oldBlocks = container.querySelector('.hash-blocks'); if (oldBlocks) oldBlocks.remove(); const hashContainer = document.createElement('div'); hashContainer.className = 'hash-blocks'; // Size and position to match preview image exactly const imgWidth = preview.offsetWidth; const imgHeight = preview.offsetHeight; const imgTop = preview.offsetTop; const imgLeft = preview.offsetLeft; hashContainer.style.width = imgWidth + 'px'; hashContainer.style.height = imgHeight + 'px'; hashContainer.style.top = imgTop + 'px'; hashContainer.style.left = imgLeft + 'px'; // Create grid of hash blocks (10x8 for better coverage) const cols = 10; const rows = 8; hashContainer.style.gridTemplateColumns = `repeat(${cols}, 1fr)`; hashContainer.style.gridTemplateRows = `repeat(${rows}, 1fr)`; // Create blocks with staggered delays for wave disappearance for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const block = document.createElement('div'); block.className = 'hash-block'; // Diagonal wave pattern for disappearance const delay = (row + col) * 25 + Math.random() * 30; block.style.animationDelay = delay + 'ms'; hashContainer.appendChild(block); } } container.appendChild(hashContainer); }; // Wait for image to be ready if (preview.complete && preview.naturalWidth) { createHashBlocks(); } else { preview.onload = createHashBlocks; } // After animation duration, switch to complete state setTimeout(() => { container.classList.remove('scanning'); container.classList.add('scan-complete'); // Remove hash blocks container const hashBlocks = container.querySelector('.hash-blocks'); if (hashBlocks) hashBlocks.remove(); // Populate data panel if file provided if (file) { const nameEl = container.querySelector('#refFileName') || container.querySelector('.scan-data-filename span'); const sizeEl = container.querySelector('#refFileSize') || container.querySelector('.scan-data-value'); const hashEl = container.querySelector('#refHashPreview') || container.querySelector('.scan-hash-preview'); if (nameEl) { nameEl.textContent = file.name; } if (sizeEl) { const sizeKB = (file.size / 1024).toFixed(1); const sizeMB = (file.size / (1024 * 1024)).toFixed(2); sizeEl.textContent = file.size > 1024 * 1024 ? `${sizeMB} MB` : `${sizeKB} KB`; } if (hashEl) { // Generate a deterministic fake hash preview from filename + size const fakeHash = Stego.generateFakeHash(file.name + file.size); hashEl.textContent = `SHA256: ${fakeHash.substring(0, 8)}····${fakeHash.substring(56)}`; } } }, duration); }, generateFakeHash(input) { // Simple deterministic hash-like string for display purposes let hash = ''; const chars = '0123456789abcdef'; let seed = 0; for (let i = 0; i < input.length; i++) { seed = ((seed << 5) - seed) + input.charCodeAt(i); seed = seed & seed; } for (let i = 0; i < 64; i++) { seed = (seed * 1103515245 + 12345) & 0x7fffffff; hash += chars[seed % 16]; } return hash; }, // ======================================================================== // CARRIER/STEGO PIXEL REVEAL ANIMATION // ======================================================================== triggerPixelReveal(container, file, duration = 700) { // Reset any previous state container.classList.remove('load-complete'); container.classList.add('loading'); const preview = container.querySelector('.drop-zone-preview'); // Create embed traces container sized to image const createTraces = () => { // Remove old elements let tracesContainer = container.querySelector('.embed-traces'); if (tracesContainer) tracesContainer.remove(); let oldGrid = container.querySelector('.embed-grid'); if (oldGrid) oldGrid.remove(); // Add grid overlay (covers whole panel like ref does) const grid = document.createElement('div'); grid.className = 'embed-grid'; container.appendChild(grid); // Create traces container tracesContainer = document.createElement('div'); tracesContainer.className = 'embed-traces'; container.appendChild(tracesContainer); // Size and position traces to match preview image exactly const imgWidth = preview.offsetWidth; const imgHeight = preview.offsetHeight; const imgTop = preview.offsetTop; const imgLeft = preview.offsetLeft; tracesContainer.style.width = imgWidth + 'px'; tracesContainer.style.height = imgHeight + 'px'; tracesContainer.style.top = imgTop + 'px'; tracesContainer.style.left = imgLeft + 'px'; // Generate Tron-style circuit traces covering the image Stego.generateEmbedTraces(tracesContainer, imgWidth, imgHeight); }; // Wait for image to be ready if (preview.complete && preview.naturalWidth) { createTraces(); } else { preview.onload = createTraces; } setTimeout(() => { container.classList.remove('loading'); container.classList.add('load-complete'); // Remove traces and grid const traces = container.querySelector('.embed-traces'); if (traces) traces.remove(); const grid = container.querySelector('.embed-grid'); if (grid) grid.remove(); // Populate data panel Stego.populatePixelDataPanel(container, file, preview); }, duration); }, generateEmbedTraces(container, width, height) { // Color classes for variety const colors = ['color-yellow', 'color-cyan', 'color-purple', 'color-blue']; // Grid-based distribution: divide image into cells for even coverage const gridCols = 5; const gridRows = 4; const cellWidth = width / gridCols; const cellHeight = height / gridRows; let pathIndex = 0; // Spawn 1-2 paths from each grid cell for even distribution for (let row = 0; row < gridRows; row++) { for (let col = 0; col < gridCols; col++) { // 1-2 paths per cell const pathsInCell = 1 + Math.floor(Math.random() * 2); for (let p = 0; p < pathsInCell; p++) { const pathColor = colors[Math.floor(Math.random() * colors.length)]; // Start within this grid cell (with padding) let x = (col * cellWidth) + (cellWidth * 0.15) + (Math.random() * cellWidth * 0.7); let y = (row * cellHeight) + (cellHeight * 0.15) + (Math.random() * cellHeight * 0.7); let delay = pathIndex * 15; // Each path has 3-5 short segments const numSegments = 3 + Math.floor(Math.random() * 3); let horizontal = Math.random() > 0.5; for (let s = 0; s < numSegments; s++) { const trace = document.createElement('div'); trace.className = 'embed-trace ' + (horizontal ? 'h' : 'v') + ' ' + pathColor; // Shorter segments: 12-30px for denser circuit look const length = 12 + Math.random() * 18; trace.style.left = Math.max(0, Math.min(x, width - length)) + 'px'; trace.style.top = Math.max(0, Math.min(y, height - length)) + 'px'; trace.style.animationDelay = delay + 'ms'; if (horizontal) { trace.style.width = length + 'px'; } else { trace.style.height = length + 'px'; } container.appendChild(trace); // Move position for next segment if (horizontal) { x += length * (Math.random() > 0.5 ? 1 : -1); } else { y += length * (Math.random() > 0.5 ? 1 : -1); } // Keep within bounds x = Math.max(5, Math.min(x, width - 20)); y = Math.max(5, Math.min(y, height - 20)); // Alternate direction (90 degree turn) horizontal = !horizontal; delay += 20; } pathIndex++; } } } }, populatePixelDataPanel(container, file, preview) { const nameEl = container.querySelector('.pixel-data-filename span'); const sizeEl = container.querySelector('.pixel-data-value'); const dimsEl = container.querySelector('.pixel-dimensions'); if (nameEl) { nameEl.textContent = file.name; } if (sizeEl) { const sizeKB = (file.size / 1024).toFixed(1); const sizeMB = (file.size / (1024 * 1024)).toFixed(2); sizeEl.textContent = file.size > 1024 * 1024 ? `${sizeMB} MB` : `${sizeKB} KB`; } if (dimsEl && preview) { dimsEl.textContent = `${preview.naturalWidth} × ${preview.naturalHeight} px`; } }, initReferenceScanAnimation() { // Find all scan containers and wire up their inputs document.querySelectorAll('.scan-container').forEach(container => { const input = container.querySelector('input[type="file"]'); const preview = container.querySelector('.drop-zone-preview'); const label = container.querySelector('.drop-zone-label'); if (!input) return; input.addEventListener('change', function() { if (this.files && this.files[0]) { Stego.showImagePreview(this.files[0], preview, label, container); } }); }); }, // ======================================================================== // CLIPBOARD PASTE // ======================================================================== initClipboardPaste(imageInputSelectors) { document.addEventListener('paste', function(e) { const items = e.clipboardData?.items; if (!items) return; for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { const blob = items[i].getAsFile(); // Find first empty input from the list let targetInput = null; for (const selector of imageInputSelectors) { const input = document.querySelector(selector); if (input && (!input.files || !input.files.length)) { targetInput = input; break; } } // Fallback to first input if all have files if (!targetInput) { targetInput = document.querySelector(imageInputSelectors[0]); } if (targetInput) { const container = new DataTransfer(); container.items.add(blob); targetInput.files = container.files; targetInput.dispatchEvent(new Event('change')); } break; } } }); }, // ======================================================================== // QR CODE CROP ANIMATION WITH SECTION SCANNING // ======================================================================== initQrCropAnimation(inputId = 'rsaKeyQrInput') { const input = document.getElementById(inputId); const container = document.getElementById('qrCropContainer'); const original = document.getElementById('qrOriginal'); const cropped = document.getElementById('qrCropped'); const dropZone = document.getElementById('qrDropZone'); if (!input || !container || !original || !cropped) return; input.addEventListener('change', function() { if (!this.files || !this.files[0]) return; const file = this.files[0]; if (!file.type.startsWith('image/')) return; const label = dropZone?.querySelector('.drop-zone-label'); // Reset animation state container.classList.remove('scan-complete', 'scanning'); container.classList.add('d-none'); // Remove old overlay if exists const oldOverlay = container.querySelector('.qr-section-overlay'); if (oldOverlay) oldOverlay.remove(); // Show loading state immediately container.classList.remove('d-none'); container.classList.add('loading'); label?.classList.add('d-none'); // Add loading indicator if not present let loader = container.querySelector('.qr-loader'); if (!loader) { loader = document.createElement('div'); loader.className = 'qr-loader'; loader.innerHTML = ` Detecting QR code... `; container.appendChild(loader); } // Fetch cropped version const formData = new FormData(); formData.append('image', file); fetch('/qr/crop', { method: 'POST', body: formData }) .then(response => { if (!response.ok) throw new Error('No QR detected'); return response.blob(); }) .then(blob => { // Hide loader, show cropped image container.classList.remove('loading'); cropped.src = URL.createObjectURL(blob); return new Promise((resolve) => { cropped.onload = () => { // Start scanning animation container.classList.add('scanning'); // Add scanner overlay - will be positioned via CSS to cover the image let overlay = container.querySelector('.qr-scanner-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.className = 'qr-scanner-overlay'; ['tl', 'tr', 'bl', 'br'].forEach(pos => { const bracket = document.createElement('div'); bracket.className = `qr-finder-bracket ${pos}`; overlay.appendChild(bracket); }); // Add data panel inside overlay const dataPanel = document.createElement('div'); dataPanel.className = 'qr-data-panel'; dataPanel.innerHTML = `
KEY LOADED --
`; overlay.appendChild(dataPanel); container.appendChild(overlay); } // Let CSS handle overlay positioning (inset with padding) resolve(); }; }); }) .then(() => { // Now verify key extraction const keyFormData = new FormData(); keyFormData.append('qr_image', file); return fetch('/extract-key-from-qr', { method: 'POST', body: keyFormData }); }) .then(response => response.json()) .then(data => { // Extraction complete - stop animation container.classList.remove('scanning'); container.classList.add('scan-complete'); // Update data panel (inside overlay) const overlay = container.querySelector('.qr-scanner-overlay'); const sizeEl = overlay?.querySelector('.qr-data-value'); if (data.success && sizeEl) { const sizeKB = (file.size / 1024).toFixed(1); sizeEl.textContent = sizeKB + ' KB'; } }) .catch(err => { console.log('QR crop/extract error:', err); container.classList.remove('loading', 'scanning'); container.classList.add('error'); // Update loader to show error const loader = container.querySelector('.qr-loader'); if (loader) { loader.innerHTML = ` No QR code detected `; } // Reset after delay so user can try again setTimeout(() => { container.classList.remove('error'); container.classList.add('d-none'); label?.classList.remove('d-none'); // Clear the file input so same file can be re-selected input.value = ''; // Remove loader if (loader) loader.remove(); }, 2000); }); }); }, // ======================================================================== // COLLAPSE CHEVRON ANIMATION // ======================================================================== initCollapseChevrons() { document.querySelectorAll('[data-chevron]').forEach(collapse => { const chevronId = collapse.dataset.chevron; const chevron = document.getElementById(chevronId); if (!chevron) return; collapse.addEventListener('show.bs.collapse', () => { chevron.classList.add('bi-chevron-up'); chevron.classList.remove('bi-chevron-down'); }); collapse.addEventListener('hide.bs.collapse', () => { chevron.classList.remove('bi-chevron-up'); chevron.classList.add('bi-chevron-down'); }); }); }, // ======================================================================== // FORM LOADING STATE // ======================================================================== initFormLoading(formId, buttonId, loadingText = 'Processing...') { const form = document.getElementById(formId); const btn = document.getElementById(buttonId); if (!form || !btn) return; form.addEventListener('submit', () => { btn.disabled = true; btn.innerHTML = `${loadingText}`; }); }, // ======================================================================== // COPY TO CLIPBOARD // ======================================================================== copyToClipboard(text, iconEl, textEl) { navigator.clipboard.writeText(text).then(() => { const origIcon = iconEl?.className; const origText = textEl?.textContent; if (iconEl) iconEl.className = 'bi bi-check'; if (textEl) textEl.textContent = 'Copied!'; setTimeout(() => { if (iconEl) iconEl.className = origIcon; if (textEl) textEl.textContent = origText; }, 2000); }); }, // ======================================================================== // MODE CARD HIGHLIGHTING // ======================================================================== initModeCards(config) { // config: { radioName: 'embed_mode', cards: { 'lsb': { id: 'lsbCard', borderClass: 'border-primary' }, ... } } const radios = document.querySelectorAll(`input[name="${config.radioName}"]`); const update = () => { radios.forEach(radio => { const cardConfig = config.cards[radio.value]; if (!cardConfig) return; const card = document.getElementById(cardConfig.id); if (!card) return; card.classList.toggle(cardConfig.borderClass, radio.checked); card.classList.toggle('border-2', radio.checked); }); }; radios.forEach(radio => radio.addEventListener('change', update)); update(); // Initial state }, // ======================================================================== // PASSPHRASE FONT SIZE AUTO-ADJUST // ======================================================================== initPassphraseFontResize(inputId = 'passphraseInput') { const input = document.getElementById(inputId); if (!input) return; const steps = [ { maxChars: 30, size: 1.1 }, { maxChars: 45, size: 1.0 }, { maxChars: 60, size: 0.95 }, { maxChars: Infinity, size: 0.9 } ]; const adjust = () => { const len = input.value.length; for (const step of steps) { if (len <= step.maxChars) { input.style.fontSize = step.size + 'rem'; break; } } }; input.addEventListener('input', adjust); adjust(); }, // ======================================================================== // CHANNEL KEY HANDLING (v4.0.0) // ======================================================================== /** * Generate a random channel key in format XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX * @returns {string} Generated key */ generateChannelKey() { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; let key = ''; for (let i = 0; i < 8; i++) { if (i > 0) key += '-'; for (let j = 0; j < 4; j++) { key += chars.charAt(Math.floor(Math.random() * chars.length)); } } return key; }, /** * Validate channel key format * @param {string} key - Key to validate * @returns {boolean} True if valid */ validateChannelKey(key) { const pattern = /^[A-Z0-9]{4}(-[A-Z0-9]{4}){7}$/; return pattern.test(key); }, /** * Format channel key input (auto-add dashes, uppercase) * @param {HTMLInputElement} input - Input element */ formatChannelKeyInput(input) { let value = input.value.toUpperCase(); const clean = value.replace(/-/g, ''); if (clean.length > 0 && clean.length <= 32) { const formatted = clean.match(/.{1,4}/g)?.join('-') || clean; if (formatted !== value && formatted.length <= 39) { input.value = formatted; } else { input.value = value; } } // Validate and show/hide error state const isValid = this.validateChannelKey(input.value); input.classList.toggle('is-invalid', input.value.length > 0 && !isValid); }, /** * Initialize channel key UI for encode/decode pages * @param {Object} config - Configuration object * @param {string} config.selectId - ID of channel select dropdown * @param {string} config.customInputId - ID of custom key input container * @param {string} config.keyInputId - ID of key input field * @param {string} config.generateBtnId - ID of generate button (optional) */ initChannelKey(config = {}) { const selectId = config.selectId || 'channelSelect'; const customInputId = config.customInputId || 'channelCustomInput'; const keyInputId = config.keyInputId || 'channelKeyInput'; const generateBtnId = config.generateBtnId; const serverInfoId = config.serverInfoId || 'channelServerInfo'; const select = document.getElementById(selectId); const customInput = document.getElementById(customInputId); const keyInput = document.getElementById(keyInputId); const generateBtn = generateBtnId ? document.getElementById(generateBtnId) : null; const serverInfo = document.getElementById(serverInfoId); // Show/hide custom input and server info based on selection const updateVisibility = () => { const value = select?.value; const isCustom = value === 'custom'; const isPublic = value === 'none'; const isAuto = value === 'auto'; // Custom input visibility customInput?.classList.toggle('d-none', !isCustom); if (isCustom && keyInput) { keyInput.focus(); // Pulse highlight effect customInput?.classList.add('channel-highlight'); setTimeout(() => customInput?.classList.remove('channel-highlight'), 400); } // Server info: show for auto, hide for custom, show "no key" for public if (serverInfo) { if (isAuto) { serverInfo.innerHTML = 'Server: ' + (serverInfo.dataset.fingerprint || '••••-••••-···-••••-••••') + ''; serverInfo.className = 'small text-success mt-2'; serverInfo.classList.remove('d-none'); } else if (isPublic) { serverInfo.innerHTML = 'No channel key will be used'; serverInfo.className = 'small text-muted mt-2'; serverInfo.classList.remove('d-none'); } else { serverInfo.classList.add('d-none'); } } }; select?.addEventListener('change', updateVisibility); // Initial state updateVisibility(); // Format and validate key input keyInput?.addEventListener('input', () => { this.formatChannelKeyInput(keyInput); }); // Generate button (if present) generateBtn?.addEventListener('click', () => { if (keyInput) { keyInput.value = this.generateChannelKey(); keyInput.classList.remove('is-invalid'); } }); }, /** * Handle form submission with channel key validation * @param {HTMLFormElement} form - Form element * @param {string} selectId - ID of channel select dropdown * @param {string} keyInputId - ID of key input field * @returns {boolean} True if valid, false to prevent submission */ validateChannelKeyOnSubmit(form, selectId, keyInputId) { const select = document.getElementById(selectId); const keyInput = document.getElementById(keyInputId); if (select?.value === 'custom' && keyInput) { if (!this.validateChannelKey(keyInput.value)) { keyInput.classList.add('is-invalid'); keyInput.focus(); return false; } // Set the select value to the actual key for form submission select.value = keyInput.value; } // Track saved key usage (fire-and-forget) const selectedOption = select?.selectedOptions?.[0]; const keyId = selectedOption?.dataset?.keyId; if (keyId) { fetch(`/api/channel/keys/${keyId}/use`, { method: 'POST' }).catch(() => {}); } return true; }, /** * Initialize standalone channel key generator (for generate page) * @param {string} inputId - ID of generated key input * @param {string} generateBtnId - ID of generate button * @param {string} copyBtnId - ID of copy button */ initChannelKeyGenerator(inputId, generateBtnId, copyBtnId) { const input = document.getElementById(inputId); const generateBtn = document.getElementById(generateBtnId); const copyBtn = document.getElementById(copyBtnId); generateBtn?.addEventListener('click', () => { if (input) { input.value = this.generateChannelKey(); } if (copyBtn) { copyBtn.disabled = false; } }); copyBtn?.addEventListener('click', () => { if (input?.value) { navigator.clipboard.writeText(input.value).then(() => { const icon = copyBtn.querySelector('i'); if (icon) { icon.className = 'bi bi-check'; setTimeout(() => { icon.className = 'bi bi-clipboard'; }, 2000); } }); } }); }, // ======================================================================== // ASYNC ENCODE WITH PROGRESS (v4.1.2) // ======================================================================== /** * Submit encode form asynchronously with progress tracking * @param {HTMLFormElement} form - The encode form * @param {HTMLElement} btn - The submit button */ async submitEncodeAsync(form, btn) { const formData = new FormData(form); formData.append('async', 'true'); // Show progress modal this.showProgressModal('Encoding'); try { // Start encode job const response = await fetch('/encode', { method: 'POST', body: formData, }); if (!response.ok) { throw new Error('Failed to start encode'); } const result = await response.json(); if (result.error) { throw new Error(result.error); } const jobId = result.job_id; // Poll for progress await this.pollEncodeProgress(jobId); } catch (error) { this.hideProgressModal(); alert('Encode failed: ' + error.message); btn.disabled = false; btn.innerHTML = 'Encode'; } }, /** * Poll encode progress until complete * @param {string} jobId - The job ID */ async pollEncodeProgress(jobId) { const progressBar = document.getElementById('progressBar'); const progressText = document.getElementById('progressText'); const phaseText = document.getElementById('progressPhase'); const poll = async () => { try { // Check status first const statusResponse = await fetch(`/encode/status/${jobId}`); const statusData = await statusResponse.json(); if (statusData.status === 'complete') { // Done - redirect to result this.updateProgress(100, 'Complete!'); setTimeout(() => { window.location.href = `/encode/result/${statusData.file_id}`; }, 500); return; } if (statusData.status === 'error') { throw new Error(statusData.error || 'Encode failed'); } // Get progress const progressResponse = await fetch(`/encode/progress/${jobId}`); const progressData = await progressResponse.json(); const percent = progressData.percent || 0; const phase = progressData.phase || 'processing'; // Use indeterminate mode for initializing/starting phases const isIndeterminate = (phase === 'initializing' || phase === 'starting'); this.updateProgress(percent, this.formatPhase(phase), isIndeterminate); // Continue polling setTimeout(poll, 500); } catch (error) { this.hideProgressModal(); alert('Encode failed: ' + error.message); } }; await poll(); }, /** * Format phase name for display */ formatPhase(phase) { const phases = { 'starting': 'Starting...', 'initializing': 'Deriving keys (may take a moment)...', 'embedding': 'Embedding data...', 'saving': 'Saving image...', 'finalizing': 'Finalizing...', 'complete': 'Complete!', // Audio encode phases (v4.3.0) 'audio_transcoding': 'Transcoding audio...', 'audio_embedding': 'Embedding in audio...', 'spread_embedding': 'Spread spectrum embedding...', }; return phases[phase] || phase; }, /** * Show progress modal */ showProgressModal(operation = 'Processing') { // Create modal if doesn't exist let modal = document.getElementById('progressModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'progressModal'; modal.className = 'modal fade'; modal.setAttribute('data-bs-backdrop', 'static'); modal.setAttribute('data-bs-keyboard', 'false'); modal.innerHTML = ` `; document.body.appendChild(modal); } // Reset progress tracking and start with indeterminate state this.resetProgressTracking(); this.updateProgress(0, 'Initializing...', true); // Show modal const bsModal = new bootstrap.Modal(modal); bsModal.show(); }, /** * Hide progress modal */ hideProgressModal() { const modal = document.getElementById('progressModal'); if (modal) { const bsModal = bootstrap.Modal.getInstance(modal); bsModal?.hide(); } }, /** * Track max progress to prevent backwards jumps */ _maxProgress: 0, /** * Reset progress tracking (call when starting new operation) */ resetProgressTracking() { this._maxProgress = 0; }, /** * Update progress bar and text * Supports indeterminate mode for initializing phase (barber pole at full width) */ updateProgress(percent, phase, indeterminate = false) { const progressBar = document.getElementById('progressBar'); const progressText = document.getElementById('progressText'); const phaseText = document.getElementById('progressPhase'); if (indeterminate) { // Barber pole animation at full width, no percentage if (progressBar) { progressBar.style.width = '100%'; progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); } if (progressText) progressText.textContent = ''; if (phaseText) phaseText.textContent = phase; } else { // Determinate progress - never go backwards const safePercent = Math.max(percent, this._maxProgress); this._maxProgress = safePercent; if (progressBar) { progressBar.style.width = safePercent + '%'; // Keep animation but show actual progress progressBar.classList.add('progress-bar-striped', 'progress-bar-animated'); } if (progressText) progressText.textContent = Math.round(safePercent) + '%'; if (phaseText) phaseText.textContent = phase; } }, // ======================================================================== // ASYNC DECODE WITH PROGRESS (v4.1.5) // ======================================================================== /** * Submit decode form asynchronously with progress tracking * @param {HTMLFormElement} form - The decode form * @param {HTMLElement} btn - The submit button */ async submitDecodeAsync(form, btn) { const formData = new FormData(form); formData.append('async', 'true'); // Show progress modal this.showProgressModal('Decoding'); try { // Start decode job const response = await fetch('/decode', { method: 'POST', body: formData, }); if (!response.ok) { throw new Error('Failed to start decode'); } const result = await response.json(); if (result.error) { throw new Error(result.error); } const jobId = result.job_id; // Poll for progress await this.pollDecodeProgress(jobId); } catch (error) { this.hideProgressModal(); alert('Decode failed: ' + error.message); btn.disabled = false; btn.innerHTML = 'Decode'; } }, /** * Poll decode progress until complete * @param {string} jobId - The job ID */ async pollDecodeProgress(jobId) { const poll = async () => { try { // Check status first const statusResponse = await fetch(`/decode/status/${jobId}`); const statusData = await statusResponse.json(); if (statusData.status === 'complete') { // Done - redirect to result page this.updateProgress(100, 'Complete!'); setTimeout(() => { window.location.href = `/decode/result/${jobId}`; }, 500); return; } if (statusData.status === 'error') { // Handle specific error types const errorType = statusData.error_type; let errorMsg = statusData.error || 'Decode failed'; if (errorType === 'DecryptionError' || errorMsg.toLowerCase().includes('decrypt')) { errorMsg = 'Wrong credentials. Double-check your reference photo, passphrase, PIN, and channel key.'; } throw new Error(errorMsg); } // Get progress const progressResponse = await fetch(`/decode/progress/${jobId}`); const progressData = await progressResponse.json(); const percent = progressData.percent || 0; const phase = progressData.phase || 'processing'; // Use indeterminate mode for initializing/starting/loading phases const isIndeterminate = (phase === 'initializing' || phase === 'starting' || phase === 'loading'); this.updateProgress(percent, this.formatDecodePhase(phase), isIndeterminate); // Continue polling setTimeout(poll, 500); } catch (error) { this.hideProgressModal(); alert(error.message); } }; await poll(); }, /** * Format decode phase name for display */ formatDecodePhase(phase) { const phases = { 'starting': 'Starting...', 'initializing': 'Deriving keys (may take a moment)...', 'loading': 'Deriving keys (may take a moment)...', 'reading': 'Reading image...', 'extracting': 'Extracting data...', 'decoding': 'Decoding data...', 'decrypting': 'Decrypting...', 'verifying': 'Verifying...', 'finalizing': 'Finalizing...', 'complete': 'Complete!', // Audio decode phases (v4.3.0) 'audio_transcoding': 'Transcoding audio...', 'audio_extracting': 'Extracting from audio...', 'spread_extracting': 'Spread spectrum extracting...', }; return phases[phase] || phase; }, // ======================================================================== // WEBCAM QR SCANNING (v4.1.5) // ======================================================================== /** * Active scanner instance */ _qrScanner: null, _qrScannerModal: null, _qrScannerCallback: null, /** * Show webcam QR scanner modal * @param {Function} onSuccess - Callback with decoded QR text * @param {string} title - Modal title */ showQrScanner(onSuccess, title = 'Scan QR Code') { this._qrScannerCallback = onSuccess; // Create modal if doesn't exist let modal = document.getElementById('qrScannerModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'qrScannerModal'; modal.className = 'modal fade'; modal.innerHTML = ` `; document.body.appendChild(modal); // Clean up scanner when modal hides modal.addEventListener('hidden.bs.modal', () => { this.stopQrScanner(); }); // Manual capture button modal.querySelector('#qrCaptureBtn')?.addEventListener('click', () => { this.captureQrFrame(); }); } // Update title const titleEl = modal.querySelector('#qrScannerTitle'); if (titleEl) titleEl.textContent = title; // Reset status const statusEl = modal.querySelector('#qrScannerStatus'); if (statusEl) { statusEl.innerHTML = 'Point camera at QR code'; statusEl.className = 'text-center py-3 text-muted'; } // Show modal this._qrScannerModal = new bootstrap.Modal(modal); this._qrScannerModal.show(); // Start scanner after modal is shown modal.addEventListener('shown.bs.modal', () => { this.startQrScanner(); }, { once: true }); }, /** * Start the QR scanner */ startQrScanner() { const readerEl = document.getElementById('qrScannerReader'); if (!readerEl) return; // Check if Html5Qrcode is available if (typeof Html5Qrcode === 'undefined') { console.error('Html5Qrcode library not loaded'); const statusEl = document.getElementById('qrScannerStatus'); if (statusEl) { statusEl.innerHTML = 'QR scanner not available'; } return; } this._qrScanner = new Html5Qrcode('qrScannerReader'); const config = { fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1.0, }; this._qrScanner.start( { facingMode: 'environment' }, // Prefer back camera config, (decodedText, decodedResult) => { // QR code detected this.onQrCodeDetected(decodedText); }, (errorMessage) => { // Scan error (ignore, keep scanning) } ).catch((err) => { console.error('Failed to start scanner:', err); const statusEl = document.getElementById('qrScannerStatus'); if (statusEl) { if (err.toString().includes('Permission')) { statusEl.innerHTML = 'Camera permission denied'; } else { statusEl.innerHTML = 'Could not access camera'; } statusEl.className = 'text-center py-3'; } }); }, /** * Capture a frame with countdown and try to decode */ captureQrFrame() { const statusEl = document.getElementById('qrScannerStatus'); const captureBtn = document.getElementById('qrCaptureBtn'); if (!statusEl || !this._qrScanner) return; // Disable button during countdown if (captureBtn) captureBtn.disabled = true; let count = 3; const countdown = () => { if (count > 0) { statusEl.innerHTML = `${count}`; statusEl.className = 'text-center py-3 text-warning'; count--; setTimeout(countdown, 1000); } else { // Capture! statusEl.innerHTML = 'Analyzing...'; statusEl.className = 'text-center py-3 text-info'; // Get video element and capture frame const video = document.querySelector('#qrScannerReader video'); if (video) { const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); ctx.drawImage(video, 0, 0); // Stop the scanner before file scan (prevents conflicts) const scanner = this._qrScanner; scanner.stop().then(() => { canvas.toBlob((blob) => { const file = new File([blob], 'capture.png', { type: 'image/png' }); scanner.scanFile(file, true) .then((decodedText) => { this.onQrCodeDetected(decodedText); }) .catch((err) => { statusEl.innerHTML = 'No QR code found. Try again.'; statusEl.className = 'text-center py-3 text-danger'; if (captureBtn) captureBtn.disabled = false; // Restart the scanner this.startQrScanner(); }); }, 'image/png'); }).catch(() => { statusEl.innerHTML = 'Scanner error'; statusEl.className = 'text-center py-3 text-danger'; if (captureBtn) captureBtn.disabled = false; }); } else { statusEl.innerHTML = 'Camera not ready'; statusEl.className = 'text-center py-3 text-danger'; if (captureBtn) captureBtn.disabled = false; } } }; countdown(); }, /** * Stop the QR scanner */ stopQrScanner() { if (this._qrScanner) { this._qrScanner.stop().then(() => { this._qrScanner.clear(); this._qrScanner = null; }).catch((err) => { console.log('Scanner stop error:', err); }); } }, /** * Handle detected QR code * @param {string} text - Decoded QR text */ onQrCodeDetected(text) { // Update status const statusEl = document.getElementById('qrScannerStatus'); if (statusEl) { statusEl.innerHTML = 'QR code detected!'; statusEl.className = 'text-center py-3 text-success'; } // Close modal after brief delay setTimeout(() => { this._qrScannerModal?.hide(); // Call callback if (this._qrScannerCallback) { this._qrScannerCallback(text); } }, 500); }, /** * Add camera scan button to an input field * @param {string} inputId - ID of the input field * @param {string} title - Modal title * @param {Function} validator - Optional validation function for scanned text */ addCameraScanButton(inputId, title = 'Scan QR Code', validator = null) { const input = document.getElementById(inputId); if (!input) return; // Create button const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-outline-secondary'; btn.innerHTML = ''; btn.title = 'Scan QR code with camera'; btn.addEventListener('click', () => { this.showQrScanner((text) => { // Validate if validator provided if (validator && !validator(text)) { alert('Invalid QR code format'); return; } // Set input value input.value = text; // Trigger input event for formatting input.dispatchEvent(new Event('input', { bubbles: true })); }, title); }); // Wrap input in input-group if not already const parent = input.parentElement; if (!parent.classList.contains('input-group')) { const wrapper = document.createElement('div'); wrapper.className = 'input-group'; parent.insertBefore(wrapper, input); wrapper.appendChild(input); wrapper.appendChild(btn); } else { parent.appendChild(btn); } }, // ======================================================================== // INITIALIZATION HELPERS // ======================================================================== initEncodePage() { this.initPasswordToggles(); this.initRsaMethodToggle(); this.initDropZones(); this.initClipboardPaste(['input[name="carrier"]', 'input[name="reference_photo"]']); this.initQrCropAnimation('rsaQrInput'); this.initCollapseChevrons(); this.initPassphraseFontResize(); // Channel key (v4.0.0) - uses select dropdown this.initChannelKey({ selectId: 'channelSelect', customInputId: 'channelCustomInput', keyInputId: 'channelKeyInput', generateBtnId: 'channelKeyGenerate' }); // Webcam QR scanning for channel key (v4.1.5) document.getElementById('channelKeyScan')?.addEventListener('click', () => { this.showQrScanner((text) => { const input = document.getElementById('channelKeyInput'); if (input) { const clean = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase(); input.value = clean.length === 32 ? clean.match(/.{4}/g).join('-') : text.toUpperCase(); input.dispatchEvent(new Event('input', { bubbles: true })); } }, 'Scan Channel Key'); }); // Webcam QR scanning for RSA key (v4.1.5) document.getElementById('rsaQrWebcam')?.addEventListener('click', () => { this.showQrScanner((text) => { // Check for raw PEM or compressed format (STEGASOO-Z: prefix) const isRawPem = text.includes('-----BEGIN') && text.includes('KEY-----'); const isCompressed = text.startsWith('STEGASOO-Z:'); if (isRawPem || isCompressed) { // Valid RSA key data scanned document.getElementById('rsaKeyPem').value = text; // Show success in drop zone const dropZone = document.getElementById('qrDropZone'); const label = dropZone?.querySelector('.drop-zone-label'); if (label) { label.innerHTML = 'RSA Key scanned successfully'; } } else { alert('QR code does not contain a valid RSA key'); } }, 'Scan RSA Key QR'); }); // Form submission with async progress tracking (v4.1.2) const form = document.getElementById('encodeForm'); const btn = document.getElementById('encodeBtn'); form?.addEventListener('submit', (e) => { e.preventDefault(); if (!this.validateChannelKeyOnSubmit(form, 'channelSelect', 'channelKeyInput')) { return false; } if (btn) { btn.disabled = true; btn.innerHTML = 'Starting...'; } // Use async submission with progress tracking this.submitEncodeAsync(form, btn); }); }, initDecodePage() { this.initPasswordToggles(); this.initRsaMethodToggle(); this.initDropZones(); this.initClipboardPaste(['input[name="stego_image"]', 'input[name="reference_photo"]']); this.initQrCropAnimation('rsaQrInput'); this.initCollapseChevrons(); this.initPassphraseFontResize(); // Channel key (v4.0.0) - uses select dropdown this.initChannelKey({ selectId: 'channelSelectDec', customInputId: 'channelCustomInputDec', keyInputId: 'channelKeyInputDec', serverInfoId: 'channelServerInfoDec' }); // Webcam QR scanning for channel key (v4.1.5) document.getElementById('channelKeyScanDec')?.addEventListener('click', () => { this.showQrScanner((text) => { const input = document.getElementById('channelKeyInputDec'); if (input) { const clean = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase(); input.value = clean.length === 32 ? clean.match(/.{4}/g).join('-') : text.toUpperCase(); input.dispatchEvent(new Event('input', { bubbles: true })); } }, 'Scan Channel Key'); }); // Webcam QR scanning for RSA key (v4.1.5) document.getElementById('rsaQrWebcam')?.addEventListener('click', () => { this.showQrScanner((text) => { // Check for raw PEM or compressed format (STEGASOO-Z: prefix) const isRawPem = text.includes('-----BEGIN') && text.includes('KEY-----'); const isCompressed = text.startsWith('STEGASOO-Z:'); if (isRawPem || isCompressed) { // Valid RSA key data scanned document.getElementById('rsaKeyPem').value = text; // Show success in drop zone const dropZone = document.getElementById('qrDropZone'); const label = dropZone?.querySelector('.drop-zone-label'); if (label) { label.innerHTML = 'RSA Key scanned successfully'; } } else { alert('QR code does not contain a valid RSA key'); } }, 'Scan RSA Key QR'); }); // Form submission with async progress tracking (v4.1.5) const form = document.getElementById('decodeForm'); const btn = document.getElementById('decodeBtn'); form?.addEventListener('submit', (e) => { e.preventDefault(); if (!this.validateChannelKeyOnSubmit(form, 'channelSelectDec', 'channelKeyInputDec')) { return false; } if (btn) { btn.disabled = true; btn.innerHTML = 'Starting...'; } // Use async submission with progress tracking this.submitDecodeAsync(form, btn); }); }, initGeneratePage() { this.initPasswordToggles(); // Channel key generator (v4.0.0) this.initChannelKeyGenerator('channelKeyGenerated', 'generateChannelKeyBtn', 'copyChannelKeyBtn'); } }; // Auto-init based on page document.addEventListener('DOMContentLoaded', () => { // Detect page and initialize if (document.getElementById('encodeForm')) { Stego.initEncodePage(); } else if (document.getElementById('decodeForm')) { Stego.initDecodePage(); } else if (document.querySelector('[data-page="generate"]')) { Stego.initGeneratePage(); } });