v4.1.5: Accordion UI, webcam QR scanning, Pi image fix
Encode/Decode UI: - New accordion layout with 3 steps (encode) / 2 steps (decode) - Gold step numbers with checkmarks on completion - Dynamic right-aligned summaries as fields are filled - Subtle gradient highlight on active accordion step Webcam QR Scanning: - Camera button for RSA key QR codes on encode/decode pages - Camera button for channel key scanning - 3-2-1 countdown capture for dense QR codes - Proper scanner stop/restart on retry - Backend decompression for STEGASOO-Z: compressed keys RSA Key Print: - Removed identifying text from QR print output - Now prints plain QR code for discretion Pi Image Script: - Fixed 16GB resize to detect expand vs shrink - Fresh images now properly EXPAND to 16GB - Already-expanded images properly SHRINK to 16GB UI Polish: - Removed PIN helper text for compactness - Fixed QR drop zone centering - Fixed decode page element IDs for JS 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -169,6 +169,7 @@ from subprocess_stego import (
|
||||
|
||||
from stegasoo.qr_utils import (
|
||||
can_fit_in_qr,
|
||||
decompress_data,
|
||||
detect_and_crop_qr,
|
||||
extract_key_from_qr,
|
||||
generate_qr_code,
|
||||
@@ -1049,12 +1050,19 @@ def encode_page():
|
||||
ref_data = ref_photo.read()
|
||||
carrier_data = carrier.read()
|
||||
|
||||
# Handle RSA key - can come from .pem file or QR code image
|
||||
# Handle RSA key - can come from .pem file, QR code image, or webcam-scanned PEM (v4.1.5)
|
||||
rsa_key_data = None
|
||||
rsa_key_pem = request.form.get("rsa_key_pem", "").strip()
|
||||
rsa_key_qr = request.files.get("rsa_key_qr")
|
||||
rsa_key_from_qr = False
|
||||
|
||||
if rsa_key_file and rsa_key_file.filename:
|
||||
if rsa_key_pem:
|
||||
# Webcam-scanned PEM key (v4.1.5) - may be compressed
|
||||
if rsa_key_pem.startswith("STEGASOO-Z:"):
|
||||
rsa_key_pem = decompress_data(rsa_key_pem)
|
||||
rsa_key_data = rsa_key_pem.encode("utf-8")
|
||||
rsa_key_from_qr = True
|
||||
elif rsa_key_file and rsa_key_file.filename:
|
||||
rsa_key_data = rsa_key_file.read()
|
||||
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
|
||||
qr_image_data = rsa_key_qr.read()
|
||||
@@ -1371,6 +1379,82 @@ def encode_cleanup(file_id):
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _run_decode_job(job_id: str, decode_params: dict) -> None:
|
||||
"""Background thread function for async decode."""
|
||||
progress_file = get_progress_file_path(job_id)
|
||||
|
||||
try:
|
||||
_store_job(job_id, {"status": "running", "created": time.time()})
|
||||
|
||||
# Run decode with progress file
|
||||
decode_result = subprocess_stego.decode(
|
||||
stego_data=decode_params["stego_data"],
|
||||
reference_data=decode_params["ref_data"],
|
||||
passphrase=decode_params["passphrase"],
|
||||
pin=decode_params.get("pin"),
|
||||
rsa_key_data=decode_params.get("rsa_key_data"),
|
||||
rsa_password=decode_params.get("rsa_password"),
|
||||
embed_mode=decode_params.get("embed_mode", "auto"),
|
||||
channel_key=decode_params.get("channel_key"),
|
||||
progress_file=progress_file,
|
||||
)
|
||||
|
||||
if not decode_result.success:
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "error",
|
||||
"error": decode_result.error or "Decoding failed",
|
||||
"error_type": decode_result.error_type,
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# Store result based on type
|
||||
if decode_result.is_file:
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
filename = decode_result.filename or "decoded_file"
|
||||
temp_storage.save_temp_file(file_id, decode_result.file_data, {
|
||||
"filename": filename,
|
||||
"mime_type": decode_result.mime_type,
|
||||
})
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "complete",
|
||||
"file_id": file_id,
|
||||
"is_file": True,
|
||||
"filename": filename,
|
||||
"file_size": len(decode_result.file_data),
|
||||
"mime_type": decode_result.mime_type,
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
else:
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "complete",
|
||||
"is_file": False,
|
||||
"message": decode_result.message,
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
cleanup_progress_file(job_id)
|
||||
|
||||
|
||||
@app.route("/decode", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def decode_page():
|
||||
@@ -1414,12 +1498,19 @@ def decode_page():
|
||||
ref_data = ref_photo.read()
|
||||
stego_data = stego_image.read()
|
||||
|
||||
# Handle RSA key - can come from .pem file or QR code image
|
||||
# Handle RSA key - can come from .pem file, QR code image, or webcam-scanned PEM (v4.1.5)
|
||||
rsa_key_data = None
|
||||
rsa_key_pem = request.form.get("rsa_key_pem", "").strip()
|
||||
rsa_key_qr = request.files.get("rsa_key_qr")
|
||||
rsa_key_from_qr = False
|
||||
|
||||
if rsa_key_file and rsa_key_file.filename:
|
||||
if rsa_key_pem:
|
||||
# Webcam-scanned PEM key (v4.1.5) - may be compressed
|
||||
if rsa_key_pem.startswith("STEGASOO-Z:"):
|
||||
rsa_key_pem = decompress_data(rsa_key_pem)
|
||||
rsa_key_data = rsa_key_pem.encode("utf-8")
|
||||
rsa_key_from_qr = True
|
||||
elif rsa_key_file and rsa_key_file.filename:
|
||||
rsa_key_data = rsa_key_file.read()
|
||||
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
|
||||
qr_image_data = rsa_key_qr.read()
|
||||
@@ -1454,6 +1545,29 @@ def decode_page():
|
||||
flash(result.error_message, "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
# Check for async mode (v4.1.5)
|
||||
is_async = request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||
|
||||
# Build decode params
|
||||
decode_params = {
|
||||
"stego_data": stego_data,
|
||||
"ref_data": ref_data,
|
||||
"passphrase": passphrase,
|
||||
"pin": pin if pin else None,
|
||||
"rsa_key_data": rsa_key_data,
|
||||
"rsa_password": key_password,
|
||||
"embed_mode": embed_mode,
|
||||
"channel_key": channel_key,
|
||||
}
|
||||
|
||||
# ASYNC MODE: Start background job and return JSON
|
||||
if is_async:
|
||||
job_id = generate_job_id()
|
||||
_store_job(job_id, {"status": "pending", "created": time.time()})
|
||||
_executor.submit(_run_decode_job, job_id, decode_params)
|
||||
return jsonify({"job_id": job_id, "status": "pending"})
|
||||
|
||||
# SYNC MODE: Run inline (original behavior)
|
||||
# v4.0.0: Include channel_key parameter
|
||||
# Use subprocess-isolated decode to prevent crashes
|
||||
decode_result = subprocess_stego.decode(
|
||||
@@ -1559,6 +1673,92 @@ def decode_download(file_id):
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DECODE PROGRESS ENDPOINTS (v4.1.5)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@app.route("/decode/status/<job_id>")
|
||||
@login_required
|
||||
def decode_status(job_id):
|
||||
"""Get the status of an async decode job."""
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
return jsonify({"error": "Job not found"}), 404
|
||||
|
||||
response = {"status": job.get("status", "unknown")}
|
||||
|
||||
if job["status"] == "complete":
|
||||
response["is_file"] = job.get("is_file", False)
|
||||
if job.get("is_file"):
|
||||
response["file_id"] = job.get("file_id")
|
||||
response["filename"] = job.get("filename")
|
||||
response["file_size"] = job.get("file_size")
|
||||
response["mime_type"] = job.get("mime_type")
|
||||
else:
|
||||
response["message"] = job.get("message")
|
||||
elif job["status"] == "error":
|
||||
response["error"] = job.get("error", "Unknown error")
|
||||
response["error_type"] = job.get("error_type")
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
|
||||
@app.route("/decode/progress/<job_id>")
|
||||
@login_required
|
||||
def decode_progress(job_id):
|
||||
"""Get the progress of an async decode job."""
|
||||
progress = read_progress(job_id)
|
||||
if progress:
|
||||
return jsonify(progress)
|
||||
|
||||
# No progress file yet - check job status
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
return jsonify({"error": "Job not found"}), 404
|
||||
|
||||
if job["status"] == "complete":
|
||||
return jsonify({"percent": 100, "phase": "complete"})
|
||||
elif job["status"] == "error":
|
||||
return jsonify({"percent": 0, "phase": "error", "error": job.get("error")})
|
||||
elif job["status"] == "pending":
|
||||
return jsonify({"percent": 0, "phase": "starting"})
|
||||
|
||||
# Running but no progress file yet
|
||||
return jsonify({"percent": 5, "phase": "reading"})
|
||||
|
||||
|
||||
@app.route("/decode/result/<job_id>")
|
||||
@login_required
|
||||
def decode_result(job_id):
|
||||
"""Get the result page for an async decode job."""
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
flash("Job not found or expired.", "error")
|
||||
return redirect(url_for("decode_page"))
|
||||
|
||||
if job["status"] != "complete":
|
||||
flash("Decode not complete.", "error")
|
||||
return redirect(url_for("decode_page"))
|
||||
|
||||
if job.get("is_file"):
|
||||
return render_template(
|
||||
"decode.html",
|
||||
decoded_file=True,
|
||||
file_id=job.get("file_id"),
|
||||
filename=job.get("filename"),
|
||||
file_size=format_size(job.get("file_size", 0)),
|
||||
mime_type=job.get("mime_type"),
|
||||
has_qrcode_read=HAS_QRCODE_READ,
|
||||
)
|
||||
else:
|
||||
return render_template(
|
||||
"decode.html",
|
||||
decoded_message=job.get("message"),
|
||||
has_qrcode_read=HAS_QRCODE_READ,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/about")
|
||||
def about():
|
||||
from stegasoo.channel import get_channel_status
|
||||
|
||||
@@ -231,20 +231,14 @@ const StegasooGenerate = {
|
||||
printWindow.document.write(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Stegasoo RSA Key QR Code</title>
|
||||
<title>QR Code</title>
|
||||
<style>
|
||||
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; font-family: sans-serif; }
|
||||
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||
img { max-width: 400px; }
|
||||
.warning { margin-top: 20px; padding: 10px; border: 2px solid #ff9800; background: #fff3e0; max-width: 400px; text-align: center; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Stegasoo RSA Private Key</h2>
|
||||
<img src="${qrImg.src}" alt="RSA Key QR Code">
|
||||
<div class="warning">
|
||||
<strong>Warning:</strong> This QR code contains your unencrypted RSA private key.
|
||||
Store securely and destroy after use.
|
||||
</div>
|
||||
<img src="${qrImg.src}" alt="QR Code">
|
||||
<script>window.onload = function() { window.print(); }<\/script>
|
||||
</body>
|
||||
</html>`);
|
||||
|
||||
@@ -1090,6 +1090,400 @@ const Stegasoo = {
|
||||
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';
|
||||
|
||||
this.updateProgress(percent, this.formatDecodePhase(phase));
|
||||
|
||||
// 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...',
|
||||
'reading': 'Reading image...',
|
||||
'extracting': 'Extracting data...',
|
||||
'decrypting': 'Decrypting...',
|
||||
'verifying': 'Verifying...',
|
||||
'finalizing': 'Finalizing...',
|
||||
'complete': 'Complete!',
|
||||
};
|
||||
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
|
||||
// ========================================================================
|
||||
@@ -1111,6 +1505,39 @@ const Stegasoo = {
|
||||
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');
|
||||
@@ -1136,7 +1563,7 @@ const Stegasoo = {
|
||||
this.initRsaMethodToggle();
|
||||
this.initDropZones();
|
||||
this.initClipboardPaste(['input[name="stego_image"]', 'input[name="reference_photo"]']);
|
||||
this.initQrCropAnimation('rsaKeyQrInput');
|
||||
this.initQrCropAnimation('rsaQrInput');
|
||||
this.initCollapseChevrons();
|
||||
this.initPassphraseFontResize();
|
||||
|
||||
@@ -1148,28 +1575,56 @@ const Stegasoo = {
|
||||
serverInfoId: 'channelServerInfoDec'
|
||||
});
|
||||
|
||||
// Form submission with channel key validation and mode display
|
||||
// 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')) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
const selectedMode = document.querySelector('input[name="embed_mode"]:checked')?.value || 'auto';
|
||||
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
const startTime = Date.now();
|
||||
const updateTimer = () => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
const mins = Math.floor(elapsed / 60);
|
||||
const secs = elapsed % 60;
|
||||
const timeStr = mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}s`;
|
||||
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Decoding (${selectedMode.toUpperCase()})... ${timeStr}`;
|
||||
};
|
||||
updateTimer();
|
||||
setInterval(updateTimer, 1000);
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting...';
|
||||
}
|
||||
|
||||
// Use async submission with progress tracking
|
||||
this.submitDecodeAsync(form, btn);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -136,14 +136,33 @@ def encode_operation(params: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _write_decode_progress(progress_file: str | None, percent: int, phase: str) -> None:
|
||||
"""Write decode progress to file."""
|
||||
if not progress_file:
|
||||
return
|
||||
try:
|
||||
import json
|
||||
with open(progress_file, "w") as f:
|
||||
json.dump({"percent": percent, "phase": phase}, f)
|
||||
except Exception:
|
||||
pass # Best effort
|
||||
|
||||
|
||||
def decode_operation(params: dict) -> dict:
|
||||
"""Handle decode operation."""
|
||||
from stegasoo import decode
|
||||
|
||||
progress_file = params.get("progress_file")
|
||||
|
||||
# Progress: starting
|
||||
_write_decode_progress(progress_file, 5, "reading")
|
||||
|
||||
# Decode base64 inputs
|
||||
stego_data = base64.b64decode(params["stego_b64"])
|
||||
reference_data = base64.b64decode(params["reference_b64"])
|
||||
|
||||
_write_decode_progress(progress_file, 15, "reading")
|
||||
|
||||
# Optional RSA key
|
||||
rsa_key_data = None
|
||||
if params.get("rsa_key_b64"):
|
||||
@@ -152,6 +171,8 @@ def decode_operation(params: dict) -> dict:
|
||||
# Resolve channel key (v4.0.0)
|
||||
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
|
||||
|
||||
_write_decode_progress(progress_file, 25, "extracting")
|
||||
|
||||
# Call decode with correct parameter names
|
||||
result = decode(
|
||||
stego_image=stego_data,
|
||||
@@ -164,6 +185,8 @@ def decode_operation(params: dict) -> dict:
|
||||
channel_key=resolved_channel_key, # v4.0.0
|
||||
)
|
||||
|
||||
_write_decode_progress(progress_file, 90, "finalizing")
|
||||
|
||||
if result.is_file:
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@@ -314,6 +314,8 @@ class SubprocessStego:
|
||||
# Channel key (v4.0.0)
|
||||
channel_key: str | None = "auto",
|
||||
timeout: int | None = None,
|
||||
# Progress tracking (v4.1.5)
|
||||
progress_file: str | None = None,
|
||||
) -> DecodeResult:
|
||||
"""
|
||||
Decode a message or file from a stego image.
|
||||
@@ -328,6 +330,7 @@ class SubprocessStego:
|
||||
embed_mode: 'auto', 'lsb', or 'dct'
|
||||
channel_key: 'auto' (server config), 'none' (public), or explicit key (v4.0.0)
|
||||
timeout: Operation timeout in seconds
|
||||
progress_file: Path to write progress updates (v4.1.5)
|
||||
|
||||
Returns:
|
||||
DecodeResult with message or file_data on success
|
||||
@@ -340,6 +343,7 @@ class SubprocessStego:
|
||||
"pin": pin,
|
||||
"embed_mode": embed_mode,
|
||||
"channel_key": channel_key, # v4.0.0
|
||||
"progress_file": progress_file, # v4.1.5
|
||||
}
|
||||
|
||||
if rsa_key_data:
|
||||
|
||||
@@ -177,9 +177,16 @@
|
||||
placeholder="Key name" required maxlength="50">
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<input type="text" name="channel_key" class="form-control form-control-sm font-monospace"
|
||||
placeholder="Channel key (32 hex chars)" required
|
||||
pattern="[0-9a-fA-F\-]{32,39}" title="32 hex characters">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" name="channel_key" id="channelKeyInput"
|
||||
class="form-control font-monospace"
|
||||
placeholder="XXXX-XXXX-..." required
|
||||
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}">
|
||||
<button type="button" class="btn btn-outline-secondary" id="scanChannelKeyBtn"
|
||||
title="Scan QR code with camera">
|
||||
<i class="bi bi-camera"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||
@@ -254,12 +261,34 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||
{% if is_admin %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
|
||||
{% endif %}
|
||||
<script>
|
||||
StegasooAuth.initPasswordConfirmation('accountForm', 'newPasswordInput', 'newPasswordConfirmInput');
|
||||
|
||||
// Webcam QR scanning for channel key input (v4.1.5)
|
||||
document.getElementById('scanChannelKeyBtn')?.addEventListener('click', function() {
|
||||
Stegasoo.showQrScanner((text) => {
|
||||
const input = document.getElementById('channelKeyInput');
|
||||
if (input) {
|
||||
// Clean and format the key
|
||||
const clean = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
|
||||
if (clean.length === 32) {
|
||||
input.value = clean.match(/.{4}/g).join('-');
|
||||
} else {
|
||||
input.value = text.toUpperCase();
|
||||
}
|
||||
}
|
||||
}, 'Scan Channel Key');
|
||||
});
|
||||
|
||||
// Format channel key input as user types
|
||||
document.getElementById('channelKeyInput')?.addEventListener('input', function() {
|
||||
Stegasoo.formatChannelKeyInput(this);
|
||||
});
|
||||
|
||||
function renameKey(keyId, currentName) {
|
||||
document.getElementById('renameInput').value = currentName;
|
||||
document.getElementById('renameForm').action = '/account/keys/' + keyId + '/rename';
|
||||
|
||||
@@ -101,6 +101,8 @@
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- QR Code scanning library (v4.1.5) -->
|
||||
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||||
<script>
|
||||
// Initialize toasts (auto-hide after delay)
|
||||
document.querySelectorAll('.toast').forEach(el => new bootstrap.Toast(el));
|
||||
|
||||
@@ -4,6 +4,74 @@
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
/* Accordion styling */
|
||||
.step-accordion .accordion-button {
|
||||
background: rgba(30, 40, 50, 0.6);
|
||||
color: #fff;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.step-accordion .accordion-button:not(.collapsed) {
|
||||
background: linear-gradient(90deg, rgba(99, 179, 237, 0.15) 0%, rgba(40, 50, 60, 0.8) 40%, rgba(40, 50, 60, 0.8) 100%);
|
||||
color: #fff;
|
||||
box-shadow: inset 0 1px 0 rgba(99, 179, 237, 0.1);
|
||||
border-left: 3px solid rgba(99, 179, 237, 0.6);
|
||||
}
|
||||
.step-accordion .accordion-button::after {
|
||||
filter: invert(1);
|
||||
}
|
||||
.step-accordion .accordion-body {
|
||||
background: rgba(30, 40, 50, 0.4);
|
||||
padding: 1rem;
|
||||
}
|
||||
.step-accordion .accordion-item {
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
background: transparent;
|
||||
}
|
||||
.step-accordion .accordion-item:first-child .accordion-button {
|
||||
border-radius: 0;
|
||||
}
|
||||
.step-accordion .accordion-item:last-child .accordion-button.collapsed {
|
||||
border-radius: 0;
|
||||
}
|
||||
.step-summary {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255,255,255,0.5);
|
||||
margin-left: auto;
|
||||
padding-right: 1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 50%;
|
||||
}
|
||||
.step-summary.has-content {
|
||||
color: rgba(99, 179, 237, 0.8);
|
||||
}
|
||||
.step-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.step-number {
|
||||
background: rgba(246, 173, 85, 0.2);
|
||||
color: #f6ad55;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
border: 1px solid rgba(246, 173, 85, 0.3);
|
||||
}
|
||||
.step-number.complete {
|
||||
background: rgba(72, 187, 120, 0.2);
|
||||
color: #48bb78;
|
||||
border-color: rgba(72, 187, 120, 0.3);
|
||||
}
|
||||
|
||||
/* Glowing passphrase input */
|
||||
.passphrase-input {
|
||||
background: rgba(30, 40, 50, 0.8) !important;
|
||||
@@ -13,20 +81,17 @@
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 12px 16px;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease, background 0.3s ease;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.passphrase-input:focus {
|
||||
border-color: rgba(99, 179, 237, 0.8) !important;
|
||||
box-shadow: 0 0 20px rgba(99, 179, 237, 0.4), 0 0 40px rgba(99, 179, 237, 0.2) !important;
|
||||
background: rgba(30, 40, 50, 0.95) !important;
|
||||
box-shadow: 0 0 20px rgba(99, 179, 237, 0.4) !important;
|
||||
}
|
||||
|
||||
.passphrase-input::placeholder {
|
||||
color: rgba(99, 179, 237, 0.4);
|
||||
}
|
||||
|
||||
/* Glowing PIN input */
|
||||
/* PIN input */
|
||||
.pin-input-container .form-control {
|
||||
background: rgba(30, 40, 50, 0.8) !important;
|
||||
border: 2px solid rgba(246, 173, 85, 0.3) !important;
|
||||
@@ -35,18 +100,10 @@
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 3px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.pin-input-container .form-control:focus {
|
||||
border-color: rgba(246, 173, 85, 0.8) !important;
|
||||
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4), 0 0 40px rgba(246, 173, 85, 0.2) !important;
|
||||
background: rgba(30, 40, 50, 0.95) !important;
|
||||
}
|
||||
|
||||
.pin-input-container .form-control::placeholder {
|
||||
color: rgba(246, 173, 85, 0.4);
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important;
|
||||
}
|
||||
|
||||
/* QR Crop Animation */
|
||||
@@ -55,8 +112,12 @@
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.qr-crop-container img {
|
||||
display: block;
|
||||
max-height: 180px;
|
||||
@@ -65,15 +126,10 @@
|
||||
margin: 0 auto;
|
||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.qr-crop-container .qr-original {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.qr-crop-container .qr-original { opacity: 1; }
|
||||
.qr-crop-container .qr-cropped {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.3);
|
||||
opacity: 0;
|
||||
max-height: 160px;
|
||||
@@ -81,30 +137,15 @@
|
||||
min-height: 140px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.qr-crop-container.scan-complete .qr-original {
|
||||
opacity: 0;
|
||||
transform: scale(1.1);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
.qr-crop-container.scan-complete .qr-cropped {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
.qr-crop-container .crop-badge {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
font-size: 0.65rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease 0.4s;
|
||||
}
|
||||
|
||||
.qr-crop-container.scan-complete .crop-badge {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
@@ -113,13 +154,13 @@
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-unlock-fill me-2"></i>Decode Secret Message or File</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body {% if not decoded_message and not decoded_file %}p-0{% endif %}">
|
||||
{% if decoded_message %}
|
||||
<!-- Text Message Result -->
|
||||
<div class="alert alert-success">
|
||||
<h6><i class="bi bi-check-circle me-2"></i>Message Decrypted Successfully!</h6>
|
||||
</div>
|
||||
|
||||
|
||||
<label class="form-label text-muted">Decoded Message: <small class="text-secondary">(click to copy)</small></label>
|
||||
<div class="alert-message p-3 rounded bg-dark border border-secondary mb-3" id="decodedContent" style="white-space: pre-wrap; cursor: pointer; transition: border-color 0.2s;"
|
||||
onclick="navigator.clipboard.writeText(this.innerText).then(() => { this.style.borderColor = '#198754'; this.dataset.origText = this.innerHTML; this.innerHTML = '<i class=\'bi bi-check-circle text-success\'></i> Copied to clipboard!'; setTimeout(() => { this.innerHTML = this.dataset.origText; this.style.borderColor = ''; }, 1500); }).catch(() => alert('Failed to copy'))"
|
||||
@@ -129,13 +170,13 @@
|
||||
<a href="/decode" class="btn btn-outline-light w-100">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
|
||||
</a>
|
||||
|
||||
|
||||
{% elif decoded_file %}
|
||||
<!-- File Result -->
|
||||
<div class="alert alert-success">
|
||||
<h6><i class="bi bi-check-circle me-2"></i>File Decrypted Successfully!</h6>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-file-earmark-check text-success" style="font-size: 4rem;"></i>
|
||||
<h5 class="mt-3">{{ filename }}</h5>
|
||||
@@ -144,345 +185,270 @@
|
||||
<small class="text-muted">Type: {{ mime_type }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<a href="{{ url_for('decode_download', file_id=file_id) }}" class="btn btn-primary btn-lg w-100 mb-3">
|
||||
<i class="bi bi-download me-2"></i>Download File
|
||||
</a>
|
||||
|
||||
|
||||
<div class="alert alert-warning small">
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
<strong>File expires in 5 minutes.</strong> Download now.
|
||||
</div>
|
||||
|
||||
|
||||
<a href="/decode" class="btn btn-outline-light w-100">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
|
||||
</a>
|
||||
|
||||
|
||||
{% else %}
|
||||
<!-- Decode Form -->
|
||||
<form method="POST" enctype="multipart/form-data" id="decodeForm">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-image me-1"></i> Reference Photo
|
||||
</label>
|
||||
<div class="drop-zone scan-container" id="refDropZone">
|
||||
<input type="file" name="reference_photo" accept="image/*" required>
|
||||
<div class="drop-zone-label">
|
||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
||||
<span class="text-muted">Drop image or click to browse</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="refPreview">
|
||||
<!-- Scan overlay elements -->
|
||||
<div class="scan-overlay">
|
||||
<div class="scan-grid"></div>
|
||||
<div class="scan-line"></div>
|
||||
</div>
|
||||
<!-- Corner brackets (shown after scan) -->
|
||||
<div class="scan-corners">
|
||||
<div class="scan-corner tl"></div>
|
||||
<div class="scan-corner tr"></div>
|
||||
<div class="scan-corner bl"></div>
|
||||
<div class="scan-corner br"></div>
|
||||
</div>
|
||||
<!-- Data panel (shown after scan) -->
|
||||
<div class="scan-data-panel">
|
||||
<div class="scan-data-filename">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span id="refFileName">image.jpg</span>
|
||||
</div>
|
||||
<div class="scan-data-row">
|
||||
<span class="scan-status-badge">Hash Acquired</span>
|
||||
<span class="scan-data-value" id="refFileSize">--</span>
|
||||
</div>
|
||||
<div class="scan-hash-preview" id="refHashPreview">SHA256: ················</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
The same reference photo used for encoding
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
|
||||
</label>
|
||||
<div class="drop-zone pixel-container" id="stegoDropZone">
|
||||
<input type="file" name="stego_image" accept="image/*" required>
|
||||
<div class="drop-zone-label">
|
||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
||||
<span class="text-muted">Drop image or click to browse</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="stegoPreview">
|
||||
<!-- Pixel blocks overlay - populated by JS -->
|
||||
<div class="pixel-blocks"></div>
|
||||
<!-- Pixel scan line -->
|
||||
<div class="pixel-scan-line"></div>
|
||||
<!-- Corner brackets -->
|
||||
<div class="pixel-corners">
|
||||
<div class="pixel-corner tl"></div>
|
||||
<div class="pixel-corner tr"></div>
|
||||
<div class="pixel-corner bl"></div>
|
||||
<div class="pixel-corner br"></div>
|
||||
</div>
|
||||
<!-- Data panel -->
|
||||
<div class="pixel-data-panel">
|
||||
<div class="pixel-data-filename">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span id="stegoFileName">image.png</span>
|
||||
</div>
|
||||
<div class="pixel-data-row">
|
||||
<span class="pixel-status-badge">Stego Loaded</span>
|
||||
<span class="pixel-data-value" id="stegoFileSize">--</span>
|
||||
</div>
|
||||
<div class="pixel-dimensions" id="stegoDims">-- × -- px</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
The image containing the hidden message/file
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-chat-quote me-1"></i> Passphrase
|
||||
</label>
|
||||
<input type="text" name="passphrase" id="passphraseInput" class="form-control passphrase-input"
|
||||
placeholder="e.g., correct horse battery staple" required>
|
||||
<div class="form-text">
|
||||
The passphrase used during encoding (typically 4 words)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<h6 class="text-muted mb-3">
|
||||
SECURITY FACTORS
|
||||
<span class="text-warning small">(provide same factors used during encoding)</span>
|
||||
</h6>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="security-box">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
|
||||
</label>
|
||||
|
||||
<!-- RSA Input Method Toggle -->
|
||||
<div class="btn-group w-100 mb-2" role="group">
|
||||
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked>
|
||||
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile">
|
||||
<i class="bi bi-file-earmark me-1"></i>.pem File
|
||||
</label>
|
||||
<div class="accordion step-accordion" id="decodeAccordion">
|
||||
|
||||
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr">
|
||||
<i class="bi bi-qr-code me-1"></i>QR Code
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- .pem File Input -->
|
||||
<div id="rsaFileSection">
|
||||
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
|
||||
</div>
|
||||
|
||||
<!-- QR Code Input -->
|
||||
<div id="rsaQrSection" class="d-none">
|
||||
<div class="drop-zone p-3" id="qrDropZone">
|
||||
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaKeyQrInput">
|
||||
<div class="drop-zone-label text-center">
|
||||
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
|
||||
<span class="text-muted small">Drop QR image or click to browse</span>
|
||||
</div>
|
||||
<!-- Crop animation container -->
|
||||
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
|
||||
<img class="qr-original" id="qrOriginal" alt="Original">
|
||||
<img class="qr-cropped" id="qrCropped" alt="Cropped QR">
|
||||
<!-- Data panel -->
|
||||
<div class="qr-data-panel">
|
||||
<div class="qr-data-filename">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span>RSA Key loaded</span>
|
||||
</div>
|
||||
<div class="qr-data-row">
|
||||
<span class="qr-status-badge">RSA Key</span>
|
||||
<span class="qr-data-value">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Password (always visible) -->
|
||||
<div class="input-group input-group-sm mt-2">
|
||||
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PIN + Channel Row -->
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
|
||||
<div class="input-group pin-input-container">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
|
||||
<i class="bi bi-eye"></i>
|
||||
<!-- ================================================================
|
||||
STEP 1: IMAGES & MODE
|
||||
================================================================ -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
||||
<span class="step-title">
|
||||
<span class="step-number" id="stepImagesNumber">1</span>
|
||||
<i class="bi bi-images me-1"></i> Images & Mode
|
||||
</span>
|
||||
<span class="step-summary" id="stepImagesSummary">Select reference & stego</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">If PIN was used during encoding</div>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
|
||||
<div class="accordion-body">
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-broadcast me-1"></i> Channel
|
||||
<span class="badge bg-info ms-1">v4.1</span>
|
||||
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
|
||||
</label>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-image me-1"></i> Reference Photo
|
||||
</label>
|
||||
<div class="drop-zone scan-container" id="refDropZone">
|
||||
<input type="file" name="reference_photo" accept="image/*" required id="refPhotoInput">
|
||||
<div class="drop-zone-label">
|
||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
||||
<span class="text-muted">Drop image or click</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="refPreview">
|
||||
<div class="scan-overlay"><div class="scan-grid"></div><div class="scan-line"></div></div>
|
||||
<div class="scan-corners">
|
||||
<div class="scan-corner tl"></div><div class="scan-corner tr"></div>
|
||||
<div class="scan-corner bl"></div><div class="scan-corner br"></div>
|
||||
</div>
|
||||
<div class="scan-data-panel">
|
||||
<div class="scan-data-filename"><i class="bi bi-check-circle-fill"></i><span id="refFileName">image.jpg</span></div>
|
||||
<div class="scan-data-row"><span class="scan-status-badge">Hash Acquired</span><span class="scan-data-value" id="refFileSize">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Same reference photo used for encoding</div>
|
||||
</div>
|
||||
|
||||
<select class="form-select" name="channel_key" id="channelSelectDec">
|
||||
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
|
||||
<option value="none">Public</option>
|
||||
{% if saved_channel_keys %}
|
||||
<optgroup label="Saved Keys">
|
||||
{% for key in saved_channel_keys %}
|
||||
<option value="{{ key.channel_key }}" data-key-id="{{ key.id }}">{{ key.name }} ({{ key.channel_key[:4] }}...)</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
|
||||
</label>
|
||||
<div class="drop-zone pixel-container" id="stegoDropZone">
|
||||
<input type="file" name="stego_image" accept="image/*" required id="stegoInput">
|
||||
<div class="drop-zone-label">
|
||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
||||
<span class="text-muted">Drop image or click</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="stegoPreview">
|
||||
<div class="pixel-blocks"></div>
|
||||
<div class="pixel-scan-line"></div>
|
||||
<div class="pixel-corners">
|
||||
<div class="pixel-corner tl"></div><div class="pixel-corner tr"></div>
|
||||
<div class="pixel-corner bl"></div><div class="pixel-corner br"></div>
|
||||
</div>
|
||||
<div class="pixel-data-panel">
|
||||
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="stegoFileName">image.png</span></div>
|
||||
<div class="pixel-data-row"><span class="pixel-status-badge">Stego Loaded</span><span class="pixel-data-value" id="stegoFileSize">--</span></div>
|
||||
<div class="pixel-dimensions" id="stegoDims">-- x -- px</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Image containing the hidden message</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server channel indicator (compact) -->
|
||||
<div class="small text-success mt-2 {% if not channel_configured %}d-none{% endif %}" id="channelServerInfoDec" data-fingerprint="{{ (channel_fingerprint[:4] if channel_fingerprint else '') }}-••••-···-••••-{{ channel_fingerprint[-4:] if channel_fingerprint else '' }}">
|
||||
{% if channel_configured and channel_fingerprint %}
|
||||
<i class="bi bi-shield-lock me-1"></i>
|
||||
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Channel Key Input (shown when Custom selected) -->
|
||||
<div class="mb-4 d-none" id="channelCustomInputDec">
|
||||
<div class="security-box">
|
||||
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
|
||||
<div class="input-group">
|
||||
<input type="text" name="channel_key_custom" class="form-control font-monospace"
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
|
||||
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}"
|
||||
id="channelKeyInputDec">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
ADVANCED OPTIONS (v3.0) - Extraction Mode
|
||||
================================================================ -->
|
||||
<div class="mb-4">
|
||||
<a class="btn btn-sm btn-outline-secondary w-100" data-bs-toggle="collapse" href="#advancedOptionsDec" role="button" aria-expanded="false">
|
||||
<i class="bi bi-gear me-1"></i> Advanced Options
|
||||
<i class="bi bi-chevron-down ms-1" id="advancedChevronDec"></i>
|
||||
</a>
|
||||
|
||||
<div class="collapse" id="advancedOptionsDec">
|
||||
<div class="card card-body mt-2 bg-dark border-secondary">
|
||||
|
||||
<!-- Extraction Mode Selection -->
|
||||
<div class="mb-0">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-cpu me-1"></i> Extraction Mode
|
||||
<span class="badge bg-info ms-1">v3.0</span>
|
||||
</label>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<!-- Auto Mode -->
|
||||
<!-- Extraction Mode -->
|
||||
<label class="form-label"><i class="bi bi-cpu me-1"></i> Extraction Mode</label>
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<label class="mode-btn flex-fill active" id="autoModeCard" for="modeAuto">
|
||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeAuto" value="auto" checked>
|
||||
<i class="bi bi-magic text-success"></i>
|
||||
<i class="bi bi-magic text-success ms-2"></i>
|
||||
<span class="ms-2"><strong>Auto</strong> <span class="text-muted d-none d-sm-inline">· Try both</span></span>
|
||||
</label>
|
||||
|
||||
<!-- LSB Mode -->
|
||||
<label class="mode-btn flex-fill" id="lsbModeCardDec" for="modeLsbDec">
|
||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsbDec" value="lsb">
|
||||
<i class="bi bi-grid-3x3-gap text-primary"></i>
|
||||
<span class="ms-2"><strong>LSB</strong> <span class="text-muted d-none d-sm-inline">· Spatial</span></span>
|
||||
<label class="mode-btn flex-fill" id="lsbModeCard" for="modeLsb">
|
||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsb" value="lsb">
|
||||
<i class="bi bi-grid-3x3-gap text-primary ms-2"></i>
|
||||
<span class="ms-2"><strong>LSB</strong> <span class="text-muted d-none d-sm-inline">· Email</span></span>
|
||||
</label>
|
||||
|
||||
<!-- DCT Mode -->
|
||||
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %}" id="dctModeCardDec" for="modeDctDec">
|
||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeDctDec" value="dct" {% if not has_dct %}disabled{% endif %}>
|
||||
<i class="bi bi-soundwave text-warning"></i>
|
||||
<span class="ms-2"><strong>DCT</strong> <span class="text-muted d-none d-sm-inline">· Frequency</span></span>
|
||||
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %}" id="dctModeCard" for="modeDct">
|
||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
|
||||
<i class="bi bi-soundwave text-warning ms-2"></i>
|
||||
<span class="ms-2"><strong>DCT</strong> <span class="text-muted d-none d-sm-inline">· Social</span></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-text mt-2">
|
||||
<i class="bi bi-lightbulb me-1"></i>
|
||||
<strong>Auto</strong> tries LSB first, then DCT.
|
||||
{% if not has_dct %}
|
||||
<span class="text-warning ms-2"><i class="bi bi-exclamation-triangle me-1"></i>DCT requires scipy</span>
|
||||
{% endif %}
|
||||
<div class="form-text">
|
||||
<i class="bi bi-lightbulb me-1"></i><strong>Auto</strong> tries LSB first, then DCT.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
STEP 2: SECURITY
|
||||
================================================================ -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
|
||||
<span class="step-title">
|
||||
<span class="step-number" id="stepSecurityNumber">2</span>
|
||||
<i class="bi bi-shield-lock me-1"></i> Security
|
||||
</span>
|
||||
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="stepSecurity" class="accordion-collapse collapse" data-bs-parent="#decodeAccordion">
|
||||
<div class="accordion-body">
|
||||
|
||||
<!-- Passphrase -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-chat-quote me-1"></i> Passphrase</label>
|
||||
<input type="text" name="passphrase" class="form-control passphrase-input"
|
||||
placeholder="e.g., apple forest thunder mountain" required id="passphraseInput">
|
||||
<div class="form-text">The passphrase used during encoding</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-3 opacity-25">
|
||||
<div class="small text-muted mb-2">Provide same factors used during encoding</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- PIN -->
|
||||
<div class="col-md-6 mb-2">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
|
||||
<div class="input-group pin-input-container">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel -->
|
||||
<div class="col-md-6 mb-2">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label"><i class="bi bi-broadcast me-1"></i> Channel</label>
|
||||
<select class="form-select form-select-sm" name="channel_key" id="channelSelectDec">
|
||||
<option value="auto" selected>Auto{% if channel_configured %} (Server){% endif %}</option>
|
||||
<option value="none">Public</option>
|
||||
{% if saved_channel_keys %}
|
||||
<optgroup label="Saved Keys">
|
||||
{% for key in saved_channel_keys %}
|
||||
<option value="{{ key.channel_key }}">{{ key.name }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Channel Key -->
|
||||
<div class="mb-3 d-none" id="channelCustomInputDec">
|
||||
<div class="security-box">
|
||||
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
|
||||
<div class="input-group">
|
||||
<input type="text" name="channel_key_custom" class="form-control form-control-sm font-monospace"
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" id="channelKeyInputDec">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" id="channelKeyScanDec" title="Scan QR"><i class="bi bi-camera"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSA Key -->
|
||||
<div class="mb-3">
|
||||
<div class="security-box">
|
||||
<label class="form-label"><i class="bi bi-file-earmark-lock me-1"></i> RSA Key <span class="text-muted">(if used)</span></label>
|
||||
<div class="btn-group w-100 mb-2" role="group">
|
||||
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked>
|
||||
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile"><i class="bi bi-file-earmark me-1"></i>.pem</label>
|
||||
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr"><i class="bi bi-qr-code me-1"></i>QR</label>
|
||||
</div>
|
||||
<div id="rsaFileSection">
|
||||
<input type="file" name="rsa_key" class="form-control form-control-sm" accept=".pem">
|
||||
</div>
|
||||
<div id="rsaQrSection" class="d-none d-flex flex-column">
|
||||
<input type="hidden" name="rsa_key_pem" id="rsaKeyPem">
|
||||
<div class="drop-zone p-2 w-100" id="qrDropZone">
|
||||
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaQrInput">
|
||||
<div class="drop-zone-label text-center">
|
||||
<i class="bi bi-qr-code-scan fs-5 d-block text-muted mb-1"></i>
|
||||
<span class="text-muted small">Drop QR image</span>
|
||||
</div>
|
||||
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
|
||||
<img class="qr-original" id="qrOriginal" alt="Original">
|
||||
<img class="qr-cropped" id="qrCropped" alt="Cropped">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-2" id="rsaQrWebcam">
|
||||
<i class="bi bi-camera me-1"></i>Scan with Camera
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group input-group-sm mt-2">
|
||||
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput"><i class="bi bi-eye"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn">
|
||||
<i class="bi bi-unlock me-2"></i>Decode
|
||||
</button>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="p-3">
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn">
|
||||
<i class="bi bi-unlock me-2"></i>Decode
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if not decoded_message and not decoded_file %}
|
||||
<!-- Troubleshooting Card -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-3"><i class="bi bi-question-circle me-2"></i>Troubleshooting</h6>
|
||||
<ul class="list-unstyled text-muted small mb-0">
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle-fill text-success me-1"></i>
|
||||
Use the <strong>exact same reference photo</strong> file (byte-for-byte identical)
|
||||
Use the <strong>exact same reference photo</strong> (byte-for-byte identical)
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle-fill text-success me-1"></i>
|
||||
Enter the <strong>exact passphrase</strong> used during encoding (case-sensitive, spacing matters)
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle-fill text-success me-1"></i>
|
||||
Provide the <strong>same security factors</strong> (PIN and/or RSA key) used during encoding
|
||||
Enter the <strong>exact passphrase</strong> used during encoding
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>
|
||||
Ensure the stego image hasn't been <strong>resized, cropped, or recompressed</strong>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>
|
||||
<strong>Format compatibility:</strong> v4.0 cannot decode messages from v3.1 or earlier (different format)
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-broadcast text-info me-1"></i>
|
||||
<strong>Channel key:</strong> Use the same channel (Auto/Public/Custom) that was used during encoding
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-info-circle-fill text-info me-1"></i>
|
||||
If using an RSA key, verify the <strong>password is correct</strong> (if key is encrypted)
|
||||
Ensure the stego image hasn't been <strong>resized or recompressed</strong>
|
||||
</li>
|
||||
<li class="mb-0">
|
||||
<i class="bi bi-info-circle-fill text-info me-1"></i>
|
||||
If auto-detection fails, try specifying <strong>LSB or DCT mode</strong> in Advanced Options
|
||||
If auto-detection fails, try specifying <strong>LSB or DCT mode</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -495,31 +461,92 @@
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||
<script>
|
||||
// Extraction mode button active state toggle
|
||||
const extractModeRadios = document.querySelectorAll('input[name="embed_mode"]');
|
||||
const extractModeBtns = {
|
||||
'auto': document.getElementById('autoModeCard'),
|
||||
'lsb': document.getElementById('lsbModeCardDec'),
|
||||
'dct': document.getElementById('dctModeCardDec')
|
||||
};
|
||||
// ============================================================================
|
||||
// ACCORDION SUMMARY UPDATES
|
||||
// ============================================================================
|
||||
|
||||
extractModeRadios.forEach(radio => {
|
||||
function updateImagesSummary() {
|
||||
const ref = document.getElementById('refPhotoInput')?.files[0];
|
||||
const stego = document.getElementById('stegoInput')?.files[0];
|
||||
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'AUTO';
|
||||
const summary = document.getElementById('stepImagesSummary');
|
||||
const stepNum = document.getElementById('stepImagesNumber');
|
||||
|
||||
if (ref && stego) {
|
||||
const refName = ref.name.length > 12 ? ref.name.slice(0, 10) + '..' : ref.name;
|
||||
const stegoName = stego.name.length > 12 ? stego.name.slice(0, 10) + '..' : stego.name;
|
||||
summary.textContent = `${refName} + ${stegoName}, ${mode}`;
|
||||
summary.classList.add('has-content');
|
||||
stepNum.classList.add('complete');
|
||||
stepNum.innerHTML = '<i class="bi bi-check"></i>';
|
||||
} else if (ref || stego) {
|
||||
summary.textContent = ref ? ref.name.slice(0, 15) : stego.name.slice(0, 15);
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '1';
|
||||
} else {
|
||||
summary.textContent = 'Select reference & stego';
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '1';
|
||||
}
|
||||
}
|
||||
|
||||
function updateSecuritySummary() {
|
||||
const passphrase = document.getElementById('passphraseInput')?.value || '';
|
||||
const pin = document.getElementById('pinInput')?.value || '';
|
||||
const rsaFile = document.querySelector('input[name="rsa_key"]')?.files[0];
|
||||
const rsaPem = document.getElementById('rsaKeyPem')?.value || '';
|
||||
const summary = document.getElementById('stepSecuritySummary');
|
||||
const stepNum = document.getElementById('stepSecurityNumber');
|
||||
|
||||
const parts = [];
|
||||
if (passphrase.trim()) parts.push('passphrase');
|
||||
if (pin) parts.push('PIN');
|
||||
if (rsaFile || rsaPem) parts.push('RSA');
|
||||
|
||||
if (parts.length > 0) {
|
||||
summary.textContent = parts.join(' + ');
|
||||
summary.classList.add('has-content');
|
||||
if (passphrase.trim()) {
|
||||
stepNum.classList.add('complete');
|
||||
stepNum.innerHTML = '<i class="bi bi-check"></i>';
|
||||
}
|
||||
} else {
|
||||
summary.textContent = 'Passphrase & keys';
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '2';
|
||||
}
|
||||
}
|
||||
|
||||
// Attach listeners
|
||||
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
|
||||
document.getElementById('stegoInput')?.addEventListener('change', updateImagesSummary);
|
||||
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||
|
||||
document.getElementById('passphraseInput')?.addEventListener('input', updateSecuritySummary);
|
||||
document.getElementById('pinInput')?.addEventListener('input', updateSecuritySummary);
|
||||
document.querySelector('input[name="rsa_key"]')?.addEventListener('change', updateSecuritySummary);
|
||||
|
||||
// ============================================================================
|
||||
// MODE SWITCHING
|
||||
// ============================================================================
|
||||
|
||||
const modeRadios = document.querySelectorAll('input[name="embed_mode"]');
|
||||
const modeBtns = { 'auto': document.getElementById('autoModeCard'), 'lsb': document.getElementById('lsbModeCard'), 'dct': document.getElementById('dctModeCard') };
|
||||
|
||||
modeRadios.forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
Object.values(extractModeBtns).forEach(btn => btn?.classList.remove('active'));
|
||||
extractModeBtns[radio.value]?.classList.add('active');
|
||||
Object.values(modeBtns).forEach(btn => btn?.classList.remove('active'));
|
||||
modeBtns[radio.value]?.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// Advanced options chevron
|
||||
const advancedOptionsDec = document.getElementById('advancedOptionsDec');
|
||||
advancedOptionsDec?.addEventListener('show.bs.collapse', () => {
|
||||
document.getElementById('advancedChevronDec')?.classList.replace('bi-chevron-down', 'bi-chevron-up');
|
||||
});
|
||||
advancedOptionsDec?.addEventListener('hide.bs.collapse', () => {
|
||||
document.getElementById('advancedChevronDec')?.classList.replace('bi-chevron-up', 'bi-chevron-down');
|
||||
});
|
||||
// ============================================================================
|
||||
// LOADING STATE
|
||||
// ============================================================================
|
||||
|
||||
// Loading state for decode button
|
||||
Stegasoo.initFormLoading('decodeForm', 'decodeBtn', 'Decoding...');
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user