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:
adlee-was-taken
2026-02-28 11:58:40 -05:00
parent 0248bec813
commit ef5a9ce9cb
41 changed files with 4281 additions and 732 deletions

View File

@@ -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)