Add per-channel hybrid audio spread spectrum and env feature toggles
Spread spectrum v2: independent per-channel embedding with round-robin bit distribution, preserving spatial stereo/surround mix. Adaptive chip tiers (256/512/1024) trade capacity for lossy codec robustness. LFE channel skipped for 5.1+ layouts. v2 header (20B) with backward- compatible v0 decode fallback. Environment toggles (STEGASOO_AUDIO, STEGASOO_VIDEO) gate audio/video features for minimal builds (e.g. Raspberry Pi image-only). Values: auto (default, detect deps), 1/true (force on), 0/false (force off). Web UI fixes: accordion defaults to step 1 on load, chevron arrow styling, required attribute toggling for audio carrier type switch, "Images & Mode" renamed to "Reference, Carrier, Mode". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import stegasoo
|
||||
from stegasoo import (
|
||||
HAS_AUDIO_SUPPORT,
|
||||
CapacityError,
|
||||
DecryptionError,
|
||||
FilePayload,
|
||||
@@ -463,6 +464,9 @@ def inject_globals():
|
||||
"is_admin": is_admin(),
|
||||
# NEW in v4.2.0 - Saved channel keys
|
||||
"saved_channel_keys": saved_channel_keys,
|
||||
# NEW in v4.3.0 - Audio support
|
||||
"has_audio": HAS_AUDIO_SUPPORT,
|
||||
"supported_audio_formats": "WAV, FLAC, MP3, OGG, AAC, M4A" if HAS_AUDIO_SUPPORT else "",
|
||||
}
|
||||
|
||||
|
||||
@@ -564,6 +568,14 @@ def allowed_image(filename: str) -> bool:
|
||||
return ext in {"png", "jpg", "jpeg", "bmp", "gif"}
|
||||
|
||||
|
||||
def allowed_audio(filename: str) -> bool:
|
||||
"""Check if file has allowed audio extension."""
|
||||
if not filename or "." not in filename:
|
||||
return False
|
||||
ext = filename.rsplit(".", 1)[1].lower()
|
||||
return ext in {"wav", "flac", "mp3", "ogg", "aac", "m4a", "aiff", "aif"}
|
||||
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""Format file size for display."""
|
||||
if size_bytes < 1024:
|
||||
@@ -710,11 +722,15 @@ def generate():
|
||||
if not qr_too_large:
|
||||
qr_token = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
temp_storage.save_temp_file(qr_token, creds.rsa_key_pem.encode(), {
|
||||
"filename": "rsa_key.pem",
|
||||
"type": "rsa_key",
|
||||
"compress": qr_needs_compression,
|
||||
})
|
||||
temp_storage.save_temp_file(
|
||||
qr_token,
|
||||
creds.rsa_key_pem.encode(),
|
||||
{
|
||||
"filename": "rsa_key.pem",
|
||||
"type": "rsa_key",
|
||||
"compress": qr_needs_compression,
|
||||
},
|
||||
)
|
||||
|
||||
# v3.2.0: Single passphrase instead of daily phrases
|
||||
return render_template(
|
||||
@@ -1001,6 +1017,37 @@ def api_check_fit():
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/audio-capacity", methods=["POST"])
|
||||
@login_required
|
||||
def api_audio_capacity():
|
||||
"""Get audio file capacity for steganography (v4.3.0)."""
|
||||
audio_file = request.files.get("carrier")
|
||||
if not audio_file:
|
||||
return jsonify({"error": "No audio file provided"}), 400
|
||||
|
||||
try:
|
||||
audio_data = audio_file.read()
|
||||
result = subprocess_stego.audio_info(audio_data)
|
||||
|
||||
if not result.success:
|
||||
return jsonify({"error": result.error or "Audio analysis failed"}), 500
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"sample_rate": result.sample_rate,
|
||||
"channels": result.channels,
|
||||
"duration": round(result.duration_seconds, 2),
|
||||
"format": result.format,
|
||||
"bit_depth": result.bit_depth,
|
||||
"lsb_capacity": result.capacity_lsb,
|
||||
"spread_capacity": result.capacity_spread,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENCODE
|
||||
# ============================================================================
|
||||
@@ -1078,15 +1125,105 @@ def _run_encode_job(job_id: str, encode_params: dict) -> None:
|
||||
|
||||
# Store result
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
temp_storage.save_temp_file(file_id, encode_result.stego_data, {
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||
"color_mode": dct_color_mode if embed_mode == "dct" else None,
|
||||
"mime_type": output_mime,
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
})
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
encode_result.stego_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||
"color_mode": dct_color_mode if embed_mode == "dct" else None,
|
||||
"mime_type": output_mime,
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
},
|
||||
)
|
||||
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "complete",
|
||||
"file_id": file_id,
|
||||
"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)
|
||||
|
||||
|
||||
def _run_encode_audio_job(job_id: str, encode_params: dict) -> None:
|
||||
"""Background thread function for async audio encode (v4.3.0)."""
|
||||
progress_file = get_progress_file_path(job_id)
|
||||
|
||||
try:
|
||||
_store_job(job_id, {"status": "running", "created": time.time()})
|
||||
|
||||
if encode_params.get("file_data"):
|
||||
encode_result = subprocess_stego.encode_audio(
|
||||
carrier_data=encode_params["carrier_data"],
|
||||
reference_data=encode_params["ref_data"],
|
||||
file_data=encode_params["file_data"],
|
||||
file_name=encode_params["file_name"],
|
||||
file_mime=encode_params["file_mime"],
|
||||
passphrase=encode_params["passphrase"],
|
||||
pin=encode_params.get("pin"),
|
||||
rsa_key_data=encode_params.get("rsa_key_data"),
|
||||
rsa_password=encode_params.get("key_password"),
|
||||
embed_mode=encode_params["embed_mode"],
|
||||
channel_key=encode_params.get("channel_key"),
|
||||
progress_file=progress_file,
|
||||
chip_tier=encode_params.get("chip_tier"),
|
||||
)
|
||||
else:
|
||||
encode_result = subprocess_stego.encode_audio(
|
||||
carrier_data=encode_params["carrier_data"],
|
||||
reference_data=encode_params["ref_data"],
|
||||
message=encode_params.get("message"),
|
||||
passphrase=encode_params["passphrase"],
|
||||
pin=encode_params.get("pin"),
|
||||
rsa_key_data=encode_params.get("rsa_key_data"),
|
||||
rsa_password=encode_params.get("key_password"),
|
||||
embed_mode=encode_params["embed_mode"],
|
||||
channel_key=encode_params.get("channel_key"),
|
||||
progress_file=progress_file,
|
||||
chip_tier=encode_params.get("chip_tier"),
|
||||
)
|
||||
|
||||
if not encode_result.success:
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "error",
|
||||
"error": encode_result.error or "Audio encoding failed",
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
filename = generate_filename("stego_audio", ".wav")
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
encode_result.stego_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"embed_mode": encode_params["embed_mode"],
|
||||
"carrier_type": "audio",
|
||||
"mime_type": "audio/wav",
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
},
|
||||
)
|
||||
|
||||
_store_job(
|
||||
job_id,
|
||||
@@ -1131,6 +1268,196 @@ def encode_page():
|
||||
rsa_key_file = request.files.get("rsa_key")
|
||||
payload_file = request.files.get("payload_file")
|
||||
|
||||
# Determine carrier type (v4.3.0)
|
||||
carrier_type = request.form.get("carrier_type", "image")
|
||||
|
||||
if carrier_type == "audio":
|
||||
# ========== AUDIO ENCODE PATH (v4.3.0) ==========
|
||||
if not HAS_AUDIO_SUPPORT:
|
||||
return _error_response(
|
||||
"Audio steganography is not available. Install audio dependencies."
|
||||
)
|
||||
|
||||
if not ref_photo or not carrier:
|
||||
return _error_response("Both reference photo and audio carrier are required")
|
||||
|
||||
if not allowed_image(ref_photo.filename):
|
||||
return _error_response("Reference must be an image (PNG, JPG, BMP)")
|
||||
|
||||
if not allowed_audio(carrier.filename):
|
||||
return _error_response(
|
||||
"Invalid audio format. Use WAV, FLAC, MP3, OGG, AAC, or M4A"
|
||||
)
|
||||
|
||||
# Get form data
|
||||
message = request.form.get("message", "")
|
||||
passphrase = request.form.get("passphrase", "")
|
||||
pin = request.form.get("pin", "").strip()
|
||||
rsa_password = request.form.get("rsa_password", "")
|
||||
payload_type = request.form.get("payload_type", "text")
|
||||
|
||||
embed_mode = request.form.get("embed_mode", "audio_lsb")
|
||||
if embed_mode not in ("audio_lsb", "audio_spread"):
|
||||
embed_mode = "audio_lsb"
|
||||
|
||||
# Chip tier for spread spectrum (None = default)
|
||||
chip_tier_str = request.form.get("chip_tier")
|
||||
chip_tier = None
|
||||
if chip_tier_str and chip_tier_str.isdigit():
|
||||
chip_tier = int(chip_tier_str)
|
||||
if chip_tier not in (0, 1, 2):
|
||||
chip_tier = None
|
||||
|
||||
channel_key = resolve_channel_key_form(request.form.get("channel_key", "auto"))
|
||||
|
||||
# Determine payload
|
||||
if payload_type == "file" and payload_file and payload_file.filename:
|
||||
file_data = payload_file.read()
|
||||
result = validate_file_payload(file_data, payload_file.filename)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
mime_type, _ = mimetypes.guess_type(payload_file.filename)
|
||||
payload = FilePayload(
|
||||
data=file_data,
|
||||
filename=payload_file.filename,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
else:
|
||||
result = validate_message(message)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
payload = message
|
||||
|
||||
if not passphrase:
|
||||
return _error_response("Passphrase is required")
|
||||
|
||||
result = validate_passphrase(passphrase)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
if result.warning:
|
||||
flash(result.warning, "warning")
|
||||
|
||||
ref_data = ref_photo.read()
|
||||
carrier_data = carrier.read()
|
||||
|
||||
# Handle RSA key (same as image path)
|
||||
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_pem:
|
||||
if is_compressed(rsa_key_pem):
|
||||
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()
|
||||
key_pem = extract_key_from_qr(qr_image_data)
|
||||
if key_pem:
|
||||
rsa_key_data = key_pem.encode("utf-8")
|
||||
rsa_key_from_qr = True
|
||||
else:
|
||||
return _error_response("Could not extract RSA key from QR code image.")
|
||||
|
||||
result = validate_security_factors(pin, rsa_key_data)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
|
||||
if pin:
|
||||
result = validate_pin(pin)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
|
||||
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
|
||||
|
||||
if rsa_key_data:
|
||||
result = validate_rsa_key(rsa_key_data, key_password)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
|
||||
# Build audio encode params
|
||||
encode_params = {
|
||||
"carrier_data": carrier_data,
|
||||
"ref_data": ref_data,
|
||||
"passphrase": passphrase,
|
||||
"pin": pin if pin else None,
|
||||
"rsa_key_data": rsa_key_data,
|
||||
"key_password": key_password,
|
||||
"embed_mode": embed_mode,
|
||||
"channel_key": channel_key,
|
||||
"carrier_type": "audio",
|
||||
"chip_tier": chip_tier,
|
||||
}
|
||||
|
||||
if payload_type == "file" and payload_file and payload_file.filename:
|
||||
encode_params["file_data"] = payload.data
|
||||
encode_params["file_name"] = payload.filename
|
||||
encode_params["file_mime"] = payload.mime_type
|
||||
else:
|
||||
encode_params["message"] = payload
|
||||
|
||||
if is_async:
|
||||
job_id = generate_job_id()
|
||||
_store_job(job_id, {"status": "pending", "created": time.time()})
|
||||
_executor.submit(_run_encode_audio_job, job_id, encode_params)
|
||||
return jsonify({"job_id": job_id, "status": "pending"})
|
||||
|
||||
# Sync audio encode
|
||||
if encode_params.get("file_data"):
|
||||
encode_result = subprocess_stego.encode_audio(
|
||||
carrier_data=carrier_data,
|
||||
reference_data=ref_data,
|
||||
file_data=encode_params["file_data"],
|
||||
file_name=encode_params["file_name"],
|
||||
file_mime=encode_params["file_mime"],
|
||||
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,
|
||||
chip_tier=chip_tier,
|
||||
)
|
||||
else:
|
||||
encode_result = subprocess_stego.encode_audio(
|
||||
carrier_data=carrier_data,
|
||||
reference_data=ref_data,
|
||||
message=payload,
|
||||
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,
|
||||
chip_tier=chip_tier,
|
||||
)
|
||||
|
||||
if not encode_result.success:
|
||||
error_msg = encode_result.error or "Audio encoding failed"
|
||||
return _error_response(error_msg)
|
||||
|
||||
filename = generate_filename("stego_audio", ".wav")
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
encode_result.stego_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"carrier_type": "audio",
|
||||
"mime_type": "audio/wav",
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
},
|
||||
)
|
||||
|
||||
return redirect(url_for("encode_result", file_id=file_id))
|
||||
|
||||
# ========== IMAGE ENCODE PATH (original) ==========
|
||||
if not ref_photo or not carrier:
|
||||
return _error_response("Both reference photo and carrier image are required")
|
||||
|
||||
@@ -1356,16 +1683,20 @@ def encode_page():
|
||||
# Store temporarily
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
temp_storage.save_temp_file(file_id, encode_result.stego_data, {
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||
"color_mode": dct_color_mode if embed_mode == "dct" else None,
|
||||
"mime_type": output_mime,
|
||||
# Channel info (v4.0.0)
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
})
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
encode_result.stego_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||
"color_mode": dct_color_mode if embed_mode == "dct" else None,
|
||||
"mime_type": output_mime,
|
||||
# Channel info (v4.0.0)
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
},
|
||||
)
|
||||
|
||||
return redirect(url_for("encode_result", file_id=file_id))
|
||||
|
||||
@@ -1434,13 +1765,16 @@ def encode_result(file_id):
|
||||
flash("File expired or not found. Please encode again.", "error")
|
||||
return redirect(url_for("encode_page"))
|
||||
|
||||
# Generate thumbnail
|
||||
thumbnail_data = generate_thumbnail(file_info["data"])
|
||||
thumbnail_id = None
|
||||
carrier_type = file_info.get("carrier_type", "image")
|
||||
|
||||
if thumbnail_data:
|
||||
thumbnail_id = f"{file_id}_thumb"
|
||||
temp_storage.save_thumbnail(thumbnail_id, thumbnail_data)
|
||||
# Generate thumbnail only for images
|
||||
thumbnail_data = None
|
||||
thumbnail_id = None
|
||||
if carrier_type != "audio":
|
||||
thumbnail_data = generate_thumbnail(file_info["data"])
|
||||
if thumbnail_data:
|
||||
thumbnail_id = f"{file_id}_thumb"
|
||||
temp_storage.save_thumbnail(thumbnail_id, thumbnail_data)
|
||||
|
||||
return render_template(
|
||||
"encode_result.html",
|
||||
@@ -1450,6 +1784,7 @@ def encode_result(file_id):
|
||||
embed_mode=file_info.get("embed_mode", "lsb"),
|
||||
output_format=file_info.get("output_format", "png"),
|
||||
color_mode=file_info.get("color_mode"),
|
||||
carrier_type=carrier_type,
|
||||
# Channel info (v4.0.0)
|
||||
channel_mode=file_info.get("channel_mode", "public"),
|
||||
channel_fingerprint=file_info.get("channel_fingerprint"),
|
||||
@@ -1464,9 +1799,7 @@ def encode_thumbnail(thumb_id):
|
||||
if not thumb_data:
|
||||
return "Thumbnail not found", 404
|
||||
|
||||
return send_file(
|
||||
io.BytesIO(thumb_data), mimetype="image/jpeg", as_attachment=False
|
||||
)
|
||||
return send_file(io.BytesIO(thumb_data), mimetype="image/jpeg", as_attachment=False)
|
||||
|
||||
|
||||
@app.route("/encode/download/<file_id>")
|
||||
@@ -1559,10 +1892,92 @@ def _run_decode_job(job_id: str, decode_params: dict) -> None:
|
||||
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,
|
||||
})
|
||||
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)
|
||||
|
||||
|
||||
def _run_decode_audio_job(job_id: str, decode_params: dict) -> None:
|
||||
"""Background thread function for async audio decode (v4.3.0)."""
|
||||
progress_file = get_progress_file_path(job_id)
|
||||
|
||||
try:
|
||||
_store_job(job_id, {"status": "running", "created": time.time()})
|
||||
|
||||
decode_result = subprocess_stego.decode_audio(
|
||||
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", "audio_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 "Audio decoding failed",
|
||||
"error_type": decode_result.error_type,
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
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,
|
||||
{
|
||||
@@ -1609,6 +2024,163 @@ def decode_page():
|
||||
stego_image = request.files.get("stego_image")
|
||||
rsa_key_file = request.files.get("rsa_key")
|
||||
|
||||
# Determine carrier type (v4.3.0)
|
||||
carrier_type = request.form.get("carrier_type", "image")
|
||||
|
||||
if carrier_type == "audio":
|
||||
# ========== AUDIO DECODE PATH (v4.3.0) ==========
|
||||
if not HAS_AUDIO_SUPPORT:
|
||||
flash("Audio steganography is not available.", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
if not ref_photo or not stego_image:
|
||||
flash("Both reference photo and stego audio are required", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
if not allowed_image(ref_photo.filename):
|
||||
flash("Reference must be an image", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
if not allowed_audio(stego_image.filename):
|
||||
flash("Invalid audio format", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
passphrase = request.form.get("passphrase", "")
|
||||
pin = request.form.get("pin", "").strip()
|
||||
rsa_password = request.form.get("rsa_password", "")
|
||||
|
||||
embed_mode = request.form.get("embed_mode", "audio_auto")
|
||||
if embed_mode not in ("audio_auto", "audio_lsb", "audio_spread"):
|
||||
embed_mode = "audio_auto"
|
||||
|
||||
channel_key = resolve_channel_key_form(request.form.get("channel_key", "auto"))
|
||||
|
||||
if not passphrase:
|
||||
flash("Passphrase is required", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
ref_data = ref_photo.read()
|
||||
stego_data = stego_image.read()
|
||||
|
||||
# Handle RSA key (same as image path)
|
||||
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_pem:
|
||||
if is_compressed(rsa_key_pem):
|
||||
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()
|
||||
key_pem = extract_key_from_qr(qr_image_data)
|
||||
if key_pem:
|
||||
rsa_key_data = key_pem.encode("utf-8")
|
||||
rsa_key_from_qr = True
|
||||
else:
|
||||
flash("Could not extract RSA key from QR code image.", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
result = validate_security_factors(pin, rsa_key_data)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
if pin:
|
||||
result = validate_pin(pin)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
|
||||
|
||||
if rsa_key_data:
|
||||
result = validate_rsa_key(rsa_key_data, key_password)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
is_async = (
|
||||
request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
if is_async:
|
||||
job_id = generate_job_id()
|
||||
_store_job(job_id, {"status": "pending", "created": time.time()})
|
||||
_executor.submit(_run_decode_audio_job, job_id, decode_params)
|
||||
return jsonify({"job_id": job_id, "status": "pending"})
|
||||
|
||||
# Sync audio decode
|
||||
decode_result = subprocess_stego.decode_audio(
|
||||
stego_data=stego_data,
|
||||
reference_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,
|
||||
)
|
||||
|
||||
if not decode_result.success:
|
||||
error_msg = decode_result.error or "Audio decoding failed"
|
||||
if (
|
||||
"decrypt" in error_msg.lower()
|
||||
or decode_result.error_type == "DecryptionError"
|
||||
):
|
||||
flash(
|
||||
"Wrong credentials. Double-check your reference photo, "
|
||||
"passphrase, PIN, and channel key.",
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
flash(error_msg, "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
if decode_result.is_file:
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
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,
|
||||
},
|
||||
)
|
||||
return render_template(
|
||||
"decode.html",
|
||||
decoded_file=True,
|
||||
file_id=file_id,
|
||||
filename=filename,
|
||||
file_size=format_size(len(decode_result.file_data)),
|
||||
mime_type=decode_result.mime_type,
|
||||
has_qrcode_read=HAS_QRCODE_READ,
|
||||
)
|
||||
else:
|
||||
return render_template(
|
||||
"decode.html",
|
||||
decoded_message=decode_result.message,
|
||||
has_qrcode_read=HAS_QRCODE_READ,
|
||||
)
|
||||
|
||||
# ========== IMAGE DECODE PATH (original) ==========
|
||||
if not ref_photo or not stego_image:
|
||||
flash("Both reference photo and stego image are required", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
@@ -1690,7 +2262,9 @@ def decode_page():
|
||||
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"
|
||||
is_async = (
|
||||
request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||
)
|
||||
|
||||
# Build decode params
|
||||
decode_params = {
|
||||
@@ -1742,10 +2316,14 @@ def decode_page():
|
||||
cleanup_temp_files()
|
||||
|
||||
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,
|
||||
})
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
decode_result.file_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"mime_type": decode_result.mime_type,
|
||||
},
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"decode.html",
|
||||
@@ -2101,11 +2679,12 @@ def api_tools_exif_clear():
|
||||
@login_required
|
||||
def api_tools_rotate():
|
||||
"""Rotate and/or flip an image, using lossless jpegtran for JPEGs."""
|
||||
from PIL import Image
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from PIL import Image
|
||||
|
||||
image_file = request.files.get("image")
|
||||
if not image_file:
|
||||
return jsonify({"success": False, "error": "No image provided"}), 400
|
||||
@@ -2136,9 +2715,18 @@ def api_tools_rotate():
|
||||
output_path = tempfile.mktemp(suffix=".jpg")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jpegtran", "-rotate", str(rotation), "-copy", "all",
|
||||
"-outfile", output_path, input_path],
|
||||
capture_output=True, timeout=30
|
||||
[
|
||||
"jpegtran",
|
||||
"-rotate",
|
||||
str(rotation),
|
||||
"-copy",
|
||||
"all",
|
||||
"-outfile",
|
||||
output_path,
|
||||
input_path,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
with open(output_path, "rb") as f:
|
||||
@@ -2158,9 +2746,18 @@ def api_tools_rotate():
|
||||
output_path = tempfile.mktemp(suffix=".jpg")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jpegtran", "-flip", "horizontal", "-copy", "all",
|
||||
"-outfile", output_path, input_path],
|
||||
capture_output=True, timeout=30
|
||||
[
|
||||
"jpegtran",
|
||||
"-flip",
|
||||
"horizontal",
|
||||
"-copy",
|
||||
"all",
|
||||
"-outfile",
|
||||
output_path,
|
||||
input_path,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
with open(output_path, "rb") as f:
|
||||
@@ -2180,9 +2777,18 @@ def api_tools_rotate():
|
||||
output_path = tempfile.mktemp(suffix=".jpg")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jpegtran", "-flip", "vertical", "-copy", "all",
|
||||
"-outfile", output_path, input_path],
|
||||
capture_output=True, timeout=30
|
||||
[
|
||||
"jpegtran",
|
||||
"-flip",
|
||||
"vertical",
|
||||
"-copy",
|
||||
"all",
|
||||
"-outfile",
|
||||
output_path,
|
||||
input_path,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
with open(output_path, "rb") as f:
|
||||
@@ -2839,10 +3445,7 @@ def admin_settings_unlock():
|
||||
channel_status = get_channel_status()
|
||||
channel_key = channel_status.get("key") if channel_status["configured"] else ""
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"channel_key": channel_key
|
||||
})
|
||||
return jsonify({"success": True, "channel_key": channel_key})
|
||||
|
||||
|
||||
@app.route("/admin/users")
|
||||
@@ -2976,6 +3579,7 @@ if __name__ == "__main__":
|
||||
ssl_context = None
|
||||
if app.config.get("HTTPS_ENABLED", False):
|
||||
import socket
|
||||
|
||||
hostname = os.environ.get("STEGASOO_HOSTNAME") or socket.gethostname()
|
||||
try:
|
||||
cert_path, key_path = ensure_certs(base_dir, hostname)
|
||||
|
||||
@@ -77,14 +77,10 @@ def init_db():
|
||||
db = get_db()
|
||||
|
||||
# Check if we need to migrate from old single-user schema
|
||||
cursor = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'"
|
||||
)
|
||||
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='admin_user'")
|
||||
has_old_table = cursor.fetchone() is not None
|
||||
|
||||
cursor = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||
)
|
||||
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
|
||||
has_new_table = cursor.fetchone() is not None
|
||||
|
||||
if has_old_table and not has_new_table:
|
||||
@@ -189,9 +185,7 @@ def _ensure_channel_keys_table(db: sqlite3.Connection):
|
||||
|
||||
def _ensure_app_settings_table(db: sqlite3.Connection):
|
||||
"""Ensure app_settings table exists (v4.1.0 migration)."""
|
||||
cursor = db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
|
||||
)
|
||||
cursor = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'")
|
||||
if cursor.fetchone() is None:
|
||||
db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
@@ -212,9 +206,7 @@ def _ensure_app_settings_table(db: sqlite3.Connection):
|
||||
def get_app_setting(key: str) -> str | None:
|
||||
"""Get an app-level setting value."""
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"SELECT value FROM app_settings WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
row = db.execute("SELECT value FROM app_settings WHERE key = ?", (key,)).fetchone()
|
||||
return row["value"] if row else None
|
||||
|
||||
|
||||
@@ -384,12 +376,10 @@ def get_user_by_username(username: str) -> User | None:
|
||||
def get_all_users() -> list[User]:
|
||||
"""Get all users, admins first, then by creation date."""
|
||||
db = get_db()
|
||||
rows = db.execute(
|
||||
"""
|
||||
rows = db.execute("""
|
||||
SELECT id, username, role, created_at FROM users
|
||||
ORDER BY role = 'admin' DESC, created_at ASC
|
||||
"""
|
||||
).fetchall()
|
||||
""").fetchall()
|
||||
return [
|
||||
User(
|
||||
id=row["id"],
|
||||
@@ -596,9 +586,7 @@ def create_admin_user(username: str, password: str) -> tuple[bool, str]:
|
||||
return success, msg
|
||||
|
||||
|
||||
def change_password(
|
||||
user_id: int, current_password: str, new_password: str
|
||||
) -> tuple[bool, str]:
|
||||
def change_password(user_id: int, current_password: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change a user's password (requires current password)."""
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
@@ -667,9 +655,7 @@ def delete_user(user_id: int, current_user_id: int) -> tuple[bool, str]:
|
||||
# Check if this is the last admin
|
||||
if user.role == ROLE_ADMIN:
|
||||
db = get_db()
|
||||
admin_count = db.execute(
|
||||
"SELECT COUNT(*) FROM users WHERE role = 'admin'"
|
||||
).fetchone()[0]
|
||||
admin_count = db.execute("SELECT COUNT(*) FROM users WHERE role = 'admin'").fetchone()[0]
|
||||
if admin_count <= 1:
|
||||
return False, "Cannot delete the last admin"
|
||||
|
||||
@@ -848,9 +834,7 @@ def save_channel_key(
|
||||
return False, "This channel key is already saved", None
|
||||
|
||||
|
||||
def update_channel_key_name(
|
||||
key_id: int, user_id: int, new_name: str
|
||||
) -> tuple[bool, str]:
|
||||
def update_channel_key_name(key_id: int, user_id: int, new_name: str) -> tuple[bool, str]:
|
||||
"""Update the name of a saved channel key."""
|
||||
new_name = new_name.strip()
|
||||
if not new_name:
|
||||
|
||||
@@ -81,10 +81,12 @@ def generate_self_signed_cert(
|
||||
)
|
||||
|
||||
# Create certificate
|
||||
subject = issuer = x509.Name([
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
||||
])
|
||||
subject = issuer = x509.Name(
|
||||
[
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
||||
]
|
||||
)
|
||||
|
||||
# Subject Alternative Names
|
||||
san_list = [
|
||||
@@ -112,7 +114,7 @@ def generate_self_signed_cert(
|
||||
except (ipaddress.AddressValueError, ValueError):
|
||||
pass
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
|
||||
@@ -95,7 +95,16 @@ const Stegasoo = {
|
||||
if (!isPayloadZone && !isQrZone) {
|
||||
input.addEventListener('change', function() {
|
||||
if (this.files && this.files[0]) {
|
||||
Stegasoo.showImagePreview(this.files[0], preview, label, zone);
|
||||
const file = this.files[0];
|
||||
if (file.type.startsWith('image/') && preview) {
|
||||
Stegasoo.showImagePreview(file, preview, label, zone);
|
||||
} else if (file.type.startsWith('audio/') || !file.type.startsWith('image/')) {
|
||||
// Audio or non-image files: show file info instead of image preview
|
||||
Stegasoo.showAudioFileInfo(file, zone);
|
||||
if (label) {
|
||||
label.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -153,7 +162,21 @@ const Stegasoo = {
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Format audio file info for display in drop zones (v4.3.0)
|
||||
*/
|
||||
showAudioFileInfo(file, zone) {
|
||||
const filenameEl = zone.querySelector('.pixel-data-filename span, .scan-data-filename span');
|
||||
const sizeEl = zone.querySelector('.pixel-data-value, .scan-data-value');
|
||||
if (filenameEl) filenameEl.textContent = file.name;
|
||||
if (sizeEl) {
|
||||
const kb = file.size / 1024;
|
||||
sizeEl.textContent = kb >= 1024 ? (kb / 1024).toFixed(1) + ' MB' : kb.toFixed(1) + ' KB';
|
||||
}
|
||||
zone.classList.add('has-file');
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// REFERENCE PHOTO SCAN ANIMATION
|
||||
// ========================================================================
|
||||
@@ -1036,6 +1059,10 @@ const Stegasoo = {
|
||||
'saving': 'Saving image...',
|
||||
'finalizing': 'Finalizing...',
|
||||
'complete': 'Complete!',
|
||||
// Audio encode phases (v4.3.0)
|
||||
'audio_transcoding': 'Transcoding audio...',
|
||||
'audio_embedding': 'Embedding in audio...',
|
||||
'spread_embedding': 'Spread spectrum embedding...',
|
||||
};
|
||||
return phases[phase] || phase;
|
||||
},
|
||||
@@ -1252,6 +1279,10 @@ const Stegasoo = {
|
||||
'verifying': 'Verifying...',
|
||||
'finalizing': 'Finalizing...',
|
||||
'complete': 'Complete!',
|
||||
// Audio decode phases (v4.3.0)
|
||||
'audio_transcoding': 'Transcoding audio...',
|
||||
'audio_extracting': 'Extracting from audio...',
|
||||
'spread_extracting': 'Spread spectrum extracting...',
|
||||
};
|
||||
return phases[phase] || phase;
|
||||
},
|
||||
|
||||
@@ -19,6 +19,8 @@ Usage:
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
@@ -27,6 +29,24 @@ from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
# Configure logging for worker subprocess
|
||||
_log_level = os.environ.get("STEGASOO_LOG_LEVEL", "").strip().upper()
|
||||
if _log_level and hasattr(logging, _log_level):
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, _log_level),
|
||||
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
elif os.environ.get("STEGASOO_DEBUG", "").strip() in ("1", "true", "yes"):
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="[%(asctime)s.%(msecs)03d] [%(levelname)s] [%(name)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
logger = logging.getLogger("stegasoo.worker")
|
||||
|
||||
|
||||
def _resolve_channel_key(channel_key_param):
|
||||
"""
|
||||
@@ -73,6 +93,7 @@ def _get_channel_info(resolved_key):
|
||||
|
||||
def encode_operation(params: dict) -> dict:
|
||||
"""Handle encode operation."""
|
||||
logger.debug("encode_operation: mode=%s", params.get("embed_mode", "lsb"))
|
||||
from stegasoo import FilePayload, encode
|
||||
|
||||
# Decode base64 inputs
|
||||
@@ -142,6 +163,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
|
||||
return
|
||||
try:
|
||||
import json
|
||||
|
||||
with open(progress_file, "w") as f:
|
||||
json.dump({"percent": percent, "phase": phase}, f)
|
||||
except Exception:
|
||||
@@ -150,6 +172,7 @@ def _write_decode_progress(progress_file: str | None, percent: int, phase: str)
|
||||
|
||||
def decode_operation(params: dict) -> dict:
|
||||
"""Handle decode operation."""
|
||||
logger.debug("decode_operation: mode=%s", params.get("embed_mode", "auto"))
|
||||
from stegasoo import decode
|
||||
|
||||
progress_file = params.get("progress_file")
|
||||
@@ -233,6 +256,145 @@ def capacity_check_operation(params: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def encode_audio_operation(params: dict) -> dict:
|
||||
"""Handle audio encode operation (v4.3.0)."""
|
||||
logger.debug("encode_audio_operation: mode=%s", params.get("embed_mode", "audio_lsb"))
|
||||
from stegasoo import FilePayload, encode_audio
|
||||
|
||||
carrier_data = base64.b64decode(params["carrier_b64"])
|
||||
reference_data = base64.b64decode(params["reference_b64"])
|
||||
|
||||
# Optional RSA key
|
||||
rsa_key_data = None
|
||||
if params.get("rsa_key_b64"):
|
||||
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
|
||||
|
||||
# Determine payload type
|
||||
if params.get("file_b64"):
|
||||
file_data = base64.b64decode(params["file_b64"])
|
||||
payload = FilePayload(
|
||||
data=file_data,
|
||||
filename=params.get("file_name", "file"),
|
||||
mime_type=params.get("file_mime", "application/octet-stream"),
|
||||
)
|
||||
else:
|
||||
payload = params.get("message", "")
|
||||
|
||||
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
|
||||
|
||||
# Resolve chip_tier from params (None means use default)
|
||||
chip_tier_val = params.get("chip_tier")
|
||||
if chip_tier_val is not None:
|
||||
chip_tier_val = int(chip_tier_val)
|
||||
|
||||
stego_audio, stats = encode_audio(
|
||||
message=payload,
|
||||
reference_photo=reference_data,
|
||||
carrier_audio=carrier_data,
|
||||
passphrase=params.get("passphrase", ""),
|
||||
pin=params.get("pin"),
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=params.get("rsa_password"),
|
||||
embed_mode=params.get("embed_mode", "audio_lsb"),
|
||||
channel_key=resolved_channel_key,
|
||||
progress_file=params.get("progress_file"),
|
||||
chip_tier=chip_tier_val,
|
||||
)
|
||||
|
||||
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"stego_b64": base64.b64encode(stego_audio).decode("ascii"),
|
||||
"stats": {
|
||||
"samples_modified": stats.samples_modified,
|
||||
"total_samples": stats.total_samples,
|
||||
"capacity_used": stats.capacity_used,
|
||||
"bytes_embedded": stats.bytes_embedded,
|
||||
"sample_rate": stats.sample_rate,
|
||||
"channels": stats.channels,
|
||||
"duration_seconds": stats.duration_seconds,
|
||||
"embed_mode": stats.embed_mode,
|
||||
},
|
||||
"channel_mode": channel_mode,
|
||||
"channel_fingerprint": channel_fingerprint,
|
||||
}
|
||||
|
||||
|
||||
def decode_audio_operation(params: dict) -> dict:
|
||||
"""Handle audio decode operation (v4.3.0)."""
|
||||
logger.debug("decode_audio_operation: mode=%s", params.get("embed_mode", "audio_auto"))
|
||||
from stegasoo import decode_audio
|
||||
|
||||
progress_file = params.get("progress_file")
|
||||
_write_decode_progress(progress_file, 5, "reading")
|
||||
|
||||
stego_data = base64.b64decode(params["stego_b64"])
|
||||
reference_data = base64.b64decode(params["reference_b64"])
|
||||
|
||||
_write_decode_progress(progress_file, 15, "reading")
|
||||
|
||||
rsa_key_data = None
|
||||
if params.get("rsa_key_b64"):
|
||||
rsa_key_data = base64.b64decode(params["rsa_key_b64"])
|
||||
|
||||
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
|
||||
|
||||
result = decode_audio(
|
||||
stego_audio=stego_data,
|
||||
reference_photo=reference_data,
|
||||
passphrase=params.get("passphrase", ""),
|
||||
pin=params.get("pin"),
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=params.get("rsa_password"),
|
||||
embed_mode=params.get("embed_mode", "audio_auto"),
|
||||
channel_key=resolved_channel_key,
|
||||
progress_file=progress_file,
|
||||
)
|
||||
|
||||
if result.is_file:
|
||||
return {
|
||||
"success": True,
|
||||
"is_file": True,
|
||||
"file_b64": base64.b64encode(result.file_data).decode("ascii"),
|
||||
"filename": result.filename,
|
||||
"mime_type": result.mime_type,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"is_file": False,
|
||||
"message": result.message,
|
||||
}
|
||||
|
||||
|
||||
def audio_info_operation(params: dict) -> dict:
|
||||
"""Handle audio info operation (v4.3.0)."""
|
||||
from stegasoo import get_audio_info
|
||||
from stegasoo.audio_steganography import calculate_audio_lsb_capacity
|
||||
from stegasoo.spread_steganography import calculate_audio_spread_capacity
|
||||
|
||||
audio_data = base64.b64decode(params["audio_b64"])
|
||||
|
||||
info = get_audio_info(audio_data)
|
||||
lsb_capacity = calculate_audio_lsb_capacity(audio_data)
|
||||
spread_capacity = calculate_audio_spread_capacity(audio_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"info": {
|
||||
"sample_rate": info.sample_rate,
|
||||
"channels": info.channels,
|
||||
"duration_seconds": round(info.duration_seconds, 2),
|
||||
"num_samples": info.num_samples,
|
||||
"format": info.format,
|
||||
"bit_depth": info.bit_depth,
|
||||
"capacity_lsb": lsb_capacity,
|
||||
"capacity_spread": spread_capacity.usable_capacity_bytes,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def channel_status_operation(params: dict) -> dict:
|
||||
"""Handle channel status check (v4.0.0)."""
|
||||
from stegasoo import get_channel_status
|
||||
@@ -263,6 +425,7 @@ def main():
|
||||
else:
|
||||
params = json.loads(input_text)
|
||||
operation = params.get("operation")
|
||||
logger.info("Worker handling operation: %s", operation)
|
||||
|
||||
if operation == "encode":
|
||||
output = encode_operation(params)
|
||||
@@ -274,6 +437,13 @@ def main():
|
||||
output = capacity_check_operation(params)
|
||||
elif operation == "channel_status":
|
||||
output = channel_status_operation(params)
|
||||
# Audio operations (v4.3.0)
|
||||
elif operation == "encode_audio":
|
||||
output = encode_audio_operation(params)
|
||||
elif operation == "decode_audio":
|
||||
output = decode_audio_operation(params)
|
||||
elif operation == "audio_info":
|
||||
output = audio_info_operation(params)
|
||||
else:
|
||||
output = {"success": False, "error": f"Unknown operation: {operation}"}
|
||||
|
||||
|
||||
@@ -115,6 +115,35 @@ class CapacityResult:
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioEncodeResult:
|
||||
"""Result from audio encode operation (v4.3.0)."""
|
||||
|
||||
success: bool
|
||||
stego_data: bytes | None = None
|
||||
stats: dict[str, Any] | None = None
|
||||
channel_mode: str | None = None
|
||||
channel_fingerprint: str | None = None
|
||||
error: str | None = None
|
||||
error_type: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioInfoResult:
|
||||
"""Result from audio info operation (v4.3.0)."""
|
||||
|
||||
success: bool
|
||||
sample_rate: int = 0
|
||||
channels: int = 0
|
||||
duration_seconds: float = 0.0
|
||||
num_samples: int = 0
|
||||
format: str = ""
|
||||
bit_depth: int | None = None
|
||||
capacity_lsb: int = 0
|
||||
capacity_spread: int = 0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelStatusResult:
|
||||
"""Result from channel status check (v4.0.0)."""
|
||||
@@ -456,6 +485,201 @@ class SubprocessStego:
|
||||
error=result.get("error", "Unknown error"),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Audio Steganography (v4.3.0)
|
||||
# =========================================================================
|
||||
|
||||
def encode_audio(
|
||||
self,
|
||||
carrier_data: bytes,
|
||||
reference_data: bytes,
|
||||
message: str | None = None,
|
||||
file_data: bytes | None = None,
|
||||
file_name: str | None = None,
|
||||
file_mime: str | None = None,
|
||||
passphrase: str = "",
|
||||
pin: str | None = None,
|
||||
rsa_key_data: bytes | None = None,
|
||||
rsa_password: str | None = None,
|
||||
embed_mode: str = "audio_lsb",
|
||||
channel_key: str | None = "auto",
|
||||
timeout: int | None = None,
|
||||
progress_file: str | None = None,
|
||||
chip_tier: int | None = None,
|
||||
) -> AudioEncodeResult:
|
||||
"""
|
||||
Encode a message or file into an audio carrier.
|
||||
|
||||
Args:
|
||||
carrier_data: Carrier audio bytes (WAV, FLAC, MP3, etc.)
|
||||
reference_data: Reference photo bytes
|
||||
message: Text message to encode (if not file)
|
||||
file_data: File bytes to encode (if not message)
|
||||
file_name: Original filename (for file payload)
|
||||
file_mime: MIME type (for file payload)
|
||||
passphrase: Encryption passphrase
|
||||
pin: Optional PIN
|
||||
rsa_key_data: Optional RSA key PEM bytes
|
||||
rsa_password: RSA key password if encrypted
|
||||
embed_mode: 'audio_lsb' or 'audio_spread'
|
||||
channel_key: 'auto', 'none', or explicit key
|
||||
timeout: Operation timeout (default 300s for audio)
|
||||
progress_file: Path to write progress updates
|
||||
|
||||
Returns:
|
||||
AudioEncodeResult with stego audio data on success
|
||||
"""
|
||||
params = {
|
||||
"operation": "encode_audio",
|
||||
"carrier_b64": base64.b64encode(carrier_data).decode("ascii"),
|
||||
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
|
||||
"message": message,
|
||||
"passphrase": passphrase,
|
||||
"pin": pin,
|
||||
"embed_mode": embed_mode,
|
||||
"channel_key": channel_key,
|
||||
"progress_file": progress_file,
|
||||
"chip_tier": chip_tier,
|
||||
}
|
||||
|
||||
if file_data:
|
||||
params["file_b64"] = base64.b64encode(file_data).decode("ascii")
|
||||
params["file_name"] = file_name
|
||||
params["file_mime"] = file_mime
|
||||
|
||||
if rsa_key_data:
|
||||
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
|
||||
params["rsa_password"] = rsa_password
|
||||
|
||||
# Audio operations can be slower (especially spread spectrum)
|
||||
result = self._run_worker(params, timeout or 300)
|
||||
|
||||
if result.get("success"):
|
||||
return AudioEncodeResult(
|
||||
success=True,
|
||||
stego_data=base64.b64decode(result["stego_b64"]),
|
||||
stats=result.get("stats"),
|
||||
channel_mode=result.get("channel_mode"),
|
||||
channel_fingerprint=result.get("channel_fingerprint"),
|
||||
)
|
||||
else:
|
||||
return AudioEncodeResult(
|
||||
success=False,
|
||||
error=result.get("error", "Unknown error"),
|
||||
error_type=result.get("error_type"),
|
||||
)
|
||||
|
||||
def decode_audio(
|
||||
self,
|
||||
stego_data: bytes,
|
||||
reference_data: bytes,
|
||||
passphrase: str = "",
|
||||
pin: str | None = None,
|
||||
rsa_key_data: bytes | None = None,
|
||||
rsa_password: str | None = None,
|
||||
embed_mode: str = "audio_auto",
|
||||
channel_key: str | None = "auto",
|
||||
timeout: int | None = None,
|
||||
progress_file: str | None = None,
|
||||
) -> DecodeResult:
|
||||
"""
|
||||
Decode a message or file from stego audio.
|
||||
|
||||
Args:
|
||||
stego_data: Stego audio bytes
|
||||
reference_data: Reference photo bytes
|
||||
passphrase: Decryption passphrase
|
||||
pin: Optional PIN
|
||||
rsa_key_data: Optional RSA key PEM bytes
|
||||
rsa_password: RSA key password if encrypted
|
||||
embed_mode: 'audio_auto', 'audio_lsb', or 'audio_spread'
|
||||
channel_key: 'auto', 'none', or explicit key
|
||||
timeout: Operation timeout (default 300s for audio)
|
||||
progress_file: Path to write progress updates
|
||||
|
||||
Returns:
|
||||
DecodeResult with message or file_data on success
|
||||
"""
|
||||
params = {
|
||||
"operation": "decode_audio",
|
||||
"stego_b64": base64.b64encode(stego_data).decode("ascii"),
|
||||
"reference_b64": base64.b64encode(reference_data).decode("ascii"),
|
||||
"passphrase": passphrase,
|
||||
"pin": pin,
|
||||
"embed_mode": embed_mode,
|
||||
"channel_key": channel_key,
|
||||
"progress_file": progress_file,
|
||||
}
|
||||
|
||||
if rsa_key_data:
|
||||
params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii")
|
||||
params["rsa_password"] = rsa_password
|
||||
|
||||
result = self._run_worker(params, timeout or 300)
|
||||
|
||||
if result.get("success"):
|
||||
if result.get("is_file"):
|
||||
return DecodeResult(
|
||||
success=True,
|
||||
is_file=True,
|
||||
file_data=base64.b64decode(result["file_b64"]),
|
||||
filename=result.get("filename"),
|
||||
mime_type=result.get("mime_type"),
|
||||
)
|
||||
else:
|
||||
return DecodeResult(
|
||||
success=True,
|
||||
is_file=False,
|
||||
message=result.get("message"),
|
||||
)
|
||||
else:
|
||||
return DecodeResult(
|
||||
success=False,
|
||||
error=result.get("error", "Unknown error"),
|
||||
error_type=result.get("error_type"),
|
||||
)
|
||||
|
||||
def audio_info(
|
||||
self,
|
||||
audio_data: bytes,
|
||||
timeout: int | None = None,
|
||||
) -> AudioInfoResult:
|
||||
"""
|
||||
Get audio file information and steganographic capacity.
|
||||
|
||||
Args:
|
||||
audio_data: Audio file bytes
|
||||
timeout: Operation timeout in seconds
|
||||
|
||||
Returns:
|
||||
AudioInfoResult with metadata and capacity info
|
||||
"""
|
||||
params = {
|
||||
"operation": "audio_info",
|
||||
"audio_b64": base64.b64encode(audio_data).decode("ascii"),
|
||||
}
|
||||
|
||||
result = self._run_worker(params, timeout)
|
||||
|
||||
if result.get("success"):
|
||||
info = result.get("info", {})
|
||||
return AudioInfoResult(
|
||||
success=True,
|
||||
sample_rate=info.get("sample_rate", 0),
|
||||
channels=info.get("channels", 0),
|
||||
duration_seconds=info.get("duration_seconds", 0.0),
|
||||
num_samples=info.get("num_samples", 0),
|
||||
format=info.get("format", ""),
|
||||
bit_depth=info.get("bit_depth"),
|
||||
capacity_lsb=info.get("capacity_lsb", 0),
|
||||
capacity_spread=info.get("capacity_spread", 0),
|
||||
)
|
||||
else:
|
||||
return AudioInfoResult(
|
||||
success=False,
|
||||
error=result.get("error", "Unknown error"),
|
||||
)
|
||||
|
||||
def get_channel_status(
|
||||
self,
|
||||
reveal: bool = False,
|
||||
|
||||
@@ -24,7 +24,11 @@
|
||||
border-left: 3px solid #ffe699;
|
||||
}
|
||||
.step-accordion .accordion-button::after {
|
||||
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2);
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.step-accordion .accordion-button:not(.collapsed)::after {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.step-accordion .accordion-body {
|
||||
background: rgba(30, 40, 50, 0.4);
|
||||
@@ -172,19 +176,51 @@
|
||||
<div class="accordion step-accordion" id="decodeAccordion">
|
||||
|
||||
<!-- ================================================================
|
||||
STEP 1: IMAGES & MODE
|
||||
STEP 1: CARRIER TYPE (v4.3.0)
|
||||
================================================================ -->
|
||||
<div class="accordion-item" id="carrierTypeStep">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepCarrierType">
|
||||
<span class="step-title">
|
||||
<span class="step-number" id="stepCarrierTypeNumber">1</span>
|
||||
<i class="bi bi-collection me-1"></i> Carrier Type
|
||||
</span>
|
||||
<span class="step-summary" id="stepCarrierTypeSummary"></span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="stepCarrierType" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
|
||||
<div class="accordion-body">
|
||||
<input type="hidden" name="carrier_type" id="carrierTypeInput" value="image">
|
||||
<div class="btn-group w-100" role="group">
|
||||
<input type="radio" class="btn-check" name="carrier_type_select" id="typeImage" value="image" checked>
|
||||
<label class="btn btn-outline-secondary" for="typeImage">
|
||||
<i class="bi bi-image me-1"></i> Image
|
||||
</label>
|
||||
<input type="radio" class="btn-check" name="carrier_type_select" id="typeAudio" value="audio"
|
||||
{% if not has_audio %}disabled{% endif %}>
|
||||
<label class="btn btn-outline-secondary {% if not has_audio %}disabled text-muted{% endif %}" for="typeAudio">
|
||||
<i class="bi bi-music-note-beamed me-1"></i> Audio
|
||||
{% if not has_audio %}<small class="d-block" style="font-size: 0.65rem;">(not available)</small>{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
STEP 2: IMAGES & MODE
|
||||
================================================================ -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
||||
<button class="accordion-button collapsed" 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 class="step-number" id="stepImagesNumber">2</span>
|
||||
<i class="bi bi-images me-1"></i> Reference, Carrier, 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 id="stepImages" class="accordion-collapse collapse" data-bs-parent="#decodeAccordion">
|
||||
<div class="accordion-body">
|
||||
|
||||
<div class="row">
|
||||
@@ -213,41 +249,74 @@
|
||||
</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 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 id="imageStegoSection">
|
||||
<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>
|
||||
<!-- Audio Stego (hidden by default) -->
|
||||
<div class="d-none" id="audioStegoSection">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-music me-1"></i> Stego Audio
|
||||
</label>
|
||||
<div class="drop-zone pixel-container" id="audioStegoDropZone">
|
||||
<input type="file" name="stego_image" accept="audio/*" id="audioStegoInput">
|
||||
<div class="drop-zone-label">
|
||||
<i class="bi bi-music-note-beamed fs-3 d-block mb-2 text-muted"></i>
|
||||
<span class="text-muted">Drop audio or click</span>
|
||||
</div>
|
||||
<div class="pixel-data-panel">
|
||||
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="audioStegoFileName">audio.wav</span></div>
|
||||
<div class="pixel-data-row"><span class="pixel-status-badge">Audio Loaded</span><span class="pixel-data-value" id="audioStegoFileSize">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Audio file containing the hidden message</div>
|
||||
</div>
|
||||
<div class="form-text">Image containing the hidden message</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extraction Mode -->
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked>
|
||||
<label class="btn btn-outline-secondary text-nowrap" for="modeAuto"><i class="bi bi-magic me-1"></i>Auto</label>
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb">
|
||||
<label class="btn btn-outline-secondary text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
|
||||
<label class="btn btn-outline-secondary text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
|
||||
<div id="imageModeGroup">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked>
|
||||
<label class="btn btn-outline-secondary text-nowrap" for="modeAuto"><i class="bi bi-magic me-1"></i>Auto</label>
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb">
|
||||
<label class="btn btn-outline-secondary text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
|
||||
<label class="btn btn-outline-secondary text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Audio Extraction Modes (hidden by default) -->
|
||||
<div class="d-none" id="audioModeGroup">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioAuto" value="audio_auto">
|
||||
<label class="btn btn-outline-secondary text-nowrap" for="modeAudioAuto"><i class="bi bi-magic me-1"></i>Auto</label>
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioLsb" value="audio_lsb">
|
||||
<label class="btn btn-outline-secondary text-nowrap" for="modeAudioLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioSpread" value="audio_spread">
|
||||
<label class="btn btn-outline-secondary text-nowrap" for="modeAudioSpread"><i class="bi bi-broadcast me-1"></i>Spread</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text" id="modeHint">
|
||||
@@ -259,13 +328,13 @@
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
STEP 2: SECURITY
|
||||
STEP 3: 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>
|
||||
<span class="step-number" id="stepSecurityNumber">3</span>
|
||||
<i class="bi bi-shield-lock me-1"></i> Security
|
||||
</span>
|
||||
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
|
||||
@@ -425,7 +494,10 @@
|
||||
const modeHints = {
|
||||
auto: { icon: 'lightning', text: 'Tries LSB first, then DCT' },
|
||||
lsb: { icon: 'hdd', text: 'For email and direct transfers' },
|
||||
dct: { icon: 'phone', text: 'For social media images' }
|
||||
dct: { icon: 'phone', text: 'For social media images' },
|
||||
audio_auto: { icon: 'lightning', text: 'Tries LSB first, then Spread Spectrum' },
|
||||
audio_lsb: { icon: 'grid-3x3-gap', text: 'Direct bit embedding in audio samples' },
|
||||
audio_spread: { icon: 'broadcast', text: 'Noise-resistant spread spectrum encoding' }
|
||||
};
|
||||
|
||||
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
||||
@@ -442,9 +514,14 @@ document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
||||
// ACCORDION SUMMARY UPDATES
|
||||
// ============================================================================
|
||||
|
||||
const carrierTypeInput = document.getElementById('carrierTypeInput');
|
||||
|
||||
function updateImagesSummary() {
|
||||
const ref = document.getElementById('refPhotoInput')?.files[0];
|
||||
const stego = document.getElementById('stegoInput')?.files[0];
|
||||
const isAudio = carrierTypeInput?.value === 'audio';
|
||||
const stego = isAudio
|
||||
? document.getElementById('audioStegoInput')?.files[0]
|
||||
: 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');
|
||||
@@ -460,12 +537,12 @@ function updateImagesSummary() {
|
||||
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';
|
||||
stepNum.textContent = '2';
|
||||
} else {
|
||||
summary.textContent = 'Select reference & stego';
|
||||
summary.textContent = isAudio ? 'Select reference & audio' : 'Select reference & stego';
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '1';
|
||||
stepNum.textContent = '2';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,19 +570,99 @@ function updateSecuritySummary() {
|
||||
summary.textContent = 'Passphrase & keys';
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '2';
|
||||
stepNum.textContent = '3';
|
||||
}
|
||||
}
|
||||
|
||||
// Attach listeners
|
||||
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
|
||||
document.getElementById('stegoInput')?.addEventListener('change', updateImagesSummary);
|
||||
document.getElementById('audioStegoInput')?.addEventListener('change', updateImagesSummary);
|
||||
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||
document.querySelectorAll('#audioModeGroup 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);
|
||||
|
||||
// ============================================================================
|
||||
// CARRIER TYPE TOGGLE (v4.3.0)
|
||||
// ============================================================================
|
||||
|
||||
const carrierTypeRadios = document.querySelectorAll('input[name="carrier_type_select"]');
|
||||
const imageStegoSection = document.getElementById('imageStegoSection');
|
||||
const audioStegoSection = document.getElementById('audioStegoSection');
|
||||
const imageModeGroup = document.getElementById('imageModeGroup');
|
||||
const audioModeGroup = document.getElementById('audioModeGroup');
|
||||
const stepCarrierTypeSummary = document.getElementById('stepCarrierTypeSummary');
|
||||
|
||||
carrierTypeRadios.forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
const isAudio = this.value === 'audio';
|
||||
carrierTypeInput.value = this.value;
|
||||
|
||||
// Toggle stego sections
|
||||
if (imageStegoSection) imageStegoSection.classList.toggle('d-none', isAudio);
|
||||
if (audioStegoSection) audioStegoSection.classList.toggle('d-none', !isAudio);
|
||||
|
||||
// Toggle required attribute so hidden inputs don't block form submission
|
||||
const imgStego = document.getElementById('stegoInput');
|
||||
const audStego = document.getElementById('audioStegoInput');
|
||||
if (imgStego) { if (isAudio) imgStego.removeAttribute('required'); else imgStego.setAttribute('required', ''); }
|
||||
if (audStego) { if (isAudio) audStego.setAttribute('required', ''); else audStego.removeAttribute('required'); }
|
||||
|
||||
// Toggle mode groups
|
||||
if (imageModeGroup) imageModeGroup.classList.toggle('d-none', isAudio);
|
||||
if (audioModeGroup) audioModeGroup.classList.toggle('d-none', !isAudio);
|
||||
|
||||
// Update summary
|
||||
if (stepCarrierTypeSummary) {
|
||||
stepCarrierTypeSummary.textContent = isAudio ? 'Audio' : 'Image';
|
||||
}
|
||||
|
||||
// Select default mode
|
||||
if (isAudio) {
|
||||
const audioAuto = document.getElementById('modeAudioAuto');
|
||||
if (audioAuto) audioAuto.checked = true;
|
||||
} else {
|
||||
const autoMode = document.getElementById('modeAuto');
|
||||
if (autoMode) autoMode.checked = true;
|
||||
}
|
||||
|
||||
// Clear stego file selections
|
||||
const stegoInput = document.getElementById('stegoInput');
|
||||
const audioStegoInput = document.getElementById('audioStegoInput');
|
||||
if (stegoInput) stegoInput.value = '';
|
||||
if (audioStegoInput) audioStegoInput.value = '';
|
||||
|
||||
// Reset previews
|
||||
document.getElementById('stegoPreview')?.classList.add('d-none');
|
||||
|
||||
// Update mode hint
|
||||
const hint = document.getElementById('modeHint');
|
||||
if (hint) {
|
||||
if (isAudio) {
|
||||
hint.innerHTML = '<i class="bi bi-lightning me-1"></i>Tries LSB first, then Spread Spectrum';
|
||||
} else {
|
||||
hint.innerHTML = '<i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT';
|
||||
}
|
||||
}
|
||||
|
||||
updateImagesSummary();
|
||||
});
|
||||
});
|
||||
|
||||
// Audio stego file info display
|
||||
const audioStegoInput = document.getElementById('audioStegoInput');
|
||||
audioStegoInput?.addEventListener('change', function() {
|
||||
if (this.files && this.files[0]) {
|
||||
const file = this.files[0];
|
||||
document.getElementById('audioStegoFileName').textContent = file.name;
|
||||
document.getElementById('audioStegoFileSize').textContent = (file.size / 1024).toFixed(1) + ' KB';
|
||||
updateImagesSummary();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// MODE SWITCHING
|
||||
// ============================================================================
|
||||
|
||||
@@ -24,7 +24,11 @@
|
||||
border-left: 3px solid #ffe699;
|
||||
}
|
||||
.step-accordion .accordion-button::after {
|
||||
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2);
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.step-accordion .accordion-button:not(.collapsed)::after {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.step-accordion .accordion-body {
|
||||
background: rgba(30, 40, 50, 0.4);
|
||||
@@ -126,19 +130,56 @@
|
||||
<div class="accordion step-accordion" id="encodeAccordion">
|
||||
|
||||
<!-- ================================================================
|
||||
STEP 1: IMAGES
|
||||
STEP 1: CARRIER TYPE (v4.3.0)
|
||||
================================================================ -->
|
||||
<div class="accordion-item" id="carrierTypeStep">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepCarrierType">
|
||||
<span class="step-title">
|
||||
<span class="step-number" id="stepCarrierTypeNumber">1</span>
|
||||
<i class="bi bi-collection me-1"></i> Carrier Type
|
||||
</span>
|
||||
<span class="step-summary" id="stepCarrierTypeSummary"></span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="stepCarrierType" class="accordion-collapse collapse show" data-bs-parent="#encodeAccordion">
|
||||
<div class="accordion-body">
|
||||
<input type="hidden" name="carrier_type" id="carrierTypeInput" value="image">
|
||||
<div class="btn-group w-100" role="group">
|
||||
<input type="radio" class="btn-check" name="carrier_type_select" id="typeImage" value="image" checked>
|
||||
<label class="btn btn-outline-secondary" for="typeImage">
|
||||
<i class="bi bi-image me-1"></i> Image
|
||||
</label>
|
||||
<input type="radio" class="btn-check" name="carrier_type_select" id="typeAudio" value="audio"
|
||||
{% if not has_audio %}disabled{% endif %}>
|
||||
<label class="btn btn-outline-secondary {% if not has_audio %}disabled text-muted{% endif %}" for="typeAudio">
|
||||
<i class="bi bi-music-note-beamed me-1"></i> Audio
|
||||
{% if not has_audio %}<small class="d-block" style="font-size: 0.65rem;">(not available)</small>{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
{% if not has_audio %}
|
||||
<div class="form-text text-warning mt-2">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Audio requires numpy and soundfile packages
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
STEP 2: IMAGES & MODE
|
||||
================================================================ -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
||||
<button class="accordion-button collapsed" 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 class="step-number" id="stepImagesNumber">2</span>
|
||||
<i class="bi bi-images me-1"></i> Reference, Carrier, Mode
|
||||
</span>
|
||||
<span class="step-summary" id="stepImagesSummary">Select reference & carrier</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#encodeAccordion">
|
||||
<div id="stepImages" class="accordion-collapse collapse" data-bs-parent="#encodeAccordion">
|
||||
<div class="accordion-body">
|
||||
|
||||
<div class="row">
|
||||
@@ -167,29 +208,51 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-image me-1"></i> Carrier Image
|
||||
</label>
|
||||
<div class="drop-zone pixel-container" id="carrierDropZone">
|
||||
<input type="file" name="carrier" accept="image/*" required id="carrierInput">
|
||||
<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="carrierPreview">
|
||||
<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="carrierFileName">image.jpg</span></div>
|
||||
<div class="pixel-data-row"><span class="pixel-status-badge">Carrier Loaded</span><span class="pixel-data-value" id="carrierFileSize">--</span></div>
|
||||
<div class="pixel-dimensions" id="carrierDims">-- x -- px</div>
|
||||
<div id="imageCarrierSection">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-image me-1"></i> Carrier Image
|
||||
</label>
|
||||
<div class="drop-zone pixel-container" id="carrierDropZone">
|
||||
<input type="file" name="carrier" accept="image/*" required id="carrierInput">
|
||||
<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="carrierPreview">
|
||||
<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="carrierFileName">image.jpg</span></div>
|
||||
<div class="pixel-data-row"><span class="pixel-status-badge">Carrier Loaded</span><span class="pixel-data-value" id="carrierFileSize">--</span></div>
|
||||
<div class="pixel-dimensions" id="carrierDims">-- x -- px</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Image to hide your message in</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio Carrier (hidden by default, shown when audio type selected) -->
|
||||
<div class="d-none" id="audioCarrierSection">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-music me-1"></i> Carrier Audio
|
||||
</label>
|
||||
<div class="drop-zone pixel-container" id="audioCarrierDropZone">
|
||||
<input type="file" name="carrier" accept="audio/*" id="audioCarrierInput">
|
||||
<div class="drop-zone-label">
|
||||
<i class="bi bi-music-note-beamed fs-3 d-block mb-2 text-muted"></i>
|
||||
<span class="text-muted">Drop audio or click</span>
|
||||
</div>
|
||||
<div class="pixel-data-panel">
|
||||
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="audioCarrierFileName">audio.wav</span></div>
|
||||
<div class="pixel-data-row"><span class="pixel-status-badge">Audio Loaded</span><span class="pixel-data-value" id="audioCarrierFileSize">--</span></div>
|
||||
<div class="pixel-dimensions" id="audioCarrierDuration">--:-- duration</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Audio file to hide your message in</div>
|
||||
</div>
|
||||
<div class="form-text">Image to hide your message in</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -204,30 +267,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embedding Mode (compact inline) -->
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if has_dct %}checked{% endif %} {% if not has_dct %}disabled{% endif %}>
|
||||
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb" {% if not has_dct %}checked{% endif %}>
|
||||
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
||||
<!-- Audio Capacity Info (v4.3.0) -->
|
||||
<div class="alert alert-info small d-none mb-3" id="audioCapacityPanel">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-music-note-beamed me-1"></i><span id="audioInfo">-</span></span>
|
||||
<span>
|
||||
<span class="badge bg-primary me-1" id="lsbAudioCapacityBadge">LSB: -</span>
|
||||
<span class="badge bg-warning text-dark" id="spreadCapacityBadge">Spread: -</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-muted d-none d-sm-inline">|</span>
|
||||
<span class="d-flex gap-2 align-items-center" id="outputOptions">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<input type="radio" class="btn-check" name="dct_color_mode" id="colorMode" value="color" checked>
|
||||
<label class="btn btn-outline-secondary btn-sm" for="colorMode">Color</label>
|
||||
<input type="radio" class="btn-check" name="dct_color_mode" id="grayMode" value="grayscale">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="grayMode" id="grayModeLabel">Gray</label>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<input type="radio" class="btn-check" name="dct_output_format" id="jpegFormat" value="jpeg" checked>
|
||||
<label class="btn btn-outline-secondary btn-sm" for="jpegFormat" id="jpegFormatLabel">JPEG</label>
|
||||
<input type="radio" class="btn-check" name="dct_output_format" id="pngFormat" value="png">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="pngFormat">PNG</label>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Embedding Mode (compact inline) -->
|
||||
<div id="imageModeGroup">
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if has_dct %}checked{% endif %} {% if not has_dct %}disabled{% endif %}>
|
||||
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb" {% if not has_dct %}checked{% endif %}>
|
||||
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
||||
</div>
|
||||
<span class="text-muted d-none d-sm-inline">|</span>
|
||||
<span class="d-flex gap-2 align-items-center" id="outputOptions">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<input type="radio" class="btn-check" name="dct_color_mode" id="colorMode" value="color" checked>
|
||||
<label class="btn btn-outline-secondary btn-sm" for="colorMode">Color</label>
|
||||
<input type="radio" class="btn-check" name="dct_color_mode" id="grayMode" value="grayscale">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="grayMode" id="grayModeLabel">Gray</label>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<input type="radio" class="btn-check" name="dct_output_format" id="jpegFormat" value="jpeg" checked>
|
||||
<label class="btn btn-outline-secondary btn-sm" for="jpegFormat" id="jpegFormatLabel">JPEG</label>
|
||||
<input type="radio" class="btn-check" name="dct_output_format" id="pngFormat" value="png">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="pngFormat">PNG</label>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio Modes (hidden by default) -->
|
||||
<div class="d-none" id="audioModeGroup">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioLsb" value="audio_lsb">
|
||||
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeAudioLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeAudioSpread" value="audio_spread">
|
||||
<label class="btn btn-outline-secondary btn-sm text-nowrap" for="modeAudioSpread"><i class="bi bi-broadcast me-1"></i>Spread</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-text" id="modeHint">
|
||||
<i class="bi bi-{% if has_dct %}phone{% else %}hdd{% endif %} me-1"></i>{% if has_dct %}Survives social media compression{% else %}Higher capacity for direct transfers{% endif %}
|
||||
</div>
|
||||
@@ -237,13 +324,13 @@
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
STEP 2: PAYLOAD
|
||||
STEP 3: PAYLOAD
|
||||
================================================================ -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepPayload">
|
||||
<span class="step-title">
|
||||
<span class="step-number" id="stepPayloadNumber">2</span>
|
||||
<span class="step-number" id="stepPayloadNumber">3</span>
|
||||
<i class="bi bi-box me-1"></i> Payload
|
||||
</span>
|
||||
<span class="step-summary" id="stepPayloadSummary">Message or file to hide</span>
|
||||
@@ -295,13 +382,13 @@
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
STEP 3: SECURITY
|
||||
STEP 4: 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">3</span>
|
||||
<span class="step-number" id="stepSecurityNumber">4</span>
|
||||
<i class="bi bi-shield-lock me-1"></i> Security
|
||||
</span>
|
||||
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
|
||||
@@ -462,13 +549,131 @@ document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// CARRIER TYPE TOGGLE (v4.3.0)
|
||||
// ============================================================================
|
||||
|
||||
const carrierTypeRadios = document.querySelectorAll('input[name="carrier_type_select"]');
|
||||
const carrierTypeInput = document.getElementById('carrierTypeInput');
|
||||
const imageCarrierSection = document.getElementById('imageCarrierSection');
|
||||
const audioCarrierSection = document.getElementById('audioCarrierSection');
|
||||
const imageModeGroup = document.getElementById('imageModeGroup');
|
||||
const audioModeGroup = document.getElementById('audioModeGroup');
|
||||
const capacityPanel = document.getElementById('capacityPanel');
|
||||
const audioCapacityPanel = document.getElementById('audioCapacityPanel');
|
||||
const stepCarrierTypeSummary = document.getElementById('stepCarrierTypeSummary');
|
||||
|
||||
carrierTypeRadios.forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
const isAudio = this.value === 'audio';
|
||||
carrierTypeInput.value = this.value;
|
||||
|
||||
// Toggle carrier sections
|
||||
if (imageCarrierSection) imageCarrierSection.classList.toggle('d-none', isAudio);
|
||||
if (audioCarrierSection) audioCarrierSection.classList.toggle('d-none', !isAudio);
|
||||
|
||||
// Toggle required attribute so hidden inputs don't block form submission
|
||||
const imgCarrier = document.getElementById('carrierInput');
|
||||
const audCarrier = document.getElementById('audioCarrierInput');
|
||||
if (imgCarrier) { if (isAudio) imgCarrier.removeAttribute('required'); else imgCarrier.setAttribute('required', ''); }
|
||||
if (audCarrier) { if (isAudio) audCarrier.setAttribute('required', ''); else audCarrier.removeAttribute('required'); }
|
||||
|
||||
// Toggle mode groups
|
||||
if (imageModeGroup) imageModeGroup.classList.toggle('d-none', isAudio);
|
||||
if (audioModeGroup) audioModeGroup.classList.toggle('d-none', !isAudio);
|
||||
|
||||
// Toggle capacity panels
|
||||
if (capacityPanel) capacityPanel.classList.add('d-none');
|
||||
if (audioCapacityPanel) audioCapacityPanel.classList.add('d-none');
|
||||
|
||||
// Update summary
|
||||
if (stepCarrierTypeSummary) {
|
||||
stepCarrierTypeSummary.textContent = isAudio ? 'Audio' : 'Image';
|
||||
}
|
||||
|
||||
// Select default mode for the active type
|
||||
if (isAudio) {
|
||||
const audioLsb = document.getElementById('modeAudioLsb');
|
||||
if (audioLsb) audioLsb.checked = true;
|
||||
} else {
|
||||
// Reset to DCT if available, else LSB
|
||||
const dctRadio = document.getElementById('modeDct');
|
||||
const lsbRadio = document.getElementById('modeLsb');
|
||||
if (dctRadio && !dctRadio.disabled) {
|
||||
dctRadio.checked = true;
|
||||
} else if (lsbRadio) {
|
||||
lsbRadio.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear carrier file selections
|
||||
const carrierInput = document.getElementById('carrierInput');
|
||||
const audioCarrierInput = document.getElementById('audioCarrierInput');
|
||||
if (carrierInput) carrierInput.value = '';
|
||||
if (audioCarrierInput) audioCarrierInput.value = '';
|
||||
|
||||
// Reset previews
|
||||
document.getElementById('carrierPreview')?.classList.add('d-none');
|
||||
|
||||
// Update step title
|
||||
const stepImagesTitle = document.querySelector('#stepImages')?.closest('.accordion-item')?.querySelector('.accordion-button .step-title');
|
||||
if (stepImagesTitle) {
|
||||
const icon = stepImagesTitle.querySelector('i:not(.step-number i)');
|
||||
const textNode = stepImagesTitle.childNodes[stepImagesTitle.childNodes.length - 1];
|
||||
if (icon) {
|
||||
icon.className = isAudio ? 'bi bi-music-note-beamed me-1' : 'bi bi-images me-1';
|
||||
}
|
||||
}
|
||||
|
||||
updateImagesSummary();
|
||||
});
|
||||
});
|
||||
|
||||
// Audio carrier file change handler
|
||||
const audioCarrierInput = document.getElementById('audioCarrierInput');
|
||||
audioCarrierInput?.addEventListener('change', function() {
|
||||
if (this.files && this.files[0]) {
|
||||
const file = this.files[0];
|
||||
document.getElementById('audioCarrierFileName').textContent = file.name;
|
||||
document.getElementById('audioCarrierFileSize').textContent = (file.size / 1024).toFixed(1) + ' KB';
|
||||
|
||||
// Fetch audio capacity
|
||||
const formData = new FormData();
|
||||
formData.append('carrier', file);
|
||||
fetch('/api/audio-capacity', { method: 'POST', body: formData })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.error) return;
|
||||
const info = `${data.format || 'Audio'} · ${data.sample_rate}Hz · ${data.channels}ch · ${data.duration}s`;
|
||||
document.getElementById('audioInfo').textContent = info;
|
||||
document.getElementById('lsbAudioCapacityBadge').textContent = `LSB: ${(data.lsb_capacity / 1024).toFixed(1)} KB`;
|
||||
document.getElementById('spreadCapacityBadge').textContent = `Spread: ${(data.spread_capacity / 1024).toFixed(1)} KB`;
|
||||
document.getElementById('audioCapacityPanel')?.classList.remove('d-none');
|
||||
if (data.duration) {
|
||||
document.getElementById('audioCarrierDuration').textContent = data.duration + 's duration';
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// Trigger the drop zone animation
|
||||
const dropZone = document.getElementById('audioCarrierDropZone');
|
||||
if (dropZone) {
|
||||
dropZone.classList.add('has-file');
|
||||
}
|
||||
|
||||
updateImagesSummary();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ACCORDION SUMMARY UPDATES
|
||||
// ============================================================================
|
||||
|
||||
function updateImagesSummary() {
|
||||
const ref = document.getElementById('refPhotoInput')?.files[0];
|
||||
const carrier = document.getElementById('carrierInput')?.files[0];
|
||||
const isAudio = carrierTypeInput?.value === 'audio';
|
||||
const carrier = isAudio
|
||||
? document.getElementById('audioCarrierInput')?.files[0]
|
||||
: document.getElementById('carrierInput')?.files[0];
|
||||
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'LSB';
|
||||
const summary = document.getElementById('stepImagesSummary');
|
||||
const stepNum = document.getElementById('stepImagesNumber');
|
||||
@@ -484,12 +689,12 @@ function updateImagesSummary() {
|
||||
summary.textContent = ref ? ref.name.slice(0, 15) : carrier.name.slice(0, 15);
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '1';
|
||||
stepNum.textContent = '2';
|
||||
} else {
|
||||
summary.textContent = 'Select reference & carrier';
|
||||
summary.textContent = isAudio ? 'Select reference & audio' : 'Select reference & carrier';
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '1';
|
||||
stepNum.textContent = '2';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,7 +720,7 @@ function updatePayloadSummary() {
|
||||
summary.textContent = 'Message or file to hide';
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '2';
|
||||
stepNum.textContent = '3';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,14 +748,16 @@ function updateSecuritySummary() {
|
||||
summary.textContent = 'Passphrase & keys';
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '3';
|
||||
stepNum.textContent = '4';
|
||||
}
|
||||
}
|
||||
|
||||
// Attach listeners
|
||||
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
|
||||
document.getElementById('carrierInput')?.addEventListener('change', updateImagesSummary);
|
||||
document.getElementById('audioCarrierInput')?.addEventListener('change', updateImagesSummary);
|
||||
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||
document.querySelectorAll('#audioModeGroup input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||
|
||||
document.getElementById('messageInput')?.addEventListener('input', updatePayloadSummary);
|
||||
document.getElementById('payloadFileInput')?.addEventListener('change', updatePayloadSummary);
|
||||
|
||||
@@ -12,12 +12,26 @@
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
{% if carrier_type == 'audio' %}
|
||||
<!-- Audio Preview -->
|
||||
<div class="my-4">
|
||||
<div class="text-center">
|
||||
<i class="bi bi-music-note-beamed text-success" style="font-size: 4rem;"></i>
|
||||
<div class="mt-2">
|
||||
<audio controls src="{{ url_for('encode_file_route', file_id=file_id) }}" class="w-100" style="max-width: 400px;"></audio>
|
||||
</div>
|
||||
<div class="mt-2 small text-muted">
|
||||
<i class="bi bi-music-note-beamed me-1"></i>Encoded Audio Preview
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="my-4">
|
||||
{% if thumbnail_url %}
|
||||
<!-- Thumbnail of the actual encoded image -->
|
||||
<div class="encoded-image-thumbnail">
|
||||
<img src="{{ thumbnail_url }}"
|
||||
alt="Encoded image thumbnail"
|
||||
<img src="{{ thumbnail_url }}"
|
||||
alt="Encoded image thumbnail"
|
||||
class="img-thumbnail rounded"
|
||||
style="max-width: 250px; max-height: 250px; object-fit: contain;">
|
||||
<div class="mt-2 small text-muted">
|
||||
@@ -29,8 +43,9 @@
|
||||
<i class="bi bi-file-earmark-image text-success" style="font-size: 4rem;"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="lead mb-4">Your secret has been hidden in the image.</p>
|
||||
<p class="lead mb-4">Your secret has been hidden in the {{ 'audio file' if carrier_type == 'audio' else 'image' }}.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<code class="fs-5">{{ filename }}</code>
|
||||
@@ -38,11 +53,32 @@
|
||||
|
||||
<!-- Mode and format badges -->
|
||||
<div class="mb-4">
|
||||
{% if embed_mode == 'dct' %}
|
||||
{% if carrier_type == 'audio' %}
|
||||
<!-- Audio mode badges -->
|
||||
{% if embed_mode == 'audio_spread' %}
|
||||
<span class="badge bg-warning text-dark fs-6">
|
||||
<i class="bi bi-broadcast me-1"></i>Spread Spectrum
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-primary fs-6">
|
||||
<i class="bi bi-grid-3x3-gap me-1"></i>Audio LSB
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="badge bg-info fs-6 ms-1">
|
||||
<i class="bi bi-file-earmark-music me-1"></i>WAV
|
||||
</span>
|
||||
<div class="small text-muted mt-2">
|
||||
{% if embed_mode == 'audio_spread' %}
|
||||
Spread spectrum embedding in audio samples
|
||||
{% else %}
|
||||
LSB embedding in audio samples, WAV output
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif embed_mode == 'dct' %}
|
||||
<span class="badge bg-info fs-6">
|
||||
<i class="bi bi-soundwave me-1"></i>DCT Mode
|
||||
</span>
|
||||
|
||||
|
||||
<!-- Color mode badge (v3.0.1) -->
|
||||
{% if color_mode == 'color' %}
|
||||
<span class="badge bg-success fs-6 ms-1">
|
||||
@@ -53,7 +89,7 @@
|
||||
<i class="bi bi-circle-half me-1"></i>Grayscale
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- Output format badge -->
|
||||
{% if output_format == 'jpeg' %}
|
||||
<span class="badge bg-warning text-dark fs-6 ms-1">
|
||||
@@ -78,7 +114,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% else %}
|
||||
<span class="badge bg-primary fs-6">
|
||||
<i class="bi bi-grid-3x3-gap me-1"></i>LSB Mode
|
||||
@@ -114,7 +150,7 @@
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{{ url_for('encode_download', file_id=file_id) }}"
|
||||
class="btn btn-primary btn-lg" id="downloadBtn">
|
||||
<i class="bi bi-download me-2"></i>Download Image
|
||||
<i class="bi bi-download me-2"></i>Download {{ 'Audio' if carrier_type == 'audio' else 'Image' }}
|
||||
</a>
|
||||
|
||||
<button type="button" class="btn btn-outline-primary" id="shareBtn" style="display: none;">
|
||||
@@ -129,6 +165,11 @@
|
||||
<strong>Important:</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
<li>This file expires in <strong>10 minutes</strong></li>
|
||||
{% if carrier_type == 'audio' %}
|
||||
<li>Do <strong>not</strong> re-encode or convert the audio file</li>
|
||||
<li>WAV format preserves your hidden data losslessly</li>
|
||||
<li>Sharing via platforms that re-encode audio will destroy the hidden data</li>
|
||||
{% else %}
|
||||
<li>Do <strong>not</strong> resize or recompress the image</li>
|
||||
{% if embed_mode == 'dct' and output_format == 'jpeg' %}
|
||||
<li>JPEG format is lossy - avoid re-saving or editing</li>
|
||||
@@ -141,6 +182,7 @@
|
||||
<li>Color preserved - extraction works on both color and grayscale</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if channel_mode == 'private' %}
|
||||
<li><i class="bi bi-shield-lock text-warning me-1"></i>Recipient needs the <strong>same channel key</strong> to decode</li>
|
||||
{% endif %}
|
||||
@@ -148,7 +190,7 @@
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('encode_page') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Encode Another Message
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Encode Another
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,7 +204,7 @@
|
||||
const shareBtn = document.getElementById('shareBtn');
|
||||
const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}";
|
||||
const fileName = "{{ filename }}";
|
||||
const mimeType = "{{ 'image/jpeg' if embed_mode == 'dct' and output_format == 'jpeg' else 'image/png' }}";
|
||||
const mimeType = "{{ 'audio/wav' if carrier_type == 'audio' else ('image/jpeg' if embed_mode == 'dct' and output_format == 'jpeg' else 'image/png') }}";
|
||||
|
||||
if (navigator.share && navigator.canShare) {
|
||||
// Check if we can share files
|
||||
|
||||
Reference in New Issue
Block a user