/** * 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 = `
' + (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 = `