40 Commits

Author SHA1 Message Date
Aaron D. Lee
597a9c6411 Prepare 4.1.2 release documentation
Some checks failed
Release / test (push) Failing after 38s
Release / publish (push) Has been skipped
Release / github-release (push) Has been skipped
- Add 4.1.2 changelog: Docker, Pi wizard, unit tests, validation script
- Add Raspberry Pi section to README with first-boot wizard info
- Document new features: TUI setup, overclock presets, sanitize scripts

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:10:02 -05:00
Aaron D. Lee
67b25a43a6 Update RPi banner styling: purple→blue gradient + gold logo
- Horizontal borders: deep purple (93) → light blue (117) gradient
- STEGASOO ASCII logo: gold (220) to match web UI
- Applied to all RPi scripts: first-boot-wizard, setup, sanitize

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:06:35 -05:00
Aaron D. Lee
65a663fe3b Add Docker deployment documentation
- New DOCKER.md with comprehensive Docker setup guide
- Added Docker quick start section to README.md
- Documents environment variables, volumes, build process
- Includes production deployment and troubleshooting tips

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 22:00:01 -05:00
Aaron D. Lee
fc6e4eb805 Add comprehensive pytest unit tests for stegasoo library
Tests cover:
- Version info
- Credential generation (passphrase, PIN, channel key)
- Validation functions (passphrase, PIN, message, image)
- LSB encode/decode roundtrip and failure cases
- DCT encode/decode roundtrip and JPEG output
- Channel key encode/decode and wrong key rejection
- Compression of long messages
- Edge cases: Unicode, special chars, minimum passphrase

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:53:42 -05:00
Aaron D. Lee
50f07a0ce9 Adjust banner alignment - logo +1, tagline +5 spaces
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:32:41 -05:00
Aaron D. Lee
7accd26821 Standardized the ASCII banners in pi scripts
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:30:35 -05:00
Aaron D. Lee
075e10792c Simplify wizard banners - no side borders, pale pink lines
- Removed side borders from logo/sparkle sections
- Use horizontal lines only (no corner chars)
- Changed to pale pink (256-color 218) for softer look
- Centered "First Boot Wizard" and "Setup Complete!" text
- Both banners now identical except bottom text

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:27:21 -05:00
Aaron D. Lee
9a790de5c3 Fix wizard banner alignment - indent sparkles and logo
- Added 4 extra spaces to sparkle lines
- Added 2 extra spaces to logo lines
- Both banners now properly aligned within the border

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:23:28 -05:00
Aaron D. Lee
3c91c92a4d Fix wizard banner alignment and use light pink border
- Fixed logo alignment to be consistent across all lines
- Changed border from 0;35 (magenta) to 1;35 (light pink)
- Updated sparkle pattern for better visual consistency
- Both welcome and Setup Complete banners now match

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:21:31 -05:00
Aaron D. Lee
9d1bc7f829 Tighten wizard banners - remove extra blank line
Both welcome and Setup Complete banners now have consistent
design with sparkles directly above the bottom text line.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:19:46 -05:00
Aaron D. Lee
d8118d688b Fix wizard welcome banner to match Setup Complete style
Same cyan (0;36), pink border, and indentation as the completion banner

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:17:02 -05:00
Aaron D. Lee
b6acee1acb Add bright cyan STEGASOO logo to wizard welcome banner
Pink border, bright cyan logo, gray sparkles, white 'First Boot Wizard'

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:15:22 -05:00
Aaron D. Lee
b9baf35dfa Show CPU speed and temp in MOTD when overclocked
Displays MHz and temperature when arm_freq or over_voltage is set in config.txt

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:13:16 -05:00
Aaron D. Lee
561f03ffde Full bordered Setup Complete banner with colored text
Pink border, cyan logo, gray sparkles, green 'Setup Complete!'

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:12:12 -05:00
Aaron D. Lee
038347a505 Add pink border lines to Setup Complete banner
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:11:37 -05:00
Aaron D. Lee
e026d1a4db Update about.html version history, fix API exports
About page:
- Version history now shows v4.1.2 prominently with accordion for older versions
- Shortened 'Error Correction Reed-Solomon' to 'DCT ECC / RS Code'
- Removed v4.1 badges from established features

API fixes:
- Export MAX_FILE_PAYLOAD_SIZE from constants
- Export calculate_capacity_by_mode from steganography

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:09:28 -05:00
Aaron D. Lee
3f93e7a752 Add sparkly banner to first-boot wizard completion
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:07:56 -05:00
Aaron D. Lee
cdc7ffd3bf Fix gum --inline flag not supported in first-boot wizard
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:02:12 -05:00
Aaron D. Lee
6c3bc995f1 Mobile polish, release validation script, bump to v4.1.2
Mobile-responsive CSS improvements:
- Larger touch targets for drop zones and buttons (56px min)
- Touch feedback with active states for touch devices
- Camera hint text on mobile ("Tap to take photo or choose file")
- Mode buttons stack vertically on small screens
- Full-width download buttons on mobile
- Navbar doesn't stick on mobile to save screen space

Release validation script (scripts/validate-release.sh):
- Automated pre-release checks: ruff, imports, encode/decode sanity
- Optional Docker build/test (--docker flag)
- Optional Pi smoke test via SSH (--pi flag)
- Pass/fail summary with exit codes

