6 Commits

Author SHA1 Message Date
Aaron D. Lee
d8fb95b68e Add optional partition wipe to flash-pi.sh
Some checks failed
Release / test (push) Failing after 29s
Release / publish (push) Has been skipped
Release / github-release (push) Has been skipped
Prompts user to wipe partition table before flashing,
helpful when SD card has corrupted partitions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 00:59:38 -05:00
Aaron D. Lee
c0b6865790 Add headless Pi flash script with NetworkManager WiFi
- Reads config from rpi/config.json
- Flashes image with dd (supports .xz and .zst)
- Configures SSH, user/password, hostname on boot partition
- Creates NetworkManager connection file on rootfs for WiFi
- Works with Trixie/Bookworm (no more wpa_supplicant)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 23:48:59 -05:00
Aaron D. Lee
6e7ae0d6f9 Docker improvements and decode loading state
- Fix .dockerignore to exclude *.img.xz files (was 2.3GB context)
- Remove deprecated 'version' attribute from docker-compose.yml
- Increase container memory limits to 2GB/1GB (prevent OOM on DCT)
- Add loading spinner to decode button during form submission

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:53:39 -05:00
Aaron D. Lee
6a5b12f98e Fix multi-worker temp file issue with file-based storage
- New temp_storage.py module stores files on disk instead of in-memory
- Multiple Gunicorn workers can now share temp files
- Startup cleanup removes leftover files from previous runs
- Dockerfile creates temp_files directory
- Added temp_files/ to .gitignore

Previously encode preview worked but download failed with "File expired"
because each worker had its own in-memory TEMP_FILES dict.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:40:42 -05:00
Aaron D. Lee
d8eb7b0160 Bump version to 4.1.3
- Version bump from 4.1.2 to 4.1.3
- Updated CHANGELOG with SSL cert fix as highlight
- Added *.img.zst.zip to .gitignore

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:20:26 -05:00
Aaron D. Lee
962c04084b Fix SSL certificate generation for HTTPS mode
- wizard/setup now generate certs when HTTPS enabled
- app.py has proper error handling for cert failures
- Add custom SSL certificate documentation to INSTALL.md
- Include SANs for hostname, localhost, and local IP

Previously HTTPS could be enabled but certs weren't generated,
causing SSL_ERROR_RX_RECORD_TOO_LONG browser errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:16:12 -05:00
14 changed files with 663 additions and 61 deletions

View File

@@ -22,8 +22,10 @@ tests/
# Pi-specific # Pi-specific
rpi/ rpi/
*.img *.img
*.img.xz
*.img.zst *.img.zst
*.img.zst.zip *.img.zst.zip
pishrink.sh
# Docs # Docs
*.md *.md
@@ -37,3 +39,15 @@ docs/
*.log *.log
*.tmp *.tmp
.DS_Store .DS_Store
# Dev scripts and old files
scripts/
old_files/
*_old
*_old.*
*.bak
*.orig
# Temp files
frontends/web/temp_files/
*.db

4
.gitignore vendored
View File

@@ -80,3 +80,7 @@ tests/
*.img.xz *.img.xz
*.img.zst *.img.zst
pishrink.sh pishrink.sh
*.img.zst.zip
# Temp file storage
frontends/web/temp_files/

View File

