Complete project rebrand for better positioning in the press freedom and digital security space. FieldWitness communicates both field deployment and evidence testimony — appropriate for the target audience of journalists, NGOs, and human rights organizations. Rename mapping: - soosef → fieldwitness (package, CLI, all imports) - soosef.stegasoo → fieldwitness.stego - soosef.verisoo → fieldwitness.attest - ~/.soosef/ → ~/.fwmetadata/ (innocuous data dir name) - SOOSEF_DATA_DIR → FIELDWITNESS_DATA_DIR - SoosefConfig → FieldWitnessConfig - SoosefError → FieldWitnessError Also includes: - License switch from MIT to GPL-3.0 - C2PA bridge module (Phase 0-2 MVP): cert.py, export.py, vendor_assertions.py - README repositioned to lead with provenance/federation, stego backgrounded - Threat model skeleton at docs/security/threat-model.md - Planning docs: docs/planning/c2pa-integration.md, docs/planning/gtm-feasibility.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1731 lines
68 KiB
JavaScript
1731 lines
68 KiB
JavaScript
/**
|
||
* 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 = `
|
||
<i class="bi bi-qr-code-scan"></i>
|
||
<span>Detecting QR code...</span>
|
||
`;
|
||
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 = `
|
||
<div class="qr-data-row">
|
||
<span class="qr-status-badge">KEY LOADED</span>
|
||
<span class="qr-data-value">--</span>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||
<span>No QR code detected</span>
|
||
`;
|
||
}
|
||
|
||
// 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 = `<span class="spinner-border spinner-border-sm me-2"></span>${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 = '<i class="bi bi-shield-lock me-1"></i>Server: <code>' + (serverInfo.dataset.fingerprint || '••••-••••-···-••••-••••') + '</code>';
|
||
serverInfo.className = 'small text-success mt-2';
|
||
serverInfo.classList.remove('d-none');
|
||
} else if (isPublic) {
|
||
serverInfo.innerHTML = '<i class="bi bi-globe me-1"></i>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 = '<i class="bi bi-lock-fill me-2"></i>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 = `
|
||
<div class="modal-dialog modal-dialog-centered">
|
||
<div class="modal-content bg-dark text-light">
|
||
<div class="modal-body p-4">
|
||
<h5 class="mb-3" id="progressTitle">${operation}...</h5>
|
||
<div class="progress mb-2" style="height: 24px;">
|
||
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated bg-success"
|
||
role="progressbar" style="width: 0%"></div>
|
||
</div>
|
||
<div class="d-flex justify-content-between text-muted small">
|
||
<span id="progressPhase">Initializing...</span>
|
||
<span id="progressText">0%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 = '<i class="bi bi-unlock-fill me-2"></i>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 = `
|
||
<div class="modal-dialog modal-dialog-centered">
|
||
<div class="modal-content bg-dark text-light">
|
||
<div class="modal-header border-secondary">
|
||
<h5 class="modal-title">
|
||
<i class="bi bi-camera-video me-2"></i>
|
||
<span id="qrScannerTitle">${title}</span>
|
||
</h5>
|
||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body p-0">
|
||
<div id="qrScannerReader" style="width: 100%;"></div>
|
||
<div id="qrScannerStatus" class="text-center py-3 text-muted">
|
||
<i class="bi bi-qr-code-scan me-2"></i>
|
||
Point camera at QR code
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer border-secondary">
|
||
<button type="button" class="btn btn-primary" id="qrCaptureBtn">
|
||
<i class="bi bi-camera me-1"></i>Capture
|
||
</button>
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 = '<i class="bi bi-qr-code-scan me-2"></i>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 = '<i class="bi bi-exclamation-triangle text-warning me-2"></i>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 = '<i class="bi bi-camera-video-off text-danger me-2"></i>Camera permission denied';
|
||
} else {
|
||
statusEl.innerHTML = '<i class="bi bi-exclamation-triangle text-warning me-2"></i>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 = `<i class="bi bi-camera me-2"></i><span style="font-size: 1.5rem; font-weight: bold;">${count}</span>`;
|
||
statusEl.className = 'text-center py-3 text-warning';
|
||
count--;
|
||
setTimeout(countdown, 1000);
|
||
} else {
|
||
// Capture!
|
||
statusEl.innerHTML = '<i class="bi bi-hourglass-split me-2"></i>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 = '<i class="bi bi-x-circle text-danger me-2"></i>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 = '<i class="bi bi-x-circle text-danger me-2"></i>Scanner error';
|
||
statusEl.className = 'text-center py-3 text-danger';
|
||
if (captureBtn) captureBtn.disabled = false;
|
||
});
|
||
} else {
|
||
statusEl.innerHTML = '<i class="bi bi-x-circle text-danger me-2"></i>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 = '<i class="bi bi-check-circle text-success me-2"></i>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 = '<i class="bi bi-camera"></i>';
|
||
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 = '<i class="bi bi-check-circle text-success fs-4 d-block mb-1"></i><span class="text-success small">RSA Key scanned successfully</span>';
|
||
}
|
||
} 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 = '<span class="spinner-border spinner-border-sm me-2"></span>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 = '<i class="bi bi-check-circle text-success fs-4 d-block mb-1"></i><span class="text-success small">RSA Key scanned successfully</span>';
|
||
}
|
||
} 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 = '<span class="spinner-border spinner-border-sm me-2"></span>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();
|
||
}
|
||
});
|