Other:
- Version bump to 4.1.2 (pyproject.toml, constants.py, __init__.py)
- Fixed ruff import sorting in cli.py
- Updated PLAN-4.1.2.md (all 9 features complete)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:34:23 -05:00
Aaron D. Lee
2d3ed8a79a 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>
2026-01-05 20:25:33 -05:00
Aaron D. Lee
040c44fec6 Remove duplicate MOTD, source bashrc after install
- System MOTD already shows banner, bashrc one was redundant
- Source bashrc immediately after copying for instant effect

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:32:20 -05:00
Aaron D. Lee
832d8be025 Fix jpegio ARM64 patch for CRLF line endings
- Convert CRLF to LF before patching (jpegio uses Windows line endings)
- Update patch context to match current jpegio setup.py

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:30:32 -05:00
Aaron D. Lee
7088623d2c adlee themed cli becuase I can.
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:25:23 -05:00
Aaron D. Lee
44a3ca8a0f Compact first-boot-wizard output for smaller terminals
- Remove sparkle decoration lines from banner
- Reduce padding and margins on boxes
- Condense first steps to single line
- Condense commands to single line
- Simplify restart notice (no bordered box)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:20:49 -05:00
Aaron D. Lee
7a35ac3df7 Update plan: mark #6 Smoke Test Benchmarking as done
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:15:05 -05:00
Aaron D. Lee
f69475b406 Implement granular decode error messages (#2)
New exceptions for specific decode failures:
- InvalidMagicBytesError: wrong mode or not a Stegasoo image
- ReedSolomonError: image too corrupted to recover
- NoDataFoundError, ModeMismatchError: additional clarity

Web UI now shows specific, actionable error messages:
- "Try a different mode (LSB/DCT)"
- "Image too corrupted, may have been re-saved"
- "Wrong credentials - check reference photo..."

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:34:05 -05:00
Aaron D. Lee
559dcd3dcf Implement forced first-login setup and dropzone UX fixes
#4 Forced First-Login Setup:
- Add before_request hook to redirect to /setup if no users exist
- Skip redirect for static files and setup routes

#5 Dropzone UX Fixes:
- Make preview images clickable to replace file
- Make entire drop zone clickable
- QR zone resets after 2s on error, allowing retry
- Clear file input on error so same file can be re-selected

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:28:28 -05:00
Aaron D. Lee
b1ddfaa75b flash-image.sh: prefer rpi-imager, fallback to dd
- Try rpi-imager first (native .zst support, faster)
- Fall back to dd if rpi-imager unavailable or fails
- pv now optional (uses dd status=progress without it)
- Handles .zst.zip GitHub wrapper automatically

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:14:01 -05:00
Aaron D. Lee
4843ec8c22 Add rpi-imager CLI option to flash docs
- rpi-imager --cli supports .zst.zip directly
- Also document flash-image.sh option
- Keep manual dd as fallback

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:11:13 -05:00
Aaron D. Lee
ac08011236 Clean up repo structure
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:05:06 -05:00
Aaron D. Lee
12c4b091fb Move smoke-test.sh to tests/, make it local-only
- Move from rpi/ to tests/ directory
- Add to .gitignore (local tool, not part of distribution)
- Pytest unit tests remain tracked

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:03:28 -05:00
Aaron D. Lee
c2c2c924e1 Add Docker support to smoke test, add inject-wifi.sh
Smoke test improvements:
- Add --docker flag for testing Docker containers
- Skip SSH/systemd checks in Docker mode
- Docker health check verifies HTTP response
- Show "Docker Smoke Test" header in Docker mode

inject-wifi.sh:
- Add to repo (was gitignored)
- Add cleanup trap for robustness
- Supports NetworkManager (Bookworm) and wpa_supplicant (legacy)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:01:36 -05:00
Aaron D. Lee
df7ad06a08 Update flash-image.sh: add .zst.zip support
- Add support for .zst.zip wrapper (GitHub releases workaround)
- Update examples to use .zst format (current default)
- Update usage to show all supported formats

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 17:50:52 -05:00
Aaron D. Lee
166b936ee5 Fix smoke test NEEDS_SETUP detection and login checks
- Check /login redirect to /setup instead of homepage redirect
- Use logout link presence to verify login success (encode/decode are public)
- Add -c flag to save cookies during homepage check

The smoke test was passing login even when not logged in because
encode/decode links are visible to everyone.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 16:43:09 -05:00
Aaron D. Lee
7138455f8d Update docs: cd /opt before git clone 2026-01-05 16:08:16 -05:00
Aaron D. Lee
9ab3260298 Update 4.1.2 plan: Docker cleanup done, add smoke test Docker support 2026-01-05 16:00:07 -05:00
Aaron D. Lee
763f7bf603 Fix Docker build: add .dockerignore, fix permissions
- Add .dockerignore to exclude instance/, test_data/, rpi/, etc.
- Create instance/certs dirs in Dockerfile for volume mounts
- Ensures stego user can write to mounted volumes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 15:59:32 -05:00
Aaron D. Lee
1059e17f4e Add release validation script to 4.1.2 plan 2026-01-05 15:12:18 -05:00
Aaron D. Lee
7cb42e189a Add release checklist for Pi and Docker validation 2026-01-05 15:11:56 -05:00
Aaron D. Lee
8c283bc4e5 Add 4.1.1 release notes 2026-01-05 14:50:37 -05:00
40 changed files with 3006 additions and 2189 deletions

39
.dockerignore Normal file
View File

@@ -0,0 +1,39 @@
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*.egg-info
.eggs
venv/
.venv/
# Instance data (user creates fresh)
frontends/web/instance/
frontends/web/certs/
instance/
# Test data
test_data/
tests/
# Pi-specific
rpi/
*.img
*.img.zst
*.img.zst.zip
# Docs
*.md
docs/
# IDE
.vscode/
.idea/
# Misc
*.log
*.tmp
.DS_Store

9
.gitignore vendored
View File

@@ -64,13 +64,16 @@ htmlcov/
# Output test files.
test_data/*.png
# Dev scripts (local convenience scripts)
scripts/
# Dev scripts (local convenience scripts - except validate-release.sh)
scripts/*
!scripts/validate-release.sh
# Web UI auth database and SSL certs
frontends/web/instance/
frontends/web/certs/
rpi/inject-wifi.sh
# Tests (private)
tests/
# RPi image build artifacts
*.img

View File

@@ -5,6 +5,41 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org).
## [4.1.2] - 2026-01-05
### Added
- **Docker Deployment**: Production-ready containerization
- `docker-compose.yml` for Web UI (port 5000) and REST API (port 8000)
- Multi-stage builds with base image for faster rebuilds
- Health checks, resource limits (768MB), and volume persistence
- Comprehensive `DOCKER.md` documentation
- **Raspberry Pi First-Boot Wizard**: Interactive TUI setup experience
- `gum` TUI toolkit for styled prompts and spinners
- WiFi configuration, HTTPS setup, channel key generation
- Overclock presets (Pi 5: 2.8/3.0 GHz with cooling recommendations)
- Port 443 redirect option for clean HTTPS URLs
- Styled banners with purple→blue gradient and gold logo
- **Pi Image Distribution**: Scripts for SD card imaging
- `sanitize-for-image.sh` removes credentials, SSH keys, user data
- Soft reset mode for testing without clearing WiFi
- Auto-validates sanitization before imaging
- **Unit Tests**: Comprehensive pytest test suite
- Tests for encode/decode, LSB/DCT modes, channel keys
- Validation, generation, compression, edge cases
- 29 tests covering core library functionality
- **Release Validation**: `scripts/validate-release.sh` for pre-release checks
### Changed
- Pi MOTD shows CPU speed and temperature when overclocked
- Mobile UI polish and responsive improvements
- Standardized ASCII banners across all Pi scripts
- Setup script uses pyenv for Python 3.12 (Pi OS ships 3.13)
### Fixed
- DCT decode reliability improvements
- Fixed `gum --inline` flag compatibility (not supported in all versions)
- Wizard banner alignment and spacing issues
## [4.1.0] - 2026-01-04
### Added
@@ -142,6 +177,8 @@ and this project adheres to [Semantic Versioning](https://semver.org).
- CLI interface
- Basic PIN authentication
[4.1.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.2
[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.1]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.0...v4.0.1
[4.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v3.2.0...v4.0.0

153
DOCKER.md Normal file
View File

@@ -0,0 +1,153 @@
# Docker Deployment
Stegasoo provides Docker images for both the Web UI and REST API.
## Quick Start
```bash
# Build and start all services
docker-compose up -d
# Check status
docker-compose ps
```
Access:
- **Web UI**: http://localhost:5000
- **REST API**: http://localhost:8000
## Services
| Service | Port | Description |
|---------|------|-------------|
| `web` | 5000 | Flask Web UI with authentication |
| `api` | 8000 | FastAPI REST API |
## Configuration
### Environment Variables
Create a `.env` file or set these variables:
```bash
# Channel key for private group communication (optional)
STEGASOO_CHANNEL_KEY=XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
# Web UI authentication (default: enabled)
STEGASOO_AUTH_ENABLED=true
# HTTPS support (default: disabled)
STEGASOO_HTTPS_ENABLED=false
STEGASOO_HOSTNAME=localhost
```
### Volume Mounts
Persistent data is stored in Docker volumes:
| Volume | Purpose |
|--------|---------|
| `stegasoo-web-data` | User database, session data |
| `stegasoo-web-certs` | SSL certificates (if HTTPS enabled) |
## Building
### Standard Build (Recommended)
Uses a pre-built base image with all dependencies:
```bash
# First time only: build the base image
docker build -f Dockerfile.base -t stegasoo-base:latest .
# Build services (fast - only copies app code)
docker-compose build
```
### Full Build (No Base Image)
If you don't have the base image, the Dockerfile will build all dependencies (slower):
```bash
docker-compose build
```
## Commands
```bash
# Start services
docker-compose up -d
# View logs
docker-compose logs -f
# Stop services
docker-compose down
# Rebuild after code changes
docker-compose build && docker-compose up -d
# Full rebuild (no cache)
docker-compose build --no-cache
```
## Resource Limits
Each container is configured with:
- **Memory limit**: 768 MB
- **Memory reservation**: 384 MB
This accounts for Argon2id's 256 MB RAM requirement during key derivation.
## Health Checks
Both services include health checks:
- Interval: 30 seconds
- Timeout: 10 seconds
- Start period: 5 seconds
- Retries: 3
Check health status:
```bash
docker-compose ps
```
## Production Deployment
For production, consider:
1. **Enable HTTPS**:
```bash
STEGASOO_HTTPS_ENABLED=true
STEGASOO_HOSTNAME=your-domain.com
```
2. **Use secrets for channel key**:
```bash
# Don't commit .env files with secrets
export STEGASOO_CHANNEL_KEY=your-key
docker-compose up -d
```
3. **Reverse proxy**: Put behind nginx/traefik for TLS termination
4. **Backup volumes**:
```bash
docker run --rm -v stegasoo-web-data:/data -v $(pwd):/backup \
alpine tar czf /backup/stegasoo-backup.tar.gz /data
```
## Troubleshooting
### Container won't start
```bash
# Check logs
docker-compose logs web
docker-compose logs api
```
### Out of memory
Increase Docker's memory allocation or reduce worker count in Dockerfile.
### Permission errors
The containers run as non-root user `stego` (UID 1000). Ensure volume permissions match.

View File

@@ -62,8 +62,8 @@ COPY src/ src/
COPY data/ data/
COPY frontends/web/ frontends/web/
# Create upload directory
RUN mkdir -p /tmp/stego_uploads
# Create upload directory and instance directories (for volumes)
RUN mkdir -p /tmp/stego_uploads /app/frontends/web/instance /app/frontends/web/certs
# Create non-root user
RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads

View File

@@ -7,7 +7,7 @@ Polish and UX improvements after the 4.1.1 stability release.
## 1. Real Progress Bar for Encode/Decode
**Status:** Planned
**Status:** Done
**Problem:** Users see elapsed time but no indication of how far along the operation is. Long DCT encodes on Pi can take 2-3 minutes with no feedback.
@@ -50,69 +50,33 @@ Polish and UX improvements after the 4.1.1 stability release.
## 2. Granular Decode Error Messages
**Status:** Planned
**Status:** Done
**Problem:** Decode failures show generic "Decryption failed" - users don't know if it's wrong photo, wrong passphrase, wrong PIN, corrupted image, or format mismatch.
**Solution:** Bubble up specific error types from library to UI
### Library Level (`src/stegasoo/`)
### Implementation
- Added new exceptions: InvalidMagicBytesError, ReedSolomonError, NoDataFoundError, ModeMismatchError
- DCT decode now raises InvalidMagicBytesError for wrong magic bytes
- DCT decode now raises ReedSolomonError (renamed from reedsolo's) for corruption
- app.py catches specific exceptions with user-friendly messages:
- Invalid magic → "Try a different mode (LSB/DCT)"
- RS error → "Image too corrupted, may have been re-saved"
- Invalid header → "Image may have been modified"
- Decryption error → "Wrong credentials"
1. **Custom exception classes:**
```python
class StegasooError(Exception): pass
class InvalidMagicBytesError(StegasooError): pass
class DecryptionError(StegasooError): pass
class ReedSolomonError(StegasooError): pass
class PayloadTooLargeError(StegasooError): pass
class InvalidHeaderError(StegasooError): pass
class NoDataFoundError(StegasooError): pass
```
2. **Raise specific exceptions** in decode paths:
- Magic bytes mismatch → "Not a Stegasoo image or wrong mode (LSB/DCT)"
- RS decode failure → "Image corrupted beyond repair"
- AES-GCM auth fail → "Wrong credentials (photo/passphrase/PIN)"
- Header parse fail → "Invalid or corrupted header"
- No stego data → "No hidden data found in image"
3. **Error codes** for programmatic handling:
```python
class ErrorCode(Enum):
INVALID_MAGIC = "invalid_magic"
DECRYPTION_FAILED = "decryption_failed"
RS_FAILED = "rs_failed"
# etc.
```
### Web UI Level (`frontends/web/`)
1. **app.py** - Catch specific exceptions, return error type:
```python
except InvalidMagicBytesError:
flash("This doesn't appear to be a Stegasoo image, or mode mismatch", "danger")
except DecryptionError:
flash("Wrong credentials - check reference photo, passphrase, and PIN", "warning")
```
2. **decode.html** - Error-specific help text:
- Wrong credentials → "Double-check your reference photo matches exactly"
- Corrupted → "Image may have been re-saved or compressed"
- Mode mismatch → "Try switching between Auto/DCT/LSB"
### Files to Modify
- `src/stegasoo/__init__.py` (export exceptions)
- `src/stegasoo/exceptions.py` (new file)
- `src/stegasoo/dct_steganography.py`
- `src/stegasoo/steganography.py` (LSB)
- `frontends/web/app.py`
- `frontends/web/templates/decode.html`
### Files Modified
- `src/stegasoo/exceptions.py` (new exceptions)
- `src/stegasoo/__init__.py` (exports)
- `src/stegasoo/dct_steganography.py` (raise specific exceptions)
- `frontends/web/app.py` (catch and display)
---
## 3. Mobile-Responsive Polish
**Status:** Planned
**Status:** Done
**Problem:** UI works on mobile but has rough edges - cramped buttons, hard-to-tap targets, awkward layouts on small screens.
@@ -167,21 +131,24 @@ Polish and UX improvements after the 4.1.1 stability release.
## 4. Forced First-Login Setup
**Status:** Planned
**Status:** Done
**Problem:** Users can navigate the app without creating an admin account first. Should force password setup before anything else.
**Solution:** Middleware/decorator that redirects to setup page if no users exist.
### Files to Modify
- `frontends/web/app.py` (add before_request check)
- `frontends/web/templates/setup.html` (ensure it blocks other nav)
### Implementation
- Added `@app.before_request` hook that redirects to /setup if no users exist
- Skips redirect for static files and setup-related routes
### Files Modified
- `frontends/web/app.py` (added require_setup before_request hook)
---
## 5. Dropzone UX Fixes
**Status:** Planned
**Status:** Done
**Problem:** Dropzone has some interaction bugs:
- Dropzone doesn't clear properly if first QR image fails
@@ -189,33 +156,95 @@ Polish and UX improvements after the 4.1.1 stability release.
**Solution:** Fix JS event handling and state management
### Files to Modify
### Implementation
- Added click handler on preview images to trigger file input
- Made entire drop zone clickable (not just label)
- QR zone now resets after 2 seconds on error, allowing retry
- Clear file input on QR error so same file can be re-selected
### Files Modified
- `frontends/web/static/js/stegasoo.js`
- `frontends/web/static/css/style.css` (clickable preview)
---
## 6. Smoke Test Benchmarking
**Status:** Planned
**Status:** Done
**Problem:** No way to measure encode/decode performance or track regressions.
**Solution:** Add timing to smoke tests using `hyperfine` or `time`.
### Features
- Benchmark encode/decode on test images
- Output timing stats (min/max/avg)
- Optional `--benchmark` flag for smoke-test.sh
- Compare NVMe vs SD card, overclocked vs stock
### Implementation
- Added `--benchmark` flag to run encode/decode benchmarks after tests
- Added `--runs=N` flag to customize number of benchmark runs (default: 5)
- Uses hyperfine if available for precise timing with warmup
- Falls back to manual timing with bc if hyperfine not installed
- Outputs min/max/avg stats for both encode and decode operations
### Files to Modify
### Files Modified
- `tests/smoke-test.sh`
---
## 7. Docker Cleanup
**Status:** Done (4.1.1)
**Problem:** Docker build context is larger than needed (includes test images, rpi scripts, etc.)
**Solution:** Added `.dockerignore` and fixed volume permissions in Dockerfile
### Files Modified
- `.dockerignore` (created)
- `Dockerfile` (instance dir permissions)
---
## 8. Release Validation Script
**Status:** Done
**Problem:** Manual release checklist is error-prone. Need automated validation.
**Solution:** Script that runs through testable checklist items
### Features
- Run pytest
- Build and test Docker image
- SSH to Pi and run smoke test (optional, if PI_IP provided)
- Report pass/fail summary
### Files to Create
- `scripts/validate-release.sh`
---
## 9. Smoke Test Docker Support
**Status:** Done
**Problem:** Smoke test expects systemd service, doesn't auto-create admin for Docker.
**Solution:** Make smoke test Docker-aware
### Features
- Skip systemd checks if not on Pi/Linux with systemd
- Auto-detect fresh Docker (no users) and create admin via /setup
- Add `--docker` flag to skip Pi-specific checks
### Implementation
- Added `--docker` flag that sets localhost and skips SSH/systemd checks
- Docker health check verifies container responds with HTTP 200/302
- Header shows "Docker Smoke Test" in Docker mode
### Files Modified
- `rpi/smoke-test.sh`
---
## Notes
- Keep 4.1.2 focused - 6 small features
- Keep 4.1.2 focused - 9 features (9 done)
- Don't break DCT compatibility (4.1.1 RS format is stable)
- Test on Pi before release

View File

@@ -102,9 +102,40 @@ black src/ tests/ frontends/
ruff check src/ tests/ frontends/
```
## Docker
```bash
# Quick start
docker-compose up -d
# Access
# Web UI: http://localhost:5000
# REST API: http://localhost:8000
```
See [DOCKER.md](DOCKER.md) for full documentation.
## Raspberry Pi
Pre-built SD card images available for Pi 4/5:
```bash
# Flash image (download from GitHub Releases)
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
# First boot runs interactive setup wizard:
# - WiFi configuration
# - HTTPS with port 443
# - Channel key generation
# - Optional overclocking
```
See [rpi/README.md](rpi/README.md) for manual installation.
## Documentation
- [INSTALL.md](INSTALL.md) - Installation guide
- [DOCKER.md](DOCKER.md) - Docker deployment
- [CLI.md](CLI.md) - Command-line reference
- [API.md](API.md) - REST API documentation
- [WEB_UI.md](WEB_UI.md) - Web interface guide

93
RELEASE-4.1.1.md Normal file
View File

@@ -0,0 +1,93 @@
# Stegasoo 4.1.1 Release Notes
**Release Date:** January 5, 2026
## Highlights
- **Reed-Solomon Error Correction** - DCT steganography now includes RS error correction, making encoded images more resilient to minor corruption and compression artifacts
- **Completely Rewritten Pi Setup** - Fresh install tested and validated, works reliably from scratch
- **SSH Login Banner** - See your Stegasoo URL immediately on SSH login
## New Features
### Reed-Solomon Error Correction
DCT-encoded images now include Reed-Solomon error correction codes, allowing recovery from minor image corruption. This significantly improves reliability when images are shared through platforms that may slightly modify them.
### SSH Login Banner (MOTD)
When you SSH into your Stegasoo Pi, you'll now see:
```
___ _____ ___ ___ _ ___ ___ ___
/ __||_ _|| __| / __| /_\ / __| / _ \ / _ \
\__ \ | | | _| | (_ | / _ \ \__ \ | (_) || (_) |
|___/ |_| |___| \___//_/ \_\|___/ \___/ \___/
● Stegasoo is running
https://192.168.0.4
```
### Elapsed Time Counter
Encode/decode buttons now show elapsed time during operations.
### Click-to-Copy Decoded Message
Click the decoded message box to copy to clipboard (no button needed).
### Overclock Wizard Option
First-boot wizard now offers optional CPU overclocking for Pi 4/5 with active cooling.
## Improvements
### Setup Script (setup.sh)
- Fixed pyenv Python path resolution (handles 3.12 → 3.12.12 mapping)
- Changed default install location to `/opt/stegasoo`
- Fixed jpegio build order (clone stegasoo first, then build jpegio into venv)
- Added python3-dev to dependencies
- Added btop for system monitoring
- Shows `/setup` URL at completion for admin account creation
### Sanitize Script
- Now clears port 443 iptables redirect (clean slate for wizard)
- Removes overclock settings before imaging
### Documentation
- Updated all docs to reference `/opt/stegasoo` path
- Added pre-setup steps (chown /opt, install git)
- Added Pi 4 performance baseline (~60s for 10MB JPEG)
### About Page
- Redesigned "Limits & Specs" section with key stats cards and accordion
## Bug Fixes
- Fixed DCT steganography for non-8-aligned images
- Fixed MOTD port detection (was using iptables which requires root)
- Fixed smoke test `--443` flag parsing
## Performance
On a Raspberry Pi 4 at 2GHz with USB 3.0 NVMe:
- ~50 seconds to encode a 10MB JPEG
- ~60 seconds to decode a 10MB JPEG
- Full encryption: passphrase + PIN + reference photo
## Upgrade Notes
If upgrading from 4.1.0:
```bash
cd /opt/stegasoo # or ~/stegasoo
git pull origin 4.1
```
For fresh installs, see the [Pi README](rpi/README.md).
## Pre-built Images
- `stegasoo-rpi-4.1.1_20260105-2.img.zst` - Raspberry Pi 4/5 image
Flash with:
```bash
zstdcat stegasoo-rpi-4.1.1_20260105-2.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
```
---
Full changelog: [v4.1.0...v4.1.1](https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.1)

44
RELEASE_CHECKLIST.md Normal file
View File

@@ -0,0 +1,44 @@
# Stegasoo Release Checklist
Pre-release validation checklist. Complete all items before tagging a release.
## Code Quality
- [ ] All tests pass: `./venv/bin/pytest tests/ -v`
- [ ] No lint errors: `./venv/bin/ruff check src/`
- [ ] Version bumped in `pyproject.toml`
- [ ] CHANGELOG.md updated
## Pi Image Validation
- [ ] Fresh Pi OS install with setup.sh works
- [ ] First-boot wizard completes successfully
- [ ] MOTD shows correct URL on SSH login
- [ ] Smoke test passes: `./rpi/smoke-test.sh --443 <PI_IP>`
- [ ] Encode/decode works on large image (10MB+)
- [ ] Sanitize script runs cleanly
- [ ] Image created and compressed
## Docker Validation
- [ ] Base image builds: `docker build -f Dockerfile.base -t stegasoo-base:latest .`
- [ ] Web image builds: `docker-compose build web`
- [ ] Container starts: `docker-compose up -d web`
- [ ] Web UI accessible at http://localhost:5000
- [ ] Encode/decode works in container
- [ ] Container stops cleanly: `docker-compose down`
## Release Process
- [ ] Merge feature branch to main
- [ ] Create annotated tag: `git tag -a vX.Y.Z -m "message"`
- [ ] Push tag: `git push origin vX.Y.Z`
- [ ] Create GitHub Release with release notes
- [ ] Upload Pi image (.img.zst.zip)
- [ ] Verify download links work
## Post-Release
- [ ] Delete old/obsolete releases if needed
- [ ] Update any external documentation
- [ ] Announce release (if applicable)

View File

@@ -24,11 +24,31 @@ Usage:
stegasoo channel [SUBCOMMAND]
"""
import json
import sys
import tempfile
import threading
import time
import uuid
from pathlib import Path
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
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")
# ============================================================================
# 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
# ============================================================================
@@ -642,6 +729,7 @@ def channel_clear(project, clear_all, force):
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("--progress", is_flag=True, help="Show progress bar (requires rich)")
def encode_cmd(
ref,
carrier,
@@ -661,6 +749,7 @@ def encode_cmd(
dct_output_format,
dct_color_mode,
quiet,
progress,
):
"""
Encode a secret message or file into an image.
@@ -808,19 +897,63 @@ def encode_cmd(
click.echo(channel_status)
# v4.0.0: Include channel_key parameter
result = encode(
message=payload,
reference_photo=ref_photo,
carrier_image=carrier_image,
passphrase=passphrase,
pin=pin or "",
rsa_key_data=rsa_key_data,
rsa_password=effective_key_password,
embed_mode=embed_mode,
dct_output_format=dct_output_format,
dct_color_mode=dct_color_mode,
channel_key=resolved_channel_key,
)
# v4.1.2: Progress bar support
encode_kwargs = {
"message": payload,
"reference_photo": ref_photo,
"carrier_image": carrier_image,
"passphrase": passphrase,
"pin": pin or "",
"rsa_key_data": rsa_key_data,
"rsa_password": effective_key_password,
"embed_mode": embed_mode,
"dct_output_format": dct_output_format,
"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
if output:

View File

@@ -26,7 +26,9 @@ import mimetypes
import os
import secrets
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from auth import (
@@ -36,6 +38,7 @@ from auth import (
can_create_user,
can_save_channel_key,
change_password,
clear_recovery_key,
create_admin_user,
create_user,
delete_channel_key,
@@ -45,12 +48,11 @@ from auth import (
get_channel_key_by_id,
get_current_user,
get_non_admin_count,
get_recovery_key_hash,
get_user_by_id,
get_user_channel_keys,
get_username,
has_recovery_key,
get_recovery_key_hash,
clear_recovery_key,
is_admin,
is_authenticated,
login_required,
@@ -59,10 +61,10 @@ from auth import (
reset_user_password,
save_channel_key,
set_recovery_key_hash,
verify_and_reset_admin_password,
update_channel_key_last_used,
update_channel_key_name,
user_exists,
verify_and_reset_admin_password,
verify_user_password,
)
from auth import (
@@ -93,6 +95,9 @@ from stegasoo import (
CapacityError,
DecryptionError,
FilePayload,
InvalidHeaderError,
InvalidMagicBytesError,
ReedSolomonError,
StegasooError,
export_rsa_key_pem,
generate_credentials,
@@ -153,7 +158,13 @@ except ImportError:
# ============================================================================
# Runs encode/decode/compare in subprocesses to prevent jpegio/scipy crashes
# 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 (
can_fit_in_qr,
@@ -192,6 +203,60 @@ app.config["HTTPS_ENABLED"] = os.environ.get("STEGASOO_HTTPS_ENABLED", "false").
# Initialize auth module
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
def require_setup():
"""Force redirect to setup if no users exist (first-run)."""
if not app.config.get("AUTH_ENABLED", True):
return None
# Skip for static files and setup-related routes
if request.endpoint in ("static", "setup", "setup_recovery", None):
return None
# If no users exist, redirect to setup
if not user_exists():
return redirect(url_for("setup"))
return None
# Temporary file storage for sharing (file_id -> {data, timestamp, filename})
TEMP_FILES: dict[str, dict] = {}
THUMBNAIL_FILES: dict[str, bytes] = {}
@@ -796,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"])
@login_required
def encode_page():
if request.method == "POST":
# Check if async mode requested
is_async = request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
try:
# Get files
ref_photo = request.files.get("reference_photo")
@@ -935,7 +1109,9 @@ def encode_page():
# Pre-check payload capacity BEFORE encode (fail fast)
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)
if not fit_check.get("fits", True):
error_msg = (
@@ -951,8 +1127,35 @@ def encode_page():
flash(error_msg, "error")
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
# v4.0.0: Include channel_key parameter
# Use subprocess-isolated encode to prevent crashes
# Build encode params for either sync or async
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:
encode_result = subprocess_stego.encode(
carrier_data=carrier_data,
@@ -967,7 +1170,7 @@ def encode_page():
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, # v4.0.0
channel_key=channel_key,
)
else:
encode_result = subprocess_stego.encode(
@@ -981,7 +1184,7 @@ def encode_page():
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, # v4.0.0
channel_key=channel_key,
)
# Check for subprocess errors
@@ -1037,6 +1240,53 @@ def encode_page():
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>")
@login_required
def encode_result(file_id):
@@ -1271,10 +1521,28 @@ def decode_page():
has_qrcode_read=HAS_QRCODE_READ,
)
except InvalidMagicBytesError:
flash(
"This doesn't appear to be a Stegasoo image. Try a different mode (LSB/DCT).",
"warning",
)
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
except ReedSolomonError:
flash(
"Image too corrupted to decode. It may have been re-saved or compressed.",
"error",
)
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
except InvalidHeaderError:
flash(
"Invalid or corrupted header. The image may have been modified.",
"error",
)
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
except DecryptionError:
flash(
"Decryption failed. Check passphrase, PIN, RSA key, reference photo, and channel key.",
"error",
"Wrong credentials. Double-check your reference photo, passphrase, PIN, and channel key.",
"warning",
)
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
except StegasooError as e:
@@ -1363,12 +1631,7 @@ def api_tools_strip_metadata():
buffer = io.BytesIO(clean_data)
filename = image_file.filename.rsplit(".", 1)[0] + "_clean.png"
return send_file(
buffer,
mimetype="image/png",
as_attachment=True,
download_name=filename
)
return send_file(buffer, mimetype="image/png", as_attachment=True, download_name=filename)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 400
@@ -1390,13 +1653,15 @@ def api_tools_exif():
# Check if it's a JPEG (editable) or not
is_jpeg = image_data[:2] == b"\xff\xd8"
return jsonify({
"success": True,
"filename": image_file.filename,
"exif": exif,
"editable": is_jpeg,
"field_count": len(exif),
})
return jsonify(
{
"success": True,
"filename": image_file.filename,
"exif": exif,
"editable": is_jpeg,
"field_count": len(exif),
}
)
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 400
@@ -1415,6 +1680,7 @@ def api_tools_exif_update():
updates_json = request.form.get("updates", "{}")
try:
import json
updates = json.loads(updates_json)
except json.JSONDecodeError:
return jsonify({"success": False, "error": "Invalid updates JSON"}), 400
@@ -1460,11 +1726,19 @@ def api_tools_exif_clear():
clean_data = strip_image_metadata(image_data, output_format=output_format)
# 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"))
# 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)
return send_file(
buffer,
@@ -1605,9 +1879,10 @@ def setup():
@login_required
def setup_recovery():
"""Recovery key setup page (Step 2 of initial setup)."""
from stegasoo.recovery import generate_recovery_key, hash_recovery_key, generate_recovery_qr
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)
if has_recovery_key():
return redirect(url_for("index"))
@@ -1685,9 +1960,10 @@ def recover():
@admin_required
def regenerate_recovery():
"""Generate a new recovery key (replaces existing one)."""
from stegasoo.recovery import generate_recovery_key, hash_recovery_key, generate_recovery_qr
import base64
from stegasoo.recovery import generate_recovery_key, generate_recovery_qr, hash_recovery_key
if request.method == "POST":
action = request.form.get("action")
@@ -1886,21 +2162,23 @@ def api_channel_keys():
"""Get saved channel keys for current user (JSON API)."""
current_user = get_current_user()
keys = get_user_channel_keys(current_user.id)
return jsonify({
"success": True,
"keys": [
{
"id": k.id,
"name": k.name,
"fingerprint": f"{k.channel_key[:4]}...{k.channel_key[-4:]}",
"channel_key": k.channel_key,
"last_used_at": k.last_used_at,
}
for k in keys
],
"can_save": can_save_channel_key(current_user.id),
"max_keys": MAX_CHANNEL_KEYS,
})
return jsonify(
{
"success": True,
"keys": [
{
"id": k.id,
"name": k.name,
"fingerprint": f"{k.channel_key[:4]}...{k.channel_key[-4:]}",
"channel_key": k.channel_key,
"last_used_at": k.last_used_at,
}
for k in keys
],
"can_save": can_save_channel_key(current_user.id),
"max_keys": MAX_CHANNEL_KEYS,
}
)
@app.route("/api/channel/keys/<int:key_id>/use", methods=["POST"])

View File

@@ -99,6 +99,23 @@ const Stegasoo = {
}
});
}
// Make preview clickable to replace file
if (preview) {
preview.style.cursor = 'pointer';
preview.addEventListener('click', (e) => {
e.stopPropagation();
input.click();
});
}
// Make entire zone clickable (in case label/preview don't cover it)
zone.addEventListener('click', (e) => {
// Only trigger if not clicking directly on the input
if (e.target !== input) {
input.click();
}
});
});
},
@@ -584,6 +601,17 @@ const Stegasoo = {
<span>No QR code detected</span>
`;
}
// Reset after delay so user can try again
setTimeout(() => {
container.classList.remove('error');
container.classList.add('d-none');
label?.classList.remove('d-none');
// Clear the file input so same file can be re-selected
input.value = '';
// Remove loader
if (loader) loader.remove();
}, 2000);
});
});
},
@@ -888,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
// ========================================================================
@@ -909,27 +1111,23 @@ const Stegasoo = {
generateBtnId: 'channelKeyGenerate'
});
// Form submission with channel key validation
// Form submission with async progress tracking (v4.1.2)
const form = document.getElementById('encodeForm');
const btn = document.getElementById('encodeBtn');
form?.addEventListener('submit', (e) => {
e.preventDefault();
if (!this.validateChannelKeyOnSubmit(form, 'channelSelect', 'channelKeyInput')) {
e.preventDefault();
return false;
}
if (btn) {
btn.disabled = true;
const startTime = Date.now();
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);
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting...';
}
// Use async submission with progress tracking
this.submitEncodeAsync(form, btn);
});
},

View File

@@ -1442,3 +1442,260 @@ footer {
padding: 0.35rem 0.75rem;
background: rgba(0, 0, 0, 0.1);
}
/* ============================================================================
MOBILE RESPONSIVE IMPROVEMENTS
============================================================================ */
/* Mobile-specific drop zone improvements */
@media (max-width: 768px) {
/* Larger drop zones on mobile for easier touch targets */
.drop-zone {
padding: 2rem 1.5rem;
min-height: 140px;
}
/* Larger touch target for upload icons */
.drop-zone-label i {
font-size: 2.5rem !important;
}
/* Touch feedback - active state */
.drop-zone:active {
border-color: var(--gradient-start);
background: rgba(102, 126, 234, 0.15);
transform: scale(0.98);
}
/* Mode buttons - stack vertically on very small screens */
.d-flex.gap-2:has(.mode-btn) {
flex-direction: column;
}
.mode-btn {
padding: 1rem;
min-height: 56px; /* iOS touch target minimum */
}
/* Full-width primary buttons */
.btn-primary.btn-lg {
padding: 1rem 1.5rem;
font-size: 1.1rem;
min-height: 56px;
}
/* Security factor boxes - more padding for touch */
.security-box {
padding: 1.25rem;
}
/* Form controls - larger for touch */
.form-control,
.form-select {
padding: 0.75rem 1rem;
font-size: 1rem;
min-height: 48px;
}
/* Input groups - consistent sizing */
.input-group .form-control {
min-height: 48px;
}
.input-group .btn {
min-width: 48px;
padding: 0.75rem;
}
/* Password toggle button - easier to tap */
[data-toggle-password] {
min-width: 52px;
}
/* PIN input - larger on mobile */
.pin-input-container .form-control {
font-size: 1.4rem;
letter-spacing: 4px;
padding: 0.875rem 1rem;
}
/* Passphrase input - comfortable mobile size */
.passphrase-input {
font-size: 1rem !important;
padding: 0.875rem 1rem !important;
}
/* Card headers - compact on mobile */
.card-header h5 {
font-size: 1.1rem;
}
/* Alert info panel - readable text */
.alert.small {
font-size: 0.9rem;
}
/* Bottom info icons - larger tap targets */
.row.text-center .col-4 {
padding: 0.5rem;
}
.row.text-center .col-4 i {
font-size: 2rem !important;
}
/* Capacity panel badges - easier to read */
#capacityPanel .badge {
font-size: 0.8rem;
padding: 0.4rem 0.6rem;
}
/* Payload type toggle - full width buttons */
.btn-group[role="group"] {
flex-direction: row;
}
.btn-group .btn {
padding: 0.75rem 0.5rem;
font-size: 0.95rem;
}
/* Textarea - comfortable height */
textarea.form-control {
min-height: 120px;
}
/* Channel select - full width */
#channelSelect {
font-size: 1rem;
}
}
/* Very small screens (iPhone SE, etc.) */
@media (max-width: 375px) {
.drop-zone {
padding: 1.5rem 1rem;
}
.mode-btn {
padding: 0.875rem;
font-size: 0.9rem;
}
.mode-btn .text-muted {
display: none; /* Hide secondary text on tiny screens */
}
.card-header h5 {
font-size: 1rem;
}
/* Stack security factor row */
.row:has(.security-box) > .col-md-6 {
margin-bottom: 1rem;
}
}
/* Touch device optimizations */
@media (hover: none) and (pointer: coarse) {
/* Remove hover effects that don't work on touch */
.btn-primary:hover {
transform: none;
}
.feature-card:hover {
transform: none;
}
.card-link:hover .feature-card {
transform: none;
}
/* Add active states instead */
.btn-primary:active {
transform: scale(0.98);
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.3);
}
.feature-card:active {
transform: scale(0.98);
}
/* Drop zone active feedback */
.drop-zone:active {
border-color: var(--gradient-start);
background: rgba(102, 126, 234, 0.1);
}
/* Mode button active state */
.mode-btn:active {
background: rgba(255, 255, 255, 0.12);
border-color: var(--gradient-start);
}
}
/* Camera hint for mobile - shows on file inputs */
@media (max-width: 768px) {
.drop-zone-label span.text-muted {
display: block;
}
/* Add camera icon hint on mobile */
.drop-zone-label::after {
content: "Tap to take photo or choose file";
display: block;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
margin-top: 0.5rem;
}
/* Hide the default text and show mobile version */
.drop-zone-label > span.text-muted {
display: none;
}
}
/* Navbar mobile adjustments */
@media (max-width: 768px) {
.navbar {
padding: 0.5rem 1rem;
}
.navbar-brand img {
height: 32px;
}
/* Sticky header shouldn't eat too much space */
.navbar.sticky-top {
position: relative; /* Don't stick on mobile - saves screen space */
}
}
/* Results page mobile adjustments */
@media (max-width: 768px) {
/* Download button - full width on mobile */
.btn-success.btn-lg,
a.btn-success.btn-lg {
width: 100%;
padding: 1rem;
font-size: 1.1rem;
}
/* QR codes - appropriate sizing */
.qr-scan-container {
max-width: 280px;
margin: 0 auto;
}
/* Message display - readable on mobile */
.alert-message {
font-size: 0.9rem;
padding: 1rem;
word-break: break-word;
}
/* Result icon - slightly smaller on mobile */
.result-icon {
font-size: 3rem;
}
}