@@ -5,7 +5,7 @@ All notable changes to Stegasoo will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org). and this project adheres to [Semantic Versioning](https://semver.org).
## [4.1.2] - 2026-01-05 ## [4.1.3] - 2026-01-05
### Added ### Added
- **Docker Deployment**: Production-ready containerization - **Docker Deployment**: Production-ready containerization
@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
- Validation, generation, compression, edge cases - Validation, generation, compression, edge cases
- 29 tests covering core library functionality - 29 tests covering core library functionality
- **Release Validation**: `scripts/validate-release.sh` for pre-release checks - **Release Validation**: `scripts/validate-release.sh` for pre-release checks
- **Custom SSL Documentation**: Guide for replacing certs, Let's Encrypt setup
### Changed ### Changed
- Pi MOTD shows CPU speed and temperature when overclocked - Pi MOTD shows CPU speed and temperature when overclocked
@@ -36,9 +37,11 @@ and this project adheres to [Semantic Versioning](https://semver.org).
- Setup script uses pyenv for Python 3.12 (Pi OS ships 3.13) - Setup script uses pyenv for Python 3.12 (Pi OS ships 3.13)
### Fixed ### Fixed
- **SSL certificate generation**: Wizard and setup now generate certs when HTTPS enabled
- DCT decode reliability improvements - DCT decode reliability improvements
- Fixed `gum --inline` flag compatibility (not supported in all versions) - Fixed `gum --inline` flag compatibility (not supported in all versions)
- Wizard banner alignment and spacing issues - Wizard banner alignment and spacing issues
- Better error handling in app.py for SSL failures
## [4.1.0] - 2026-01-04 ## [4.1.0] - 2026-01-04
@@ -177,7 +180,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
- CLI interface - CLI interface
- Basic PIN authentication - Basic PIN authentication
[4.1.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.2 [4.1.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3
[4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0 [4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0
[4.0.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.1...v4.0.2 [4.0.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.1...v4.0.2
[4.0.1]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.0...v4.0.1 [4.0.1]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.0...v4.0.1

View File

@@ -63,7 +63,8 @@ COPY data/ data/
COPY frontends/web/ frontends/web/ COPY frontends/web/ frontends/web/
# Create upload directory and instance directories (for volumes) # Create upload directory and instance directories (for volumes)
RUN mkdir -p /tmp/stego_uploads /app/frontends/web/instance /app/frontends/web/certs # temp_files is for multi-worker temp file sharing
RUN mkdir -p /tmp/stego_uploads /app/frontends/web/instance /app/frontends/web/certs /app/frontends/web/temp_files
# Create non-root user # Create non-root user
RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads

View File

@@ -553,6 +553,85 @@ print(f'jpegio: {has_jpegio_support()}')
--- ---
## Custom SSL Certificates
By default, Stegasoo generates a self-signed certificate for HTTPS. To use your own certificate (e.g., from Let's Encrypt or your organization's CA):
### Replace Self-Signed Certificates
```bash
# Stop the service
sudo systemctl stop stegasoo
# Backup existing certs (optional)
mv /opt/stegasoo/frontends/web/certs /opt/stegasoo/frontends/web/certs.bak
# Create new certs directory
mkdir -p /opt/stegasoo/frontends/web/certs
# Copy your certificates (adjust paths as needed)
cp /path/to/your/certificate.crt /opt/stegasoo/frontends/web/certs/server.crt
cp /path/to/your/private.key /opt/stegasoo/frontends/web/certs/server.key
# Set permissions (key must be readable by service user)
chmod 600 /opt/stegasoo/frontends/web/certs/server.key
chown -R $(whoami):$(whoami) /opt/stegasoo/frontends/web/certs
# Start the service
sudo systemctl start stegasoo
```
### Generate New Self-Signed Certificate
If your certificate expires or you need to regenerate:
```bash
# Stop service
sudo systemctl stop stegasoo
# Generate new cert with SANs
CERT_DIR="/opt/stegasoo/frontends/web/certs"
LOCAL_IP=$(hostname -I | awk '{print $1}')
HOSTNAME=$(hostname)
openssl req -x509 -newkey rsa:2048 \
-keyout "$CERT_DIR/server.key" \
-out "$CERT_DIR/server.crt" \
-days 365 -nodes \
-subj "/O=Stegasoo/CN=$HOSTNAME" \
-addext "subjectAltName=DNS:$HOSTNAME,DNS:$HOSTNAME.local,DNS:localhost,IP:$LOCAL_IP,IP:127.0.0.1"
chmod 600 "$CERT_DIR/server.key"
# Start service
sudo systemctl start stegasoo
```
### Let's Encrypt with Certbot
For publicly accessible servers:
```bash
# Install certbot
sudo apt install certbot
# Get certificate (standalone mode)
sudo certbot certonly --standalone -d yourdomain.com
# Copy to Stegasoo
sudo cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem /opt/stegasoo/frontends/web/certs/server.crt
sudo cp /etc/letsencrypt/live/yourdomain.com/privkey.pem /opt/stegasoo/frontends/web/certs/server.key
sudo chown $(whoami):$(whoami) /opt/stegasoo/frontends/web/certs/*
sudo chmod 600 /opt/stegasoo/frontends/web/certs/server.key
# Restart
sudo systemctl restart stegasoo
```
**Note:** Set up a cron job or systemd timer to copy renewed certificates and restart Stegasoo.
---
## Verification ## Verification
### Check Installation ### Check Installation

View File

@@ -1,5 +1,3 @@
version: '3.8'
# Shared environment variables # Shared environment variables
x-common-env: &common-env x-common-env: &common-env
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-} STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
@@ -30,9 +28,9 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 768M memory: 2048M
reservations: reservations:
memory: 384M memory: 1024M
# ============================================================================ # ============================================================================
# REST API (FastAPI) # REST API (FastAPI)
@@ -50,9 +48,9 @@ services:
deploy: deploy:
resources: resources:
limits: limits:
memory: 768M memory: 2048M
reservations: reservations:
memory: 384M memory: 1024M
# Named volumes for persistent data # Named volumes for persistent data
volumes: volumes:

View File

@@ -83,6 +83,7 @@ from flask import (
) )
from PIL import Image from PIL import Image
from ssl_utils import ensure_certs from ssl_utils import ensure_certs
import temp_storage
os.environ["NUMPY_MADVISE_HUGEPAGE"] = "0" os.environ["NUMPY_MADVISE_HUGEPAGE"] = "0"
os.environ["OMP_NUM_THREADS"] = "1" os.environ["OMP_NUM_THREADS"] = "1"
@@ -257,9 +258,10 @@ def require_setup():
return None return None
# Temporary file storage for sharing (file_id -> {data, timestamp, filename}) # DEPRECATED: In-memory storage replaced by file-based temp_storage module
TEMP_FILES: dict[str, dict] = {} # Kept for backwards compatibility during transition
THUMBNAIL_FILES: dict[str, bytes] = {} TEMP_FILES: dict[str, dict] = {} # Not used - see temp_storage.py
THUMBNAIL_FILES: dict[str, bytes] = {} # Not used - see temp_storage.py
# ============================================================================ # ============================================================================
@@ -397,16 +399,7 @@ def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes
def cleanup_temp_files(): def cleanup_temp_files():
"""Remove expired temporary files.""" """Remove expired temporary files."""
now = time.time() temp_storage.cleanup_expired(TEMP_FILE_EXPIRY)
expired = [
fid for fid, info in TEMP_FILES.items() if now - info["timestamp"] > TEMP_FILE_EXPIRY
]
for fid in expired:
TEMP_FILES.pop(fid, None)
# Also clean up corresponding thumbnail
thumb_id = f"{fid}_thumb"
THUMBNAIL_FILES.pop(thumb_id, None)
def allowed_image(filename: str) -> bool: def allowed_image(filename: str) -> bool:
@@ -563,13 +556,11 @@ def generate():
if not qr_too_large: if not qr_too_large:
qr_token = secrets.token_urlsafe(16) qr_token = secrets.token_urlsafe(16)
cleanup_temp_files() cleanup_temp_files()
TEMP_FILES[qr_token] = { temp_storage.save_temp_file(qr_token, creds.rsa_key_pem.encode(), {
"data": creds.rsa_key_pem.encode(),
"filename": "rsa_key.pem", "filename": "rsa_key.pem",
"timestamp": time.time(),
"type": "rsa_key", "type": "rsa_key",
"compress": qr_needs_compression, "compress": qr_needs_compression,
} })
# v3.2.0: Single passphrase instead of daily phrases # v3.2.0: Single passphrase instead of daily phrases
return render_template( return render_template(
@@ -606,10 +597,10 @@ def generate_qr(token):
if not HAS_QRCODE: if not HAS_QRCODE:
return "QR code support not available", 501 return "QR code support not available", 501
if token not in TEMP_FILES: file_info = temp_storage.get_temp_file(token)
if not file_info:
return "Token expired or invalid", 404 return "Token expired or invalid", 404
file_info = TEMP_FILES[token]
if file_info.get("type") != "rsa_key": if file_info.get("type") != "rsa_key":
return "Invalid token type", 400 return "Invalid token type", 400
@@ -630,10 +621,10 @@ def generate_qr_download(token):
if not HAS_QRCODE: if not HAS_QRCODE:
return "QR code support not available", 501 return "QR code support not available", 501
if token not in TEMP_FILES: file_info = temp_storage.get_temp_file(token)
if not file_info:
return "Token expired or invalid", 404 return "Token expired or invalid", 404
file_info = TEMP_FILES[token]
if file_info.get("type") != "rsa_key": if file_info.get("type") != "rsa_key":
return "Invalid token type", 400 return "Invalid token type", 400
@@ -933,17 +924,15 @@ def _run_encode_job(job_id: str, encode_params: dict) -> None:
# Store result # Store result
file_id = secrets.token_urlsafe(16) file_id = secrets.token_urlsafe(16)
TEMP_FILES[file_id] = { temp_storage.save_temp_file(file_id, encode_result.stego_data, {
"data": encode_result.stego_data,
"filename": filename, "filename": filename,
"timestamp": time.time(),
"embed_mode": embed_mode, "embed_mode": embed_mode,
"output_format": dct_output_format if embed_mode == "dct" else "png", "output_format": dct_output_format if embed_mode == "dct" else "png",
"color_mode": dct_color_mode if embed_mode == "dct" else None, "color_mode": dct_color_mode if embed_mode == "dct" else None,
"mime_type": output_mime, "mime_type": output_mime,
"channel_mode": encode_result.channel_mode, "channel_mode": encode_result.channel_mode,
"channel_fingerprint": encode_result.channel_fingerprint, "channel_fingerprint": encode_result.channel_fingerprint,
} })
_store_job( _store_job(
job_id, job_id,
@@ -1212,10 +1201,8 @@ def encode_page():
# Store temporarily # Store temporarily
file_id = secrets.token_urlsafe(16) file_id = secrets.token_urlsafe(16)
cleanup_temp_files() cleanup_temp_files()
TEMP_FILES[file_id] = { temp_storage.save_temp_file(file_id, encode_result.stego_data, {
"data": encode_result.stego_data,
"filename": filename, "filename": filename,
"timestamp": time.time(),
"embed_mode": embed_mode, "embed_mode": embed_mode,
"output_format": dct_output_format if embed_mode == "dct" else "png", "output_format": dct_output_format if embed_mode == "dct" else "png",
"color_mode": dct_color_mode if embed_mode == "dct" else None, "color_mode": dct_color_mode if embed_mode == "dct" else None,
@@ -1223,7 +1210,7 @@ def encode_page():
# Channel info (v4.0.0) # Channel info (v4.0.0)
"channel_mode": encode_result.channel_mode, "channel_mode": encode_result.channel_mode,
"channel_fingerprint": encode_result.channel_fingerprint, "channel_fingerprint": encode_result.channel_fingerprint,
} })
return redirect(url_for("encode_result", file_id=file_id)) return redirect(url_for("encode_result", file_id=file_id))
@@ -1290,19 +1277,18 @@ def encode_progress(job_id):
@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):
if file_id not in TEMP_FILES: file_info = temp_storage.get_temp_file(file_id)
if not file_info:
flash("File expired or not found. Please encode again.", "error") flash("File expired or not found. Please encode again.", "error")
return redirect(url_for("encode_page")) return redirect(url_for("encode_page"))
file_info = TEMP_FILES[file_id]
# Generate thumbnail # Generate thumbnail
thumbnail_data = generate_thumbnail(file_info["data"]) thumbnail_data = generate_thumbnail(file_info["data"])
thumbnail_id = None thumbnail_id = None
if thumbnail_data: if thumbnail_data:
thumbnail_id = f"{file_id}_thumb" thumbnail_id = f"{file_id}_thumb"
THUMBNAIL_FILES[thumbnail_id] = thumbnail_data temp_storage.save_thumbnail(thumbnail_id, thumbnail_data)
return render_template( return render_template(
"encode_result.html", "encode_result.html",
@@ -1322,22 +1308,23 @@ def encode_result(file_id):
@login_required @login_required
def encode_thumbnail(thumb_id): def encode_thumbnail(thumb_id):
"""Serve thumbnail image.""" """Serve thumbnail image."""
if thumb_id not in THUMBNAIL_FILES: thumb_data = temp_storage.get_thumbnail(thumb_id)
if not thumb_data:
return "Thumbnail not found", 404 return "Thumbnail not found", 404
return send_file( return send_file(
io.BytesIO(THUMBNAIL_FILES[thumb_id]), mimetype="image/jpeg", as_attachment=False io.BytesIO(thumb_data), mimetype="image/jpeg", as_attachment=False
) )
@app.route("/encode/download/<file_id>") @app.route("/encode/download/<file_id>")
@login_required @login_required
def encode_download(file_id): def encode_download(file_id):
if file_id not in TEMP_FILES: file_info = temp_storage.get_temp_file(file_id)
if not file_info:
flash("File expired or not found.", "error") flash("File expired or not found.", "error")
return redirect(url_for("encode_page")) return redirect(url_for("encode_page"))
file_info = TEMP_FILES[file_id]
mime_type = file_info.get("mime_type", "image/png") mime_type = file_info.get("mime_type", "image/png")
return send_file( return send_file(
@@ -1352,10 +1339,10 @@ def encode_download(file_id):
@login_required @login_required
def encode_file_route(file_id): def encode_file_route(file_id):
"""Serve file for Web Share API.""" """Serve file for Web Share API."""
if file_id not in TEMP_FILES: file_info = temp_storage.get_temp_file(file_id)
if not file_info:
return "Not found", 404 return "Not found", 404
file_info = TEMP_FILES[file_id]
mime_type = file_info.get("mime_type", "image/png") mime_type = file_info.get("mime_type", "image/png")
return send_file( return send_file(
@@ -1370,11 +1357,11 @@ def encode_file_route(file_id):
@login_required @login_required
def encode_cleanup(file_id): def encode_cleanup(file_id):
"""Manually cleanup a file after sharing.""" """Manually cleanup a file after sharing."""
TEMP_FILES.pop(file_id, None) temp_storage.delete_temp_file(file_id)
# Also cleanup thumbnail if exists # Also cleanup thumbnail if exists
thumb_id = f"{file_id}_thumb" thumb_id = f"{file_id}_thumb"
THUMBNAIL_FILES.pop(thumb_id, None) temp_storage.delete_thumbnail(thumb_id)
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
@@ -1497,12 +1484,10 @@ def decode_page():
cleanup_temp_files() cleanup_temp_files()
filename = decode_result.filename or "decoded_file" filename = decode_result.filename or "decoded_file"
TEMP_FILES[file_id] = { temp_storage.save_temp_file(file_id, decode_result.file_data, {
"data": decode_result.file_data,
"filename": filename, "filename": filename,
"mime_type": decode_result.mime_type, "mime_type": decode_result.mime_type,
"timestamp": time.time(), })
}
return render_template( return render_template(
"decode.html", "decode.html",
@@ -1559,11 +1544,11 @@ def decode_page():
@login_required @login_required
def decode_download(file_id): def decode_download(file_id):
"""Download decoded file.""" """Download decoded file."""
if file_id not in TEMP_FILES: file_info = temp_storage.get_temp_file(file_id)
if not file_info:
flash("File expired or not found.", "error") flash("File expired or not found.", "error")
return redirect(url_for("decode_page")) return redirect(url_for("decode_page"))
file_info = TEMP_FILES[file_id]
mime_type = file_info.get("mime_type", "application/octet-stream") mime_type = file_info.get("mime_type", "application/octet-stream")
return send_file( return send_file(
@@ -2320,13 +2305,31 @@ def admin_user_password_reset():
if __name__ == "__main__": if __name__ == "__main__":
base_dir = Path(__file__).parent base_dir = Path(__file__).parent
# Clean up any leftover temp files from previous runs
temp_storage.init(base_dir / "temp_files")
cleaned = temp_storage.cleanup_all()
if cleaned > 0:
print(f"Cleaned up {cleaned} leftover temp files from previous run")
# HTTPS configuration # HTTPS configuration
ssl_context = None ssl_context = None
if app.config.get("HTTPS_ENABLED", False): if app.config.get("HTTPS_ENABLED", False):
hostname = os.environ.get("STEGASOO_HOSTNAME", "localhost") hostname = os.environ.get("STEGASOO_HOSTNAME", "localhost")
cert_path, key_path = ensure_certs(base_dir, hostname) try:
ssl_context = (str(cert_path), str(key_path)) cert_path, key_path = ensure_certs(base_dir, hostname)
print(f"HTTPS enabled with self-signed certificate for {hostname}") if cert_path.exists() and key_path.exists():
ssl_context = (str(cert_path), str(key_path))
print(f"HTTPS enabled with self-signed certificate for {hostname}")
else:
print("ERROR: SSL certificates not found after generation attempt")
print(f" Expected: {cert_path}, {key_path}")
print(" Falling back to HTTP (INSECURE)")
except Exception as e:
print(f"ERROR: Failed to generate SSL certificates: {e}")
print(" Falling back to HTTP (INSECURE)")
print(" To fix: mkdir -p certs && openssl req -x509 -newkey rsa:2048 \\")
print(" -keyout certs/server.key -out certs/server.crt -days 365 -nodes \\")
print(" -subj '/CN=localhost'")
# Auth status # Auth status
if app.config.get("AUTH_ENABLED", True): if app.config.get("AUTH_ENABLED", True):

View File

@@ -0,0 +1,214 @@
"""
File-based Temporary Storage
Stores temp files on disk instead of in-memory dict.
This allows multiple Gunicorn workers to share temp files
and survives service restarts within the expiry window.
Files are stored in a temp directory with:
- {file_id}.data - The actual file data
- {file_id}.json - Metadata (filename, timestamp, mime_type, etc.)
IMPORTANT: This module ONLY manages files in the temp_files/ directory.
It does NOT touch instance/ (auth database) or any other directories.
"""
import json
import os
import shutil
import time
from pathlib import Path
from threading import Lock
# Default temp directory (can be overridden)
DEFAULT_TEMP_DIR = Path(__file__).parent / "temp_files"
# Lock for thread-safe operations
_lock = Lock()
# Module-level temp directory (set on init)
_temp_dir: Path = DEFAULT_TEMP_DIR
def init(temp_dir: Path | str | None = None):
"""Initialize temp storage with optional custom directory."""
global _temp_dir
_temp_dir = Path(temp_dir) if temp_dir else DEFAULT_TEMP_DIR
_temp_dir.mkdir(parents=True, exist_ok=True)
def _data_path(file_id: str) -> Path:
"""Get path for file data."""
return _temp_dir / f"{file_id}.data"
def _meta_path(file_id: str) -> Path:
"""Get path for file metadata."""
return _temp_dir / f"{file_id}.json"
def _thumb_path(thumb_id: str) -> Path:
"""Get path for thumbnail data."""
return _temp_dir / f"{thumb_id}.thumb"
def save_temp_file(file_id: str, data: bytes, metadata: dict) -> None:
"""
Save a temp file with its metadata.
Args:
file_id: Unique identifier for the file
data: File contents as bytes
metadata: Dict with filename, mime_type, timestamp, etc.
"""
init() # Ensure directory exists
with _lock:
# Add timestamp if not present
if "timestamp" not in metadata:
metadata["timestamp"] = time.time()
# Write data file
_data_path(file_id).write_bytes(data)
# Write metadata
_meta_path(file_id).write_text(json.dumps(metadata))
def get_temp_file(file_id: str) -> dict | None:
"""
Get a temp file and its metadata.
Returns:
Dict with 'data' (bytes) and all metadata fields, or None if not found.
"""
init()
data_file = _data_path(file_id)
meta_file = _meta_path(file_id)
if not data_file.exists() or not meta_file.exists():
return None
try:
data = data_file.read_bytes()
metadata = json.loads(meta_file.read_text())
return {"data": data, **metadata}
except (OSError, json.JSONDecodeError):
return None
def has_temp_file(file_id: str) -> bool:
"""Check if a temp file exists."""
init()
return _data_path(file_id).exists() and _meta_path(file_id).exists()
def delete_temp_file(file_id: str) -> None:
"""Delete a temp file and its metadata."""
init()
with _lock:
_data_path(file_id).unlink(missing_ok=True)
_meta_path(file_id).unlink(missing_ok=True)
def save_thumbnail(thumb_id: str, data: bytes) -> None:
"""Save a thumbnail."""
init()
with _lock:
_thumb_path(thumb_id).write_bytes(data)
def get_thumbnail(thumb_id: str) -> bytes | None:
"""Get thumbnail data."""
init()
thumb_file = _thumb_path(thumb_id)
if not thumb_file.exists():
return None
try:
return thumb_file.read_bytes()
except OSError:
return None
def delete_thumbnail(thumb_id: str) -> None:
"""Delete a thumbnail."""
init()
with _lock:
_thumb_path(thumb_id).unlink(missing_ok=True)
def cleanup_expired(max_age_seconds: float) -> int:
"""
Delete expired temp files.
Args:
max_age_seconds: Maximum age in seconds before expiry
Returns:
Number of files deleted
"""
init()
now = time.time()
deleted = 0
with _lock:
# Find all metadata files
for meta_file in _temp_dir.glob("*.json"):
try:
metadata = json.loads(meta_file.read_text())
timestamp = metadata.get("timestamp", 0)
if now - timestamp > max_age_seconds:
file_id = meta_file.stem
_data_path(file_id).unlink(missing_ok=True)
meta_file.unlink(missing_ok=True)
# Also delete thumbnail if exists
_thumb_path(f"{file_id}_thumb").unlink(missing_ok=True)
deleted += 1
except (OSError, json.JSONDecodeError):
# Remove corrupted files
meta_file.unlink(missing_ok=True)
deleted += 1
return deleted
def cleanup_all() -> int:
"""
Delete all temp files. Call on service start/stop.
Returns:
Number of files deleted
"""
init()
deleted = 0
with _lock:
for f in _temp_dir.iterdir():
if f.is_file():
f.unlink(missing_ok=True)
deleted += 1
return deleted
def get_stats() -> dict:
"""Get temp storage statistics."""
init()
files = list(_temp_dir.glob("*.data"))
total_size = sum(f.stat().st_size for f in files if f.exists())
return {
"file_count": len(files),
"total_size_bytes": total_size,
"temp_dir": str(_temp_dir),
}

View File

@@ -518,5 +518,8 @@ advancedOptionsDec?.addEventListener('show.bs.collapse', () => {
advancedOptionsDec?.addEventListener('hide.bs.collapse', () => { advancedOptionsDec?.addEventListener('hide.bs.collapse', () => {
document.getElementById('advancedChevronDec')?.classList.replace('bi-chevron-up', 'bi-chevron-down'); document.getElementById('advancedChevronDec')?.classList.replace('bi-chevron-up', 'bi-chevron-down');
}); });
// Loading state for decode button
Stegasoo.initFormLoading('decodeForm', 'decodeBtn', 'Decoding...');
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -279,6 +279,32 @@ EOF
" "
gum style --foreground 82 "✓ Service configured" gum style --foreground 82 "✓ Service configured"
# Generate SSL certificates if HTTPS enabled
if [ "$ENABLE_HTTPS" = "true" ]; then
gum spin --spinner dot --title "Generating SSL certificates..." -- bash -c "
CERT_DIR='$INSTALL_DIR/frontends/web/certs'
mkdir -p \"\$CERT_DIR\"
# Get local IP for SAN
LOCAL_IP=\$(hostname -I | awk '{print \$1}')
HOSTNAME=\$(hostname)
# Generate cert with SANs for IP, hostname, and localhost
openssl req -x509 -newkey rsa:2048 \
-keyout \"\$CERT_DIR/server.key\" \
-out \"\$CERT_DIR/server.crt\" \
-days 365 -nodes \
-subj \"/O=Stegasoo/CN=\$HOSTNAME\" \
-addext \"subjectAltName=DNS:\$HOSTNAME,DNS:\$HOSTNAME.local,DNS:localhost,IP:\$LOCAL_IP,IP:127.0.0.1\" \
2>/dev/null
# Fix permissions
chmod 600 \"\$CERT_DIR/server.key\"
chown -R $STEGASOO_USER:\$(id -gn $STEGASOO_USER) \"\$CERT_DIR\"
"
gum style --foreground 82 "✓ SSL certificates generated"
fi
# Setup port 443 if requested # Setup port 443 if requested
if [ "$USE_PORT_443" = "true" ]; then if [ "$USE_PORT_443" = "true" ]; then
gum spin --spinner dot --title "Setting up port 443 redirect..." -- bash -c " gum spin --spinner dot --title "Setting up port 443 redirect..." -- bash -c "

232
rpi/flash-pi.sh Executable file
View File

@@ -0,0 +1,232 @@
#!/bin/bash
# Flash Raspberry Pi image with headless config (Trixie/Bookworm compatible)
# Usage: ./flash-pi.sh <image.img.xz> <device>
# Reads settings from config.json in same directory
#
# Uses the same firstrun.sh approach as rpi-imager for compatibility
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG_FILE="$SCRIPT_DIR/config.json"
# ============================================================================
# Load config
# ============================================================================
if [ ! -f "$CONFIG_FILE" ]; then
echo "Error: config.json not found at $CONFIG_FILE"
exit 1
fi
PI_USER=$(jq -r '.username' "$CONFIG_FILE")
PI_PASS=$(jq -r '.password' "$CONFIG_FILE")
WIFI_SSID=$(jq -r '.wifiSSID' "$CONFIG_FILE")
WIFI_PASS=$(jq -r '.wifiPassword' "$CONFIG_FILE")
WIFI_COUNTRY=$(jq -r '.wifiCountry // "US"' "$CONFIG_FILE")
PI_HOSTNAME=$(jq -r '.hostname' "$CONFIG_FILE")
PI_TIMEZONE=$(jq -r '.timezone // "America/New_York"' "$CONFIG_FILE")
PI_KEYMAP=$(jq -r '.keyboardLayout // "us"' "$CONFIG_FILE")
echo "Loaded config from $CONFIG_FILE"
echo " Hostname: $PI_HOSTNAME"
echo " User: $PI_USER"
echo " WiFi: $WIFI_SSID"
echo " Timezone: $PI_TIMEZONE"
echo
# ============================================================================
# Validate args
# ============================================================================
if [ $# -ne 2 ]; then
echo "Usage: $0 <image.img.xz> <device>"
echo "Example: $0 2025-12-04-raspios-trixie-arm64-lite.img.xz /dev/sdb"
exit 1
fi
IMAGE="$1"
DEVICE="$2"
if [ ! -f "$IMAGE" ]; then
echo "Error: Image file not found: $IMAGE"
exit 1
fi
if [ ! -b "$DEVICE" ]; then
echo "Error: Device not found: $DEVICE"
exit 1
fi
# Safety check
echo "WARNING: This will ERASE all data on $DEVICE"
echo "Device info:"
lsblk "$DEVICE"
echo
read -p "Type 'yes' to continue: " confirm
if [ "$confirm" != "yes" ]; then
echo "Aborted."
exit 1
fi
# Ask about wiping
echo
read -p "Wipe partition table first? (recommended if having issues) [y/N] " wipe_confirm
if [[ "$wipe_confirm" =~ ^[Yy]$ ]]; then
echo "Wiping partition table..."
sudo wipefs -a "$DEVICE"
sudo dd if=/dev/zero of="$DEVICE" bs=1M count=10 status=none
sync
echo " Wiped clean"
fi
# ============================================================================
# Flash image
# ============================================================================
echo "Flashing $IMAGE to $DEVICE..."
if [[ "$IMAGE" == *.xz ]]; then
xzcat "$IMAGE" | sudo dd of="$DEVICE" bs=4M status=progress conv=fsync
elif [[ "$IMAGE" == *.zst ]]; then
zstdcat "$IMAGE" | sudo dd of="$DEVICE" bs=4M status=progress conv=fsync
else
sudo dd if="$IMAGE" of="$DEVICE" bs=4M status=progress conv=fsync
fi
echo "Syncing..."
sync
# Wait for partitions
sleep 2
sudo partprobe "$DEVICE" 2>/dev/null || true
sleep 1
# ============================================================================
# Find partitions
# ============================================================================
if [ -b "${DEVICE}1" ]; then
BOOT_PART="${DEVICE}1"
elif [ -b "${DEVICE}p1" ]; then
BOOT_PART="${DEVICE}p1"
else
echo "Error: Could not find boot partition"
exit 1
fi
MOUNT_DIR=$(mktemp -d)
# ============================================================================
# Configure boot partition with firstrun.sh (rpi-imager method)
# ============================================================================
echo "Mounting boot partition..."
sudo mount "$BOOT_PART" "$MOUNT_DIR"
# Enable SSH
echo "Enabling SSH..."
sudo touch "$MOUNT_DIR/ssh"
# Generate password hash
PASS_HASH=$(echo "$PI_PASS" | openssl passwd -6 -stdin)
# Create firstrun.sh - this is exactly what rpi-imager generates
echo "Creating firstrun.sh..."
sudo tee "$MOUNT_DIR/firstrun.sh" > /dev/null << 'EOFSCRIPT'
#!/bin/bash
set +e
CURRENT_HOSTNAME=$(cat /etc/hostname | tr -d " \t\n\r")
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
/usr/lib/raspberrypi-sys-mods/imager_custom set_hostname PLACEHOLDER_HOSTNAME
else
echo PLACEHOLDER_HOSTNAME >/etc/hostname
sed -i "s/127.0.1.1.*$CURRENT_HOSTNAME/127.0.1.1\tPLACEHOLDER_HOSTNAME/g" /etc/hosts
fi
FIRSTUSER=$(getent passwd 1000 | cut -d: -f1)
FIRSTUSERHOME=$(getent passwd 1000 | cut -d: -f6)
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
/usr/lib/raspberrypi-sys-mods/imager_custom enable_ssh
else
systemctl enable ssh
fi
if [ -f /usr/lib/userconf-pi/userconf ]; then
/usr/lib/userconf-pi/userconf 'PLACEHOLDER_USER' 'PLACEHOLDER_HASH'
else
echo "$FIRSTUSER:"'PLACEHOLDER_HASH' | chpasswd -e
if [ "$FIRSTUSER" != "PLACEHOLDER_USER" ]; then
usermod -l "PLACEHOLDER_USER" "$FIRSTUSER"
usermod -m -d "/home/PLACEHOLDER_USER" "PLACEHOLDER_USER"
groupmod -n "PLACEHOLDER_USER" "$FIRSTUSER"
if grep -q "^autologin-user=" /etc/lightdm/lightdm.conf 2>/dev/null; then
sed -i "s/^autologin-user=.*/autologin-user=PLACEHOLDER_USER/" /etc/lightdm/lightdm.conf
fi
if [ -f /etc/systemd/system/getty@tty1.service.d/autologin.conf ]; then
sed -i "s/$FIRSTUSER/PLACEHOLDER_USER/" /etc/systemd/system/getty@tty1.service.d/autologin.conf
fi
fi
fi
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
/usr/lib/raspberrypi-sys-mods/imager_custom set_keymap 'PLACEHOLDER_KEYMAP'
/usr/lib/raspberrypi-sys-mods/imager_custom set_timezone 'PLACEHOLDER_TIMEZONE'
fi
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
/usr/lib/raspberrypi-sys-mods/imager_custom set_wlan 'PLACEHOLDER_SSID' 'PLACEHOLDER_WIFIPASS' 'PLACEHOLDER_COUNTRY'
else
cat >/etc/wpa_supplicant/wpa_supplicant.conf <<'WPAEOF'
country=PLACEHOLDER_COUNTRY
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
ap_scan=1
update_config=1
network={
ssid="PLACEHOLDER_SSID"
psk="PLACEHOLDER_WIFIPASS"
}
WPAEOF
chmod 600 /etc/wpa_supplicant/wpa_supplicant.conf
rfkill unblock wifi
for filename in /var/lib/systemd/rfkill/*:wlan ; do
echo 0 > "$filename"
done
fi
rm -f /boot/firstrun.sh
rm -f /boot/firmware/firstrun.sh
sed -i 's| systemd.run.*||g' /boot/cmdline.txt 2>/dev/null
sed -i 's| systemd.run.*||g' /boot/firmware/cmdline.txt 2>/dev/null
exit 0
EOFSCRIPT
# Replace placeholders with actual values
sudo sed -i "s/PLACEHOLDER_HOSTNAME/$PI_HOSTNAME/g" "$MOUNT_DIR/firstrun.sh"
sudo sed -i "s/PLACEHOLDER_USER/$PI_USER/g" "$MOUNT_DIR/firstrun.sh"
sudo sed -i "s|PLACEHOLDER_HASH|$PASS_HASH|g" "$MOUNT_DIR/firstrun.sh"
sudo sed -i "s/PLACEHOLDER_KEYMAP/$PI_KEYMAP/g" "$MOUNT_DIR/firstrun.sh"
sudo sed -i "s|PLACEHOLDER_TIMEZONE|$PI_TIMEZONE|g" "$MOUNT_DIR/firstrun.sh"
sudo sed -i "s/PLACEHOLDER_SSID/$WIFI_SSID/g" "$MOUNT_DIR/firstrun.sh"
sudo sed -i "s/PLACEHOLDER_WIFIPASS/$WIFI_PASS/g" "$MOUNT_DIR/firstrun.sh"
sudo sed -i "s/PLACEHOLDER_COUNTRY/$WIFI_COUNTRY/g" "$MOUNT_DIR/firstrun.sh"
sudo chmod +x "$MOUNT_DIR/firstrun.sh"
# Update cmdline.txt to run firstrun.sh on boot
echo "Updating cmdline.txt..."
CMDLINE="$MOUNT_DIR/cmdline.txt"
if [ -f "$CMDLINE" ]; then
# Read current cmdline, strip any existing systemd.run, append new one
CURRENT=$(cat "$CMDLINE" | tr -d '\n' | sed 's| systemd.run.*||g')
echo "$CURRENT systemd.run=/boot/firmware/firstrun.sh systemd.run_success_action=reboot systemd.unit=kernel-command-line.target" | sudo tee "$CMDLINE" > /dev/null
echo " cmdline.txt updated"
fi
sudo umount "$MOUNT_DIR"
rmdir "$MOUNT_DIR"
echo
echo "Done! SD card is ready."
echo " Hostname: $PI_HOSTNAME"
echo " User: $PI_USER"
echo " SSH: enabled"
echo " WiFi: $WIFI_SSID"
echo
echo "Insert into Pi and boot. Find it with: ping $PI_HOSTNAME.local"

View File

@@ -465,6 +465,31 @@ RestartSec=5
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
# Generate SSL certificates if HTTPS enabled
if [ "$ENABLE_HTTPS" = "true" ]; then
echo " Generating SSL certificates..."
CERT_DIR="$INSTALL_DIR/frontends/web/certs"
mkdir -p "$CERT_DIR"
# Get local IP for SAN
LOCAL_IP=$(hostname -I | awk '{print $1}')
PI_HOSTNAME=$(hostname)
# Generate cert with SANs for IP, hostname, and localhost
openssl req -x509 -newkey rsa:2048 \
-keyout "$CERT_DIR/server.key" \
-out "$CERT_DIR/server.crt" \
-days 365 -nodes \
-subj "/O=Stegasoo/CN=$PI_HOSTNAME" \
-addext "subjectAltName=DNS:$PI_HOSTNAME,DNS:$PI_HOSTNAME.local,DNS:localhost,IP:$LOCAL_IP,IP:127.0.0.1" \
2>/dev/null
# Fix permissions
chmod 600 "$CERT_DIR/server.key"
chown -R "$USER:$USER" "$CERT_DIR"
echo -e " ${GREEN}${NC} SSL certificates generated"
fi
# Setup port 443 redirect if requested # Setup port 443 redirect if requested
if [ "$USE_PORT_443" = "true" ]; then if [ "$USE_PORT_443" = "true" ]; then
echo " Setting up port 443 redirect..." echo " Setting up port 443 redirect..."

View File

@@ -7,7 +7,7 @@ Changes in v4.0.0:
- encode() and decode() now accept channel_key parameter - encode() and decode() now accept channel_key parameter
""" """
__version__ = "4.1.2" __version__ = "4.1.3"
# Core functionality # Core functionality
# Channel key management (v4.0.0) # Channel key management (v4.0.0)

View File

@@ -25,7 +25,7 @@ from pathlib import Path
# VERSION # VERSION
# ============================================================================ # ============================================================================
__version__ = "4.1.2" __version__ = "4.1.3"
# ============================================================================ # ============================================================================
# FILE FORMAT # FILE FORMAT