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:
@@ -95,6 +95,47 @@ src/stegasoo/dct_steganography.py
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Browser Webcam QR Scanning
|
||||
|
||||
Add webcam-based QR code scanning for all key input fields.
|
||||
|
||||
### Use Cases
|
||||
- Import channel key via QR scan on account page
|
||||
- Scan QR codes instead of typing long keys
|
||||
|
||||
### Implementation
|
||||
|
||||
**1. Add JS QR scanning library**
|
||||
- Use `jsQR` or `html5-qrcode` (client-side, no server needed)
|
||||
- Include via CDN in base template
|
||||
|
||||
**2. Add camera button to channel key inputs**
|
||||
- Account page: "Add Key" field
|
||||
- Encode/decode pages: channel key selector (if manual input)
|
||||
|
||||
**3. Camera modal component**
|
||||
- Request camera permission
|
||||
- Live video preview
|
||||
- Auto-detect QR and populate input field
|
||||
- Close modal on successful scan
|
||||
|
||||
### Files to Modify
|
||||
```
|
||||
frontends/web/templates/base.html - Add QR library CDN
|
||||
frontends/web/templates/account.html - Camera button + modal
|
||||
frontends/web/static/js/stegasoo.js - QR scan methods
|
||||
```
|
||||
|
||||
### Testing Checklist
|
||||
- [ ] Camera permission prompt works
|
||||
- [ ] QR detected and input populated
|
||||
- [ ] Works on mobile browsers
|
||||
- [ ] Graceful fallback if no camera
|
||||
|
||||
---
|
||||
|
||||
## Other 4.1.5 Ideas (if time)
|
||||
|
||||
- [ ] Role-based permissions: admin / mod / user
|
||||
|
||||
@@ -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) => {
|
||||
if (!this.validateChannelKeyOnSubmit(form, 'channelSelectDec', 'channelKeyInputDec')) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.validateChannelKeyOnSubmit(form, 'channelSelectDec', 'channelKeyInputDec')) {
|
||||
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,7 +154,7 @@
|
||||
<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">
|
||||
@@ -161,46 +202,48 @@
|
||||
{% else %}
|
||||
<!-- Decode Form -->
|
||||
<form method="POST" enctype="multipart/form-data" id="decodeForm">
|
||||
|
||||
<div class="accordion step-accordion" id="decodeAccordion">
|
||||
|
||||
<!-- ================================================================
|
||||
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>
|
||||
</h2>
|
||||
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
|
||||
<div class="accordion-body">
|
||||
|
||||
<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>
|
||||
<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 to browse</span>
|
||||
<span class="text-muted">Drop image or click</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-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 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 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">
|
||||
The same reference photo used for encoding
|
||||
</div>
|
||||
<div class="form-text">Same reference photo used for encoding</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
@@ -208,124 +251,85 @@
|
||||
<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>
|
||||
<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 to browse</span>
|
||||
<span class="text-muted">Drop image or click</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 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 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 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 class="form-text">Image containing the hidden message</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 ms-2"></i>
|
||||
<span class="ms-2"><strong>Auto</strong> <span class="text-muted d-none d-sm-inline">· Try both</span></span>
|
||||
</label>
|
||||
<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>
|
||||
<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">
|
||||
The image containing the hidden message/file
|
||||
<i class="bi bi-lightbulb me-1"></i><strong>Auto</strong> tries LSB first, then DCT.
|
||||
</div>
|
||||
|
||||
</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>
|
||||
|
||||
<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>
|
||||
<!-- ================================================================
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- PIN + Channel Row -->
|
||||
<hr class="my-3 opacity-25">
|
||||
<div class="small text-muted mb-2">Provide same factors used during encoding</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<!-- 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">
|
||||
@@ -334,103 +338,75 @@
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">If PIN was used during encoding</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<!-- 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
|
||||
<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>
|
||||
|
||||
<select class="form-select" name="channel_key" id="channelSelectDec">
|
||||
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
|
||||
<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 }}" data-key-id="{{ key.id }}">{{ key.name }} ({{ key.channel_key[:4] }}...)</option>
|
||||
<option value="{{ key.channel_key }}">{{ key.name }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
|
||||
<!-- 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">
|
||||
<!-- 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 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">
|
||||
<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>
|
||||
|
||||
<!-- ================================================================
|
||||
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 -->
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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 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>
|
||||
</div>
|
||||
|
||||
@@ -438,51 +414,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</form>
|
||||
</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
@@ -120,28 +120,64 @@ read -p "Resize rootfs to 16GB for faster imaging? [Y/n] " resize_confirm
|
||||
if [[ ! "$resize_confirm" =~ ^[Nn]$ ]]; then
|
||||
echo "Resizing rootfs partition to 16GB..."
|
||||
|
||||
# Get boot partition end
|
||||
# Get current partition size in bytes
|
||||
CURRENT_SIZE=$(sudo blockdev --getsize64 "$ROOT_PART")
|
||||
TARGET_BYTES=$((16 * 1024 * 1024 * 1024)) # 16GB in bytes
|
||||
|
||||
# Get boot partition end in sectors
|
||||
BOOT_END=$(sudo parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
||||
|
||||
# Calculate 16GB in sectors (512 byte sectors)
|
||||
# 16GB = 16 * 1024 * 1024 * 1024 / 512 = 33554432 sectors
|
||||
ROOT_SIZE_SECTORS=33554432
|
||||
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
||||
|
||||
# Delete and recreate partition 2 with fixed size
|
||||
if [ "$CURRENT_SIZE" -lt "$TARGET_BYTES" ]; then
|
||||
# EXPANDING: partition first, then filesystem
|
||||
echo "Current partition is smaller than 16GB - expanding..."
|
||||
|
||||
# Delete and recreate partition 2 with 16GB size
|
||||
echo "Expanding partition to 16GB..."
|
||||
sudo parted -s "$DEVICE" rm 2
|
||||
sudo parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||
|
||||
# Refresh partition table
|
||||
sudo partprobe "$DEVICE"
|
||||
sleep 1
|
||||
sleep 2
|
||||
|
||||
# Check and resize filesystem
|
||||
# Expand filesystem to fill the new partition
|
||||
echo "Expanding filesystem to fill partition..."
|
||||
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
sudo resize2fs "$ROOT_PART"
|
||||
else
|
||||
# SHRINKING: filesystem first, then partition
|
||||
echo "Current partition is larger than 16GB - shrinking..."
|
||||
|
||||
# Check and shrink filesystem first
|
||||
echo "Checking filesystem..."
|
||||
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
|
||||
echo "Resizing filesystem to fit partition..."
|
||||
# Shrink filesystem to 15.5GB (leave room for partition overhead)
|
||||
echo "Shrinking filesystem to 15500M..."
|
||||
sudo resize2fs "$ROOT_PART" 15500M
|
||||
|
||||
# Delete and recreate partition 2 with 16GB size
|
||||
echo "Shrinking partition to 16GB..."
|
||||
sudo parted -s "$DEVICE" rm 2
|
||||
sudo parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||
|
||||
# Refresh partition table
|
||||
sudo partprobe "$DEVICE"
|
||||
sleep 2
|
||||
|
||||
# Expand filesystem to fill the partition exactly
|
||||
echo "Expanding filesystem to fill partition..."
|
||||
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
sudo resize2fs "$ROOT_PART"
|
||||
fi
|
||||
|
||||
# Verify and show result
|
||||
echo "Verifying partition size..."
|
||||
sudo parted -s "$DEVICE" unit GB print | grep "^ 2"
|
||||
|
||||
# Disable Pi OS auto-expand on first boot
|
||||
echo "Disabling auto-expand..."
|
||||
@@ -155,12 +191,10 @@ if [[ ! "$resize_confirm" =~ ^[Nn]$ ]]; then
|
||||
# Disable the systemd resize service
|
||||
sudo rm -f "$TEMP_ROOT/etc/systemd/system/multi-user.target.wants/rpi-resizerootfs.service"
|
||||
|
||||
# Remove init= parameter from cmdline.txt on boot partition (handled later)
|
||||
|
||||
sudo umount "$TEMP_ROOT"
|
||||
rmdir "$TEMP_ROOT"
|
||||
|
||||
echo " Rootfs resized to 16GB (auto-expand disabled)"
|
||||
echo " Rootfs set to 16GB (auto-expand disabled)"
|
||||
fi
|
||||
|
||||
MOUNT_DIR=$(mktemp -d)
|
||||
@@ -283,3 +317,12 @@ echo " SSH: enabled"
|
||||
echo " WiFi: $WIFI_SSID"
|
||||
echo
|
||||
echo "Insert into Pi and boot. Find it with: ping $PI_HOSTNAME.local"
|
||||
|
||||
# If we resized, remind about pull-image.sh
|
||||
if [[ ! "$resize_confirm" =~ ^[Nn]$ ]]; then
|
||||
echo
|
||||
echo "=== After setup, use pull-image.sh to create distributable image ==="
|
||||
echo " ./pull-image.sh $DEVICE stegasoo-rpi-VERSION.img.zst"
|
||||
echo
|
||||
echo "This will only pull the 16GB partition, not the entire SD card."
|
||||
fi
|
||||
|
||||
@@ -1,236 +1,76 @@
|
||||
#!/bin/bash
|
||||
# Pull Raspberry Pi image from SD card (after setup)
|
||||
# Only pulls the actual used partition space, not the entire SD card
|
||||
#
|
||||
# Pull Stegasoo image from SD card
|
||||
# Auto-detects SD card, copies with progress, shrinks, and compresses
|
||||
#
|
||||
# Usage: ./pull-image.sh [output-name] [device]
|
||||
# Output will be: stegasoo-rpi-YYYYMMDD.img.zst (or custom name)
|
||||
# Use .img extension to skip compression: ./pull-image.sh foo.img
|
||||
#
|
||||
# If device is specified, skips auto-detection (useful for large drives)
|
||||
#
|
||||
# Usage: ./pull-image.sh <device> <output.img.zst>
|
||||
# Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.1.5.img.zst
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Check for required tools
|
||||
for cmd in dd pv zstd lsblk; do
|
||||
if ! command -v $cmd &> /dev/null; then
|
||||
echo -e "${RED}Error: $cmd is required but not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check for root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
||||
if [ $# -ne 2 ]; then
|
||||
echo "Usage: $0 <device> <output.img.zst>"
|
||||
echo "Example: $0 /dev/sdb stegasoo-rpi-4.1.5.img.zst"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Output filename and optional device
|
||||
if [ -n "$1" ]; then
|
||||
OUTPUT="$1"
|
||||
else
|
||||
OUTPUT="stegasoo-rpi-$(date +%Y%m%d).img.zst"
|
||||
fi
|
||||
MANUAL_DEVICE="$2"
|
||||
DEVICE="$1"
|
||||
OUTPUT="$2"
|
||||
|
||||
# Check if output ends in .img (skip compression) or .zst (compress)
|
||||
SKIP_COMPRESS=false
|
||||
if [[ "$OUTPUT" == *.img ]]; then
|
||||
IMG_FILE="$OUTPUT"
|
||||
SKIP_COMPRESS=true
|
||||
elif [[ "$OUTPUT" == *.zst ]]; then
|
||||
IMG_FILE="${OUTPUT%.zst}"
|
||||
else
|
||||
# No recognized extension, add .img.zst
|
||||
IMG_FILE="${OUTPUT}.img"
|
||||
OUTPUT="${OUTPUT}.img.zst"
|
||||
if [ ! -b "$DEVICE" ]; then
|
||||
echo "Error: Device not found: $DEVICE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}"
|
||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Stegasoo SD Card Image Puller ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
# Use manual device or auto-detect
|
||||
if [ -n "$MANUAL_DEVICE" ]; then
|
||||
# Manual device specified
|
||||
if [ ! -b "$MANUAL_DEVICE" ]; then
|
||||
echo -e "${RED}Error: $MANUAL_DEVICE is not a block device${NC}"
|
||||
exit 1
|
||||
fi
|
||||
SELECTED="$MANUAL_DEVICE"
|
||||
echo -e "Using specified device: ${YELLOW}$SELECTED${NC}"
|
||||
echo ""
|
||||
lsblk "$SELECTED" -o NAME,SIZE,TYPE,MODEL
|
||||
echo ""
|
||||
else
|
||||
# Auto-detect SD card candidates
|
||||
# Looking for: USB/removable, 8-128GB, not mounted as root filesystem
|
||||
echo -e "${BOLD}Scanning for SD cards...${NC}"
|
||||
echo ""
|
||||
|
||||
declare -a CANDIDATES
|
||||
declare -a CANDIDATE_INFO
|
||||
|
||||
while IFS= read -r line; do
|
||||
DEV=$(echo "$line" | awk '{print $1}')
|
||||
SIZE=$(echo "$line" | awk '{print $2}')
|
||||
TYPE=$(echo "$line" | awk '{print $3}')
|
||||
TRAN=$(echo "$line" | awk '{print $4}')
|
||||
MODEL=$(echo "$line" | awk '{print $5" "$6" "$7}' | xargs)
|
||||
|
||||
# Skip if it's the root filesystem
|
||||
if mount | grep -q "^/dev/${DEV}[0-9]* on / "; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip if any partition is mounted as root
|
||||
ROOT_DEV=$(mount | grep " on / " | awk '{print $1}' | sed 's/[0-9]*$//')
|
||||
if [[ "/dev/$DEV" == "$ROOT_DEV" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get size in bytes for reliable comparison
|
||||
SIZE_BYTES=$(lsblk -b -d -o SIZE -n "/dev/$DEV" 2>/dev/null | tr -d ' ')
|
||||
SIZE_GB_INT=$((SIZE_BYTES / 1073741824)) # 1024^3
|
||||
|
||||
# Check if size is in SD card range (8GB - 128GB)
|
||||
if [ "$SIZE_GB_INT" -ge 8 ] && [ "$SIZE_GB_INT" -le 128 ]; then
|
||||
CANDIDATES+=("/dev/$DEV")
|
||||
CANDIDATE_INFO+=("$SIZE $TYPE ${TRAN:-???} $MODEL")
|
||||
fi
|
||||
done < <(lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL -n | grep "disk")
|
||||
|
||||
if [ ${#CANDIDATES[@]} -eq 0 ]; then
|
||||
echo -e "${RED}No SD card candidates found.${NC}"
|
||||
echo "Looking for USB/removable disks between 8GB and 128GB."
|
||||
echo ""
|
||||
echo "Available disks:"
|
||||
lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL
|
||||
echo ""
|
||||
echo -e "${YELLOW}Tip: Specify device manually: $0 output.img.zst /dev/sdX${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Found ${#CANDIDATES[@]} candidate(s):${NC}"
|
||||
echo ""
|
||||
|
||||
for i in "${!CANDIDATES[@]}"; do
|
||||
echo -e " ${BOLD}[$((i+1))]${NC} ${CANDIDATES[$i]} - ${CANDIDATE_INFO[$i]}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
if [ ${#CANDIDATES[@]} -eq 1 ]; then
|
||||
SELECTED="${CANDIDATES[0]}"
|
||||
echo -e "Auto-selected: ${YELLOW}$SELECTED${NC}"
|
||||
else
|
||||
read -p "Select device [1-${#CANDIDATES[@]}]: " -r
|
||||
if [[ ! $REPLY =~ ^[0-9]+$ ]] || [ "$REPLY" -lt 1 ] || [ "$REPLY" -gt ${#CANDIDATES[@]} ]; then
|
||||
echo -e "${RED}Invalid selection.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
SELECTED="${CANDIDATES[$((REPLY-1))]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Show partitions
|
||||
echo ""
|
||||
echo -e "${BOLD}Partitions on $SELECTED:${NC}"
|
||||
lsblk "$SELECTED" -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT
|
||||
echo ""
|
||||
|
||||
# Final confirmation
|
||||
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${RED}║ WARNING: This will read the ENTIRE device: ║${NC}"
|
||||
echo -e "${RED}║ $SELECTED ║${NC}"
|
||||
echo -e "${RED}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "Output: ${YELLOW}$OUTPUT${NC}"
|
||||
echo ""
|
||||
read -p "Continue? [y/N] " -n 1 -r
|
||||
echo "Device info:"
|
||||
lsblk "$DEVICE"
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
|
||||
# Get partition info
|
||||
echo "Partition table:"
|
||||
sudo parted -s "$DEVICE" unit s print
|
||||
echo
|
||||
|
||||
# Get the end of the last partition (partition 2 = rootfs)
|
||||
END_SECTOR=$(sudo parted -s "$DEVICE" unit s print | grep "^ 2" | awk '{print $3}' | tr -d 's')
|
||||
|
||||
if [ -z "$END_SECTOR" ]; then
|
||||
echo "Error: Could not determine partition 2 end sector"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add a small buffer (1MB = 2048 sectors) for safety
|
||||
TOTAL_SECTORS=$((END_SECTOR + 2048))
|
||||
TOTAL_BYTES=$((TOTAL_SECTORS * 512))
|
||||
TOTAL_GB=$(echo "scale=2; $TOTAL_BYTES / 1073741824" | bc)
|
||||
|
||||
echo "Image will be approximately ${TOTAL_GB}GB (${TOTAL_SECTORS} sectors)"
|
||||
echo "Output file: $OUTPUT"
|
||||
echo
|
||||
|
||||
read -p "Proceed with image pull? [Y/n] " confirm
|
||||
if [[ "$confirm" =~ ^[Nn]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get device size for pv
|
||||
DEV_SIZE=$(blockdev --getsize64 "$SELECTED")
|
||||
echo "Pulling image..."
|
||||
echo
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}[1/4]${NC} Copying image from $SELECTED..."
|
||||
dd if="$SELECTED" bs=4M status=none | pv -s "$DEV_SIZE" > "$IMG_FILE"
|
||||
sync
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}[2/4]${NC} Re-enabling auto-expand for distribution..."
|
||||
# Mount the image and restore auto-expand service (may have been disabled during build)
|
||||
LOOP_DEV=$(losetup -f --show -P "$IMG_FILE")
|
||||
if [ -n "$LOOP_DEV" ]; then
|
||||
TEMP_MOUNT=$(mktemp -d)
|
||||
if mount "${LOOP_DEV}p2" "$TEMP_MOUNT" 2>/dev/null; then
|
||||
# Re-enable the resize service if the service file exists
|
||||
SERVICE_FILE="$TEMP_MOUNT/lib/systemd/system/rpi-resizerootfs.service"
|
||||
SERVICE_LINK="$TEMP_MOUNT/etc/systemd/system/multi-user.target.wants/rpi-resizerootfs.service"
|
||||
if [ -f "$SERVICE_FILE" ] && [ ! -L "$SERVICE_LINK" ]; then
|
||||
mkdir -p "$(dirname "$SERVICE_LINK")"
|
||||
ln -sf /lib/systemd/system/rpi-resizerootfs.service "$SERVICE_LINK"
|
||||
echo -e " ${GREEN}✓${NC} Auto-expand service re-enabled"
|
||||
elif [ -L "$SERVICE_LINK" ]; then
|
||||
echo -e " ${GREEN}✓${NC} Auto-expand already enabled"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Could not find resize service file"
|
||||
fi
|
||||
umount "$TEMP_MOUNT"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Could not mount rootfs, skipping auto-expand fix"
|
||||
fi
|
||||
rmdir "$TEMP_MOUNT" 2>/dev/null || true
|
||||
losetup -d "$LOOP_DEV"
|
||||
# Use pv if available for progress, otherwise fallback to dd status
|
||||
if command -v pv &> /dev/null; then
|
||||
sudo dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS 2>/dev/null | \
|
||||
pv -s $TOTAL_BYTES | \
|
||||
zstd -T0 -3 > "$OUTPUT"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Could not create loop device, skipping auto-expand fix"
|
||||
sudo dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS status=progress | \
|
||||
zstd -T0 -3 > "$OUTPUT"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}[3/4]${NC} Shrinking image..."
|
||||
if command -v pishrink.sh &> /dev/null; then
|
||||
pishrink.sh "$IMG_FILE"
|
||||
elif [ -f "./pishrink.sh" ]; then
|
||||
bash ./pishrink.sh "$IMG_FILE"
|
||||
elif [ -f "../pishrink.sh" ]; then
|
||||
bash ../pishrink.sh "$IMG_FILE"
|
||||
else
|
||||
echo -e "${YELLOW}pishrink.sh not found, skipping shrink step.${NC}"
|
||||
echo "Download from: https://github.com/Drewsif/PiShrink"
|
||||
fi
|
||||
echo
|
||||
echo "Done! Image saved to: $OUTPUT"
|
||||
ls -lh "$OUTPUT"
|
||||
|
||||
echo ""
|
||||
if [ "$SKIP_COMPRESS" = true ]; then
|
||||
echo -e "${GREEN}[4/4]${NC} Skipping compression (.img output)"
|
||||
FINAL_SIZE=$(du -h "$IMG_FILE" | awk '{print $1}')
|
||||
OUTPUT="$IMG_FILE"
|
||||
else
|
||||
echo -e "${GREEN}[4/4]${NC} Compressing with zstd..."
|
||||
pv "$IMG_FILE" | zstd -19 -T0 -q > "$OUTPUT"
|
||||
rm -f "$IMG_FILE"
|
||||
FINAL_SIZE=$(du -h "$OUTPUT" | awk '{print $1}')
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Image Complete! ║${NC}"
|
||||
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "Output: ${YELLOW}$OUTPUT${NC}"
|
||||
echo -e "Size: ${YELLOW}$FINAL_SIZE${NC}"
|
||||
echo ""
|
||||
# Show verification info
|
||||
echo
|
||||
echo "To verify, you can check the image with:"
|
||||
echo " zstdcat $OUTPUT | fdisk -l /dev/stdin"
|
||||
|
||||
Reference in New Issue
Block a user