View File

@@ -111,6 +111,7 @@ def encode_operation(params: dict) -> dict:
dct_output_format=params.get("dct_output_format", "png"),
dct_color_mode=params.get("dct_color_mode", "color"),
channel_key=resolved_channel_key, # v4.0.0
progress_file=params.get("progress_file"), # v4.1.2
)
# Build stats dict if available

View File

@@ -47,6 +47,8 @@ import base64
import json
import subprocess
import sys
import tempfile
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -233,6 +235,8 @@ class SubprocessStego:
# Channel key (v4.0.0)
channel_key: str | None = "auto",
timeout: int | None = None,
# Progress file (v4.1.2)
progress_file: str | None = None,
) -> EncodeResult:
"""
Encode a message or file into an image.
@@ -268,6 +272,7 @@ class SubprocessStego:
"dct_output_format": dct_output_format,
"dct_color_mode": dct_color_mode,
"channel_key": channel_key, # v4.0.0
"progress_file": progress_file, # v4.1.2
}
if file_data:
@@ -496,3 +501,42 @@ def get_subprocess_stego() -> SubprocessStego:
if _default_stego is None:
_default_stego = SubprocessStego()
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

View File

@@ -100,7 +100,7 @@
<li><strong>Output:</strong> JPEG or PNG</li>
<li><strong>Color:</strong> Color or grayscale</li>
<li><strong>Speed:</strong> ~2s</li>
<li><strong>Error Correction:</strong> Reed-Solomon <span class="badge bg-info ms-1">v4.1</span></li>
<li><strong>Error Correction:</strong> Reed-Solomon</li>
</ul>
<hr>
<div class="small">
@@ -375,56 +375,64 @@
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Version History</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-dark table-sm small">
<thead>
<tr>
<th>Version</th>
<th>Changes</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>4.1.0</strong></td>
<td>
<strong>Reed-Solomon error correction</strong> for DCT mode (corrects up to 16 byte errors per 223-byte chunk),
majority voting on length headers, improved robustness with problematic carrier images
</td>
</tr>
<tr>
<td><strong>4.0.0</strong></td>
<td>
<strong>Channel keys</strong> for group/deployment isolation,
DCT default, simplified auth, passphrase replaces day_phrase,
4-word default, JPEG fix, large image support, subprocess isolation, Python 3.10-3.12
</td>
</tr>
<tr>
<td>3.2.0</td>
<td>Single passphrase, more default words</td>
</tr>
<tr>
<td>3.0.0</td>
<td>DCT mode, JPEG output, color preservation</td>
</tr>
<tr>
<td>2.2.0</td>
<td>QR code RSA key import/export</td>
</tr>
<tr>
<td>2.1.0</td>
<td>File embedding, compression</td>
</tr>
<tr>
<td>2.0.0</td>
<td>Web UI, REST API, RSA keys</td>
</tr>
<tr>
<td>1.0.0</td>
<td>Initial release, CLI only, LSB mode</td>
</tr>
</tbody>
</table>
<!-- Current Version - Prominent -->
<div class="alert alert-success mb-4">
<div class="d-flex align-items-center">
<span class="badge bg-success fs-6 me-3">v4.1.2</span>
<div>
<strong>Progress bars</strong> for encode operations,
<strong>mobile-responsive polish</strong>,
DCT decode bug fix, release validation script
</div>
</div>
</div>
<!-- Previous Versions - Accordion -->
<div class="accordion" id="versionAccordion">
<div class="accordion-item bg-dark">
<h2 class="accordion-header">
<button class="accordion-button collapsed bg-dark text-light py-2" type="button"
data-bs-toggle="collapse" data-bs-target="#olderVersions">
<i class="bi bi-archive me-2"></i>Previous Versions
</button>
</h2>
<div id="olderVersions" class="accordion-collapse collapse" data-bs-parent="#versionAccordion">
<div class="accordion-body p-0">
<table class="table table-dark table-sm small mb-0">
<tbody>
<tr>
<td width="80"><strong>4.1.1</strong></td>
<td>DCT RS format stability, Docker cleanup, first-boot wizard</td>
</tr>
<tr>
<td><strong>4.1.0</strong></td>
<td>Reed-Solomon error correction for DCT, majority voting headers</td>
</tr>
<tr>
<td><strong>4.0.0</strong></td>
<td>Channel keys, DCT default, subprocess isolation</td>
</tr>
<tr>
<td>3.2.0</td>
<td>Single passphrase, more default words</td>
</tr>
<tr>
<td>3.0.0</td>
<td>DCT mode, JPEG output, color preservation</td>
</tr>
<tr>
<td>2.x</td>
<td>Web UI, REST API, RSA keys, QR codes, file embedding</td>
</tr>
<tr>
<td>1.0.0</td>
<td>Initial release, CLI only, LSB mode</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -553,9 +561,8 @@
<div class="col-6 col-md-4 col-lg-2 mb-3">
<div class="p-3 bg-dark rounded h-100">
<i class="bi bi-bandaid text-info fs-3 d-block mb-2"></i>
<div class="small text-muted">Error Correction</div>
<strong>Reed-Solomon</strong>
<span class="badge bg-info ms-1">v4.1</span>
<div class="small text-muted">DCT ECC</div>
<strong>RS Code</strong>
</div>
</div>
</div>

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "stegasoo"
version = "4.1.1"
version = "4.1.2"
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
readme = "README.md"
license = "MIT"
@@ -54,6 +54,7 @@ cli = [
"click>=8.0.0",
"qrcode>=7.30",
"piexif>=1.1.0",
"rich>=13.0.0",
]
compression = [
"lz4>=4.0.0",

View File

@@ -33,7 +33,8 @@ sudo apt-get update && sudo apt-get install -y git
## Step 4: Clone & Run Setup
```bash
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git
cd /opt
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
cd stegasoo
./rpi/setup.sh
```
@@ -102,10 +103,14 @@ Upload `.img.zst` to GitHub Releases.
Users can flash with:
```bash
# Linux
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
# Option 1: rpi-imager CLI (supports .zst.zip directly)
sudo rpi-imager --cli --disable-verify stegasoo-rpi-*.img.zst.zip /dev/sdX
# Or use rpi-imager "Use custom" option
# Option 2: flash-image.sh (auto-detects SD card, shows progress)
sudo ./rpi/flash-image.sh stegasoo-rpi-*.img.zst.zip
# Option 3: Manual dd
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
```
---
@@ -116,7 +121,7 @@ zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
# On Pi (after SSH):
sudo chown admin:admin /opt
sudo apt-get update && sudo apt-get install -y git
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git
cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
cd stegasoo && ./rpi/setup.sh
sudo systemctl start stegasoo
curl -k https://localhost:5000

View File

@@ -12,8 +12,9 @@ sudo chown $USER:$USER /opt
sudo apt-get update && sudo apt-get install -y git
# Clone and run setup
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git /opt/stegasoo
cd /opt/stegasoo
cd /opt
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
cd stegasoo
./rpi/setup.sh
```
@@ -138,8 +139,9 @@ sudo chown admin:admin /opt
sudo apt-get update && sudo apt-get install -y git
# Clone and run setup
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git /opt/stegasoo
cd /opt/stegasoo
cd /opt
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
cd stegasoo
./rpi/setup.sh
```
@@ -196,7 +198,12 @@ Upload the `.img.zst` file to GitHub Releases.
Users flash with:
```bash
# Option 1: rpi-imager CLI (supports .zst.zip directly)
sudo rpi-imager --cli --disable-verify stegasoo-rpi-*.img.zst.zip /dev/sdX
# Option 2: flash-image.sh (auto-detects SD card, shows progress)
sudo ./rpi/flash-image.sh stegasoo-rpi-*.img.zst.zip
# Option 3: Manual dd
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
```
Or use rpi-imager's "Use custom" option.

View File

@@ -39,21 +39,17 @@ clear
# Welcome
# =============================================================================
gum style \
--border double \
--border-foreground 212 \
--padding "1 2" \
--margin "1" \
--align center \
" . * . . * . * . * . * ." \
" ___ _____ ___ ___ _ ___ ___ ___ " \
" / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\" \
" \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |" \
" |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/" \
"" \
" * . * . * . * . * . *" \
"" \
"First Boot Wizard"
echo ""
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
echo -e "\033[38;5;220m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "\033[1;37m First Boot Wizard\033[0m"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo ""
gum style --foreground 245 "This wizard will help you configure your Stegasoo server."
@@ -384,72 +380,49 @@ else
ACCESS_URL_LOCAL="http://$HOSTNAME.local:5000/setup"
fi
gum style \
--border double \
--border-foreground 82 \
--padding "1 2" \
--margin "1" \
--align center \
" . * . . * . * . * . * ." \
" ___ _____ ___ ___ _ ___ ___ ___" \
" / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\" \
" \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |" \
" |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/" \
"" \
" * . * . * . * . * . *" \
"" \
"Setup Complete!"
echo ""
echo ""
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
echo -e "\033[38;5;220m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "\033[1;32m Setup Complete!\033[0m"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo ""
gum style --foreground 82 --bold "Create your admin account:"
gum style --foreground 226 " $ACCESS_URL"
gum style --foreground 245 " $ACCESS_URL_LOCAL (if mDNS works)"
echo ""
if [ -n "$CHANNEL_KEY" ]; then
gum style --foreground 82 --bold "Channel Key:"
gum style --foreground 226 " $CHANNEL_KEY"
echo ""
echo -e "\033[1;32mChannel Key:\033[0m \033[0;33m$CHANNEL_KEY\033[0m"
fi
echo ""
gum style --foreground 82 --bold "First Steps:"
gum style --foreground 255 \
" 1. Open the URL above in your browser" \
" 2. Accept the security warning (self-signed cert)" \
" 3. Create your admin account" \
" 4. Start encoding secret messages!"
echo ""
gum style --foreground 255 " 1. Open URL → 2. Accept cert → 3. Create admin → 4. Encode!"
gum style --foreground 82 --bold "Useful Commands:"
gum style --foreground 245 \
" sudo systemctl status stegasoo # Check status" \
" sudo systemctl restart stegasoo # Restart" \
" journalctl -u stegasoo -f # View logs"
echo ""
gum style --foreground 212 --bold "Enjoy Stegasoo!"
echo ""
gum style --foreground 245 "Commands: systemctl {status|restart} stegasoo, journalctl -u stegasoo -f"
# Prompt for restart if overclock was enabled
if [ "$NEEDS_RESTART" = "true" ]; then
echo ""
gum style \
--border rounded \
--border-foreground 226 \
--padding "1 2" \
--foreground 226 \
"Restart Required" \
"" \
"Overclock settings require a restart to take effect."
echo ""
gum style --foreground 226 --bold "⚠ Restart required for overclock settings"
if gum confirm "Restart now?" --default=true; then
gum style --foreground 82 "Restarting in 3 seconds..."
sleep 3
sudo reboot
else
gum style --foreground 214 "Remember to restart later for overclock to take effect:"
gum style --foreground 245 " sudo reboot"
echo ""
gum style --foreground 214 "Run 'sudo reboot' later to apply overclock."
fi
fi
echo ""
gum style --foreground 212 --bold "Enjoy Stegasoo!"
echo ""

View File

@@ -1,12 +1,12 @@
#!/bin/bash
#
# Flash Stegasoo image to SD card
# Auto-detects SD card, decompresses and writes with progress
# Uses rpi-imager if available, falls back to dd
#
# Usage: ./flash-image.sh <image.img.xz> [device]
# ./flash-image.sh <image.img> [device]
# Usage: ./flash-image.sh <image> [device]
#
# If device is specified, skips auto-detection (useful for large drives)
# Supports: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip (GitHub release format)
# If device is specified, skips auto-detection (useful for NVMe/large drives)
#
set -e
@@ -19,13 +19,27 @@ BOLD='\033[1m'
NC='\033[0m'
# Check for required tools
for cmd in dd pv lsblk; do
for cmd in dd lsblk; do
if ! command -v $cmd &> /dev/null; then
echo -e "${RED}Error: $cmd is required but not installed.${NC}"
exit 1
fi
done
# Check for optional tools
HAS_RPI_IMAGER=false
HAS_PV=false
if command -v rpi-imager &> /dev/null; then
HAS_RPI_IMAGER=true
fi
if command -v pv &> /dev/null; then
HAS_PV=true
fi
if [ "$HAS_RPI_IMAGER" = false ] && [ "$HAS_PV" = false ]; then
echo -e "${YELLOW}Warning: Neither rpi-imager nor pv found. Progress will not be shown.${NC}"
fi
# Check for root
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: Must run as root (sudo)${NC}"
@@ -34,11 +48,14 @@ fi
# Check for image argument
if [ -z "$1" ]; then
echo -e "${RED}Usage: $0 <image.img.xz|image.img> [device]${NC}"
echo -e "${RED}Usage: $0 <image> [device]${NC}"
echo ""
echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
echo ""
echo "Examples:"
echo " $0 stegasoo-rpi-20260103.img.xz # auto-detect SD card"
echo " $0 stegasoo-rpi-20260103.img.xz /dev/sdb # specify device"
echo " $0 stegasoo-rpi-4.1.1.img.zst # auto-detect SD card"
echo " $0 stegasoo-rpi-4.1.1.img.zst.zip # from GitHub release"
echo " $0 stegasoo-rpi-4.1.1.img.zst /dev/sdb # specify device"
exit 1
fi
@@ -50,6 +67,25 @@ if [ ! -f "$IMAGE" ]; then
exit 1
fi
# Handle .zst.zip wrapper (GitHub releases workaround)
if [[ "$IMAGE" == *.zst.zip ]]; then
echo -e "${YELLOW}Extracting .zst from zip wrapper...${NC}"
if ! command -v unzip &> /dev/null; then
echo -e "${RED}Error: unzip is required for .zst.zip files but not installed.${NC}"
exit 1
fi
TEMP_DIR=$(mktemp -d)
trap "rm -rf $TEMP_DIR" EXIT
unzip -q "$IMAGE" -d "$TEMP_DIR"
IMAGE=$(find "$TEMP_DIR" -name "*.zst" | head -1)
if [ -z "$IMAGE" ]; then
echo -e "${RED}Error: No .zst file found in zip archive${NC}"
exit 1
fi
echo -e "${GREEN}Found: $(basename "$IMAGE")${NC}"
echo ""
fi
# Detect compression
COMPRESSED=false
COMP_TYPE=""
@@ -202,14 +238,46 @@ echo ""
echo -e "${GREEN}Flashing image to $SELECTED...${NC}"
echo ""
if [ "$COMPRESSED" = true ]; then
case "$COMP_TYPE" in
xz) pv "$IMAGE" | xzcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
zst) pv "$IMAGE" | zstdcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
gz) pv "$IMAGE" | zcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
esac
# Try rpi-imager first (faster, native support for compressed images)
if command -v rpi-imager &> /dev/null; then
echo -e "${YELLOW}Using rpi-imager...${NC}"
if rpi-imager --cli --disable-verify "$IMAGE" "$SELECTED"; then
# rpi-imager succeeded
:
else
echo -e "${YELLOW}rpi-imager failed, falling back to dd...${NC}"
# Fall through to dd
USE_DD=true
fi
else
pv "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null
USE_DD=true
fi
# Fallback to dd
if [ "$USE_DD" = true ]; then
if [ "$HAS_PV" = true ]; then
echo -e "${YELLOW}Using dd with progress...${NC}"
if [ "$COMPRESSED" = true ]; then
case "$COMP_TYPE" in
xz) pv "$IMAGE" | xzcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
zst) pv "$IMAGE" | zstdcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
gz) pv "$IMAGE" | zcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
esac
else
pv "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null
fi
else
echo -e "${YELLOW}Using dd (no progress - install pv for progress bar)...${NC}"
if [ "$COMPRESSED" = true ]; then
case "$COMP_TYPE" in
xz) xzcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
zst) zstdcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
gz) zcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
esac
else
dd if="$IMAGE" of="$SELECTED" bs=4M conv=fsync status=progress
fi
fi
fi
echo ""

200
rpi/inject-wifi.sh Executable file
View File

@@ -0,0 +1,200 @@
#!/bin/bash
#
# Inject WiFi credentials into SD card for Raspberry Pi
# Supports both Bookworm (NetworkManager) and older (wpa_supplicant)
#
# First-time setup:
# ./inject-wifi.sh --setup
#
# Then after flashing:
# sudo ./inject-wifi.sh # auto-detect partitions
# sudo ./inject-wifi.sh /dev/sdb # specify device (finds partitions)
#
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
CONFIG_DIR="$HOME/.config/stegasoo"
CONFIG_FILE="$CONFIG_DIR/wifi.conf"
# Setup mode - save credentials
if [ "$1" == "--setup" ]; then
echo -e "${BLUE}Stegasoo WiFi Config Setup${NC}"
echo ""
read -p "WiFi SSID: " WIFI_SSID
read -s -p "WiFi Password: " WIFI_PASS
echo ""
read -p "Country code [US]: " WIFI_COUNTRY
WIFI_COUNTRY=${WIFI_COUNTRY:-US}
# Generate hashed PSK for wpa_supplicant (legacy)
if command -v wpa_passphrase &> /dev/null; then
HASHED_PSK=$(wpa_passphrase "$WIFI_SSID" "$WIFI_PASS" | grep -E "^\s+psk=" | tr -d '\t' | cut -d= -f2)
else
HASHED_PSK=""
echo -e "${YELLOW}Note: wpa_passphrase not found, legacy mode disabled${NC}"
fi
# Save config (includes plaintext for NetworkManager)
mkdir -p "$CONFIG_DIR"
chmod 700 "$CONFIG_DIR"
cat > "$CONFIG_FILE" << EOF
# Stegasoo WiFi config
WIFI_SSID="$WIFI_SSID"
WIFI_PASS="$WIFI_PASS"
WIFI_PSK_HASH="$HASHED_PSK"
WIFI_COUNTRY="$WIFI_COUNTRY"
EOF
chmod 600 "$CONFIG_FILE"
echo ""
echo -e "${GREEN}Config saved to $CONFIG_FILE${NC}"
exit 0
fi
# Normal mode - inject credentials
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: Must run as root (sudo)${NC}"
echo "Usage: sudo $0 [/dev/sdX]"
echo ""
echo "First-time setup (no sudo): $0 --setup"
exit 1
fi
# Load config
if [ -n "$SUDO_USER" ]; then
USER_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6)
CONFIG_FILE="$USER_HOME/.config/stegasoo/wifi.conf"
fi
if [ ! -f "$CONFIG_FILE" ]; then
echo -e "${RED}Config not found: $CONFIG_FILE${NC}"
echo ""
echo "Run setup first (without sudo):"
echo " ./inject-wifi.sh --setup"
exit 1
fi
source "$CONFIG_FILE"
if [ -z "$WIFI_SSID" ] || [ -z "$WIFI_PASS" ]; then
echo -e "${RED}Invalid config. Run --setup again.${NC}"
exit 1
fi
# Find partitions
MANUAL_DEV="$1"
if [ -n "$MANUAL_DEV" ]; then
# Strip partition number if given (e.g., /dev/sdb1 -> /dev/sdb)
BASE_DEV=$(echo "$MANUAL_DEV" | sed 's/[0-9]*$//')
BOOT_DEV="${BASE_DEV}1"
ROOT_DEV="${BASE_DEV}2"
else
# Auto-detect by label
BOOT_PART=$(lsblk -o NAME,FSTYPE,LABEL -rn | grep -E "vfat.*(bootfs|boot)" | head -1 | awk '{print $1}')
ROOT_PART=$(lsblk -o NAME,FSTYPE,LABEL -rn | grep -E "ext4.*rootfs" | head -1 | awk '{print $1}')
if [ -z "$BOOT_PART" ] || [ -z "$ROOT_PART" ]; then
echo -e "${RED}Could not find boot/root partitions. Is the SD card inserted?${NC}"
echo ""
lsblk -o NAME,SIZE,FSTYPE,LABEL
echo ""
echo -e "${YELLOW}Tip: Specify device manually: sudo $0 /dev/sdX${NC}"
exit 1
fi
BOOT_DEV="/dev/$BOOT_PART"
ROOT_DEV="/dev/$ROOT_PART"
fi
echo -e "${GREEN}Found partitions:${NC}"
echo -e " Boot: ${YELLOW}$BOOT_DEV${NC}"
echo -e " Root: ${YELLOW}$ROOT_DEV${NC}"
# Mount points
BOOT_MNT="/tmp/stegasoo-boot-$$"
ROOT_MNT="/tmp/stegasoo-root-$$"
cleanup() {
umount "$BOOT_MNT" 2>/dev/null || true
umount "$ROOT_MNT" 2>/dev/null || true
rmdir "$BOOT_MNT" "$ROOT_MNT" 2>/dev/null || true
}
trap cleanup EXIT
mkdir -p "$BOOT_MNT" "$ROOT_MNT"
# Mount partitions
mount "$BOOT_DEV" "$BOOT_MNT"
mount "$ROOT_DEV" "$ROOT_MNT"
echo ""
# 1. Write NetworkManager config (Bookworm+)
NM_DIR="$ROOT_MNT/etc/NetworkManager/system-connections"
if [ -d "$ROOT_MNT/etc/NetworkManager" ]; then
mkdir -p "$NM_DIR"
# NetworkManager connection file
NM_FILE="$NM_DIR/stegasoo-wifi.nmconnection"
cat > "$NM_FILE" << EOF
[connection]
id=$WIFI_SSID
type=wifi
autoconnect=true
[wifi]
mode=infrastructure
ssid=$WIFI_SSID
[wifi-security]
auth-alg=open
key-mgmt=wpa-psk
psk=$WIFI_PASS
[ipv4]
method=auto
[ipv6]
method=auto
EOF
chmod 600 "$NM_FILE"
echo -e "${GREEN}Created NetworkManager config (Bookworm)${NC}"
fi
# 2. Write wpa_supplicant.conf (legacy, boot partition)
if [ -n "$WIFI_PSK_HASH" ]; then
cat > "$BOOT_MNT/wpa_supplicant.conf" << EOF
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1
country=$WIFI_COUNTRY
network={
ssid="$WIFI_SSID"
psk=$WIFI_PSK_HASH
}
EOF
echo -e "${GREEN}Created wpa_supplicant.conf (legacy)${NC}"
fi
# 3. Set WiFi country in boot config
if [ -f "$BOOT_MNT/config.txt" ]; then
if ! grep -q "^dtparam=cfg80211" "$BOOT_MNT/config.txt"; then
echo "" >> "$BOOT_MNT/config.txt"
echo "# WiFi country" >> "$BOOT_MNT/config.txt"
echo "dtparam=cfg80211" >> "$BOOT_MNT/config.txt"
fi
fi
echo -e " SSID: ${YELLOW}$WIFI_SSID${NC}"
echo ""
echo -e "${GREEN}Done! WiFi credentials injected for Bookworm + legacy.${NC}"

View File

@@ -21,6 +21,12 @@ cd "$JPEGIO_DIR"
echo "Applying ARM64 patch to jpegio..."
# Fix CRLF line endings (jpegio uses Windows line endings)
if file setup.py | grep -q CRLF; then
echo " Converting CRLF to LF..."
sed -i 's/\r$//' setup.py
fi
# Strategy 1: Try the standard patch file
if [ -f "$PATCH_FILE" ]; then
echo " Trying patch file..."

View File

@@ -1,6 +1,6 @@
--- a/setup.py
+++ b/setup.py
@@ -69,12 +69,12 @@
@@ -64,7 +64,7 @@ elif sys.platform == 'darwin': # macOS
largs.append('-mmacosx-version-min=10.9')
if arch == 'x64':
@@ -9,6 +9,9 @@
elif sys.platform == 'linux':
cargs.extend(['-w', '-fPIC'])
@@ -68,7 +68,7 @@ elif sys.platform == 'linux':
cargs.extend(['-w', '-fPIC'])
if arch == 'x64':
- cargs.append('-m64')
+ pass # ARM64: removed x86-specific -m64 flag

View File

@@ -71,19 +71,20 @@ fi
clear
echo ""
echo -e "${GRAY} . * . . * . * . * . * .${NC}"
echo -e "${CYAN} ___ _____ ___ ___ _ ___ ___ ___${NC}"
echo -e "${CYAN} / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\${NC}"
echo -e "${CYAN} \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |${NC}"
echo -e "${CYAN} |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/${NC}"
echo ""
echo -e "${GRAY} * . * . * . * . * . *${NC}"
echo ""
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\\033[0m"
echo -e "\033[38;5;220m \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |\033[0m"
echo -e "\033[38;5;220m |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/\033[0m"
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
if [ "$SOFT_RESET" = true ]; then
echo -e "${CYAN} Soft Reset (Factory)${NC}"
echo -e "\033[1;37m Soft Reset (Factory)\033[0m"
else
echo -e "${CYAN} Sanitize for Imaging${NC}"
echo -e "\033[1;37m Sanitize for Imaging\033[0m"
fi
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo ""
if [ "$SOFT_RESET" = true ]; then

View File

@@ -81,15 +81,16 @@ done
clear
echo ""
echo -e "${GRAY} . * . . * . * . * . * .${NC}"
echo -e "${CYAN} ___ _____ ___ ___ _ ___ ___ ___${NC}"
echo -e "${CYAN} / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\${NC}"
echo -e "${CYAN} \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |${NC}"
echo -e "${CYAN} |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/${NC}"
echo ""
echo -e "${GRAY} * . * . * . * . * . *${NC}"
echo ""
echo -e "${CYAN} Raspberry Pi Setup${NC}"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\\033[0m"
echo -e "\033[38;5;220m \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |\033[0m"
echo -e "\033[38;5;220m |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/\033[0m"
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "\033[1;37m Raspberry Pi Setup\033[0m"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo ""
echo " This will install Stegasoo with full DCT support"
echo " Estimated time: 15-20 minutes on Pi 5"
@@ -290,7 +291,7 @@ echo -e "${GREEN}[10/12]${NC} Enabling service..."
sudo systemctl daemon-reload
sudo systemctl enable stegasoo.service
echo -e "${GREEN}[11/12]${NC} Adding stegasoo to PATH..."
echo -e "${GREEN}[11/12]${NC} Setting up user environment..."
# Add stegasoo venv and rpi scripts to PATH for all users
sudo tee /etc/profile.d/stegasoo-path.sh > /dev/null <<'PATHEOF'
@@ -303,7 +304,18 @@ if [ -d /opt/stegasoo/rpi ]; then
fi
PATHEOF
sudo chmod 644 /etc/profile.d/stegasoo-path.sh
echo " Added /opt/stegasoo/venv/bin and /opt/stegasoo/rpi to PATH"
echo " Added stegasoo to PATH"
# Install custom bashrc if not already customized
if [ -f "$INSTALL_DIR/rpi/skel/.bashrc" ]; then
if ! grep -q "Stegasoo Pi" ~/.bashrc 2>/dev/null; then
cp "$INSTALL_DIR/rpi/skel/.bashrc" ~/.bashrc
source ~/.bashrc 2>/dev/null || true
echo " Installed custom .bashrc"
else
echo " Custom .bashrc already installed"
fi
fi
echo -e "${GREEN}[12/12]${NC} Setting up login banner..."
@@ -324,13 +336,26 @@ if systemctl is-active --quiet stegasoo 2>/dev/null; then
STEGASOO_URL="http://$PI_IP:5000"
fi
echo ""
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 -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
echo -e "\033[38;5;220m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
echo -e " \033[0;32m●\033[0m Stegasoo is running"
echo -e " \033[0;33m$STEGASOO_URL\033[0m"
# Show CPU stats if overclocked
if grep -qE "^(arm_freq|over_voltage)" /boot/firmware/config.txt 2>/dev/null || \
grep -qE "^(arm_freq|over_voltage)" /boot/config.txt 2>/dev/null; then
CPU_FREQ=$(vcgencmd measure_clock arm 2>/dev/null | cut -d= -f2)
CPU_TEMP=$(vcgencmd measure_temp 2>/dev/null | cut -d= -f2)
if [ -n "$CPU_FREQ" ] && [ -n "$CPU_TEMP" ]; then
CPU_MHZ=$((CPU_FREQ / 1000000))
echo -e " \033[0;35m⚡\033[0m ${CPU_MHZ} MHz \033[0;35m🌡\033[0m ${CPU_TEMP}"
fi
fi
echo ""
else
echo ""

214
rpi/skel/.bashrc Normal file
View File

@@ -0,0 +1,214 @@
# ============================================================================
# Stegasoo Pi - Bash Configuration
# ============================================================================
# If not running interactively, don't do anything
case $- in
*i*) ;;
*) return;;
esac
# ============================================================================
# History
# ============================================================================
HISTCONTROL=ignoreboth
HISTSIZE=5000
HISTFILESIZE=10000
shopt -s histappend
# ============================================================================
# Shell Options
# ============================================================================
shopt -s checkwinsize
shopt -s globstar 2>/dev/null
shopt -s cdspell 2>/dev/null
# ============================================================================
# Colors
# ============================================================================
# Color definitions
C_RESET='\[\e[0m\]'
C_GREY='\[\e[38;5;241m\]'
C_GREEN='\[\e[38;5;118m\]'
C_YELLOW='\[\e[38;5;179m\]'
C_BLUE='\[\e[38;5;69m\]'
C_RED='\[\e[38;5;196m\]'
C_BOLD='\[\e[1m\]'
# Enable color support
if [ -x /usr/bin/dircolors ]; then
test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)"
alias ls='ls --color=auto'
alias grep='grep --color=auto'
alias fgrep='fgrep --color=auto'
alias egrep='egrep --color=auto'
fi
# ============================================================================
# Prompt
# ============================================================================
# Git branch in prompt (if git installed)
_git_branch() {
git branch 2>/dev/null | sed -e '/^[^*]/d' -e 's/* \(.*\)/ \xe2\x8e\x87 \1/'
}
# Two-line prompt similar to zsh theme
# ┌「user@host」 「path」 「git」
# └$
_build_prompt() {
local git_info="$(_git_branch)"
if [ -n "$git_info" ]; then
git_info="${C_GREEN}${git_info}${C_GREY}"
fi
PS1="${C_GREY}┌「${C_GREEN}\u@\h${C_GREY}」 「${C_YELLOW}\w${C_GREY}${git_info}」\n${C_GREY}${C_BOLD}${C_BLUE}\$ ${C_RESET}"
}
PROMPT_COMMAND='_build_prompt'
# ============================================================================
# Navigation
# ============================================================================
alias ..='cd ..'
alias ...='cd ../..'
alias ....='cd ../../..'
alias ~='cd ~'
# ============================================================================
# Listing
# ============================================================================
alias ll='ls -lah'
alias la='ls -A'
alias l='ls -CF'
alias lt='ls -lahtr'
# ============================================================================
# Safety
# ============================================================================
alias rm='rm -i'
alias cp='cp -i'
alias mv='mv -i'
# ============================================================================
# Shortcuts
# ============================================================================
alias h='history'
alias c='clear'
alias q='exit'
alias reload='source ~/.bashrc'
# ============================================================================
# System
# ============================================================================
alias myip='curl -s ifconfig.me'
alias ports='netstat -tulanp 2>/dev/null || ss -tulanp'
alias df='df -h'
alias du='du -h'
alias free='free -h'
alias temp='vcgencmd measure_temp 2>/dev/null || sensors 2>/dev/null | grep -i temp || echo "No temp sensor"'
# ============================================================================
# Stegasoo
# ============================================================================
alias steg='stegasoo'
alias steglog='journalctl -u stegasoo -f'
alias stegstatus='systemctl status stegasoo'
alias stegrestart='sudo systemctl restart stegasoo'
alias stegstop='sudo systemctl stop stegasoo'
alias stegstart='sudo systemctl start stegasoo'
# Quick access to stegasoo directories
alias cdsteg='cd /opt/stegasoo'
alias cdweb='cd /opt/stegasoo/frontends/web'
# ============================================================================
# Git (if available)
# ============================================================================
alias g='git'
alias gs='git status'
alias ga='git add'
alias gc='git commit'
alias gp='git push'
alias gl='git pull'
alias gd='git diff'
alias gco='git checkout'
alias glog='git log --oneline --graph --decorate -10'
# ============================================================================
# Functions
# ============================================================================
# Create directory and cd into it
mkcd() { mkdir -p "$1" && cd "$1"; }
# Find files by name
ff() { find . -type f -iname "*$1*" 2>/dev/null; }
# Find directories by name
fdir() { find . -type d -iname "*$1*" 2>/dev/null; }
# Quick backup
backup() { cp "$1" "$1.backup-$(date +%Y%m%d-%H%M%S)"; }
# Extract archives
extract() {
if [ ! -f "$1" ]; then
echo "'$1' is not a valid file"
return 1
fi
case "$1" in
*.tar.bz2) tar xjf "$1" ;;
*.tar.gz) tar xzf "$1" ;;
*.tar.xz) tar xJf "$1" ;;
*.bz2) bunzip2 "$1" ;;
*.gz) gunzip "$1" ;;
*.tar) tar xf "$1" ;;
*.tbz2) tar xjf "$1" ;;
*.tgz) tar xzf "$1" ;;
*.zip) unzip "$1" ;;
*.Z) uncompress "$1" ;;
*.7z) 7z x "$1" ;;
*.zst) zstd -d "$1" ;;
*) echo "'$1' cannot be extracted" ;;
esac
}
# Show system info
sysinfo() {
echo -e "\e[1;32mHostname:\e[0m $(hostname)"
echo -e "\e[1;32mUptime:\e[0m $(uptime -p)"
echo -e "\e[1;32mMemory:\e[0m $(free -h | awk '/^Mem:/ {print $3 "/" $2}')"
echo -e "\e[1;32mDisk:\e[0m $(df -h / | awk 'NR==2 {print $3 "/" $2 " (" $5 ")"}')"
echo -e "\e[1;32mTemp:\e[0m $(vcgencmd measure_temp 2>/dev/null | cut -d= -f2 || echo 'N/A')"
echo -e "\e[1;32mIP:\e[0m $(hostname -I | awk '{print $1}')"
}
# ============================================================================
# Completion
# ============================================================================
if ! shopt -oq posix; then
if [ -f /usr/share/bash-completion/bash_completion ]; then
. /usr/share/bash-completion/bash_completion
elif [ -f /etc/bash_completion ]; then
. /etc/bash_completion
fi
fi
# ============================================================================
# Path
# ============================================================================
export PATH="$HOME/.local/bin:$PATH"

View File

@@ -1,541 +0,0 @@
#!/bin/bash
#
# Stegasoo Pi Image Smoke Test
# Automated testing of a fresh Pi image
#
# Usage: ./smoke-test.sh [ip] [--https] [--443] [--port=PORT]
# Default IP: 192.168.0.4
# --https Use HTTPS (port 5000)
# --443 Use HTTPS on port 443
# --port=N Specify custom port
#
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# Configuration
PI_IP="192.168.0.4"
HTTPS=false
PORT=5000
# Parse arguments
for arg in "$@"; do
case $arg in
--https) HTTPS=true ;;
--443) HTTPS=true; PORT=443 ;;
--port=*) PORT="${arg#*=}" ;;
--*) ;; # Ignore other flags
*)
# If it looks like an IP, use it
if [[ "$arg" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
PI_IP="$arg"
fi
;;
esac
done
if [ "$HTTPS" = true ]; then
if [ "$PORT" = "443" ]; then
BASE_URL="https://$PI_IP"
else
BASE_URL="https://$PI_IP:$PORT"
fi
CURL_OPTS="-k" # Allow self-signed certs
else
BASE_URL="http://$PI_IP:$PORT"
CURL_OPTS=""
fi
# Test credentials
ADMIN_USER="admin"
ADMIN_PASS="stegasoo"
REGULAR_USER="smokeuser"
REGULAR_PASS="SmokeUser123!"
# Temp files
COOKIE_JAR=$(mktemp)
COOKIE_JAR_USER=$(mktemp)
TEST_IMAGE=$(mktemp --suffix=.png)
ENCODED_IMAGE=$(mktemp --suffix=.png)
RESPONSE=$(mktemp)
ENCODED_IMAGE_USER=$(mktemp --suffix=.png)
QR_IMAGE=$(mktemp --suffix=.png)
cleanup() {
rm -f "$COOKIE_JAR" "$COOKIE_JAR_USER" "$TEST_IMAGE" "$ENCODED_IMAGE" "$ENCODED_IMAGE_USER" "$QR_IMAGE" "$RESPONSE"
}
trap cleanup EXIT
# Create a simple test image (red square)
create_test_image() {
if command -v convert &>/dev/null; then
convert -size 100x100 xc:red "$TEST_IMAGE"
elif command -v python3 &>/dev/null; then
python3 -c "
from PIL import Image
img = Image.new('RGB', (100, 100), color='red')
img.save('$TEST_IMAGE')
"
else
echo -e "${YELLOW}Warning: No image tool available, skipping encode/decode tests${NC}"
return 1
fi
}
# Results tracking
TESTS_PASSED=0
TESTS_FAILED=0
pass() {
echo -e " ${GREEN}[PASS]${NC} $1"
TESTS_PASSED=$((TESTS_PASSED + 1))
}
fail() {
echo -e " ${RED}[FAIL]${NC} $1"
TESTS_FAILED=$((TESTS_FAILED + 1))
}
skip() {
echo -e " ${YELLOW}[SKIP]${NC} $1"
}
# =============================================================================
# Header
# =============================================================================
echo ""
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ Stegasoo Pi Image Smoke Test ║${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "Target: ${YELLOW}$BASE_URL${NC}"
echo ""
# =============================================================================
# Test 1: Web UI Reachable
# =============================================================================
echo -e "${BOLD}[1/9] Web UI Accessibility${NC}"
if curl $CURL_OPTS -s -o /dev/null -w "%{http_code}" "$BASE_URL" | grep -q "200\|302"; then
pass "Web UI is reachable"
else
fail "Web UI not reachable at $BASE_URL"
echo -e "${RED}Cannot continue without web access. Is the Pi running?${NC}"
exit 1
fi
# Check if redirected to setup (first run) or login
REDIRECT=$(curl $CURL_OPTS -s -o /dev/null -w "%{redirect_url}" "$BASE_URL")
if echo "$REDIRECT" | grep -q "setup"; then
pass "Redirected to setup (fresh install)"
NEEDS_SETUP=true
elif echo "$REDIRECT" | grep -q "login"; then
pass "Redirected to login (already configured)"
NEEDS_SETUP=false
else
# Check page content
if curl $CURL_OPTS -s "$BASE_URL" | grep -q "setup\|Setup\|Create.*Admin"; then
pass "Setup page detected"
NEEDS_SETUP=true
else
pass "Login page detected"
NEEDS_SETUP=false
fi
fi
# =============================================================================
# Test 2: Create Admin User (if needed)
# =============================================================================
echo ""
echo -e "${BOLD}[2/9] Admin Setup${NC}"
if [ "$NEEDS_SETUP" = true ]; then
# Get CSRF token from setup page
SETUP_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR" "$BASE_URL/setup")
CSRF_TOKEN=$(echo "$SETUP_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
if [ -z "$CSRF_TOKEN" ]; then
# Try alternate pattern
CSRF_TOKEN=$(echo "$SETUP_PAGE" | grep -oP 'csrf_token.*?value="\K[^"]+' || echo "")
fi
# Create admin user
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
-X POST "$BASE_URL/setup" \
-d "username=$ADMIN_USER" \
-d "password=$ADMIN_PASS" \
-d "password_confirm=$ADMIN_PASS" \
-d "csrf_token=$CSRF_TOKEN")
if [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "200" ]; then
if curl $CURL_OPTS -s "$BASE_URL" | grep -q "login\|Login"; then
pass "Admin user created successfully"
else
pass "Setup completed (assuming success)"
fi
else
fail "Failed to create admin user (HTTP $HTTP_CODE)"
fi
else
skip "Setup already complete"
fi
# =============================================================================
# Test 3: Admin Login
# =============================================================================
echo ""
echo -e "${BOLD}[3/9] Admin Authentication${NC}"
# Get login page and CSRF
LOGIN_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR" "$BASE_URL/login")
CSRF_TOKEN=$(echo "$LOGIN_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
# Try login as admin
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
-X POST "$BASE_URL/login" \
-d "username=$ADMIN_USER" \
-d "password=$ADMIN_PASS" \
-d "csrf_token=$CSRF_TOKEN" \
-L)
# Check if we're logged in by accessing a protected page
if curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/" | grep -qi "encode\|decode\|logout"; then
pass "Admin login successful"
ADMIN_LOGGED_IN=true
else
fail "Admin login failed"
ADMIN_LOGGED_IN=false
fi
# =============================================================================
# Test 4: Admin Encode/Decode
# =============================================================================
echo ""
echo -e "${BOLD}[4/9] Admin Encode/Decode${NC}"
if [ "$ADMIN_LOGGED_IN" = true ]; then
ENCODE_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/encode")
if echo "$ENCODE_PAGE" | grep -qi "encode\|message\|image\|upload"; then
pass "Encode page loads"
else
fail "Encode page not accessible"
fi
# Try actual encoding if we have image tools
if create_test_image 2>/dev/null; then
CSRF_TOKEN=$(echo "$ENCODE_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
# For encode: use same image as reference_photo and carrier (for simplicity)
# First POST (no redirect follow), get Location header, then GET result page
ENCODE_RESULT=$(curl $CURL_OPTS -s -D - -o /dev/null \
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
-X POST "$BASE_URL/encode" \
-F "reference_photo=@$TEST_IMAGE" \
-F "carrier=@$TEST_IMAGE" \
-F "message=Admin smoke test" \
-F "passphrase=smoke test phrase" \
-F "pin=123456" \
-F "csrf_token=$CSRF_TOKEN")
# Extract redirect location
RESULT_LOCATION=$(echo "$ENCODE_RESULT" | grep -i "^location:" | tr -d '\r' | awk '{print $2}')
if [ -n "$RESULT_LOCATION" ]; then
# GET the result page
RESULT_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL$RESULT_LOCATION")
# Look for download link in result page
DOWNLOAD_URL=$(echo "$RESULT_PAGE" | grep -oP 'href="(/encode/download/[^"]+)"' | head -1 | grep -oP '/encode/download/[^"]+')
fi
if [ -n "$DOWNLOAD_URL" ]; then
# Download the encoded image
HTTP_CODE=$(curl $CURL_OPTS -s -o "$ENCODED_IMAGE" -w "%{http_code}" \
-b "$COOKIE_JAR" "$BASE_URL$DOWNLOAD_URL")
if [ "$HTTP_CODE" = "200" ] && file "$ENCODED_IMAGE" | grep -qi "image\|PNG\|JPEG"; then
pass "Admin encoding works"
# Now decode it
DECODE_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/decode")
CSRF_TOKEN=$(echo "$DECODE_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
DECODED=$(curl $CURL_OPTS -s \
-b "$COOKIE_JAR" \
-X POST "$BASE_URL/decode" \
-F "reference_photo=@$TEST_IMAGE" \
-F "stego_image=@$ENCODED_IMAGE" \
-F "passphrase=smoke test phrase" \
-F "pin=123456" \
-F "csrf_token=$CSRF_TOKEN")
if echo "$DECODED" | grep -q "Admin smoke test"; then
pass "Admin decoding works"
else
fail "Admin decode failed"
fi
else
fail "Failed to download encoded image (HTTP $HTTP_CODE)"
fi
else
# Check for error messages in result page
ERROR_MSG=$(echo "$RESULT_PAGE" | grep -oP 'toast-body">[^<]*<[^>]*>[^<]*' | head -1)
if [ -n "$ERROR_MSG" ]; then
fail "Encoding failed: $ERROR_MSG"
else
fail "No download link found in encode result"
fi
fi
else
skip "Encode/Decode (no image tools)"
fi
else
skip "Admin encode/decode (not logged in)"
fi
# =============================================================================
# Test 5: Create Regular User
# =============================================================================
echo ""
echo -e "${BOLD}[5/9] Create Regular User${NC}"
if [ "$ADMIN_LOGGED_IN" = true ]; then
# Check if there's a user management page
USERS_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/users" 2>/dev/null || echo "")
if echo "$USERS_PAGE" | grep -qi "user\|create\|add"; then
CSRF_TOKEN=$(echo "$USERS_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
-b "$COOKIE_JAR" \
-X POST "$BASE_URL/users/create" \
-d "username=$REGULAR_USER" \
-d "password=$REGULAR_PASS" \
-d "password_confirm=$REGULAR_PASS" \
-d "csrf_token=$CSRF_TOKEN")
if [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "200" ]; then
pass "Regular user created"
USER_CREATED=true
else
# Try alternate endpoint
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
-b "$COOKIE_JAR" \
-X POST "$BASE_URL/register" \
-d "username=$REGULAR_USER" \
-d "password=$REGULAR_PASS" \
-d "password_confirm=$REGULAR_PASS" \
-d "csrf_token=$CSRF_TOKEN")
if [ "$HTTP_CODE" = "302" ] || [ "$HTTP_CODE" = "200" ]; then
pass "Regular user created (via register)"
USER_CREATED=true
else
fail "Failed to create regular user"
USER_CREATED=false
fi
fi
else
skip "User creation (no user management page)"
USER_CREATED=false
fi
else
skip "User creation (admin not logged in)"
USER_CREATED=false
fi
# =============================================================================
# Test 6: Regular User Login & Encode/Decode
# =============================================================================
echo ""
echo -e "${BOLD}[6/9] Regular User Workflow${NC}"
if [ "$USER_CREATED" = true ]; then
# Logout admin first (get fresh session)
curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/logout" >/dev/null
# Login as regular user
LOGIN_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR_USER" "$BASE_URL/login")
CSRF_TOKEN=$(echo "$LOGIN_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
HTTP_CODE=$(curl $CURL_OPTS -s -o "$RESPONSE" -w "%{http_code}" \
-b "$COOKIE_JAR_USER" -c "$COOKIE_JAR_USER" \
-X POST "$BASE_URL/login" \
-d "username=$REGULAR_USER" \
-d "password=$REGULAR_PASS" \
-d "csrf_token=$CSRF_TOKEN" \
-L)
if curl $CURL_OPTS -s -b "$COOKIE_JAR_USER" "$BASE_URL/" | grep -qi "encode\|decode\|logout"; then
pass "Regular user login successful"
# Try encode/decode as regular user
if [ -f "$TEST_IMAGE" ]; then
ENCODE_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR_USER" "$BASE_URL/encode")
CSRF_TOKEN=$(echo "$ENCODE_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
HTTP_CODE=$(curl $CURL_OPTS -s -o "$ENCODED_IMAGE_USER" -w "%{http_code}" \
-b "$COOKIE_JAR_USER" \
-X POST "$BASE_URL/encode" \
-F "reference_photo=@$TEST_IMAGE" \
-F "carrier=@$TEST_IMAGE" \
-F "message=User smoke test" \
-F "passphrase=user test phrase" \
-F "pin=567890" \
-F "csrf_token=$CSRF_TOKEN")
if [ "$HTTP_CODE" = "200" ] && [ -s "$ENCODED_IMAGE_USER" ] && file "$ENCODED_IMAGE_USER" | grep -qi "image\|PNG"; then
pass "Regular user encoding works"
else
fail "Regular user encoding failed"
fi
fi
else
fail "Regular user login failed"
fi
else
skip "Regular user workflow (user not created)"
fi
# =============================================================================
# Test 7: Password Recovery QR
# =============================================================================
echo ""
echo -e "${BOLD}[7/9] Password Recovery QR${NC}"
# Re-login as admin
LOGIN_PAGE=$(curl $CURL_OPTS -s -c "$COOKIE_JAR" "$BASE_URL/login")
CSRF_TOKEN=$(echo "$LOGIN_PAGE" | grep -oP 'name="csrf_token"[^>]*value="\K[^"]+' || echo "")
curl $CURL_OPTS -s -o /dev/null \
-b "$COOKIE_JAR" -c "$COOKIE_JAR" \
-X POST "$BASE_URL/login" \
-d "username=$ADMIN_USER" \
-d "password=$ADMIN_PASS" \
-d "csrf_token=$CSRF_TOKEN" \
-L
# Check for recovery QR endpoint
RECOVERY_PAGE=$(curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/recovery" 2>/dev/null ||
curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/settings" 2>/dev/null ||
curl $CURL_OPTS -s -b "$COOKIE_JAR" "$BASE_URL/account" 2>/dev/null || echo "")
if echo "$RECOVERY_PAGE" | grep -qi "recovery\|qr\|backup"; then
pass "Recovery page accessible"
# Try to get QR image
QR_URL=$(echo "$RECOVERY_PAGE" | grep -oP 'src="[^"]*qr[^"]*"' | head -1 | sed 's/src="//;s/"$//' || echo "")
if [ -n "$QR_URL" ]; then
if [[ "$QR_URL" != http* ]]; then
QR_URL="$BASE_URL$QR_URL"
fi
HTTP_CODE=$(curl $CURL_OPTS -s -o "$QR_IMAGE" -w "%{http_code}" -b "$COOKIE_JAR" "$QR_URL")
if [ "$HTTP_CODE" = "200" ] && [ -s "$QR_IMAGE" ]; then
if file "$QR_IMAGE" | grep -qi "image\|PNG"; then
pass "Recovery QR code generated"
else
fail "QR endpoint returned non-image"
fi
else
fail "Failed to fetch QR code"
fi
else
skip "QR code URL not found in page"
fi
else
skip "Password recovery (no recovery page found)"
fi
# =============================================================================
# Test 8: System Health
# =============================================================================
echo ""
echo -e "${BOLD}[8/9] System Health${NC}"
# Check if stegasoo CLI works via SSH (optional)
if command -v sshpass &>/dev/null; then
CLI_VERSION=$(sshpass -p 'stegasoo' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
admin@$PI_IP "stegasoo --version" 2>/dev/null || echo "")
if [ -n "$CLI_VERSION" ]; then
pass "CLI accessible: $CLI_VERSION"
else
skip "CLI check (SSH failed or CLI not in PATH)"
fi
else
skip "CLI check (sshpass not installed)"
fi
# Check service status via SSH
if command -v sshpass &>/dev/null; then
SERVICE_STATUS=$(sshpass -p 'stegasoo' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
admin@$PI_IP "systemctl is-active stegasoo" 2>/dev/null || echo "unknown")
if [ "$SERVICE_STATUS" = "active" ]; then
pass "Stegasoo service is active"
else
fail "Stegasoo service status: $SERVICE_STATUS"
fi
else
skip "Service check (sshpass not installed)"
fi
# =============================================================================
# Test 9: Cleanup
# =============================================================================
echo ""
echo -e "${BOLD}[9/9] Cleanup${NC}"
# Just verify we can still access the site
if curl $CURL_OPTS -s -o /dev/null -w "%{http_code}" "$BASE_URL" | grep -q "200\|302"; then
pass "Site still accessible after tests"
else
fail "Site not accessible after tests"
fi
# =============================================================================
# Summary
# =============================================================================
echo ""
echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}"
echo ""
TOTAL=$((TESTS_PASSED + TESTS_FAILED))
if [ $TESTS_FAILED -eq 0 ]; then
echo -e "${GREEN}${BOLD}All tests passed!${NC} ($TESTS_PASSED/$TOTAL)"
else
echo -e "${RED}${BOLD}Some tests failed${NC} ($TESTS_PASSED passed, $TESTS_FAILED failed)"
fi
echo ""
echo -e "Target: $BASE_URL"
echo -e "Admin user: $ADMIN_USER"
echo -e "Regular user: $REGULAR_USER"
echo ""
exit $TESTS_FAILED

307
scripts/validate-release.sh Executable file
View File

@@ -0,0 +1,307 @@
#!/bin/bash
# =============================================================================
# Stegasoo Release Validation Script
# =============================================================================
# Automated pre-release validation to catch issues before tagging a release.
#
# Usage:
# ./scripts/validate-release.sh # Local validation only
# ./scripts/validate-release.sh --pi # Include Pi smoke test
# PI_IP=192.168.0.4 ./scripts/validate-release.sh --pi
#
# Exit codes:
# 0 = All tests passed
# 1 = One or more tests failed
# =============================================================================
# Don't use set -e as we need to handle test failures gracefully
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
NC='\033[0m'
# Default Pi IP (can be overridden via environment)
PI_IP="${PI_IP:-192.168.0.4}"
PI_USER="${PI_USER:-alee}"
INCLUDE_PI=false
INCLUDE_DOCKER=true
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--pi)
INCLUDE_PI=true
shift
;;
--no-docker)
INCLUDE_DOCKER=false
shift
;;
--help|-h)
echo "Usage: $0 [--pi] [--no-docker]"
echo ""
echo "Options:"
echo " --pi Include Pi smoke test (requires SSH access)"
echo " --no-docker Skip Docker build/test"
echo ""
echo "Environment:"
echo " PI_IP Pi IP address (default: 192.168.0.4)"
echo " PI_USER Pi SSH user (default: alee)"
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
# Track results
TESTS_RUN=0
TESTS_PASSED=0
TESTS_FAILED=0
FAILED_TESTS=()
# Helper functions
pass() {
echo -e "${GREEN}[PASS]${NC} $1"
((TESTS_PASSED++))
((TESTS_RUN++))
}
fail() {
echo -e "${RED}[FAIL]${NC} $1"
FAILED_TESTS+=("$1")
((TESTS_FAILED++))
((TESTS_RUN++))
}
skip() {
echo -e "${YELLOW}[SKIP]${NC} $1"
}
section() {
echo ""
echo -e "${CYAN}━━━ $1 ━━━${NC}"
}
# =============================================================================
# Header
# =============================================================================
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ Stegasoo Release Validation ║${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
echo ""
# Get version from pyproject.toml
VERSION=$(grep '^version = ' pyproject.toml | head -1 | cut -d'"' -f2)
echo -e "Version: ${YELLOW}${VERSION}${NC}"
echo -e "Branch: ${YELLOW}$(git branch --show-current)${NC}"
echo ""
# =============================================================================
# 1. Code Quality Checks
# =============================================================================
section "Code Quality"
# Ruff linting
if command -v ./venv/bin/ruff &> /dev/null; then
echo -n "Running ruff check... "
if ./venv/bin/ruff check src/ frontends/ --quiet 2>/dev/null; then
pass "Ruff linting"
else
fail "Ruff linting (run: ./venv/bin/ruff check src/ frontends/)"
fi
else
skip "Ruff not installed"
fi
# =============================================================================
# 2. Unit Tests (if they exist)
# =============================================================================
section "Unit Tests"
if ls tests/test_*.py 1> /dev/null 2>&1; then
echo -n "Running pytest... "
if ./venv/bin/pytest tests/ -q --tb=no 2>/dev/null; then
pass "Pytest unit tests"
else
fail "Pytest unit tests"
fi
else
skip "No unit tests found (tests/test_*.py)"
fi
# =============================================================================
# 3. Import Tests
# =============================================================================
section "Import Tests"
# Test core library import
echo -n "Testing stegasoo import... "
if ./venv/bin/python -c "from stegasoo import encode, decode; print('OK')" 2>/dev/null | grep -q OK; then
pass "Core library import"
else
fail "Core library import"
fi
# Test DCT support
echo -n "Testing DCT support... "
if ./venv/bin/python -c "from stegasoo import has_dct_support; assert has_dct_support(), 'No DCT'; print('OK')" 2>/dev/null | grep -q OK; then
pass "DCT support available"
else
fail "DCT support (scipy/jpegio missing?)"
fi
# Test CLI import
echo -n "Testing CLI import... "
if ./venv/bin/python -c "from stegasoo.cli import main; print('OK')" 2>/dev/null | grep -q OK; then
pass "CLI module import"
else
fail "CLI module import"
fi
# =============================================================================
# 4. Encode/Decode Sanity Test
# =============================================================================
section "Encode/Decode Test"
echo -n "Running encode/decode sanity check... "
SANITY_RESULT=$(./venv/bin/python << 'EOF' 2>&1
import sys
sys.path.insert(0, 'src')
from stegasoo import encode, decode
with open('test_data/carrier.jpg', 'rb') as f:
carrier = f.read()
with open('test_data/ref.jpg', 'rb') as f:
ref = f.read()
# LSB test
result = encode(message="sanity test", reference_photo=ref, carrier_image=carrier,
passphrase="test", pin="123456", embed_mode="lsb")
decoded = decode(stego_image=result.stego_image, reference_photo=ref,
passphrase="test", pin="123456", embed_mode="lsb")
assert decoded.message == "sanity test", f"LSB mismatch: {decoded.message}"
# DCT test
result = encode(message="dct sanity", reference_photo=ref, carrier_image=carrier,
passphrase="dct", pin="654321", embed_mode="dct")
decoded = decode(stego_image=result.stego_image, reference_photo=ref,
passphrase="dct", pin="654321", embed_mode="dct")
assert decoded.message == "dct sanity", f"DCT mismatch: {decoded.message}"
print("OK")
EOF
)
if echo "$SANITY_RESULT" | grep -q "OK"; then
pass "Encode/decode sanity (LSB + DCT)"
else
fail "Encode/decode sanity: $SANITY_RESULT"
fi
# =============================================================================
# 5. Docker Build & Test (optional)
# =============================================================================
if $INCLUDE_DOCKER; then
section "Docker"
if command -v docker &> /dev/null || command -v sudo &> /dev/null; then
DOCKER_CMD="docker"
if ! docker info &>/dev/null 2>&1; then
DOCKER_CMD="sudo docker"
fi
echo -n "Building Docker image... "
if $DOCKER_CMD build -t stegasoo:validate -q . >/dev/null 2>&1; then
pass "Docker build"
# Test container starts
echo -n "Testing container startup... "
CONTAINER_ID=$($DOCKER_CMD run -d -p 15000:5000 stegasoo:validate 2>/dev/null)
sleep 3
if curl -s -o /dev/null -w "%{http_code}" http://localhost:15000/ 2>/dev/null | grep -qE "200|302"; then
pass "Container responds to HTTP"
else
fail "Container HTTP response"
fi
# Cleanup
$DOCKER_CMD stop "$CONTAINER_ID" >/dev/null 2>&1 || true
$DOCKER_CMD rm "$CONTAINER_ID" >/dev/null 2>&1 || true
else
fail "Docker build"
fi
# Cleanup test image
$DOCKER_CMD rmi stegasoo:validate >/dev/null 2>&1 || true
else
skip "Docker not available"
fi
else
skip "Docker tests (use --docker to enable)"
fi
# =============================================================================
# 6. Pi Smoke Test (optional)
# =============================================================================
if $INCLUDE_PI; then
section "Pi Smoke Test"
echo -n "Testing SSH connectivity to $PI_USER@$PI_IP... "
if ssh -o ConnectTimeout=5 -o BatchMode=yes "$PI_USER@$PI_IP" "echo OK" 2>/dev/null | grep -q OK; then
pass "SSH connectivity"
echo -n "Checking stegasoo service status... "
if ssh "$PI_USER@$PI_IP" "systemctl is-active stegasoo" 2>/dev/null | grep -q active; then
pass "Stegasoo service running"
echo -n "Running smoke test on Pi... "
SMOKE_RESULT=$(ssh "$PI_USER@$PI_IP" "cd /home/$PI_USER/stegasoo && bash tests/smoke-test.sh --quick 2>&1" || echo "FAILED")
if echo "$SMOKE_RESULT" | grep -qE "All tests passed|PASS"; then
pass "Pi smoke test"
else
fail "Pi smoke test"
fi
else
fail "Stegasoo service not running"
fi
else
fail "SSH connectivity to Pi"
fi
else
skip "Pi smoke test (use --pi to enable)"
fi
# =============================================================================
# Summary
# =============================================================================
echo ""
echo -e "${CYAN}━━━ Summary ━━━${NC}"
echo ""
echo -e "Tests run: ${TESTS_RUN}"
echo -e "Passed: ${GREEN}${TESTS_PASSED}${NC}"
echo -e "Failed: ${RED}${TESTS_FAILED}${NC}"
if [ ${#FAILED_TESTS[@]} -gt 0 ]; then
echo ""
echo -e "${RED}Failed tests:${NC}"
for test in "${FAILED_TESTS[@]}"; do
echo -e " - $test"
done
fi
echo ""
if [ $TESTS_FAILED -eq 0 ]; then
echo -e "${GREEN}✓ All validation checks passed!${NC}"
echo -e " Ready to tag release ${VERSION}"
exit 0
else
echo -e "${RED}✗ Validation failed - fix issues before release${NC}"
exit 1
fi

View File

@@ -7,7 +7,7 @@ Changes in v4.0.0:
- encode() and decode() now accept channel_key parameter
"""
__version__ = "4.0.1"
__version__ = "4.1.2"
# Core functionality
# Channel key management (v4.0.0)
@@ -45,6 +45,7 @@ from .image_utils import (
# Steganography functions
from .steganography import (
calculate_capacity_by_mode,
compare_modes,
has_dct_support,
will_fit_by_mode,
@@ -92,6 +93,7 @@ from .constants import (
EMBED_MODE_LSB,
FORMAT_VERSION,
LOSSLESS_FORMATS,
MAX_FILE_PAYLOAD_SIZE,
MAX_IMAGE_PIXELS,
MAX_MESSAGE_SIZE,
MAX_PASSPHRASE_WORDS,
@@ -112,12 +114,16 @@ from .exceptions import (
ExtractionError,
ImageValidationError,
InvalidHeaderError,
InvalidMagicBytesError,
KeyDerivationError,
KeyGenerationError,
KeyPasswordError,
KeyValidationError,
MessageValidationError,
ModeMismatchError,
NoDataFoundError,
PinValidationError,
ReedSolomonError,
SecurityFactorError,
SteganographyError,
StegasooError,
@@ -145,6 +151,7 @@ from .validation import (
MIN_MESSAGE_LENGTH = 1
MAX_MESSAGE_LENGTH = MAX_MESSAGE_SIZE
MAX_PAYLOAD_SIZE = MAX_MESSAGE_SIZE
# MAX_FILE_PAYLOAD_SIZE imported from constants above
SUPPORTED_IMAGE_FORMATS = LOSSLESS_FORMATS
LSB_BYTES_PER_PIXEL = 3 / 8
DCT_BYTES_PER_PIXEL = 0.125
@@ -184,6 +191,7 @@ __all__ = [
"has_argon2",
# Steganography
"has_dct_support",
"calculate_capacity_by_mode",
"compare_modes",
"will_fit_by_mode",
# QR utilities
@@ -232,6 +240,10 @@ __all__ = [
"ExtractionError",
"EmbeddingError",
"InvalidHeaderError",
"InvalidMagicBytesError",
"ReedSolomonError",
"NoDataFoundError",
"ModeMismatchError",
# Constants
"FORMAT_VERSION",
"MIN_PASSPHRASE_WORDS",
@@ -244,6 +256,7 @@ __all__ = [
"MAX_MESSAGE_LENGTH",
"MAX_MESSAGE_SIZE",
"MAX_PAYLOAD_SIZE",
"MAX_FILE_PAYLOAD_SIZE",
"MIN_IMAGE_PIXELS",
"MAX_IMAGE_PIXELS",
"SUPPORTED_IMAGE_FORMATS",

View File

@@ -105,6 +105,7 @@ def encode(
stegasoo encode photo.png -r ref.jpg -f secret.pdf -o encoded.png
"""
from PIL import Image
from .encode import encode as stegasoo_encode
from .encode import encode_file as stegasoo_encode_file
@@ -1108,7 +1109,9 @@ def admin_recover(db_path, password):
stegasoo admin recover --db /path/to/stegasoo.db
"""
import sqlite3
from argon2 import PasswordHasher
from .recovery import verify_recovery_key
# Try default paths if not specified

View File

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

View File

@@ -54,6 +54,34 @@ except ImportError:
HAS_JPEGIO = False
jio = None
# Import custom exceptions
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
# ============================================================================
# CONSTANTS
@@ -186,7 +214,7 @@ def has_jpegio_support() -> bool:
# Check for reedsolo availability
try:
from reedsolo import RSCodec, ReedSolomonError
from reedsolo import ReedSolomonError, RSCodec
HAS_REEDSOLO = True
except ImportError:
@@ -214,7 +242,7 @@ def _rs_decode(data: bytes) -> bytes:
pass # Errors were corrected
return bytes(decoded)
except ReedSolomonError as e:
raise ValueError(f"Reed-Solomon decoding failed: {e}") from e
raise StegasooRSError(f"Image corrupted beyond repair: {e}") from e
# ============================================================================
@@ -410,7 +438,7 @@ def _parse_header(header_bits: list) -> tuple[int, int, int]:
magic, version, flags, length = struct.unpack(">4sBBI", header_bytes)
if magic != DCT_MAGIC:
raise ValueError("Invalid DCT stego magic bytes")
raise InvalidMagicBytesError("Not a Stegasoo image or wrong mode (try LSB instead of DCT)")
return version, flags, length
@@ -461,7 +489,7 @@ def _jpegio_parse_header(header_bytes: bytes) -> tuple[int, int, int]:
raise ValueError("Insufficient header data")
magic, version, flags, length = struct.unpack(">4sBBI", header_bytes[:HEADER_SIZE])
if magic != JPEGIO_MAGIC:
raise ValueError(f"Invalid JPEG stego magic: {magic}")
raise InvalidMagicBytesError("Not a Stegasoo JPEG or wrong mode")
return version, flags, length
@@ -556,6 +584,7 @@ def embed_in_dct(
seed: bytes,
output_format: str = OUTPUT_FORMAT_PNG,
color_mode: str = "color",
progress_file: str | None = None,
) -> tuple[bytes, DCTEmbedStats]:
"""Embed data using DCT coefficient modification."""
if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG):
@@ -565,10 +594,12 @@ def embed_in_dct(
color_mode = "color"
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()
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(
@@ -577,6 +608,7 @@ def _embed_scipy_dct_safe(
seed: bytes,
output_format: str,
color_mode: str = "color",
progress_file: str | None = None,
) -> tuple[bytes, DCTEmbedStats]:
"""
Embed using scipy DCT with safe memory handling.
@@ -639,7 +671,7 @@ def _embed_scipy_dct_safe(
gc.collect()
# 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
gc.collect()
@@ -663,7 +695,7 @@ def _embed_scipy_dct_safe(
del image
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
gc.collect()
@@ -696,6 +728,7 @@ def _embed_in_channel_safe(
bits: list,
block_order: list,
blocks_x: int,
progress_file: str | None = None,
) -> np.ndarray:
"""
Embed bits in channel using safe DCT operations.
@@ -708,8 +741,9 @@ def _embed_in_channel_safe(
result = np.array(channel, dtype=np.float64, copy=True, order="C")
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):
break
@@ -745,6 +779,14 @@ def _embed_in_channel_safe(
# Clean up this iteration
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
gc.collect()
@@ -801,6 +843,7 @@ def _embed_jpegio(
carrier_image: bytes,
seed: bytes,
color_mode: str = "color",
progress_file: str | None = None,
) -> tuple[bytes, DCTEmbedStats]:
"""Embed using jpegio for proper JPEG coefficient modification."""
import os
@@ -858,6 +901,9 @@ def _embed_jpegio(
)
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):
if bit_idx >= len(bits):
break
@@ -873,6 +919,14 @@ def _embed_jpegio(
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)
with open(output_path, "rb") as f:
@@ -968,8 +1022,8 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
total_needed = (HEADER_SIZE + data_length) * 8
if len(all_bits) >= total_needed:
break
except ValueError:
pass
except (ValueError, InvalidMagicBytesError):
pass # RS-protected format has length prefix first, not magic bytes
del padded
gc.collect()
@@ -994,6 +1048,7 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
# Count occurrences of each unique copy
from collections import Counter
counter = Counter(copies)
best_header, count = counter.most_common(1)[0]
@@ -1006,9 +1061,13 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
# Sanity check: both lengths should be reasonable
max_reasonable = (len(all_bits) // 8) - RS_LENGTH_PREFIX_SIZE
if (raw_payload_length > 0 and raw_payload_length <= max_reasonable and
rs_encoded_length > 0 and rs_encoded_length <= max_reasonable and
rs_encoded_length >= raw_payload_length):
if (
raw_payload_length > 0
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
total_bits_needed = (RS_LENGTH_PREFIX_SIZE + rs_encoded_length) * 8
@@ -1085,6 +1144,7 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
# Extract 3 copies and use majority voting
from collections import Counter
copies = []
for i in range(RS_LENGTH_COPIES):
start = i * RS_LENGTH_HEADER_SIZE
@@ -1101,9 +1161,13 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
# Sanity check
max_reasonable = (len(all_positions) // 8) - RS_LENGTH_PREFIX_SIZE
if (raw_payload_length > 0 and raw_payload_length <= max_reasonable and
rs_encoded_length > 0 and rs_encoded_length <= max_reasonable and
rs_encoded_length >= raw_payload_length):
if (
raw_payload_length > 0
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
if len(all_positions) >= total_bits_needed:

View File

@@ -37,6 +37,7 @@ def encode(
dct_output_format: str = "png",
dct_color_mode: str = "color",
channel_key: str | bool | None = None,
progress_file: str | None = None,
) -> EncodeResult:
"""
Encode a message or file into an image.
@@ -118,6 +119,7 @@ def encode(
embed_mode=embed_mode,
dct_output_format=dct_output_format,
dct_color_mode=dct_color_mode,
progress_file=progress_file,
)
# Generate filename

View File

@@ -133,6 +133,30 @@ class InvalidHeaderError(SteganographyError):
pass
class InvalidMagicBytesError(SteganographyError):
"""Magic bytes don't match - not a Stegasoo image or wrong mode."""
pass
class ReedSolomonError(SteganographyError):
"""Reed-Solomon error correction failed - image too corrupted."""
pass
class NoDataFoundError(SteganographyError):
"""No hidden data found in image."""
pass
class ModeMismatchError(SteganographyError):
"""Wrong steganography mode (LSB vs DCT)."""
pass
# ============================================================================
# FILE ERRORS
# ============================================================================

View File

@@ -39,6 +39,31 @@ from .debug import debug
from .exceptions import CapacityError, EmbeddingError
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 = {"PNG", "BMP", "TIFF"}
@@ -526,6 +551,7 @@ def embed_in_image(
embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = DCT_OUTPUT_PNG,
dct_color_mode: str = "color",
progress_file: str | None = None,
) -> tuple[bytes, Union[EmbedStats, "DCTEmbedStats"], str]:
"""
Embed data into an image using specified mode.
@@ -579,6 +605,7 @@ def embed_in_image(
pixel_key,
output_format=dct_output_format,
color_mode=dct_color_mode,
progress_file=progress_file,
)
# Determine extension based on output format
@@ -594,7 +621,7 @@ def embed_in_image(
return stego_bytes, dct_stats, ext
# 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(
@@ -603,6 +630,7 @@ def _embed_lsb(
pixel_key: bytes,
bits_per_channel: int = 1,
output_format: str | None = None,
progress_file: str | None = None,
) -> tuple[bytes, EmbedStats, str]:
"""
Embed data using LSB steganography (internal implementation).
@@ -659,8 +687,9 @@ def _embed_lsb(
bit_idx = 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):
break
@@ -690,6 +719,16 @@ def _embed_lsb(
new_pixels[pixel_idx] = (r, g, b)
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)")
stego_img = Image.new("RGB", img.size)

View File

View File

@@ -1,434 +0,0 @@
"""
Tests for Stegasoo batch processing module (v4.0.0).
Updated for v4.0.0:
- Uses 'passphrase' instead of 'phrase' in credentials dict
- No date_str parameter
- BatchCredentials.passphrase is a single string
"""
import shutil
import tempfile
from pathlib import Path
from unittest.mock import Mock
import pytest
from stegasoo.batch import (
BatchCredentials,
BatchItem,
BatchProcessor,
BatchResult,
BatchStatus,
batch_capacity_check,
print_batch_result,
)
@pytest.fixture
def temp_dir():
"""Create a temporary directory for tests."""
path = Path(tempfile.mkdtemp())
yield path
shutil.rmtree(path)
@pytest.fixture
def sample_images(temp_dir):
"""Create sample PNG images for testing."""
from PIL import Image
images = []
for i in range(3):
img_path = temp_dir / f"test_image_{i}.png"
img = Image.new("RGB", (100, 100), color=(i * 50, i * 50, i * 50))
img.save(img_path, "PNG")
images.append(img_path)
return images
@pytest.fixture
def sample_reference_photo():
"""Create a sample reference photo as bytes."""
from io import BytesIO
from PIL import Image
img = Image.new("RGB", (100, 100), color=(128, 128, 128))
buf = BytesIO()
img.save(buf, "PNG")
return buf.getvalue()
@pytest.fixture
def sample_credentials(sample_reference_photo):
"""Create sample v3.2.0 credentials dict."""
return {
"reference_photo": sample_reference_photo,
"passphrase": "test phrase four words", # v3.2.0: single passphrase
"pin": "123456",
}
class TestBatchItem:
"""Tests for BatchItem dataclass."""
def test_duration_calculation(self):
"""Duration should be calculated from start/end times."""
item = BatchItem(input_path=Path("test.png"))
item.start_time = 100.0
item.end_time = 105.5
assert item.duration == 5.5
def test_duration_none_without_times(self):
"""Duration should be None if times not set."""
item = BatchItem(input_path=Path("test.png"))
assert item.duration is None
def test_to_dict(self):
"""to_dict should serialize all fields."""
item = BatchItem(
input_path=Path("input.png"),
output_path=Path("output.png"),
status=BatchStatus.SUCCESS,
message="Done",
)
result = item.to_dict()
assert result["input_path"] == "input.png"
assert result["output_path"] == "output.png"
assert result["status"] == "success"
class TestBatchResult:
"""Tests for BatchResult dataclass."""
def test_to_json(self):
"""Should serialize to valid JSON."""
import json
result = BatchResult(operation="encode", total=5, succeeded=4, failed=1)
json_str = result.to_json()
parsed = json.loads(json_str)
assert parsed["operation"] == "encode"
assert parsed["summary"]["total"] == 5
def test_duration_with_end_time(self):
"""Duration should work when end_time is set."""
result = BatchResult(operation="test")
result.start_time = 100.0
result.end_time = 110.0
assert result.duration == 10.0
class TestBatchCredentials:
"""Tests for BatchCredentials dataclass (v3.2.0)."""
def test_from_dict_new_format(self, sample_reference_photo):
"""Should parse v3.2.0 format with 'passphrase' key."""
data = {
"reference_photo": sample_reference_photo,
"passphrase": "test phrase four words",
"pin": "123456",
}
creds = BatchCredentials.from_dict(data)
assert creds.passphrase == "test phrase four words"
assert creds.pin == "123456"
def test_from_dict_legacy_format(self, sample_reference_photo):
"""Should parse legacy format with 'day_phrase' key for migration."""
data = {
"reference_photo": sample_reference_photo,
"day_phrase": "legacy phrase here", # Old key name
"pin": "123456",
}
creds = BatchCredentials.from_dict(data)
# Should accept old key and map to passphrase
assert creds.passphrase == "legacy phrase here"
assert creds.pin == "123456"
def test_to_dict(self, sample_reference_photo):
"""Should serialize to v3.2.0 format."""
creds = BatchCredentials(
reference_photo=sample_reference_photo,
passphrase="test phrase four words",
pin="123456",
)
result = creds.to_dict()
assert result["passphrase"] == "test phrase four words"
assert result["pin"] == "123456"
assert "day_phrase" not in result # Old key should not be present
def test_passphrase_is_string(self, sample_reference_photo):
"""Passphrase should be a string, not a dict."""
creds = BatchCredentials(
reference_photo=sample_reference_photo,
passphrase="test phrase four words",
pin="123456",
)
assert isinstance(creds.passphrase, str)
class TestBatchProcessor:
"""Tests for BatchProcessor class."""
def test_init_default_workers(self):
"""Should default to 4 workers."""
processor = BatchProcessor()
assert processor.max_workers == 4
def test_init_custom_workers(self):
"""Should accept custom worker count."""
processor = BatchProcessor(max_workers=8)
assert processor.max_workers == 8
def test_is_valid_image_png(self, temp_dir):
"""Should recognize PNG as valid."""
processor = BatchProcessor()
png_path = temp_dir / "test.png"
png_path.touch()
assert processor._is_valid_image(png_path)
def test_is_valid_image_txt(self, temp_dir):
"""Should reject non-image files."""
processor = BatchProcessor()
txt_path = temp_dir / "test.txt"
txt_path.touch()
assert not processor._is_valid_image(txt_path)
def test_find_images_file(self, sample_images):
"""Should find single image file."""
processor = BatchProcessor()
results = list(processor.find_images([sample_images[0]]))
assert len(results) == 1
assert results[0] == sample_images[0]
def test_find_images_directory(self, sample_images, temp_dir):
"""Should find images in directory."""
processor = BatchProcessor()
results = list(processor.find_images([temp_dir]))
assert len(results) == 3
def test_find_images_recursive(self, temp_dir):
"""Should find images recursively."""
from PIL import Image
# Create nested directory
nested = temp_dir / "nested"
nested.mkdir()
img_path = nested / "nested.png"
img = Image.new("RGB", (50, 50))
img.save(img_path)
processor = BatchProcessor()
results = list(processor.find_images([temp_dir], recursive=True))
assert any(p.name == "nested.png" for p in results)
def test_batch_encode_requires_message_or_file(self, sample_images, sample_credentials):
"""Should raise if neither message nor file provided."""
processor = BatchProcessor()
with pytest.raises(ValueError, match="message or file_payload"):
processor.batch_encode(
images=sample_images,
credentials=sample_credentials,
)
def test_batch_encode_requires_credentials(self, sample_images):
"""Should raise if credentials not provided."""
processor = BatchProcessor()
with pytest.raises(ValueError, match="Credentials"):
processor.batch_encode(
images=sample_images,
message="test",
)
def test_batch_encode_accepts_passphrase_credentials(
self, sample_images, temp_dir, sample_credentials
):
"""Should accept v3.2.0 format credentials with passphrase."""
processor = BatchProcessor()
result = processor.batch_encode(
images=sample_images,
message="Test message",
output_dir=temp_dir / "output",
credentials=sample_credentials, # Uses 'passphrase' key
)
assert isinstance(result, BatchResult)
assert result.operation == "encode"
assert result.total == 3
def test_batch_encode_creates_result(self, sample_images, temp_dir, sample_credentials):
"""Should return BatchResult with correct structure."""
processor = BatchProcessor()
result = processor.batch_encode(
images=sample_images,
message="Test message",
output_dir=temp_dir / "output",
credentials=sample_credentials,
)
assert isinstance(result, BatchResult)
assert result.operation == "encode"
assert result.total == 3
assert len(result.items) == 3
def test_batch_decode_requires_credentials(self, sample_images):
"""Should raise if credentials not provided."""
processor = BatchProcessor()
with pytest.raises(ValueError, match="Credentials"):
processor.batch_decode(images=sample_images)
def test_batch_decode_accepts_passphrase_credentials(self, sample_images, sample_credentials):
"""Should accept v3.2.0 format credentials with passphrase."""
processor = BatchProcessor()
result = processor.batch_decode(
images=sample_images,
credentials=sample_credentials, # Uses 'passphrase' key
)
assert isinstance(result, BatchResult)
assert result.operation == "decode"
assert result.total == 3
def test_batch_decode_creates_result(self, sample_images, sample_credentials):
"""Should return BatchResult with correct structure."""
processor = BatchProcessor()
result = processor.batch_decode(
images=sample_images,
credentials=sample_credentials,
)
assert isinstance(result, BatchResult)
assert result.operation == "decode"
assert result.total == 3
def test_progress_callback_called(self, sample_images, sample_credentials):
"""Progress callback should be called for each item."""
processor = BatchProcessor()
callback = Mock()
processor.batch_encode(
images=sample_images,
message="Test",
credentials=sample_credentials,
progress_callback=callback,
)
assert callback.call_count == 3
def test_custom_encode_func(self, sample_images, temp_dir, sample_credentials):
"""Should use custom encode function if provided."""
processor = BatchProcessor()
encode_mock = Mock()
processor.batch_encode(
images=sample_images,
message="Test",
output_dir=temp_dir / "output",
credentials=sample_credentials,
encode_func=encode_mock,
)
assert encode_mock.call_count == 3
class TestBatchCapacityCheck:
"""Tests for batch_capacity_check function."""
def test_returns_list(self, sample_images):
"""Should return list of results."""
results = batch_capacity_check(sample_images)
assert isinstance(results, list)
assert len(results) == 3
def test_includes_capacity(self, sample_images):
"""Results should include capacity info."""
results = batch_capacity_check(sample_images)
for item in results:
assert "capacity_bytes" in item
assert "dimensions" in item
assert "valid" in item
def test_handles_invalid_files(self, temp_dir):
"""Should handle non-image files gracefully."""
bad_file = temp_dir / "not_an_image.png"
bad_file.write_bytes(b"not a png")
results = batch_capacity_check([bad_file])
assert len(results) == 1
assert "error" in results[0]
class TestPrintBatchResult:
"""Tests for print_batch_result function."""
def test_prints_summary(self, capsys, sample_images):
"""Should print summary without errors."""
result = BatchResult(
operation="encode",
total=3,
succeeded=2,
failed=1,
)
result.end_time = result.start_time + 5.0
print_batch_result(result)
captured = capsys.readouterr()
assert "ENCODE" in captured.out
assert "3" in captured.out # total
assert "2" in captured.out # succeeded
def test_verbose_shows_items(self, capsys):
"""Verbose mode should show individual items."""
result = BatchResult(operation="decode", total=1, succeeded=1)
result.items = [
BatchItem(
input_path=Path("test.png"),
status=BatchStatus.SUCCESS,
message="Decoded successfully",
)
]
result.end_time = result.start_time + 1.0
print_batch_result(result, verbose=True)
captured = capsys.readouterr()
assert "test.png" in captured.out
class TestCredentialsMigration:
"""Tests for v3.1.x to v3.2.0 credentials migration."""
def test_old_phrase_key_accepted(self, sample_reference_photo):
"""Old 'phrase' key should be accepted for migration."""
old_format = {
"reference_photo": sample_reference_photo,
"phrase": "old style phrase",
"pin": "123456",
}
# Should not raise
creds = BatchCredentials.from_dict(old_format)
assert creds.passphrase == "old style phrase"
def test_old_day_phrase_key_accepted(self, sample_reference_photo):
"""Old 'day_phrase' key should be accepted for migration."""
old_format = {
"reference_photo": sample_reference_photo,
"day_phrase": "old day phrase",
"pin": "123456",
}
creds = BatchCredentials.from_dict(old_format)
assert creds.passphrase == "old day phrase"
def test_new_passphrase_key_preferred(self, sample_reference_photo):
"""New 'passphrase' key should take precedence if both present."""
mixed_format = {
"reference_photo": sample_reference_photo,
"passphrase": "new style passphrase",
"day_phrase": "old day phrase",
"pin": "123456",
}
creds = BatchCredentials.from_dict(mixed_format)
assert creds.passphrase == "new style passphrase"

View File

@@ -1,181 +0,0 @@
"""
Tests for Stegasoo compression module.
"""
import pytest
from stegasoo.compression import (
COMPRESSION_MAGIC,
HAS_LZ4,
MIN_COMPRESS_SIZE,
CompressionAlgorithm,
CompressionError,
algorithm_name,
compress,
decompress,
estimate_compressed_size,
get_available_algorithms,
get_compression_ratio,
)
class TestCompress:
"""Tests for compress function."""
def test_compress_small_data_not_compressed(self):
"""Small data should not be compressed (overhead not worth it)."""
small_data = b"hello"
result = compress(small_data)
# Should have magic header but NONE algorithm
assert result.startswith(COMPRESSION_MAGIC)
assert result[4] == CompressionAlgorithm.NONE
def test_compress_zlib_reduces_size(self):
"""Zlib should reduce size for compressible data."""
# Highly compressible data
data = b"A" * 1000
result = compress(data, CompressionAlgorithm.ZLIB)
assert len(result) < len(data)
assert result.startswith(COMPRESSION_MAGIC)
assert result[4] == CompressionAlgorithm.ZLIB
def test_compress_incompressible_data(self):
"""Incompressible data should be stored uncompressed."""
import os
# Random data doesn't compress well
data = os.urandom(500)
result = compress(data, CompressionAlgorithm.ZLIB)
# Should fall back to NONE if compression didn't help
assert result.startswith(COMPRESSION_MAGIC)
def test_compress_none_algorithm(self):
"""NONE algorithm should just wrap data."""
data = b"Test data" * 100
result = compress(data, CompressionAlgorithm.NONE)
assert result.startswith(COMPRESSION_MAGIC)
assert result[4] == CompressionAlgorithm.NONE
# Data should be after 9-byte header
assert result[9:] == data
@pytest.mark.skipif(not HAS_LZ4, reason="LZ4 not installed")
def test_compress_lz4(self):
"""LZ4 compression should work if available."""
data = b"B" * 1000
result = compress(data, CompressionAlgorithm.LZ4)
assert len(result) < len(data)
assert result.startswith(COMPRESSION_MAGIC)
assert result[4] == CompressionAlgorithm.LZ4
class TestDecompress:
"""Tests for decompress function."""
def test_decompress_zlib(self):
"""Decompression should restore original data."""
original = b"Hello, World! " * 100
compressed = compress(original, CompressionAlgorithm.ZLIB)
result = decompress(compressed)
assert result == original
def test_decompress_none(self):
"""Uncompressed wrapped data should decompress correctly."""
original = b"Small data"
wrapped = compress(original, CompressionAlgorithm.NONE)
result = decompress(wrapped)
assert result == original
def test_decompress_no_magic(self):
"""Data without magic header should be returned as-is."""
data = b"Not compressed at all"
result = decompress(data)
assert result == data
def test_decompress_truncated_header(self):
"""Truncated header should raise CompressionError."""
bad_data = COMPRESSION_MAGIC + b"\x01" # Too short
with pytest.raises(CompressionError, match="Truncated"):
decompress(bad_data)
@pytest.mark.skipif(not HAS_LZ4, reason="LZ4 not installed")
def test_decompress_lz4(self):
"""LZ4 decompression should work."""
original = b"LZ4 test data " * 100
compressed = compress(original, CompressionAlgorithm.LZ4)
result = decompress(compressed)
assert result == original
def test_roundtrip_large_data(self):
"""Large data should survive compress/decompress roundtrip."""
import os
original = os.urandom(50000)
compressed = compress(original)
result = decompress(compressed)
assert result == original
class TestUtilities:
"""Tests for utility functions."""
def test_compression_ratio_compressed(self):
"""Ratio should be < 1 for well-compressed data."""
original = b"X" * 1000
compressed = compress(original)
ratio = get_compression_ratio(original, compressed)
assert ratio < 1.0
def test_compression_ratio_empty(self):
"""Empty data should return ratio of 1.0."""
ratio = get_compression_ratio(b"", b"")
assert ratio == 1.0
def test_estimate_compressed_size_small(self):
"""Small data estimation should be accurate."""
data = b"Test " * 100
estimate = estimate_compressed_size(data)
actual = len(compress(data))
# Should be within 20% for small data
assert abs(estimate - actual) / actual < 0.2
def test_available_algorithms(self):
"""Should always include NONE and ZLIB."""
algos = get_available_algorithms()
assert CompressionAlgorithm.NONE in algos
assert CompressionAlgorithm.ZLIB in algos
def test_algorithm_name(self):
"""Algorithm names should be human-readable."""
assert "Zlib" in algorithm_name(CompressionAlgorithm.ZLIB)
assert "None" in algorithm_name(CompressionAlgorithm.NONE)
assert "LZ4" in algorithm_name(CompressionAlgorithm.LZ4)
class TestEdgeCases:
"""Edge case tests."""
def test_empty_data(self):
"""Empty data should be handled gracefully."""
result = compress(b"")
assert decompress(result) == b""
def test_exact_min_size(self):
"""Data at exactly MIN_COMPRESS_SIZE should be compressed."""
data = b"x" * MIN_COMPRESS_SIZE
result = compress(data, CompressionAlgorithm.ZLIB)
assert result.startswith(COMPRESSION_MAGIC)
assert decompress(result) == data
def test_binary_data(self):
"""Binary data with null bytes should work."""
data = b"\x00\x01\x02\x03" * 500
compressed = compress(data)
assert decompress(compressed) == data
def test_unicode_after_encoding(self):
"""UTF-8 encoded Unicode should compress correctly."""
text = "Hello, 世界! 🎉 " * 100
data = text.encode("utf-8")
compressed = compress(data)
result = decompress(compressed)
assert result.decode("utf-8") == text

File diff suppressed because it is too large Load Diff