Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
597a9c6411 | ||
|
|
67b25a43a6 | ||
|
|
65a663fe3b | ||
|
|
fc6e4eb805 | ||
|
|
50f07a0ce9 | ||
|
|
7accd26821 | ||
|
|
075e10792c | ||
|
|
9a790de5c3 | ||
|
|
3c91c92a4d | ||
|
|
9d1bc7f829 | ||
|
|
d8118d688b | ||
|
|
b6acee1acb | ||
|
|
b9baf35dfa | ||
|
|
561f03ffde | ||
|
|
038347a505 | ||
|
|
e026d1a4db | ||
|
|
3f93e7a752 | ||
|
|
cdc7ffd3bf | ||
|
|
6c3bc995f1 | ||
|
|
2d3ed8a79a | ||
|
|
040c44fec6 | ||
|
|
832d8be025 | ||
|
|
7088623d2c | ||
|
|
44a3ca8a0f | ||
|
|
7a35ac3df7 | ||
|
|
f69475b406 | ||
|
|
559dcd3dcf | ||
|
|
b1ddfaa75b | ||
|
|
4843ec8c22 | ||
|
|
ac08011236 | ||
|
|
12c4b091fb | ||
|
|
c2c2c924e1 | ||
|
|
df7ad06a08 | ||
|
|
166b936ee5 | ||
|
|
7138455f8d | ||
|
|
9ab3260298 | ||
|
|
763f7bf603 | ||
|
|
1059e17f4e | ||
|
|
7cb42e189a | ||
|
|
8c283bc4e5 |
39
.dockerignore
Normal file
39
.dockerignore
Normal 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
9
.gitignore
vendored
@@ -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
|
||||
|
||||
37
CHANGELOG.md
37
CHANGELOG.md
@@ -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
153
DOCKER.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
165
PLAN-4.1.2.md
165
PLAN-4.1.2.md
@@ -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
|
||||
|
||||
31
README.md
31
README.md
@@ -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
93
RELEASE-4.1.1.md
Normal 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
44
RELEASE_CHECKLIST.md
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -575,7 +592,7 @@ const Stegasoo = {
|
||||
console.log('QR crop/extract error:', err);
|
||||
container.classList.remove('loading', 'scanning');
|
||||
container.classList.add('error');
|
||||
|
||||
|
||||
// Update loader to show error
|
||||
const loader = container.querySelector('.qr-loader');
|
||||
if (loader) {
|
||||
@@ -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,10 +916,184 @@ 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
|
||||
// ========================================================================
|
||||
|
||||
|
||||
initEncodePage() {
|
||||
this.initPasswordToggles();
|
||||
this.initRsaMethodToggle();
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
200
rpi/inject-wifi.sh
Executable 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}"
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
57
rpi/setup.sh
57
rpi/setup.sh
@@ -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
214
rpi/skel/.bashrc
Normal 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"
|
||||
|
||||
@@ -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
307
scripts/validate-release.sh
Executable 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,7 +25,7 @@ from pathlib import Path
|
||||
# VERSION
|
||||
# ============================================================================
|
||||
|
||||
__version__ = "4.1.0"
|
||||
__version__ = "4.1.2"
|
||||
|
||||
# ============================================================================
|
||||
# FILE FORMAT
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
Reference in New Issue
Block a user