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

@@ -1,10 +1,15 @@
#!/usr/bin/env python3
"""
Stegasoo REST API (v4.2.1)
Stegasoo REST API (v4.3.0)
FastAPI-based REST API for steganography operations.
Supports both text messages and file embedding.
CHANGES in v4.3.0:
- Audio steganography endpoints (/audio/*)
- LSB and spread spectrum (DSSS) audio embedding modes
- Audio info and capacity checking
CHANGES in v4.2.1:
- API key authentication (X-API-Key header)
- TLS support with self-signed certificates
@@ -32,11 +37,31 @@ NEW in v3.0.1: DCT color mode and JPEG output format.
import asyncio
import base64
import logging
import os
import sys
from functools import partial
from pathlib import Path
from typing import Literal
# Configure logging for API frontend
_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,
)
api_logger = logging.getLogger("stegasoo.api")
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field
@@ -44,28 +69,28 @@ from pydantic import BaseModel, Field
# API Key Authentication
try:
from .auth import (
require_api_key,
get_api_key_status,
add_api_key,
remove_api_key,
list_api_keys,
get_api_key_status,
is_auth_enabled,
list_api_keys,
remove_api_key,
require_api_key,
)
except ImportError:
# When running directly (not as package)
from auth import (
require_api_key,
get_api_key_status,
add_api_key,
remove_api_key,
get_api_key_status,
list_api_keys,
is_auth_enabled,
remove_api_key,
require_api_key,
)
# Add parent to path for development
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from stegasoo import (
HAS_AUDIO_SUPPORT,
MAX_FILE_PAYLOAD_SIZE,
CapacityError,
DecryptionError,
@@ -87,6 +112,12 @@ from stegasoo import (
validate_image,
will_fit_by_mode,
)
# Audio steganography (v4.3.0) - conditionally imported
if HAS_AUDIO_SUPPORT:
from stegasoo import decode_audio, encode_audio, get_audio_info
from stegasoo.audio_steganography import calculate_audio_lsb_capacity
from stegasoo.spread_steganography import calculate_audio_spread_capacity
from stegasoo.constants import (
DEFAULT_PASSPHRASE_WORDS,
MAX_PASSPHRASE_WORDS,
@@ -163,6 +194,8 @@ EmbedModeType = Literal["lsb", "dct"]
ExtractModeType = Literal["auto", "lsb", "dct"]
DctColorModeType = Literal["grayscale", "color"]
DctOutputFormatType = Literal["png", "jpeg"]
AudioEmbedModeType = Literal["audio_lsb", "audio_spread"]
AudioExtractModeType = Literal["audio_auto", "audio_lsb", "audio_spread"]
# ============================================================================
@@ -405,6 +438,7 @@ class ModesResponse(BaseModel):
lsb: dict
dct: DctModeInfo
audio: dict | None = Field(default=None, description="Audio steganography modes (v4.3.0)")
# Channel key status (v4.0.0)
channel: dict | None = Field(default=None, description="Channel key status (v4.0.0)")
@@ -415,6 +449,7 @@ class StatusResponse(BaseModel):
has_qrcode_read: bool
has_qrcode_write: bool # v4.2.0: QR generation capability
has_dct: bool
has_audio: bool = Field(default=False, description="Audio steganography support (v4.3.0)")
max_payload_kb: int
available_modes: list[str]
dct_features: dict | None = Field(default=None, description="DCT mode features (v3.0.1+)")
@@ -479,6 +514,124 @@ class ErrorResponse(BaseModel):
detail: str | None = None
# --- Audio models (v4.3.0) ---
class AudioEncodeRequest(BaseModel):
"""Request to encode a text message into audio."""
message: str
reference_photo_base64: str
carrier_audio_base64: str
passphrase: str = Field(description="Passphrase for key derivation")
pin: str = ""
rsa_key_base64: str | None = None
rsa_password: str | None = None
channel_key: str | None = Field(
default=None,
description="Channel key for deployment isolation. null=auto, ''=public, 'XXXX-...'=explicit",
)
embed_mode: AudioEmbedModeType = Field(
default="audio_lsb",
description="Embedding mode: 'audio_lsb' (default) or 'audio_spread' (DSSS)",
)
chip_tier: int | None = Field(
default=None,
description="Spread spectrum chip tier: 0=lossless(256), 1=high_lossy(512), 2=low_lossy(1024). Only for audio_spread.",
)
class AudioEncodeFileRequest(BaseModel):
"""Request to encode a file into audio."""
file_data_base64: str
filename: str
mime_type: str | None = None
reference_photo_base64: str
carrier_audio_base64: str
passphrase: str = Field(description="Passphrase for key derivation")
pin: str = ""
rsa_key_base64: str | None = None
rsa_password: str | None = None
channel_key: str | None = Field(
default=None,
description="Channel key for deployment isolation. null=auto, ''=public, 'XXXX-...'=explicit",
)
embed_mode: AudioEmbedModeType = Field(
default="audio_lsb",
description="Embedding mode: 'audio_lsb' (default) or 'audio_spread' (DSSS)",
)
chip_tier: int | None = Field(
default=None,
description="Spread spectrum chip tier: 0=lossless(256), 1=high_lossy(512), 2=low_lossy(1024). Only for audio_spread.",
)
class AudioEncodeResponse(BaseModel):
"""Response from audio encode operations."""
stego_audio_base64: str
embed_mode: str = Field(description="Embedding mode used: 'audio_lsb' or 'audio_spread'")
stats: dict = Field(description="Embedding statistics (samples_modified, capacity_used, etc.)")
channel_mode: str = Field(default="public", description="Channel mode: 'public' or 'private'")
channel_fingerprint: str | None = Field(
default=None, description="Channel key fingerprint (if private mode)"
)
class AudioDecodeRequest(BaseModel):
"""Request to decode a message or file from stego audio."""
stego_audio_base64: str
reference_photo_base64: str
passphrase: str = Field(description="Passphrase for key derivation")
pin: str = ""
rsa_key_base64: str | None = None
rsa_password: str | None = None
channel_key: str | None = Field(
default=None,
description="Channel key for decryption. null=auto, ''=public, 'XXXX-...'=explicit",
)
embed_mode: AudioExtractModeType = Field(
default="audio_auto",
description="Extraction mode: 'audio_auto' (default), 'audio_lsb', or 'audio_spread'",
)
class AudioInfoResponse(BaseModel):
"""Response with audio file metadata and capacity info."""
sample_rate: int
channels: int
duration_seconds: float
num_samples: int
format: str
bit_depth: int | None = None
bitrate: int | None = None
capacity_lsb: int = Field(description="LSB mode capacity in bytes")
capacity_spread: int = Field(description="Spread spectrum mode capacity in bytes")
class AudioCapacityRequest(BaseModel):
"""Request to check if a payload fits in audio carrier."""
carrier_audio_base64: str
payload_size: int = Field(ge=1, description="Payload size in bytes")
embed_mode: AudioEmbedModeType = Field(
default="audio_lsb", description="Embedding mode to check capacity for"
)
class AudioCapacityResponse(BaseModel):
"""Response for audio capacity check."""
fits: bool
payload_size: int
capacity_bytes: int
usage_percent: float
embed_mode: str
# ============================================================================
# HELPER: RESOLVE CHANNEL KEY
# ============================================================================
@@ -569,12 +722,18 @@ async def root():
"source": channel_status.get("source"),
}
# Audio modes (v4.3.0)
if HAS_AUDIO_SUPPORT:
available_modes.append("audio_lsb")
available_modes.append("audio_spread")
return StatusResponse(
version=__version__,
has_argon2=has_argon2(),
has_qrcode_read=HAS_QR_READ,
has_qrcode_write=HAS_QR_WRITE,
has_dct=has_dct_support(),
has_audio=HAS_AUDIO_SUPPORT,
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
available_modes=available_modes,
dct_features=dct_features,
@@ -606,6 +765,28 @@ async def api_modes():
"fingerprint": channel_status.get("fingerprint"),
}
# Audio modes (v4.3.0)
audio_info = None
if HAS_AUDIO_SUPPORT:
audio_info = {
"available": True,
"modes": {
"audio_lsb": {
"name": "Audio LSB",
"description": "Embed in audio sample LSBs, high capacity",
"output_format": "WAV",
},
"audio_spread": {
"name": "Spread Spectrum (DSSS)",
"description": "Direct-sequence spread spectrum with Reed-Solomon ECC, better stealth",
"output_format": "WAV",
},
},
"supported_formats": ["WAV", "FLAC", "MP3", "OGG", "AAC", "M4A"],
"output_format": "WAV",
"requires": "soundfile",
}
return ModesResponse(
lsb={
"available": True,
@@ -623,6 +804,7 @@ async def api_modes():
capacity_ratio="~20% of LSB",
requires="scipy",
),
audio=audio_info,
channel=channel_info,
)
@@ -723,7 +905,7 @@ async def api_channel_set(request: ChannelSetRequest, _: str = Depends(require_a
@app.delete("/channel")
async def api_channel_clear(
_: str = Depends(require_api_key),
location: str = Query("user", description="'user', 'project', or 'all'")
location: str = Query("user", description="'user', 'project', or 'all'"),
):
"""
Clear/remove channel key from config.
@@ -935,7 +1117,7 @@ async def api_will_fit(request: WillFitRequest, _: str = Depends(require_api_key
@app.post("/extract-key-from-qr", response_model=QrExtractResponse)
async def api_extract_key_from_qr(
_: str = Depends(require_api_key),
qr_image: UploadFile = File(..., description="QR code image containing RSA key")
qr_image: UploadFile = File(..., description="QR code image containing RSA key"),
):
"""
Extract RSA key from a QR code image.
@@ -1607,6 +1789,454 @@ async def api_image_info(
raise HTTPException(500, str(e))
# ============================================================================
# ROUTES - AUDIO STEGANOGRAPHY (v4.3.0)
# ============================================================================
def _require_audio():
"""Check that audio support is available, raise 501 if not."""
if not HAS_AUDIO_SUPPORT:
raise HTTPException(
501, "Audio steganography not available. Install with: pip install stegasoo[audio]"
)
@app.post("/audio/encode", response_model=AudioEncodeResponse)
async def api_audio_encode(request: AudioEncodeRequest, _: str = Depends(require_api_key)):
"""
Encode a text message into audio.
Audio must be base64-encoded. Returns base64-encoded stego WAV.
v4.3.0: New endpoint for audio steganography.
"""
_require_audio()
resolved_channel_key = _resolve_channel_key(request.channel_key)
try:
ref_photo = base64.b64decode(request.reference_photo_base64)
carrier = base64.b64decode(request.carrier_audio_base64)
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
stego_audio, stats = await run_in_thread(
encode_audio,
message=request.message,
reference_photo=ref_photo,
carrier_audio=carrier,
passphrase=request.passphrase,
pin=request.pin,
rsa_key_data=rsa_key,
rsa_password=request.rsa_password,
embed_mode=request.embed_mode,
channel_key=resolved_channel_key,
chip_tier=request.chip_tier,
)
stego_b64 = base64.b64encode(stego_audio).decode("utf-8")
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
return AudioEncodeResponse(
stego_audio_base64=stego_b64,
embed_mode=stats.embed_mode,
stats={
"samples_modified": stats.samples_modified,
"total_samples": stats.total_samples,
"capacity_used": round(stats.capacity_used * 100, 1),
"bytes_embedded": stats.bytes_embedded,
"sample_rate": stats.sample_rate,
"channels": stats.channels,
"duration_seconds": round(stats.duration_seconds, 2),
},
channel_mode=channel_mode,
channel_fingerprint=channel_fingerprint,
)
except CapacityError as e:
raise HTTPException(400, str(e))
except StegasooError as e:
raise HTTPException(400, str(e))
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/audio/encode/file", response_model=AudioEncodeResponse)
async def api_audio_encode_file(request: AudioEncodeFileRequest, _: str = Depends(require_api_key)):
"""
Encode a file into audio (JSON with base64).
v4.3.0: New endpoint for audio steganography.
"""
_require_audio()
resolved_channel_key = _resolve_channel_key(request.channel_key)
try:
file_data = base64.b64decode(request.file_data_base64)
ref_photo = base64.b64decode(request.reference_photo_base64)
carrier = base64.b64decode(request.carrier_audio_base64)
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
payload = FilePayload(
data=file_data, filename=request.filename, mime_type=request.mime_type
)
stego_audio, stats = await run_in_thread(
encode_audio,
message=payload,
reference_photo=ref_photo,
carrier_audio=carrier,
passphrase=request.passphrase,
pin=request.pin,
rsa_key_data=rsa_key,
rsa_password=request.rsa_password,
embed_mode=request.embed_mode,
channel_key=resolved_channel_key,
chip_tier=request.chip_tier,
)
stego_b64 = base64.b64encode(stego_audio).decode("utf-8")
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
return AudioEncodeResponse(
stego_audio_base64=stego_b64,
embed_mode=stats.embed_mode,
stats={
"samples_modified": stats.samples_modified,
"total_samples": stats.total_samples,
"capacity_used": round(stats.capacity_used * 100, 1),
"bytes_embedded": stats.bytes_embedded,
"sample_rate": stats.sample_rate,
"channels": stats.channels,
"duration_seconds": round(stats.duration_seconds, 2),
},
channel_mode=channel_mode,
channel_fingerprint=channel_fingerprint,
)
except CapacityError as e:
raise HTTPException(400, str(e))
except StegasooError as e:
raise HTTPException(400, str(e))
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/audio/encode/multipart")
async def api_audio_encode_multipart(
_: str = Depends(require_api_key),
passphrase: str = Form(..., description="Passphrase for key derivation"),
reference_photo: UploadFile = File(...),
carrier: UploadFile = File(...),
message: str = Form(""),
payload_file: UploadFile | None = File(None),
pin: str = Form(""),
rsa_key: UploadFile | None = File(None),
rsa_password: str = Form(""),
channel_key: str = Form(
"auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit"
),
embed_mode: str = Form("audio_lsb"),
chip_tier: int | None = Form(
None,
description="Spread spectrum chip tier: 0=lossless, 1=high_lossy, 2=low_lossy. Only for audio_spread.",
),
):
"""
Encode audio using multipart form data (file uploads).
Provide either 'message' (text) or 'payload_file' (binary file).
Returns the stego WAV directly with metadata headers.
v4.3.0: New endpoint for audio steganography.
"""
_require_audio()
if embed_mode not in ("audio_lsb", "audio_spread"):
raise HTTPException(400, "embed_mode must be 'audio_lsb' or 'audio_spread'")
# Resolve channel key
if channel_key.lower() == "auto":
resolved_channel_key = None
elif channel_key.lower() == "none":
resolved_channel_key = ""
else:
resolved_channel_key = _resolve_channel_key(channel_key)
try:
ref_data = await reference_photo.read()
carrier_data = await carrier.read()
rsa_key_data = None
if rsa_key and rsa_key.filename:
rsa_key_data = await rsa_key.read()
effective_password = rsa_password if rsa_password else None
# Determine payload
if payload_file and payload_file.filename:
file_data = await payload_file.read()
payload = FilePayload(
data=file_data, filename=payload_file.filename, mime_type=payload_file.content_type
)
elif message:
payload = message
else:
raise HTTPException(400, "Must provide either 'message' or 'payload_file'")
stego_audio, stats = await run_in_thread(
encode_audio,
message=payload,
reference_photo=ref_data,
carrier_audio=carrier_data,
passphrase=passphrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=effective_password,
embed_mode=embed_mode,
channel_key=resolved_channel_key,
chip_tier=chip_tier,
)
channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key)
headers = {
"Content-Disposition": "attachment; filename=stego_audio.wav",
"X-Stegasoo-Embed-Mode": stats.embed_mode,
"X-Stegasoo-Capacity-Percent": f"{stats.capacity_used * 100:.1f}",
"X-Stegasoo-Samples-Modified": str(stats.samples_modified),
"X-Stegasoo-Duration": f"{stats.duration_seconds:.2f}",
"X-Stegasoo-Channel-Mode": channel_mode,
"X-Stegasoo-Version": __version__,
}
if channel_fingerprint:
headers["X-Stegasoo-Channel-Fingerprint"] = channel_fingerprint
return Response(
content=stego_audio,
media_type="audio/wav",
headers=headers,
)
except CapacityError as e:
raise HTTPException(400, str(e))
except StegasooError as e:
raise HTTPException(400, str(e))
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/audio/decode", response_model=DecodeResponse)
async def api_audio_decode(request: AudioDecodeRequest, _: str = Depends(require_api_key)):
"""
Decode a message or file from stego audio.
Returns payload_type to indicate if result is text or file.
v4.3.0: New endpoint for audio steganography.
"""
_require_audio()
resolved_channel_key = _resolve_channel_key(request.channel_key)
try:
stego = base64.b64decode(request.stego_audio_base64)
ref_photo = base64.b64decode(request.reference_photo_base64)
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
result = await run_in_thread(
decode_audio,
stego_audio=stego,
reference_photo=ref_photo,
passphrase=request.passphrase,
pin=request.pin,
rsa_key_data=rsa_key,
rsa_password=request.rsa_password,
embed_mode=request.embed_mode,
channel_key=resolved_channel_key,
)
if result.is_file:
return DecodeResponse(
payload_type="file",
file_data_base64=base64.b64encode(result.file_data).decode("utf-8"),
filename=result.filename,
mime_type=result.mime_type,
)
else:
return DecodeResponse(payload_type="text", message=result.message)
except DecryptionError as e:
error_msg = str(e)
if "channel key" in error_msg.lower():
raise HTTPException(401, error_msg)
raise HTTPException(401, "Decryption failed. Check credentials.")
except StegasooError as e:
raise HTTPException(400, str(e))
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/audio/decode/multipart", response_model=DecodeResponse)
async def api_audio_decode_multipart(
_: str = Depends(require_api_key),
passphrase: str = Form(..., description="Passphrase for key derivation"),
reference_photo: UploadFile = File(...),
stego_audio: UploadFile = File(...),
pin: str = Form(""),
rsa_key: UploadFile | None = File(None),
rsa_password: str = Form(""),
channel_key: str = Form(
"auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit"
),
embed_mode: str = Form("audio_auto"),
):
"""
Decode audio using multipart form data (file uploads).
Returns JSON with payload_type indicating text or file.
v4.3.0: New endpoint for audio steganography.
"""
_require_audio()
if embed_mode not in ("audio_auto", "audio_lsb", "audio_spread"):
raise HTTPException(400, "embed_mode must be 'audio_auto', 'audio_lsb', or 'audio_spread'")
# Resolve channel key
if channel_key.lower() == "auto":
resolved_channel_key = None
elif channel_key.lower() == "none":
resolved_channel_key = ""
else:
resolved_channel_key = _resolve_channel_key(channel_key)
try:
ref_data = await reference_photo.read()
stego_data = await stego_audio.read()
rsa_key_data = None
if rsa_key and rsa_key.filename:
rsa_key_data = await rsa_key.read()
effective_password = rsa_password if rsa_password else None
result = await run_in_thread(
decode_audio,
stego_audio=stego_data,
reference_photo=ref_data,
passphrase=passphrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=effective_password,
embed_mode=embed_mode,
channel_key=resolved_channel_key,
)
if result.is_file:
return DecodeResponse(
payload_type="file",
file_data_base64=base64.b64encode(result.file_data).decode("utf-8"),
filename=result.filename,
mime_type=result.mime_type,
)
else:
return DecodeResponse(payload_type="text", message=result.message)
except DecryptionError as e:
error_msg = str(e)
if "channel key" in error_msg.lower():
raise HTTPException(401, error_msg)
raise HTTPException(401, "Decryption failed. Check credentials.")
except StegasooError as e:
raise HTTPException(400, str(e))
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/audio/info", response_model=AudioInfoResponse)
async def api_audio_info(
_: str = Depends(require_api_key),
audio: UploadFile = File(...),
):
"""
Get audio file metadata and embedding capacity.
v4.3.0: New endpoint for audio steganography.
"""
_require_audio()
try:
audio_data = await audio.read()
info = await run_in_thread(get_audio_info, audio_data)
# Calculate capacities for both modes
lsb_capacity = await run_in_thread(calculate_audio_lsb_capacity, audio_data)
try:
spread_info = await run_in_thread(calculate_audio_spread_capacity, audio_data)
spread_capacity = spread_info.usable_capacity_bytes
except Exception:
spread_capacity = 0
return AudioInfoResponse(
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,
bitrate=info.bitrate,
capacity_lsb=lsb_capacity,
capacity_spread=spread_capacity,
)
except StegasooError as e:
raise HTTPException(400, str(e))
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/audio/capacity", response_model=AudioCapacityResponse)
async def api_audio_capacity(request: AudioCapacityRequest, _: str = Depends(require_api_key)):
"""
Check if a payload of a given size will fit in an audio carrier.
v4.3.0: New endpoint for audio steganography.
"""
_require_audio()
try:
carrier = base64.b64decode(request.carrier_audio_base64)
if request.embed_mode == "audio_lsb":
capacity = await run_in_thread(calculate_audio_lsb_capacity, carrier)
else:
spread_info = await run_in_thread(calculate_audio_spread_capacity, carrier)
capacity = spread_info.usable_capacity_bytes
fits = request.payload_size <= capacity
usage = (request.payload_size / capacity * 100) if capacity > 0 else 100.0
return AudioCapacityResponse(
fits=fits,
payload_size=request.payload_size,
capacity_bytes=capacity,
usage_percent=round(usage, 1),
embed_mode=request.embed_mode,
)
except StegasooError as e:
raise HTTPException(400, str(e))
except Exception as e:
raise HTTPException(500, str(e))
# ============================================================================
# ERROR HANDLERS
# ============================================================================