Add progress bars, fix DCT decode, sparkly MOTD
Progress bar support (v4.1.2): - Web frontend: Real-time progress during encode with phase display - CLI: --progress flag with rich library for encode command - Backend: progress_file parameter for async progress reporting DCT decode bug fix: - Fixed InvalidMagicBytesError not being caught in early-exit check - RS-protected format (v4.1.0+) has length prefix first, not magic bytes - Exception handler now catches both ValueError and InvalidMagicBytesError MOTD update: - Added sparkly header to setup.sh MOTD (matches other rpi scripts) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,11 +24,31 @@ Usage:
|
|||||||
stegasoo channel [SUBCOMMAND]
|
stegasoo channel [SUBCOMMAND]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
# Rich progress bar (optional)
|
||||||
|
try:
|
||||||
|
from rich.progress import (
|
||||||
|
BarColumn,
|
||||||
|
Progress,
|
||||||
|
SpinnerColumn,
|
||||||
|
TaskProgressColumn,
|
||||||
|
TextColumn,
|
||||||
|
TimeElapsedColumn,
|
||||||
|
)
|
||||||
|
|
||||||
|
HAS_RICH = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_RICH = False
|
||||||
|
|
||||||
# Add parent to path for development
|
# Add parent to path for development
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||||
|
|
||||||
@@ -598,6 +618,73 @@ def channel_clear(project, clear_all, force):
|
|||||||
click.echo(" Mode is now: PUBLIC")
|
click.echo(" Mode is now: PUBLIC")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PROGRESS BAR UTILITIES (v4.1.2)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_progress_job_id() -> str:
|
||||||
|
"""Generate a unique job ID for progress tracking."""
|
||||||
|
return str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_progress_file_path(job_id: str) -> str:
|
||||||
|
"""Get the progress file path for a job ID."""
|
||||||
|
return str(Path(tempfile.gettempdir()) / f"stegasoo_progress_{job_id}.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_progress(job_id: str) -> dict | None:
|
||||||
|
"""Read progress from file for a job ID."""
|
||||||
|
progress_file = _get_progress_file_path(job_id)
|
||||||
|
try:
|
||||||
|
with open(progress_file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_progress_file(job_id: str) -> None:
|
||||||
|
"""Remove progress file for a completed job."""
|
||||||
|
progress_file = _get_progress_file_path(job_id)
|
||||||
|
try:
|
||||||
|
Path(progress_file).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _run_encode_with_progress(encode_func, encode_kwargs: dict, progress_file: str) -> tuple:
|
||||||
|
"""
|
||||||
|
Run encode in a thread and return result.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, result_or_error)
|
||||||
|
"""
|
||||||
|
result_holder = {"result": None, "error": None}
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
result_holder["result"] = encode_func(**encode_kwargs, progress_file=progress_file)
|
||||||
|
except Exception as e:
|
||||||
|
result_holder["error"] = e
|
||||||
|
|
||||||
|
thread = threading.Thread(target=run)
|
||||||
|
thread.start()
|
||||||
|
return thread, result_holder
|
||||||
|
|
||||||
|
|
||||||
|
def _format_phase(phase: str) -> str:
|
||||||
|
"""Format phase name for display."""
|
||||||
|
phases = {
|
||||||
|
"starting": "Starting",
|
||||||
|
"initializing": "Initializing",
|
||||||
|
"embedding": "Embedding",
|
||||||
|
"saving": "Saving",
|
||||||
|
"finalizing": "Finalizing",
|
||||||
|
"complete": "Complete",
|
||||||
|
}
|
||||||
|
return phases.get(phase, phase.capitalize())
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ENCODE COMMAND
|
# ENCODE COMMAND
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -642,6 +729,7 @@ def channel_clear(project, clear_all, force):
|
|||||||
help="DCT color mode: grayscale (default) or color (preserves original colors)",
|
help="DCT color mode: grayscale (default) or color (preserves original colors)",
|
||||||
)
|
)
|
||||||
@click.option("--quiet", "-q", is_flag=True, help="Suppress output except errors")
|
@click.option("--quiet", "-q", is_flag=True, help="Suppress output except errors")
|
||||||
|
@click.option("--progress", is_flag=True, help="Show progress bar (requires rich)")
|
||||||
def encode_cmd(
|
def encode_cmd(
|
||||||
ref,
|
ref,
|
||||||
carrier,
|
carrier,
|
||||||
@@ -661,6 +749,7 @@ def encode_cmd(
|
|||||||
dct_output_format,
|
dct_output_format,
|
||||||
dct_color_mode,
|
dct_color_mode,
|
||||||
quiet,
|
quiet,
|
||||||
|
progress,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Encode a secret message or file into an image.
|
Encode a secret message or file into an image.
|
||||||
@@ -808,19 +897,63 @@ def encode_cmd(
|
|||||||
click.echo(channel_status)
|
click.echo(channel_status)
|
||||||
|
|
||||||
# v4.0.0: Include channel_key parameter
|
# v4.0.0: Include channel_key parameter
|
||||||
result = encode(
|
# v4.1.2: Progress bar support
|
||||||
message=payload,
|
encode_kwargs = {
|
||||||
reference_photo=ref_photo,
|
"message": payload,
|
||||||
carrier_image=carrier_image,
|
"reference_photo": ref_photo,
|
||||||
passphrase=passphrase,
|
"carrier_image": carrier_image,
|
||||||
pin=pin or "",
|
"passphrase": passphrase,
|
||||||
rsa_key_data=rsa_key_data,
|
"pin": pin or "",
|
||||||
rsa_password=effective_key_password,
|
"rsa_key_data": rsa_key_data,
|
||||||
embed_mode=embed_mode,
|
"rsa_password": effective_key_password,
|
||||||
dct_output_format=dct_output_format,
|
"embed_mode": embed_mode,
|
||||||
dct_color_mode=dct_color_mode,
|
"dct_output_format": dct_output_format,
|
||||||
channel_key=resolved_channel_key,
|
"dct_color_mode": dct_color_mode,
|
||||||
|
"channel_key": resolved_channel_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
if progress and HAS_RICH:
|
||||||
|
# Run with progress bar
|
||||||
|
job_id = _generate_progress_job_id()
|
||||||
|
progress_file = _get_progress_file_path(job_id)
|
||||||
|
|
||||||
|
thread, result_holder = _run_encode_with_progress(encode, encode_kwargs, progress_file)
|
||||||
|
|
||||||
|
with Progress(
|
||||||
|
SpinnerColumn(),
|
||||||
|
TextColumn("[progress.description]{task.description}"),
|
||||||
|
BarColumn(),
|
||||||
|
TaskProgressColumn(),
|
||||||
|
TimeElapsedColumn(),
|
||||||
|
transient=True,
|
||||||
|
) as progress_bar:
|
||||||
|
task = progress_bar.add_task("Encoding...", total=100)
|
||||||
|
|
||||||
|
while thread.is_alive():
|
||||||
|
prog = _read_progress(job_id)
|
||||||
|
if prog:
|
||||||
|
percent = prog.get("percent", 0)
|
||||||
|
phase = _format_phase(prog.get("phase", "processing"))
|
||||||
|
progress_bar.update(task, completed=percent, description=f"{phase}...")
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
# Final update
|
||||||
|
progress_bar.update(task, completed=100, description="Complete!")
|
||||||
|
|
||||||
|
_cleanup_progress_file(job_id)
|
||||||
|
|
||||||
|
if result_holder["error"]:
|
||||||
|
raise result_holder["error"]
|
||||||
|
result = result_holder["result"]
|
||||||
|
|
||||||
|
elif progress and not HAS_RICH:
|
||||||
|
click.secho(
|
||||||
|
"Warning: --progress requires 'rich' package. Install with: pip install rich",
|
||||||
|
fg="yellow",
|
||||||
)
|
)
|
||||||
|
result = encode(**encode_kwargs)
|
||||||
|
else:
|
||||||
|
result = encode(**encode_kwargs)
|
||||||
|
|
||||||
# Determine output path
|
# Determine output path
|
||||||
if output:
|
if output:
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ import mimetypes
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from auth import (
|
from auth import (
|
||||||
@@ -36,6 +38,7 @@ from auth import (
|
|||||||
can_create_user,
|
can_create_user,
|
||||||
can_save_channel_key,
|
can_save_channel_key,
|
||||||
change_password,
|
change_password,
|
||||||
|
clear_recovery_key,
|
||||||
create_admin_user,
|
create_admin_user,
|
||||||
create_user,
|
create_user,
|
||||||
delete_channel_key,
|
delete_channel_key,
|
||||||
@@ -45,12 +48,11 @@ from auth import (
|
|||||||
get_channel_key_by_id,
|
get_channel_key_by_id,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
get_non_admin_count,
|
get_non_admin_count,
|
||||||
|
get_recovery_key_hash,
|
||||||
get_user_by_id,
|
get_user_by_id,
|
||||||
get_user_channel_keys,
|
get_user_channel_keys,
|
||||||
get_username,
|
get_username,
|
||||||
has_recovery_key,
|
has_recovery_key,
|
||||||
get_recovery_key_hash,
|
|
||||||
clear_recovery_key,
|
|
||||||
is_admin,
|
is_admin,
|
||||||
is_authenticated,
|
is_authenticated,
|
||||||
login_required,
|
login_required,
|
||||||
@@ -59,10 +61,10 @@ from auth import (
|
|||||||
reset_user_password,
|
reset_user_password,
|
||||||
save_channel_key,
|
save_channel_key,
|
||||||
set_recovery_key_hash,
|
set_recovery_key_hash,
|
||||||
verify_and_reset_admin_password,
|
|
||||||
update_channel_key_last_used,
|
update_channel_key_last_used,
|
||||||
update_channel_key_name,
|
update_channel_key_name,
|
||||||
user_exists,
|
user_exists,
|
||||||
|
verify_and_reset_admin_password,
|
||||||
verify_user_password,
|
verify_user_password,
|
||||||
)
|
)
|
||||||
from auth import (
|
from auth import (
|
||||||
@@ -156,7 +158,13 @@ except ImportError:
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Runs encode/decode/compare in subprocesses to prevent jpegio/scipy crashes
|
# Runs encode/decode/compare in subprocesses to prevent jpegio/scipy crashes
|
||||||
# from taking down the Flask server.
|
# from taking down the Flask server.
|
||||||
from subprocess_stego import SubprocessStego
|
from subprocess_stego import (
|
||||||
|
SubprocessStego,
|
||||||
|
cleanup_progress_file,
|
||||||
|
generate_job_id,
|
||||||
|
get_progress_file_path,
|
||||||
|
read_progress,
|
||||||
|
)
|
||||||
|
|
||||||
from stegasoo.qr_utils import (
|
from stegasoo.qr_utils import (
|
||||||
can_fit_in_qr,
|
can_fit_in_qr,
|
||||||
@@ -195,6 +203,42 @@ app.config["HTTPS_ENABLED"] = os.environ.get("STEGASOO_HTTPS_ENABLED", "false").
|
|||||||
# Initialize auth module
|
# Initialize auth module
|
||||||
init_auth(app)
|
init_auth(app)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ASYNC JOB MANAGEMENT (v4.1.2)
|
||||||
|
# ============================================================================
|
||||||
|
# Encode operations can run in background threads with progress reporting
|
||||||
|
|
||||||
|
# Thread pool for background encode/decode operations
|
||||||
|
_executor = ThreadPoolExecutor(max_workers=2)
|
||||||
|
|
||||||
|
# Job storage: job_id -> {status, result, error, file_id, ...}
|
||||||
|
_jobs = {}
|
||||||
|
_jobs_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _store_job(job_id: str, data: dict) -> None:
|
||||||
|
"""Thread-safe job storage."""
|
||||||
|
with _jobs_lock:
|
||||||
|
_jobs[job_id] = data
|
||||||
|
|
||||||
|
|
||||||
|
def _get_job(job_id: str) -> dict | None:
|
||||||
|
"""Thread-safe job retrieval."""
|
||||||
|
with _jobs_lock:
|
||||||
|
return _jobs.get(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_old_jobs(max_age_seconds: int = 3600) -> None:
|
||||||
|
"""Remove jobs older than max_age_seconds."""
|
||||||
|
now = time.time()
|
||||||
|
with _jobs_lock:
|
||||||
|
to_remove = [
|
||||||
|
jid for jid, data in _jobs.items() if now - data.get("created", 0) > max_age_seconds
|
||||||
|
]
|
||||||
|
for jid in to_remove:
|
||||||
|
cleanup_progress_file(jid)
|
||||||
|
del _jobs[jid]
|
||||||
|
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def require_setup():
|
def require_setup():
|
||||||
@@ -817,10 +861,119 @@ def api_check_fit():
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _run_encode_job(job_id: str, encode_params: dict) -> None:
|
||||||
|
"""Background thread function for async encode."""
|
||||||
|
progress_file = get_progress_file_path(job_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_store_job(job_id, {"status": "running", "created": time.time()})
|
||||||
|
|
||||||
|
# Run encode with progress file
|
||||||
|
if encode_params.get("file_data"):
|
||||||
|
encode_result = subprocess_stego.encode(
|
||||||
|
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"],
|
||||||
|
dct_output_format=encode_params.get("dct_output_format", "png"),
|
||||||
|
dct_color_mode=encode_params.get("dct_color_mode", "color"),
|
||||||
|
channel_key=encode_params.get("channel_key"),
|
||||||
|
progress_file=progress_file,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
encode_result = subprocess_stego.encode(
|
||||||
|
carrier_data=encode_params["carrier_data"],
|
||||||
|
reference_data=encode_params["ref_data"],
|
||||||
|
message=encode_params["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"],
|
||||||
|
dct_output_format=encode_params.get("dct_output_format", "png"),
|
||||||
|
dct_color_mode=encode_params.get("dct_color_mode", "color"),
|
||||||
|
channel_key=encode_params.get("channel_key"),
|
||||||
|
progress_file=progress_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not encode_result.success:
|
||||||
|
_store_job(
|
||||||
|
job_id,
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"error": encode_result.error or "Encoding failed",
|
||||||
|
"created": time.time(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine output format
|
||||||
|
embed_mode = encode_params["embed_mode"]
|
||||||
|
dct_output_format = encode_params.get("dct_output_format", "png")
|
||||||
|
dct_color_mode = encode_params.get("dct_color_mode", "color")
|
||||||
|
|
||||||
|
if embed_mode == "dct" and dct_output_format == "jpeg":
|
||||||
|
output_ext = ".jpg"
|
||||||
|
output_mime = "image/jpeg"
|
||||||
|
else:
|
||||||
|
output_ext = ".png"
|
||||||
|
output_mime = "image/png"
|
||||||
|
|
||||||
|
filename = encode_result.filename
|
||||||
|
if not filename:
|
||||||
|
filename = generate_filename("stego", output_ext)
|
||||||
|
elif embed_mode == "dct" and dct_output_format == "jpeg" and filename.endswith(".png"):
|
||||||
|
filename = filename[:-4] + ".jpg"
|
||||||
|
|
||||||
|
# Store result
|
||||||
|
file_id = secrets.token_urlsafe(16)
|
||||||
|
TEMP_FILES[file_id] = {
|
||||||
|
"data": encode_result.stego_data,
|
||||||
|
"filename": filename,
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"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)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/encode", methods=["GET", "POST"])
|
@app.route("/encode", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def encode_page():
|
def encode_page():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
# Check if async mode requested
|
||||||
|
is_async = request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get files
|
# Get files
|
||||||
ref_photo = request.files.get("reference_photo")
|
ref_photo = request.files.get("reference_photo")
|
||||||
@@ -956,7 +1109,9 @@ def encode_page():
|
|||||||
# Pre-check payload capacity BEFORE encode (fail fast)
|
# Pre-check payload capacity BEFORE encode (fail fast)
|
||||||
from stegasoo.steganography import will_fit_by_mode
|
from stegasoo.steganography import will_fit_by_mode
|
||||||
|
|
||||||
payload_size = len(payload.data) if hasattr(payload, "data") else len(payload.encode("utf-8"))
|
payload_size = (
|
||||||
|
len(payload.data) if hasattr(payload, "data") else len(payload.encode("utf-8"))
|
||||||
|
)
|
||||||
fit_check = will_fit_by_mode(payload_size, carrier_data, embed_mode=embed_mode)
|
fit_check = will_fit_by_mode(payload_size, carrier_data, embed_mode=embed_mode)
|
||||||
if not fit_check.get("fits", True):
|
if not fit_check.get("fits", True):
|
||||||
error_msg = (
|
error_msg = (
|
||||||
@@ -972,8 +1127,35 @@ def encode_page():
|
|||||||
flash(error_msg, "error")
|
flash(error_msg, "error")
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
# v4.0.0: Include channel_key parameter
|
# Build encode params for either sync or async
|
||||||
# Use subprocess-isolated encode to prevent crashes
|
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,
|
||||||
|
"dct_output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||||
|
"dct_color_mode": dct_color_mode if embed_mode == "dct" else "color",
|
||||||
|
"channel_key": channel_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# ASYNC MODE: Start background job and return JSON
|
||||||
|
if is_async:
|
||||||
|
job_id = generate_job_id()
|
||||||
|
_store_job(job_id, {"status": "pending", "created": time.time()})
|
||||||
|
_executor.submit(_run_encode_job, job_id, encode_params)
|
||||||
|
return jsonify({"job_id": job_id, "status": "pending"})
|
||||||
|
|
||||||
|
# SYNC MODE: Run inline (original behavior)
|
||||||
if payload_type == "file" and payload_file and payload_file.filename:
|
if payload_type == "file" and payload_file and payload_file.filename:
|
||||||
encode_result = subprocess_stego.encode(
|
encode_result = subprocess_stego.encode(
|
||||||
carrier_data=carrier_data,
|
carrier_data=carrier_data,
|
||||||
@@ -988,7 +1170,7 @@ def encode_page():
|
|||||||
embed_mode=embed_mode,
|
embed_mode=embed_mode,
|
||||||
dct_output_format=dct_output_format if embed_mode == "dct" else "png",
|
dct_output_format=dct_output_format if embed_mode == "dct" else "png",
|
||||||
dct_color_mode=dct_color_mode if embed_mode == "dct" else "color",
|
dct_color_mode=dct_color_mode if embed_mode == "dct" else "color",
|
||||||
channel_key=channel_key, # v4.0.0
|
channel_key=channel_key,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
encode_result = subprocess_stego.encode(
|
encode_result = subprocess_stego.encode(
|
||||||
@@ -1002,7 +1184,7 @@ def encode_page():
|
|||||||
embed_mode=embed_mode,
|
embed_mode=embed_mode,
|
||||||
dct_output_format=dct_output_format if embed_mode == "dct" else "png",
|
dct_output_format=dct_output_format if embed_mode == "dct" else "png",
|
||||||
dct_color_mode=dct_color_mode if embed_mode == "dct" else "color",
|
dct_color_mode=dct_color_mode if embed_mode == "dct" else "color",
|
||||||
channel_key=channel_key, # v4.0.0
|
channel_key=channel_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for subprocess errors
|
# Check for subprocess errors
|
||||||
@@ -1058,6 +1240,53 @@ def encode_page():
|
|||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ENCODE PROGRESS ENDPOINTS (v4.1.2)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/encode/status/<job_id>")
|
||||||
|
@login_required
|
||||||
|
def encode_status(job_id):
|
||||||
|
"""Get the status of an async encode job."""
|
||||||
|
job = _get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({"error": "Job not found"}), 404
|
||||||
|
|
||||||
|
response = {"status": job.get("status", "unknown")}
|
||||||
|
|
||||||
|
if job["status"] == "complete":
|
||||||
|
response["file_id"] = job.get("file_id")
|
||||||
|
elif job["status"] == "error":
|
||||||
|
response["error"] = job.get("error", "Unknown error")
|
||||||
|
|
||||||
|
return jsonify(response)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/encode/progress/<job_id>")
|
||||||
|
@login_required
|
||||||
|
def encode_progress(job_id):
|
||||||
|
"""Get the progress of an async encode job."""
|
||||||
|
progress = read_progress(job_id)
|
||||||
|
if progress:
|
||||||
|
return jsonify(progress)
|
||||||
|
|
||||||
|
# No progress file yet - check job status
|
||||||
|
job = _get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({"error": "Job not found"}), 404
|
||||||
|
|
||||||
|
if job["status"] == "complete":
|
||||||
|
return jsonify({"percent": 100, "phase": "complete"})
|
||||||
|
elif job["status"] == "error":
|
||||||
|
return jsonify({"percent": 0, "phase": "error", "error": job.get("error")})
|
||||||
|
elif job["status"] == "pending":
|
||||||
|
return jsonify({"percent": 0, "phase": "starting"})
|
||||||
|
|
||||||
|
# Running but no progress file yet
|
||||||
|
return jsonify({"percent": 0, "phase": "initializing"})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/encode/result/<file_id>")
|
@app.route("/encode/result/<file_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def encode_result(file_id):
|
def encode_result(file_id):
|
||||||
@@ -1402,12 +1631,7 @@ def api_tools_strip_metadata():
|
|||||||
buffer = io.BytesIO(clean_data)
|
buffer = io.BytesIO(clean_data)
|
||||||
filename = image_file.filename.rsplit(".", 1)[0] + "_clean.png"
|
filename = image_file.filename.rsplit(".", 1)[0] + "_clean.png"
|
||||||
|
|
||||||
return send_file(
|
return send_file(buffer, mimetype="image/png", as_attachment=True, download_name=filename)
|
||||||
buffer,
|
|
||||||
mimetype="image/png",
|
|
||||||
as_attachment=True,
|
|
||||||
download_name=filename
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "error": str(e)}), 400
|
return jsonify({"success": False, "error": str(e)}), 400
|
||||||
|
|
||||||
@@ -1429,13 +1653,15 @@ def api_tools_exif():
|
|||||||
# Check if it's a JPEG (editable) or not
|
# Check if it's a JPEG (editable) or not
|
||||||
is_jpeg = image_data[:2] == b"\xff\xd8"
|
is_jpeg = image_data[:2] == b"\xff\xd8"
|
||||||
|
|
||||||
return jsonify({
|
return jsonify(
|
||||||
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"filename": image_file.filename,
|
"filename": image_file.filename,
|
||||||
"exif": exif,
|
"exif": exif,
|
||||||
"editable": is_jpeg,
|
"editable": is_jpeg,
|
||||||
"field_count": len(exif),
|
"field_count": len(exif),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "error": str(e)}), 400
|
return jsonify({"success": False, "error": str(e)}), 400
|
||||||
|
|
||||||
@@ -1454,6 +1680,7 @@ def api_tools_exif_update():
|
|||||||
updates_json = request.form.get("updates", "{}")
|
updates_json = request.form.get("updates", "{}")
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
|
|
||||||
updates = json.loads(updates_json)
|
updates = json.loads(updates_json)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return jsonify({"success": False, "error": "Invalid updates JSON"}), 400
|
return jsonify({"success": False, "error": "Invalid updates JSON"}), 400
|
||||||
@@ -1499,11 +1726,19 @@ def api_tools_exif_clear():
|
|||||||
clean_data = strip_image_metadata(image_data, output_format=output_format)
|
clean_data = strip_image_metadata(image_data, output_format=output_format)
|
||||||
|
|
||||||
# Determine extension and mimetype
|
# Determine extension and mimetype
|
||||||
ext_map = {"PNG": ("png", "image/png"), "JPEG": ("jpg", "image/jpeg"), "BMP": ("bmp", "image/bmp")}
|
ext_map = {
|
||||||
|
"PNG": ("png", "image/png"),
|
||||||
|
"JPEG": ("jpg", "image/jpeg"),
|
||||||
|
"BMP": ("bmp", "image/bmp"),
|
||||||
|
}
|
||||||
ext, mimetype = ext_map.get(output_format, ("png", "image/png"))
|
ext, mimetype = ext_map.get(output_format, ("png", "image/png"))
|
||||||
|
|
||||||
# Return as downloadable file
|
# Return as downloadable file
|
||||||
stem = image_file.filename.rsplit(".", 1)[0] if "." in image_file.filename else image_file.filename
|
stem = (
|
||||||
|
image_file.filename.rsplit(".", 1)[0]
|
||||||
|
if "." in image_file.filename
|
||||||
|
else image_file.filename
|
||||||
|
)
|
||||||
buffer = io.BytesIO(clean_data)
|
buffer = io.BytesIO(clean_data)
|
||||||
return send_file(
|
return send_file(
|
||||||
buffer,
|
buffer,
|
||||||
@@ -1644,9 +1879,10 @@ def setup():
|
|||||||
@login_required
|
@login_required
|
||||||
def setup_recovery():
|
def setup_recovery():
|
||||||
"""Recovery key setup page (Step 2 of initial setup)."""
|
"""Recovery key setup page (Step 2 of initial setup)."""
|
||||||
from stegasoo.recovery import generate_recovery_key, hash_recovery_key, generate_recovery_qr
|
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
from stegasoo.recovery import generate_recovery_key, generate_recovery_qr, hash_recovery_key
|
||||||
|
|
||||||
# Only allow during initial setup (no recovery key yet, first admin)
|
# Only allow during initial setup (no recovery key yet, first admin)
|
||||||
if has_recovery_key():
|
if has_recovery_key():
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
@@ -1724,9 +1960,10 @@ def recover():
|
|||||||
@admin_required
|
@admin_required
|
||||||
def regenerate_recovery():
|
def regenerate_recovery():
|
||||||
"""Generate a new recovery key (replaces existing one)."""
|
"""Generate a new recovery key (replaces existing one)."""
|
||||||
from stegasoo.recovery import generate_recovery_key, hash_recovery_key, generate_recovery_qr
|
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
from stegasoo.recovery import generate_recovery_key, generate_recovery_qr, hash_recovery_key
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
action = request.form.get("action")
|
action = request.form.get("action")
|
||||||
|
|
||||||
@@ -1925,7 +2162,8 @@ def api_channel_keys():
|
|||||||
"""Get saved channel keys for current user (JSON API)."""
|
"""Get saved channel keys for current user (JSON API)."""
|
||||||
current_user = get_current_user()
|
current_user = get_current_user()
|
||||||
keys = get_user_channel_keys(current_user.id)
|
keys = get_user_channel_keys(current_user.id)
|
||||||
return jsonify({
|
return jsonify(
|
||||||
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"keys": [
|
"keys": [
|
||||||
{
|
{
|
||||||
@@ -1939,7 +2177,8 @@ def api_channel_keys():
|
|||||||
],
|
],
|
||||||
"can_save": can_save_channel_key(current_user.id),
|
"can_save": can_save_channel_key(current_user.id),
|
||||||
"max_keys": MAX_CHANNEL_KEYS,
|
"max_keys": MAX_CHANNEL_KEYS,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/channel/keys/<int:key_id>/use", methods=["POST"])
|
@app.route("/api/channel/keys/<int:key_id>/use", methods=["POST"])
|
||||||
|
|||||||
@@ -916,6 +916,180 @@ const Stegasoo = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// ASYNC ENCODE WITH PROGRESS (v4.1.2)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit encode form asynchronously with progress tracking
|
||||||
|
* @param {HTMLFormElement} form - The encode form
|
||||||
|
* @param {HTMLElement} btn - The submit button
|
||||||
|
*/
|
||||||
|
async submitEncodeAsync(form, btn) {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
formData.append('async', 'true');
|
||||||
|
|
||||||
|
// Show progress modal
|
||||||
|
this.showProgressModal('Encoding');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start encode job
|
||||||
|
const response = await fetch('/encode', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to start encode');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = result.job_id;
|
||||||
|
|
||||||
|
// Poll for progress
|
||||||
|
await this.pollEncodeProgress(jobId);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.hideProgressModal();
|
||||||
|
alert('Encode failed: ' + error.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-lock-fill me-2"></i>Encode';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll encode progress until complete
|
||||||
|
* @param {string} jobId - The job ID
|
||||||
|
*/
|
||||||
|
async pollEncodeProgress(jobId) {
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
const progressText = document.getElementById('progressText');
|
||||||
|
const phaseText = document.getElementById('progressPhase');
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
// Check status first
|
||||||
|
const statusResponse = await fetch(`/encode/status/${jobId}`);
|
||||||
|
const statusData = await statusResponse.json();
|
||||||
|
|
||||||
|
if (statusData.status === 'complete') {
|
||||||
|
// Done - redirect to result
|
||||||
|
this.updateProgress(100, 'Complete!');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `/encode/result/${statusData.file_id}`;
|
||||||
|
}, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusData.status === 'error') {
|
||||||
|
throw new Error(statusData.error || 'Encode failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get progress
|
||||||
|
const progressResponse = await fetch(`/encode/progress/${jobId}`);
|
||||||
|
const progressData = await progressResponse.json();
|
||||||
|
|
||||||
|
const percent = progressData.percent || 0;
|
||||||
|
const phase = progressData.phase || 'processing';
|
||||||
|
|
||||||
|
this.updateProgress(percent, this.formatPhase(phase));
|
||||||
|
|
||||||
|
// Continue polling
|
||||||
|
setTimeout(poll, 500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.hideProgressModal();
|
||||||
|
alert('Encode failed: ' + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await poll();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format phase name for display
|
||||||
|
*/
|
||||||
|
formatPhase(phase) {
|
||||||
|
const phases = {
|
||||||
|
'starting': 'Starting...',
|
||||||
|
'initializing': 'Initializing...',
|
||||||
|
'embedding': 'Embedding data...',
|
||||||
|
'saving': 'Saving image...',
|
||||||
|
'finalizing': 'Finalizing...',
|
||||||
|
'complete': 'Complete!',
|
||||||
|
};
|
||||||
|
return phases[phase] || phase;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show progress modal
|
||||||
|
*/
|
||||||
|
showProgressModal(operation = 'Processing') {
|
||||||
|
// Create modal if doesn't exist
|
||||||
|
let modal = document.getElementById('progressModal');
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'progressModal';
|
||||||
|
modal.className = 'modal fade';
|
||||||
|
modal.setAttribute('data-bs-backdrop', 'static');
|
||||||
|
modal.setAttribute('data-bs-keyboard', 'false');
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content bg-dark text-light">
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<h5 class="mb-3" id="progressTitle">${operation}...</h5>
|
||||||
|
<div class="progress mb-2" style="height: 24px;">
|
||||||
|
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated bg-success"
|
||||||
|
role="progressbar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between text-muted small">
|
||||||
|
<span id="progressPhase">Initializing...</span>
|
||||||
|
<span id="progressText">0%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset progress
|
||||||
|
this.updateProgress(0, 'Initializing...');
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
const bsModal = new bootstrap.Modal(modal);
|
||||||
|
bsModal.show();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide progress modal
|
||||||
|
*/
|
||||||
|
hideProgressModal() {
|
||||||
|
const modal = document.getElementById('progressModal');
|
||||||
|
if (modal) {
|
||||||
|
const bsModal = bootstrap.Modal.getInstance(modal);
|
||||||
|
bsModal?.hide();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update progress bar and text
|
||||||
|
*/
|
||||||
|
updateProgress(percent, phase) {
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
const progressText = document.getElementById('progressText');
|
||||||
|
const phaseText = document.getElementById('progressPhase');
|
||||||
|
|
||||||
|
if (progressBar) progressBar.style.width = percent + '%';
|
||||||
|
if (progressText) progressText.textContent = Math.round(percent) + '%';
|
||||||
|
if (phaseText) phaseText.textContent = phase;
|
||||||
|
},
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// INITIALIZATION HELPERS
|
// INITIALIZATION HELPERS
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -937,27 +1111,23 @@ const Stegasoo = {
|
|||||||
generateBtnId: 'channelKeyGenerate'
|
generateBtnId: 'channelKeyGenerate'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form submission with channel key validation
|
// Form submission with async progress tracking (v4.1.2)
|
||||||
const form = document.getElementById('encodeForm');
|
const form = document.getElementById('encodeForm');
|
||||||
const btn = document.getElementById('encodeBtn');
|
const btn = document.getElementById('encodeBtn');
|
||||||
form?.addEventListener('submit', (e) => {
|
form?.addEventListener('submit', (e) => {
|
||||||
if (!this.validateChannelKeyOnSubmit(form, 'channelSelect', 'channelKeyInput')) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!this.validateChannelKeyOnSubmit(form, 'channelSelect', 'channelKeyInput')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
const startTime = Date.now();
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting...';
|
||||||
const updateTimer = () => {
|
|
||||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
||||||
const mins = Math.floor(elapsed / 60);
|
|
||||||
const secs = elapsed % 60;
|
|
||||||
const timeStr = mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}s`;
|
|
||||||
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Encoding... ${timeStr}`;
|
|
||||||
};
|
|
||||||
updateTimer();
|
|
||||||
setInterval(updateTimer, 1000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use async submission with progress tracking
|
||||||
|
this.submitEncodeAsync(form, btn);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ def encode_operation(params: dict) -> dict:
|
|||||||
dct_output_format=params.get("dct_output_format", "png"),
|
dct_output_format=params.get("dct_output_format", "png"),
|
||||||
dct_color_mode=params.get("dct_color_mode", "color"),
|
dct_color_mode=params.get("dct_color_mode", "color"),
|
||||||
channel_key=resolved_channel_key, # v4.0.0
|
channel_key=resolved_channel_key, # v4.0.0
|
||||||
|
progress_file=params.get("progress_file"), # v4.1.2
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build stats dict if available
|
# Build stats dict if available
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -233,6 +235,8 @@ class SubprocessStego:
|
|||||||
# Channel key (v4.0.0)
|
# Channel key (v4.0.0)
|
||||||
channel_key: str | None = "auto",
|
channel_key: str | None = "auto",
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
|
# Progress file (v4.1.2)
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> EncodeResult:
|
) -> EncodeResult:
|
||||||
"""
|
"""
|
||||||
Encode a message or file into an image.
|
Encode a message or file into an image.
|
||||||
@@ -268,6 +272,7 @@ class SubprocessStego:
|
|||||||
"dct_output_format": dct_output_format,
|
"dct_output_format": dct_output_format,
|
||||||
"dct_color_mode": dct_color_mode,
|
"dct_color_mode": dct_color_mode,
|
||||||
"channel_key": channel_key, # v4.0.0
|
"channel_key": channel_key, # v4.0.0
|
||||||
|
"progress_file": progress_file, # v4.1.2
|
||||||
}
|
}
|
||||||
|
|
||||||
if file_data:
|
if file_data:
|
||||||
@@ -496,3 +501,42 @@ def get_subprocess_stego() -> SubprocessStego:
|
|||||||
if _default_stego is None:
|
if _default_stego is None:
|
||||||
_default_stego = SubprocessStego()
|
_default_stego = SubprocessStego()
|
||||||
return _default_stego
|
return _default_stego
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Progress File Utilities (v4.1.2)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def generate_job_id() -> str:
|
||||||
|
"""Generate a unique job ID for tracking encode/decode operations."""
|
||||||
|
return str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def get_progress_file_path(job_id: str) -> str:
|
||||||
|
"""Get the progress file path for a job ID."""
|
||||||
|
return str(Path(tempfile.gettempdir()) / f"stegasoo_progress_{job_id}.json")
|
||||||
|
|
||||||
|
|
||||||
|
def read_progress(job_id: str) -> dict | None:
|
||||||
|
"""
|
||||||
|
Read progress from file for a job ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Progress dict with current, total, percent, phase, or None if not found
|
||||||
|
"""
|
||||||
|
progress_file = get_progress_file_path(job_id)
|
||||||
|
try:
|
||||||
|
with open(progress_file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_progress_file(job_id: str) -> None:
|
||||||
|
"""Remove progress file for a completed job."""
|
||||||
|
progress_file = get_progress_file_path(job_id)
|
||||||
|
try:
|
||||||
|
Path(progress_file).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ cli = [
|
|||||||
"click>=8.0.0",
|
"click>=8.0.0",
|
||||||
"qrcode>=7.30",
|
"qrcode>=7.30",
|
||||||
"piexif>=1.1.0",
|
"piexif>=1.1.0",
|
||||||
|
"rich>=13.0.0",
|
||||||
]
|
]
|
||||||
compression = [
|
compression = [
|
||||||
"lz4>=4.0.0",
|
"lz4>=4.0.0",
|
||||||
|
|||||||
@@ -335,11 +335,14 @@ if systemctl is-active --quiet stegasoo 2>/dev/null; then
|
|||||||
STEGASOO_URL="http://$PI_IP:5000"
|
STEGASOO_URL="http://$PI_IP:5000"
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
echo -e "\033[0;90m . * . . * . * . * . * .\033[0m"
|
||||||
echo -e "\033[0;36m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
echo -e "\033[0;36m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
||||||
echo -e "\033[0;36m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
|
echo -e "\033[0;36m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
|
||||||
echo -e "\033[0;36m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
|
echo -e "\033[0;36m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
|
||||||
echo -e "\033[0;36m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
|
echo -e "\033[0;36m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo -e "\033[0;90m * . * . * . * . * . *\033[0m"
|
||||||
|
echo ""
|
||||||
echo -e " \033[0;32m●\033[0m Stegasoo is running"
|
echo -e " \033[0;32m●\033[0m Stegasoo is running"
|
||||||
echo -e " \033[0;33m$STEGASOO_URL\033[0m"
|
echo -e " \033[0;33m$STEGASOO_URL\033[0m"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -55,7 +55,32 @@ except ImportError:
|
|||||||
jio = None
|
jio = None
|
||||||
|
|
||||||
# Import custom exceptions
|
# Import custom exceptions
|
||||||
from .exceptions import InvalidMagicBytesError, ReedSolomonError as StegasooRSError
|
from .exceptions import InvalidMagicBytesError
|
||||||
|
from .exceptions import ReedSolomonError as StegasooRSError
|
||||||
|
|
||||||
|
# Progress reporting interval (write every N blocks)
|
||||||
|
PROGRESS_INTERVAL = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _write_progress(progress_file: str | None, current: int, total: int, phase: str = "embedding"):
|
||||||
|
"""Write progress to file for frontend polling."""
|
||||||
|
if progress_file is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
|
with open(progress_file, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"current": current,
|
||||||
|
"total": total,
|
||||||
|
"percent": round((current / total) * 100, 1) if total > 0 else 0,
|
||||||
|
"phase": phase,
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Don't let progress writing break encoding
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -189,7 +214,7 @@ def has_jpegio_support() -> bool:
|
|||||||
|
|
||||||
# Check for reedsolo availability
|
# Check for reedsolo availability
|
||||||
try:
|
try:
|
||||||
from reedsolo import RSCodec, ReedSolomonError
|
from reedsolo import ReedSolomonError, RSCodec
|
||||||
|
|
||||||
HAS_REEDSOLO = True
|
HAS_REEDSOLO = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -559,6 +584,7 @@ def embed_in_dct(
|
|||||||
seed: bytes,
|
seed: bytes,
|
||||||
output_format: str = OUTPUT_FORMAT_PNG,
|
output_format: str = OUTPUT_FORMAT_PNG,
|
||||||
color_mode: str = "color",
|
color_mode: str = "color",
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> tuple[bytes, DCTEmbedStats]:
|
) -> tuple[bytes, DCTEmbedStats]:
|
||||||
"""Embed data using DCT coefficient modification."""
|
"""Embed data using DCT coefficient modification."""
|
||||||
if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG):
|
if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG):
|
||||||
@@ -568,10 +594,12 @@ def embed_in_dct(
|
|||||||
color_mode = "color"
|
color_mode = "color"
|
||||||
|
|
||||||
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
|
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
|
||||||
return _embed_jpegio(data, carrier_image, seed, color_mode)
|
return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file)
|
||||||
|
|
||||||
_check_scipy()
|
_check_scipy()
|
||||||
return _embed_scipy_dct_safe(data, carrier_image, seed, output_format, color_mode)
|
return _embed_scipy_dct_safe(
|
||||||
|
data, carrier_image, seed, output_format, color_mode, progress_file
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _embed_scipy_dct_safe(
|
def _embed_scipy_dct_safe(
|
||||||
@@ -580,6 +608,7 @@ def _embed_scipy_dct_safe(
|
|||||||
seed: bytes,
|
seed: bytes,
|
||||||
output_format: str,
|
output_format: str,
|
||||||
color_mode: str = "color",
|
color_mode: str = "color",
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> tuple[bytes, DCTEmbedStats]:
|
) -> tuple[bytes, DCTEmbedStats]:
|
||||||
"""
|
"""
|
||||||
Embed using scipy DCT with safe memory handling.
|
Embed using scipy DCT with safe memory handling.
|
||||||
@@ -642,7 +671,7 @@ def _embed_scipy_dct_safe(
|
|||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
# Embed in Y channel
|
# Embed in Y channel
|
||||||
Y_embedded = _embed_in_channel_safe(Y_padded, bits, block_order, blocks_x)
|
Y_embedded = _embed_in_channel_safe(Y_padded, bits, block_order, blocks_x, progress_file)
|
||||||
del Y_padded
|
del Y_padded
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
@@ -666,7 +695,7 @@ def _embed_scipy_dct_safe(
|
|||||||
del image
|
del image
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
embedded = _embed_in_channel_safe(padded, bits, block_order, blocks_x)
|
embedded = _embed_in_channel_safe(padded, bits, block_order, blocks_x, progress_file)
|
||||||
del padded
|
del padded
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
@@ -699,6 +728,7 @@ def _embed_in_channel_safe(
|
|||||||
bits: list,
|
bits: list,
|
||||||
block_order: list,
|
block_order: list,
|
||||||
blocks_x: int,
|
blocks_x: int,
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""
|
"""
|
||||||
Embed bits in channel using safe DCT operations.
|
Embed bits in channel using safe DCT operations.
|
||||||
@@ -711,8 +741,9 @@ def _embed_in_channel_safe(
|
|||||||
result = np.array(channel, dtype=np.float64, copy=True, order="C")
|
result = np.array(channel, dtype=np.float64, copy=True, order="C")
|
||||||
|
|
||||||
bit_idx = 0
|
bit_idx = 0
|
||||||
|
total_blocks = len(block_order)
|
||||||
|
|
||||||
for block_num in block_order:
|
for block_idx, block_num in enumerate(block_order):
|
||||||
if bit_idx >= len(bits):
|
if bit_idx >= len(bits):
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -748,6 +779,14 @@ def _embed_in_channel_safe(
|
|||||||
# Clean up this iteration
|
# Clean up this iteration
|
||||||
del block, dct_block, modified_block
|
del block, dct_block, modified_block
|
||||||
|
|
||||||
|
# Report progress periodically
|
||||||
|
if progress_file and block_idx % PROGRESS_INTERVAL == 0:
|
||||||
|
_write_progress(progress_file, block_idx, total_blocks, "embedding")
|
||||||
|
|
||||||
|
# Final progress update
|
||||||
|
if progress_file:
|
||||||
|
_write_progress(progress_file, total_blocks, total_blocks, "finalizing")
|
||||||
|
|
||||||
# Force garbage collection
|
# Force garbage collection
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
@@ -804,6 +843,7 @@ def _embed_jpegio(
|
|||||||
carrier_image: bytes,
|
carrier_image: bytes,
|
||||||
seed: bytes,
|
seed: bytes,
|
||||||
color_mode: str = "color",
|
color_mode: str = "color",
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> tuple[bytes, DCTEmbedStats]:
|
) -> tuple[bytes, DCTEmbedStats]:
|
||||||
"""Embed using jpegio for proper JPEG coefficient modification."""
|
"""Embed using jpegio for proper JPEG coefficient modification."""
|
||||||
import os
|
import os
|
||||||
@@ -861,6 +901,9 @@ def _embed_jpegio(
|
|||||||
)
|
)
|
||||||
|
|
||||||
coefs_used = 0
|
coefs_used = 0
|
||||||
|
total_bits = len(bits)
|
||||||
|
progress_interval = max(total_bits // 20, 100) # Report ~20 times or every 100 bits
|
||||||
|
|
||||||
for bit_idx, pos_idx in enumerate(order):
|
for bit_idx, pos_idx in enumerate(order):
|
||||||
if bit_idx >= len(bits):
|
if bit_idx >= len(bits):
|
||||||
break
|
break
|
||||||
@@ -876,6 +919,14 @@ def _embed_jpegio(
|
|||||||
|
|
||||||
coefs_used += 1
|
coefs_used += 1
|
||||||
|
|
||||||
|
# Report progress periodically
|
||||||
|
if progress_file and bit_idx % progress_interval == 0:
|
||||||
|
_write_progress(progress_file, bit_idx, total_bits, "embedding")
|
||||||
|
|
||||||
|
# Final progress before save
|
||||||
|
if progress_file:
|
||||||
|
_write_progress(progress_file, total_bits, total_bits, "saving")
|
||||||
|
|
||||||
jio.write(jpeg, output_path)
|
jio.write(jpeg, output_path)
|
||||||
|
|
||||||
with open(output_path, "rb") as f:
|
with open(output_path, "rb") as f:
|
||||||
@@ -971,8 +1022,8 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
total_needed = (HEADER_SIZE + data_length) * 8
|
total_needed = (HEADER_SIZE + data_length) * 8
|
||||||
if len(all_bits) >= total_needed:
|
if len(all_bits) >= total_needed:
|
||||||
break
|
break
|
||||||
except ValueError:
|
except (ValueError, InvalidMagicBytesError):
|
||||||
pass
|
pass # RS-protected format has length prefix first, not magic bytes
|
||||||
|
|
||||||
del padded
|
del padded
|
||||||
gc.collect()
|
gc.collect()
|
||||||
@@ -997,6 +1048,7 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
|
|
||||||
# Count occurrences of each unique copy
|
# Count occurrences of each unique copy
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
counter = Counter(copies)
|
counter = Counter(copies)
|
||||||
best_header, count = counter.most_common(1)[0]
|
best_header, count = counter.most_common(1)[0]
|
||||||
|
|
||||||
@@ -1009,9 +1061,13 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
|
|
||||||
# Sanity check: both lengths should be reasonable
|
# Sanity check: both lengths should be reasonable
|
||||||
max_reasonable = (len(all_bits) // 8) - RS_LENGTH_PREFIX_SIZE
|
max_reasonable = (len(all_bits) // 8) - RS_LENGTH_PREFIX_SIZE
|
||||||
if (raw_payload_length > 0 and raw_payload_length <= max_reasonable and
|
if (
|
||||||
rs_encoded_length > 0 and rs_encoded_length <= max_reasonable and
|
raw_payload_length > 0
|
||||||
rs_encoded_length >= raw_payload_length):
|
and raw_payload_length <= max_reasonable
|
||||||
|
and rs_encoded_length > 0
|
||||||
|
and rs_encoded_length <= max_reasonable
|
||||||
|
and rs_encoded_length >= raw_payload_length
|
||||||
|
):
|
||||||
# This looks like RS-protected format
|
# This looks like RS-protected format
|
||||||
total_bits_needed = (RS_LENGTH_PREFIX_SIZE + rs_encoded_length) * 8
|
total_bits_needed = (RS_LENGTH_PREFIX_SIZE + rs_encoded_length) * 8
|
||||||
|
|
||||||
@@ -1088,6 +1144,7 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
|
|
||||||
# Extract 3 copies and use majority voting
|
# Extract 3 copies and use majority voting
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
copies = []
|
copies = []
|
||||||
for i in range(RS_LENGTH_COPIES):
|
for i in range(RS_LENGTH_COPIES):
|
||||||
start = i * RS_LENGTH_HEADER_SIZE
|
start = i * RS_LENGTH_HEADER_SIZE
|
||||||
@@ -1104,9 +1161,13 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
|
|
||||||
# Sanity check
|
# Sanity check
|
||||||
max_reasonable = (len(all_positions) // 8) - RS_LENGTH_PREFIX_SIZE
|
max_reasonable = (len(all_positions) // 8) - RS_LENGTH_PREFIX_SIZE
|
||||||
if (raw_payload_length > 0 and raw_payload_length <= max_reasonable and
|
if (
|
||||||
rs_encoded_length > 0 and rs_encoded_length <= max_reasonable and
|
raw_payload_length > 0
|
||||||
rs_encoded_length >= raw_payload_length):
|
and raw_payload_length <= max_reasonable
|
||||||
|
and rs_encoded_length > 0
|
||||||
|
and rs_encoded_length <= max_reasonable
|
||||||
|
and rs_encoded_length >= raw_payload_length
|
||||||
|
):
|
||||||
total_bits_needed = (RS_LENGTH_PREFIX_SIZE + rs_encoded_length) * 8
|
total_bits_needed = (RS_LENGTH_PREFIX_SIZE + rs_encoded_length) * 8
|
||||||
|
|
||||||
if len(all_positions) >= total_bits_needed:
|
if len(all_positions) >= total_bits_needed:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ def encode(
|
|||||||
dct_output_format: str = "png",
|
dct_output_format: str = "png",
|
||||||
dct_color_mode: str = "color",
|
dct_color_mode: str = "color",
|
||||||
channel_key: str | bool | None = None,
|
channel_key: str | bool | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> EncodeResult:
|
) -> EncodeResult:
|
||||||
"""
|
"""
|
||||||
Encode a message or file into an image.
|
Encode a message or file into an image.
|
||||||
@@ -118,6 +119,7 @@ def encode(
|
|||||||
embed_mode=embed_mode,
|
embed_mode=embed_mode,
|
||||||
dct_output_format=dct_output_format,
|
dct_output_format=dct_output_format,
|
||||||
dct_color_mode=dct_color_mode,
|
dct_color_mode=dct_color_mode,
|
||||||
|
progress_file=progress_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate filename
|
# Generate filename
|
||||||
|
|||||||
@@ -39,6 +39,31 @@ from .debug import debug
|
|||||||
from .exceptions import CapacityError, EmbeddingError
|
from .exceptions import CapacityError, EmbeddingError
|
||||||
from .models import EmbedStats, FilePayload
|
from .models import EmbedStats, FilePayload
|
||||||
|
|
||||||
|
# Progress reporting interval
|
||||||
|
PROGRESS_INTERVAL = 1000 # Write every N pixels for LSB
|
||||||
|
|
||||||
|
|
||||||
|
def _write_progress(progress_file: str | None, current: int, total: int, phase: str = "embedding"):
|
||||||
|
"""Write progress to file for frontend polling."""
|
||||||
|
if progress_file is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
|
with open(progress_file, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"current": current,
|
||||||
|
"total": total,
|
||||||
|
"percent": round((current / total) * 100, 1) if total > 0 else 0,
|
||||||
|
"phase": phase,
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Don't let progress writing break encoding
|
||||||
|
|
||||||
|
|
||||||
# Lossless formats that preserve LSB data
|
# Lossless formats that preserve LSB data
|
||||||
LOSSLESS_FORMATS = {"PNG", "BMP", "TIFF"}
|
LOSSLESS_FORMATS = {"PNG", "BMP", "TIFF"}
|
||||||
|
|
||||||
@@ -526,6 +551,7 @@ def embed_in_image(
|
|||||||
embed_mode: str = EMBED_MODE_LSB,
|
embed_mode: str = EMBED_MODE_LSB,
|
||||||
dct_output_format: str = DCT_OUTPUT_PNG,
|
dct_output_format: str = DCT_OUTPUT_PNG,
|
||||||
dct_color_mode: str = "color",
|
dct_color_mode: str = "color",
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> tuple[bytes, Union[EmbedStats, "DCTEmbedStats"], str]:
|
) -> tuple[bytes, Union[EmbedStats, "DCTEmbedStats"], str]:
|
||||||
"""
|
"""
|
||||||
Embed data into an image using specified mode.
|
Embed data into an image using specified mode.
|
||||||
@@ -579,6 +605,7 @@ def embed_in_image(
|
|||||||
pixel_key,
|
pixel_key,
|
||||||
output_format=dct_output_format,
|
output_format=dct_output_format,
|
||||||
color_mode=dct_color_mode,
|
color_mode=dct_color_mode,
|
||||||
|
progress_file=progress_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Determine extension based on output format
|
# Determine extension based on output format
|
||||||
@@ -594,7 +621,7 @@ def embed_in_image(
|
|||||||
return stego_bytes, dct_stats, ext
|
return stego_bytes, dct_stats, ext
|
||||||
|
|
||||||
# LSB MODE
|
# LSB MODE
|
||||||
return _embed_lsb(data, image_data, pixel_key, bits_per_channel, output_format)
|
return _embed_lsb(data, image_data, pixel_key, bits_per_channel, output_format, progress_file)
|
||||||
|
|
||||||
|
|
||||||
def _embed_lsb(
|
def _embed_lsb(
|
||||||
@@ -603,6 +630,7 @@ def _embed_lsb(
|
|||||||
pixel_key: bytes,
|
pixel_key: bytes,
|
||||||
bits_per_channel: int = 1,
|
bits_per_channel: int = 1,
|
||||||
output_format: str | None = None,
|
output_format: str | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> tuple[bytes, EmbedStats, str]:
|
) -> tuple[bytes, EmbedStats, str]:
|
||||||
"""
|
"""
|
||||||
Embed data using LSB steganography (internal implementation).
|
Embed data using LSB steganography (internal implementation).
|
||||||
@@ -659,8 +687,9 @@ def _embed_lsb(
|
|||||||
|
|
||||||
bit_idx = 0
|
bit_idx = 0
|
||||||
modified_pixels = 0
|
modified_pixels = 0
|
||||||
|
total_pixels_to_process = len(selected_indices)
|
||||||
|
|
||||||
for pixel_idx in selected_indices:
|
for progress_idx, pixel_idx in enumerate(selected_indices):
|
||||||
if bit_idx >= len(binary_data):
|
if bit_idx >= len(binary_data):
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -690,6 +719,16 @@ def _embed_lsb(
|
|||||||
new_pixels[pixel_idx] = (r, g, b)
|
new_pixels[pixel_idx] = (r, g, b)
|
||||||
modified_pixels += 1
|
modified_pixels += 1
|
||||||
|
|
||||||
|
# Report progress periodically
|
||||||
|
if progress_file and progress_idx % PROGRESS_INTERVAL == 0:
|
||||||
|
_write_progress(progress_file, progress_idx, total_pixels_to_process, "embedding")
|
||||||
|
|
||||||
|
# Final progress before save
|
||||||
|
if progress_file:
|
||||||
|
_write_progress(
|
||||||
|
progress_file, total_pixels_to_process, total_pixels_to_process, "saving"
|
||||||
|
)
|
||||||
|
|
||||||
debug.print(f"Modified {modified_pixels} pixels (out of {len(selected_indices)} selected)")
|
debug.print(f"Modified {modified_pixels} pixels (out of {len(selected_indices)} selected)")
|
||||||
|
|
||||||
stego_img = Image.new("RGB", img.size)
|
stego_img = Image.new("RGB", img.size)
|
||||||
|
|||||||
Reference in New Issue
Block a user