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.
|
# Output test files.
|
||||||
test_data/*.png
|
test_data/*.png
|
||||||
|
|
||||||
# Dev scripts (local convenience scripts)
|
# Dev scripts (local convenience scripts - except validate-release.sh)
|
||||||
scripts/
|
scripts/*
|
||||||
|
!scripts/validate-release.sh
|
||||||
|
|
||||||
# Web UI auth database and SSL certs
|
# Web UI auth database and SSL certs
|
||||||
frontends/web/instance/
|
frontends/web/instance/
|
||||||
frontends/web/certs/
|
frontends/web/certs/
|
||||||
rpi/inject-wifi.sh
|
|
||||||
|
# Tests (private)
|
||||||
|
tests/
|
||||||
|
|
||||||
# RPi image build artifacts
|
# RPi image build artifacts
|
||||||
*.img
|
*.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/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org).
|
and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [4.1.2] - 2026-01-05
|
||||||
|
|
||||||
|
### 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
|
## [4.1.0] - 2026-01-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -142,6 +177,8 @@ and this project adheres to [Semantic Versioning](https://semver.org).
|
|||||||
- CLI interface
|
- CLI interface
|
||||||
- Basic PIN authentication
|
- Basic PIN authentication
|
||||||
|
|
||||||
|
[4.1.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.2
|
||||||
|
[4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0
|
||||||
[4.0.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.1...v4.0.2
|
[4.0.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.1...v4.0.2
|
||||||
[4.0.1]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.0...v4.0.1
|
[4.0.1]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.0...v4.0.1
|
||||||
[4.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v3.2.0...v4.0.0
|
[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 data/ data/
|
||||||
COPY frontends/web/ frontends/web/
|
COPY frontends/web/ frontends/web/
|
||||||
|
|
||||||
# Create upload directory
|
# Create upload directory and instance directories (for volumes)
|
||||||
RUN mkdir -p /tmp/stego_uploads
|
RUN mkdir -p /tmp/stego_uploads /app/frontends/web/instance /app/frontends/web/certs
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads
|
RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads
|
||||||
|
|||||||
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
|
## 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.
|
**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
|
## 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.
|
**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
|
**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:**
|
### Files Modified
|
||||||
```python
|
- `src/stegasoo/exceptions.py` (new exceptions)
|
||||||
class StegasooError(Exception): pass
|
- `src/stegasoo/__init__.py` (exports)
|
||||||
class InvalidMagicBytesError(StegasooError): pass
|
- `src/stegasoo/dct_steganography.py` (raise specific exceptions)
|
||||||
class DecryptionError(StegasooError): pass
|
- `frontends/web/app.py` (catch and display)
|
||||||
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`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Mobile-Responsive Polish
|
## 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.
|
**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
|
## 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.
|
**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.
|
**Solution:** Middleware/decorator that redirects to setup page if no users exist.
|
||||||
|
|
||||||
### Files to Modify
|
### Implementation
|
||||||
- `frontends/web/app.py` (add before_request check)
|
- Added `@app.before_request` hook that redirects to /setup if no users exist
|
||||||
- `frontends/web/templates/setup.html` (ensure it blocks other nav)
|
- 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
|
## 5. Dropzone UX Fixes
|
||||||
|
|
||||||
**Status:** Planned
|
**Status:** Done
|
||||||
|
|
||||||
**Problem:** Dropzone has some interaction bugs:
|
**Problem:** Dropzone has some interaction bugs:
|
||||||
- Dropzone doesn't clear properly if first QR image fails
|
- 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
|
**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/js/stegasoo.js`
|
||||||
- `frontends/web/static/css/style.css` (clickable preview)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Smoke Test Benchmarking
|
## 6. Smoke Test Benchmarking
|
||||||
|
|
||||||
**Status:** Planned
|
**Status:** Done
|
||||||
|
|
||||||
**Problem:** No way to measure encode/decode performance or track regressions.
|
**Problem:** No way to measure encode/decode performance or track regressions.
|
||||||
|
|
||||||
**Solution:** Add timing to smoke tests using `hyperfine` or `time`.
|
**Solution:** Add timing to smoke tests using `hyperfine` or `time`.
|
||||||
|
|
||||||
### Features
|
### Implementation
|
||||||
- Benchmark encode/decode on test images
|
- Added `--benchmark` flag to run encode/decode benchmarks after tests
|
||||||
- Output timing stats (min/max/avg)
|
- Added `--runs=N` flag to customize number of benchmark runs (default: 5)
|
||||||
- Optional `--benchmark` flag for smoke-test.sh
|
- Uses hyperfine if available for precise timing with warmup
|
||||||
- Compare NVMe vs SD card, overclocked vs stock
|
- 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`
|
- `rpi/smoke-test.sh`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes
|
## 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)
|
- Don't break DCT compatibility (4.1.1 RS format is stable)
|
||||||
- Test on Pi before release
|
- Test on Pi before release
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -102,9 +102,40 @@ black src/ tests/ frontends/
|
|||||||
ruff check 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
|
## Documentation
|
||||||
|
|
||||||
- [INSTALL.md](INSTALL.md) - Installation guide
|
- [INSTALL.md](INSTALL.md) - Installation guide
|
||||||
|
- [DOCKER.md](DOCKER.md) - Docker deployment
|
||||||
- [CLI.md](CLI.md) - Command-line reference
|
- [CLI.md](CLI.md) - Command-line reference
|
||||||
- [API.md](API.md) - REST API documentation
|
- [API.md](API.md) - REST API documentation
|
||||||
- [WEB_UI.md](WEB_UI.md) - Web interface guide
|
- [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]
|
stegasoo channel [SUBCOMMAND]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
# Rich progress bar (optional)
|
||||||
|
try:
|
||||||
|
from rich.progress import (
|
||||||
|
BarColumn,
|
||||||
|
Progress,
|
||||||
|
SpinnerColumn,
|
||||||
|
TaskProgressColumn,
|
||||||
|
TextColumn,
|
||||||
|
TimeElapsedColumn,
|
||||||
|
)
|
||||||
|
|
||||||
|
HAS_RICH = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_RICH = False
|
||||||
|
|
||||||
# Add parent to path for development
|
# Add parent to path for development
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||||
|
|
||||||
@@ -598,6 +618,73 @@ def channel_clear(project, clear_all, force):
|
|||||||
click.echo(" Mode is now: PUBLIC")
|
click.echo(" Mode is now: PUBLIC")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PROGRESS BAR UTILITIES (v4.1.2)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_progress_job_id() -> str:
|
||||||
|
"""Generate a unique job ID for progress tracking."""
|
||||||
|
return str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_progress_file_path(job_id: str) -> str:
|
||||||
|
"""Get the progress file path for a job ID."""
|
||||||
|
return str(Path(tempfile.gettempdir()) / f"stegasoo_progress_{job_id}.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_progress(job_id: str) -> dict | None:
|
||||||
|
"""Read progress from file for a job ID."""
|
||||||
|
progress_file = _get_progress_file_path(job_id)
|
||||||
|
try:
|
||||||
|
with open(progress_file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_progress_file(job_id: str) -> None:
|
||||||
|
"""Remove progress file for a completed job."""
|
||||||
|
progress_file = _get_progress_file_path(job_id)
|
||||||
|
try:
|
||||||
|
Path(progress_file).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _run_encode_with_progress(encode_func, encode_kwargs: dict, progress_file: str) -> tuple:
|
||||||
|
"""
|
||||||
|
Run encode in a thread and return result.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, result_or_error)
|
||||||
|
"""
|
||||||
|
result_holder = {"result": None, "error": None}
|
||||||
|
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
result_holder["result"] = encode_func(**encode_kwargs, progress_file=progress_file)
|
||||||
|
except Exception as e:
|
||||||
|
result_holder["error"] = e
|
||||||
|
|
||||||
|
thread = threading.Thread(target=run)
|
||||||
|
thread.start()
|
||||||
|
return thread, result_holder
|
||||||
|
|
||||||
|
|
||||||
|
def _format_phase(phase: str) -> str:
|
||||||
|
"""Format phase name for display."""
|
||||||
|
phases = {
|
||||||
|
"starting": "Starting",
|
||||||
|
"initializing": "Initializing",
|
||||||
|
"embedding": "Embedding",
|
||||||
|
"saving": "Saving",
|
||||||
|
"finalizing": "Finalizing",
|
||||||
|
"complete": "Complete",
|
||||||
|
}
|
||||||
|
return phases.get(phase, phase.capitalize())
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ENCODE COMMAND
|
# ENCODE COMMAND
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -642,6 +729,7 @@ def channel_clear(project, clear_all, force):
|
|||||||
help="DCT color mode: grayscale (default) or color (preserves original colors)",
|
help="DCT color mode: grayscale (default) or color (preserves original colors)",
|
||||||
)
|
)
|
||||||
@click.option("--quiet", "-q", is_flag=True, help="Suppress output except errors")
|
@click.option("--quiet", "-q", is_flag=True, help="Suppress output except errors")
|
||||||
|
@click.option("--progress", is_flag=True, help="Show progress bar (requires rich)")
|
||||||
def encode_cmd(
|
def encode_cmd(
|
||||||
ref,
|
ref,
|
||||||
carrier,
|
carrier,
|
||||||
@@ -661,6 +749,7 @@ def encode_cmd(
|
|||||||
dct_output_format,
|
dct_output_format,
|
||||||
dct_color_mode,
|
dct_color_mode,
|
||||||
quiet,
|
quiet,
|
||||||
|
progress,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Encode a secret message or file into an image.
|
Encode a secret message or file into an image.
|
||||||
@@ -808,19 +897,63 @@ def encode_cmd(
|
|||||||
click.echo(channel_status)
|
click.echo(channel_status)
|
||||||
|
|
||||||
# v4.0.0: Include channel_key parameter
|
# v4.0.0: Include channel_key parameter
|
||||||
result = encode(
|
# v4.1.2: Progress bar support
|
||||||
message=payload,
|
encode_kwargs = {
|
||||||
reference_photo=ref_photo,
|
"message": payload,
|
||||||
carrier_image=carrier_image,
|
"reference_photo": ref_photo,
|
||||||
passphrase=passphrase,
|
"carrier_image": carrier_image,
|
||||||
pin=pin or "",
|
"passphrase": passphrase,
|
||||||
rsa_key_data=rsa_key_data,
|
"pin": pin or "",
|
||||||
rsa_password=effective_key_password,
|
"rsa_key_data": rsa_key_data,
|
||||||
embed_mode=embed_mode,
|
"rsa_password": effective_key_password,
|
||||||
dct_output_format=dct_output_format,
|
"embed_mode": embed_mode,
|
||||||
dct_color_mode=dct_color_mode,
|
"dct_output_format": dct_output_format,
|
||||||
channel_key=resolved_channel_key,
|
"dct_color_mode": dct_color_mode,
|
||||||
|
"channel_key": resolved_channel_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
if progress and HAS_RICH:
|
||||||
|
# Run with progress bar
|
||||||
|
job_id = _generate_progress_job_id()
|
||||||
|
progress_file = _get_progress_file_path(job_id)
|
||||||
|
|
||||||
|
thread, result_holder = _run_encode_with_progress(encode, encode_kwargs, progress_file)
|
||||||
|
|
||||||
|
with Progress(
|
||||||
|
SpinnerColumn(),
|
||||||
|
TextColumn("[progress.description]{task.description}"),
|
||||||
|
BarColumn(),
|
||||||
|
TaskProgressColumn(),
|
||||||
|
TimeElapsedColumn(),
|
||||||
|
transient=True,
|
||||||
|
) as progress_bar:
|
||||||
|
task = progress_bar.add_task("Encoding...", total=100)
|
||||||
|
|
||||||
|
while thread.is_alive():
|
||||||
|
prog = _read_progress(job_id)
|
||||||
|
if prog:
|
||||||
|
percent = prog.get("percent", 0)
|
||||||
|
phase = _format_phase(prog.get("phase", "processing"))
|
||||||
|
progress_bar.update(task, completed=percent, description=f"{phase}...")
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
# Final update
|
||||||
|
progress_bar.update(task, completed=100, description="Complete!")
|
||||||
|
|
||||||
|
_cleanup_progress_file(job_id)
|
||||||
|
|
||||||
|
if result_holder["error"]:
|
||||||
|
raise result_holder["error"]
|
||||||
|
result = result_holder["result"]
|
||||||
|
|
||||||
|
elif progress and not HAS_RICH:
|
||||||
|
click.secho(
|
||||||
|
"Warning: --progress requires 'rich' package. Install with: pip install rich",
|
||||||
|
fg="yellow",
|
||||||
)
|
)
|
||||||
|
result = encode(**encode_kwargs)
|
||||||
|
else:
|
||||||
|
result = encode(**encode_kwargs)
|
||||||
|
|
||||||
# Determine output path
|
# Determine output path
|
||||||
if output:
|
if output:
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ import mimetypes
|
|||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from auth import (
|
from auth import (
|
||||||
@@ -36,6 +38,7 @@ from auth import (
|
|||||||
can_create_user,
|
can_create_user,
|
||||||
can_save_channel_key,
|
can_save_channel_key,
|
||||||
change_password,
|
change_password,
|
||||||
|
clear_recovery_key,
|
||||||
create_admin_user,
|
create_admin_user,
|
||||||
create_user,
|
create_user,
|
||||||
delete_channel_key,
|
delete_channel_key,
|
||||||
@@ -45,12 +48,11 @@ from auth import (
|
|||||||
get_channel_key_by_id,
|
get_channel_key_by_id,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
get_non_admin_count,
|
get_non_admin_count,
|
||||||
|
get_recovery_key_hash,
|
||||||
get_user_by_id,
|
get_user_by_id,
|
||||||
get_user_channel_keys,
|
get_user_channel_keys,
|
||||||
get_username,
|
get_username,
|
||||||
has_recovery_key,
|
has_recovery_key,
|
||||||
get_recovery_key_hash,
|
|
||||||
clear_recovery_key,
|
|
||||||
is_admin,
|
is_admin,
|
||||||
is_authenticated,
|
is_authenticated,
|
||||||
login_required,
|
login_required,
|
||||||
@@ -59,10 +61,10 @@ from auth import (
|
|||||||
reset_user_password,
|
reset_user_password,
|
||||||
save_channel_key,
|
save_channel_key,
|
||||||
set_recovery_key_hash,
|
set_recovery_key_hash,
|
||||||
verify_and_reset_admin_password,
|
|
||||||
update_channel_key_last_used,
|
update_channel_key_last_used,
|
||||||
update_channel_key_name,
|
update_channel_key_name,
|
||||||
user_exists,
|
user_exists,
|
||||||
|
verify_and_reset_admin_password,
|
||||||
verify_user_password,
|
verify_user_password,
|
||||||
)
|
)
|
||||||
from auth import (
|
from auth import (
|
||||||
@@ -93,6 +95,9 @@ from stegasoo import (
|
|||||||
CapacityError,
|
CapacityError,
|
||||||
DecryptionError,
|
DecryptionError,
|
||||||
FilePayload,
|
FilePayload,
|
||||||
|
InvalidHeaderError,
|
||||||
|
InvalidMagicBytesError,
|
||||||
|
ReedSolomonError,
|
||||||
StegasooError,
|
StegasooError,
|
||||||
export_rsa_key_pem,
|
export_rsa_key_pem,
|
||||||
generate_credentials,
|
generate_credentials,
|
||||||
@@ -153,7 +158,13 @@ except ImportError:
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Runs encode/decode/compare in subprocesses to prevent jpegio/scipy crashes
|
# Runs encode/decode/compare in subprocesses to prevent jpegio/scipy crashes
|
||||||
# from taking down the Flask server.
|
# from taking down the Flask server.
|
||||||
from subprocess_stego import SubprocessStego
|
from subprocess_stego import (
|
||||||
|
SubprocessStego,
|
||||||
|
cleanup_progress_file,
|
||||||
|
generate_job_id,
|
||||||
|
get_progress_file_path,
|
||||||
|
read_progress,
|
||||||
|
)
|
||||||
|
|
||||||
from stegasoo.qr_utils import (
|
from stegasoo.qr_utils import (
|
||||||
can_fit_in_qr,
|
can_fit_in_qr,
|
||||||
@@ -192,6 +203,60 @@ app.config["HTTPS_ENABLED"] = os.environ.get("STEGASOO_HTTPS_ENABLED", "false").
|
|||||||
# Initialize auth module
|
# Initialize auth module
|
||||||
init_auth(app)
|
init_auth(app)
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ASYNC JOB MANAGEMENT (v4.1.2)
|
||||||
|
# ============================================================================
|
||||||
|
# Encode operations can run in background threads with progress reporting
|
||||||
|
|
||||||
|
# Thread pool for background encode/decode operations
|
||||||
|
_executor = ThreadPoolExecutor(max_workers=2)
|
||||||
|
|
||||||
|
# Job storage: job_id -> {status, result, error, file_id, ...}
|
||||||
|
_jobs = {}
|
||||||
|
_jobs_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _store_job(job_id: str, data: dict) -> None:
|
||||||
|
"""Thread-safe job storage."""
|
||||||
|
with _jobs_lock:
|
||||||
|
_jobs[job_id] = data
|
||||||
|
|
||||||
|
|
||||||
|
def _get_job(job_id: str) -> dict | None:
|
||||||
|
"""Thread-safe job retrieval."""
|
||||||
|
with _jobs_lock:
|
||||||
|
return _jobs.get(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_old_jobs(max_age_seconds: int = 3600) -> None:
|
||||||
|
"""Remove jobs older than max_age_seconds."""
|
||||||
|
now = time.time()
|
||||||
|
with _jobs_lock:
|
||||||
|
to_remove = [
|
||||||
|
jid for jid, data in _jobs.items() if now - data.get("created", 0) > max_age_seconds
|
||||||
|
]
|
||||||
|
for jid in to_remove:
|
||||||
|
cleanup_progress_file(jid)
|
||||||
|
del _jobs[jid]
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
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})
|
# Temporary file storage for sharing (file_id -> {data, timestamp, filename})
|
||||||
TEMP_FILES: dict[str, dict] = {}
|
TEMP_FILES: dict[str, dict] = {}
|
||||||
THUMBNAIL_FILES: dict[str, bytes] = {}
|
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"])
|
@app.route("/encode", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def encode_page():
|
def encode_page():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
# Check if async mode requested
|
||||||
|
is_async = request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get files
|
# Get files
|
||||||
ref_photo = request.files.get("reference_photo")
|
ref_photo = request.files.get("reference_photo")
|
||||||
@@ -935,7 +1109,9 @@ def encode_page():
|
|||||||
# Pre-check payload capacity BEFORE encode (fail fast)
|
# Pre-check payload capacity BEFORE encode (fail fast)
|
||||||
from stegasoo.steganography import will_fit_by_mode
|
from stegasoo.steganography import will_fit_by_mode
|
||||||
|
|
||||||
payload_size = len(payload.data) if hasattr(payload, "data") else len(payload.encode("utf-8"))
|
payload_size = (
|
||||||
|
len(payload.data) if hasattr(payload, "data") else len(payload.encode("utf-8"))
|
||||||
|
)
|
||||||
fit_check = will_fit_by_mode(payload_size, carrier_data, embed_mode=embed_mode)
|
fit_check = will_fit_by_mode(payload_size, carrier_data, embed_mode=embed_mode)
|
||||||
if not fit_check.get("fits", True):
|
if not fit_check.get("fits", True):
|
||||||
error_msg = (
|
error_msg = (
|
||||||
@@ -951,8 +1127,35 @@ def encode_page():
|
|||||||
flash(error_msg, "error")
|
flash(error_msg, "error")
|
||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
# v4.0.0: Include channel_key parameter
|
# Build encode params for either sync or async
|
||||||
# Use subprocess-isolated encode to prevent crashes
|
encode_params = {
|
||||||
|
"carrier_data": carrier_data,
|
||||||
|
"ref_data": ref_data,
|
||||||
|
"passphrase": passphrase,
|
||||||
|
"pin": pin if pin else None,
|
||||||
|
"rsa_key_data": rsa_key_data,
|
||||||
|
"key_password": key_password,
|
||||||
|
"embed_mode": embed_mode,
|
||||||
|
"dct_output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||||
|
"dct_color_mode": dct_color_mode if embed_mode == "dct" else "color",
|
||||||
|
"channel_key": channel_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload_type == "file" and payload_file and payload_file.filename:
|
||||||
|
encode_params["file_data"] = payload.data
|
||||||
|
encode_params["file_name"] = payload.filename
|
||||||
|
encode_params["file_mime"] = payload.mime_type
|
||||||
|
else:
|
||||||
|
encode_params["message"] = payload
|
||||||
|
|
||||||
|
# ASYNC MODE: Start background job and return JSON
|
||||||
|
if is_async:
|
||||||
|
job_id = generate_job_id()
|
||||||
|
_store_job(job_id, {"status": "pending", "created": time.time()})
|
||||||
|
_executor.submit(_run_encode_job, job_id, encode_params)
|
||||||
|
return jsonify({"job_id": job_id, "status": "pending"})
|
||||||
|
|
||||||
|
# SYNC MODE: Run inline (original behavior)
|
||||||
if payload_type == "file" and payload_file and payload_file.filename:
|
if payload_type == "file" and payload_file and payload_file.filename:
|
||||||
encode_result = subprocess_stego.encode(
|
encode_result = subprocess_stego.encode(
|
||||||
carrier_data=carrier_data,
|
carrier_data=carrier_data,
|
||||||
@@ -967,7 +1170,7 @@ def encode_page():
|
|||||||
embed_mode=embed_mode,
|
embed_mode=embed_mode,
|
||||||
dct_output_format=dct_output_format if embed_mode == "dct" else "png",
|
dct_output_format=dct_output_format if embed_mode == "dct" else "png",
|
||||||
dct_color_mode=dct_color_mode if embed_mode == "dct" else "color",
|
dct_color_mode=dct_color_mode if embed_mode == "dct" else "color",
|
||||||
channel_key=channel_key, # v4.0.0
|
channel_key=channel_key,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
encode_result = subprocess_stego.encode(
|
encode_result = subprocess_stego.encode(
|
||||||
@@ -981,7 +1184,7 @@ def encode_page():
|
|||||||
embed_mode=embed_mode,
|
embed_mode=embed_mode,
|
||||||
dct_output_format=dct_output_format if embed_mode == "dct" else "png",
|
dct_output_format=dct_output_format if embed_mode == "dct" else "png",
|
||||||
dct_color_mode=dct_color_mode if embed_mode == "dct" else "color",
|
dct_color_mode=dct_color_mode if embed_mode == "dct" else "color",
|
||||||
channel_key=channel_key, # v4.0.0
|
channel_key=channel_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for subprocess errors
|
# Check for subprocess errors
|
||||||
@@ -1037,6 +1240,53 @@ def encode_page():
|
|||||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ENCODE PROGRESS ENDPOINTS (v4.1.2)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/encode/status/<job_id>")
|
||||||
|
@login_required
|
||||||
|
def encode_status(job_id):
|
||||||
|
"""Get the status of an async encode job."""
|
||||||
|
job = _get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({"error": "Job not found"}), 404
|
||||||
|
|
||||||
|
response = {"status": job.get("status", "unknown")}
|
||||||
|
|
||||||
|
if job["status"] == "complete":
|
||||||
|
response["file_id"] = job.get("file_id")
|
||||||
|
elif job["status"] == "error":
|
||||||
|
response["error"] = job.get("error", "Unknown error")
|
||||||
|
|
||||||
|
return jsonify(response)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/encode/progress/<job_id>")
|
||||||
|
@login_required
|
||||||
|
def encode_progress(job_id):
|
||||||
|
"""Get the progress of an async encode job."""
|
||||||
|
progress = read_progress(job_id)
|
||||||
|
if progress:
|
||||||
|
return jsonify(progress)
|
||||||
|
|
||||||
|
# No progress file yet - check job status
|
||||||
|
job = _get_job(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({"error": "Job not found"}), 404
|
||||||
|
|
||||||
|
if job["status"] == "complete":
|
||||||
|
return jsonify({"percent": 100, "phase": "complete"})
|
||||||
|
elif job["status"] == "error":
|
||||||
|
return jsonify({"percent": 0, "phase": "error", "error": job.get("error")})
|
||||||
|
elif job["status"] == "pending":
|
||||||
|
return jsonify({"percent": 0, "phase": "starting"})
|
||||||
|
|
||||||
|
# Running but no progress file yet
|
||||||
|
return jsonify({"percent": 0, "phase": "initializing"})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/encode/result/<file_id>")
|
@app.route("/encode/result/<file_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def encode_result(file_id):
|
def encode_result(file_id):
|
||||||
@@ -1271,10 +1521,28 @@ def decode_page():
|
|||||||
has_qrcode_read=HAS_QRCODE_READ,
|
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:
|
except DecryptionError:
|
||||||
flash(
|
flash(
|
||||||
"Decryption failed. Check passphrase, PIN, RSA key, reference photo, and channel key.",
|
"Wrong credentials. Double-check your reference photo, passphrase, PIN, and channel key.",
|
||||||
"error",
|
"warning",
|
||||||
)
|
)
|
||||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||||
except StegasooError as e:
|
except StegasooError as e:
|
||||||
@@ -1363,12 +1631,7 @@ def api_tools_strip_metadata():
|
|||||||
buffer = io.BytesIO(clean_data)
|
buffer = io.BytesIO(clean_data)
|
||||||
filename = image_file.filename.rsplit(".", 1)[0] + "_clean.png"
|
filename = image_file.filename.rsplit(".", 1)[0] + "_clean.png"
|
||||||
|
|
||||||
return send_file(
|
return send_file(buffer, mimetype="image/png", as_attachment=True, download_name=filename)
|
||||||
buffer,
|
|
||||||
mimetype="image/png",
|
|
||||||
as_attachment=True,
|
|
||||||
download_name=filename
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "error": str(e)}), 400
|
return jsonify({"success": False, "error": str(e)}), 400
|
||||||
|
|
||||||
@@ -1390,13 +1653,15 @@ def api_tools_exif():
|
|||||||
# Check if it's a JPEG (editable) or not
|
# Check if it's a JPEG (editable) or not
|
||||||
is_jpeg = image_data[:2] == b"\xff\xd8"
|
is_jpeg = image_data[:2] == b"\xff\xd8"
|
||||||
|
|
||||||
return jsonify({
|
return jsonify(
|
||||||
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"filename": image_file.filename,
|
"filename": image_file.filename,
|
||||||
"exif": exif,
|
"exif": exif,
|
||||||
"editable": is_jpeg,
|
"editable": is_jpeg,
|
||||||
"field_count": len(exif),
|
"field_count": len(exif),
|
||||||
})
|
}
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "error": str(e)}), 400
|
return jsonify({"success": False, "error": str(e)}), 400
|
||||||
|
|
||||||
@@ -1415,6 +1680,7 @@ def api_tools_exif_update():
|
|||||||
updates_json = request.form.get("updates", "{}")
|
updates_json = request.form.get("updates", "{}")
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
|
|
||||||
updates = json.loads(updates_json)
|
updates = json.loads(updates_json)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return jsonify({"success": False, "error": "Invalid updates JSON"}), 400
|
return jsonify({"success": False, "error": "Invalid updates JSON"}), 400
|
||||||
@@ -1460,11 +1726,19 @@ def api_tools_exif_clear():
|
|||||||
clean_data = strip_image_metadata(image_data, output_format=output_format)
|
clean_data = strip_image_metadata(image_data, output_format=output_format)
|
||||||
|
|
||||||
# Determine extension and mimetype
|
# Determine extension and mimetype
|
||||||
ext_map = {"PNG": ("png", "image/png"), "JPEG": ("jpg", "image/jpeg"), "BMP": ("bmp", "image/bmp")}
|
ext_map = {
|
||||||
|
"PNG": ("png", "image/png"),
|
||||||
|
"JPEG": ("jpg", "image/jpeg"),
|
||||||
|
"BMP": ("bmp", "image/bmp"),
|
||||||
|
}
|
||||||
ext, mimetype = ext_map.get(output_format, ("png", "image/png"))
|
ext, mimetype = ext_map.get(output_format, ("png", "image/png"))
|
||||||
|
|
||||||
# Return as downloadable file
|
# Return as downloadable file
|
||||||
stem = image_file.filename.rsplit(".", 1)[0] if "." in image_file.filename else image_file.filename
|
stem = (
|
||||||
|
image_file.filename.rsplit(".", 1)[0]
|
||||||
|
if "." in image_file.filename
|
||||||
|
else image_file.filename
|
||||||
|
)
|
||||||
buffer = io.BytesIO(clean_data)
|
buffer = io.BytesIO(clean_data)
|
||||||
return send_file(
|
return send_file(
|
||||||
buffer,
|
buffer,
|
||||||
@@ -1605,9 +1879,10 @@ def setup():
|
|||||||
@login_required
|
@login_required
|
||||||
def setup_recovery():
|
def setup_recovery():
|
||||||
"""Recovery key setup page (Step 2 of initial setup)."""
|
"""Recovery key setup page (Step 2 of initial setup)."""
|
||||||
from stegasoo.recovery import generate_recovery_key, hash_recovery_key, generate_recovery_qr
|
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
from stegasoo.recovery import generate_recovery_key, generate_recovery_qr, hash_recovery_key
|
||||||
|
|
||||||
# Only allow during initial setup (no recovery key yet, first admin)
|
# Only allow during initial setup (no recovery key yet, first admin)
|
||||||
if has_recovery_key():
|
if has_recovery_key():
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
@@ -1685,9 +1960,10 @@ def recover():
|
|||||||
@admin_required
|
@admin_required
|
||||||
def regenerate_recovery():
|
def regenerate_recovery():
|
||||||
"""Generate a new recovery key (replaces existing one)."""
|
"""Generate a new recovery key (replaces existing one)."""
|
||||||
from stegasoo.recovery import generate_recovery_key, hash_recovery_key, generate_recovery_qr
|
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
from stegasoo.recovery import generate_recovery_key, generate_recovery_qr, hash_recovery_key
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
action = request.form.get("action")
|
action = request.form.get("action")
|
||||||
|
|
||||||
@@ -1886,7 +2162,8 @@ def api_channel_keys():
|
|||||||
"""Get saved channel keys for current user (JSON API)."""
|
"""Get saved channel keys for current user (JSON API)."""
|
||||||
current_user = get_current_user()
|
current_user = get_current_user()
|
||||||
keys = get_user_channel_keys(current_user.id)
|
keys = get_user_channel_keys(current_user.id)
|
||||||
return jsonify({
|
return jsonify(
|
||||||
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"keys": [
|
"keys": [
|
||||||
{
|
{
|
||||||
@@ -1900,7 +2177,8 @@ def api_channel_keys():
|
|||||||
],
|
],
|
||||||
"can_save": can_save_channel_key(current_user.id),
|
"can_save": can_save_channel_key(current_user.id),
|
||||||
"max_keys": MAX_CHANNEL_KEYS,
|
"max_keys": MAX_CHANNEL_KEYS,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/channel/keys/<int:key_id>/use", methods=["POST"])
|
@app.route("/api/channel/keys/<int:key_id>/use", methods=["POST"])
|
||||||
|
|||||||
@@ -99,6 +99,23 @@ const Stegasoo = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make preview clickable to replace file
|
||||||
|
if (preview) {
|
||||||
|
preview.style.cursor = 'pointer';
|
||||||
|
preview.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make entire zone clickable (in case label/preview don't cover it)
|
||||||
|
zone.addEventListener('click', (e) => {
|
||||||
|
// Only trigger if not clicking directly on the input
|
||||||
|
if (e.target !== input) {
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -584,6 +601,17 @@ const Stegasoo = {
|
|||||||
<span>No QR code detected</span>
|
<span>No QR code detected</span>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset after delay so user can try again
|
||||||
|
setTimeout(() => {
|
||||||
|
container.classList.remove('error');
|
||||||
|
container.classList.add('d-none');
|
||||||
|
label?.classList.remove('d-none');
|
||||||
|
// Clear the file input so same file can be re-selected
|
||||||
|
input.value = '';
|
||||||
|
// Remove loader
|
||||||
|
if (loader) loader.remove();
|
||||||
|
}, 2000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -888,6 +916,180 @@ const Stegasoo = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// ASYNC ENCODE WITH PROGRESS (v4.1.2)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit encode form asynchronously with progress tracking
|
||||||
|
* @param {HTMLFormElement} form - The encode form
|
||||||
|
* @param {HTMLElement} btn - The submit button
|
||||||
|
*/
|
||||||
|
async submitEncodeAsync(form, btn) {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
formData.append('async', 'true');
|
||||||
|
|
||||||
|
// Show progress modal
|
||||||
|
this.showProgressModal('Encoding');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start encode job
|
||||||
|
const response = await fetch('/encode', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to start encode');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = result.job_id;
|
||||||
|
|
||||||
|
// Poll for progress
|
||||||
|
await this.pollEncodeProgress(jobId);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.hideProgressModal();
|
||||||
|
alert('Encode failed: ' + error.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-lock-fill me-2"></i>Encode';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll encode progress until complete
|
||||||
|
* @param {string} jobId - The job ID
|
||||||
|
*/
|
||||||
|
async pollEncodeProgress(jobId) {
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
const progressText = document.getElementById('progressText');
|
||||||
|
const phaseText = document.getElementById('progressPhase');
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
// Check status first
|
||||||
|
const statusResponse = await fetch(`/encode/status/${jobId}`);
|
||||||
|
const statusData = await statusResponse.json();
|
||||||
|
|
||||||
|
if (statusData.status === 'complete') {
|
||||||
|
// Done - redirect to result
|
||||||
|
this.updateProgress(100, 'Complete!');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `/encode/result/${statusData.file_id}`;
|
||||||
|
}, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusData.status === 'error') {
|
||||||
|
throw new Error(statusData.error || 'Encode failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get progress
|
||||||
|
const progressResponse = await fetch(`/encode/progress/${jobId}`);
|
||||||
|
const progressData = await progressResponse.json();
|
||||||
|
|
||||||
|
const percent = progressData.percent || 0;
|
||||||
|
const phase = progressData.phase || 'processing';
|
||||||
|
|
||||||
|
this.updateProgress(percent, this.formatPhase(phase));
|
||||||
|
|
||||||
|
// Continue polling
|
||||||
|
setTimeout(poll, 500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.hideProgressModal();
|
||||||
|
alert('Encode failed: ' + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await poll();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format phase name for display
|
||||||
|
*/
|
||||||
|
formatPhase(phase) {
|
||||||
|
const phases = {
|
||||||
|
'starting': 'Starting...',
|
||||||
|
'initializing': 'Initializing...',
|
||||||
|
'embedding': 'Embedding data...',
|
||||||
|
'saving': 'Saving image...',
|
||||||
|
'finalizing': 'Finalizing...',
|
||||||
|
'complete': 'Complete!',
|
||||||
|
};
|
||||||
|
return phases[phase] || phase;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show progress modal
|
||||||
|
*/
|
||||||
|
showProgressModal(operation = 'Processing') {
|
||||||
|
// Create modal if doesn't exist
|
||||||
|
let modal = document.getElementById('progressModal');
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'progressModal';
|
||||||
|
modal.className = 'modal fade';
|
||||||
|
modal.setAttribute('data-bs-backdrop', 'static');
|
||||||
|
modal.setAttribute('data-bs-keyboard', 'false');
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content bg-dark text-light">
|
||||||
|
<div class="modal-body p-4">
|
||||||
|
<h5 class="mb-3" id="progressTitle">${operation}...</h5>
|
||||||
|
<div class="progress mb-2" style="height: 24px;">
|
||||||
|
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated bg-success"
|
||||||
|
role="progressbar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between text-muted small">
|
||||||
|
<span id="progressPhase">Initializing...</span>
|
||||||
|
<span id="progressText">0%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset progress
|
||||||
|
this.updateProgress(0, 'Initializing...');
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
const bsModal = new bootstrap.Modal(modal);
|
||||||
|
bsModal.show();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide progress modal
|
||||||
|
*/
|
||||||
|
hideProgressModal() {
|
||||||
|
const modal = document.getElementById('progressModal');
|
||||||
|
if (modal) {
|
||||||
|
const bsModal = bootstrap.Modal.getInstance(modal);
|
||||||
|
bsModal?.hide();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update progress bar and text
|
||||||
|
*/
|
||||||
|
updateProgress(percent, phase) {
|
||||||
|
const progressBar = document.getElementById('progressBar');
|
||||||
|
const progressText = document.getElementById('progressText');
|
||||||
|
const phaseText = document.getElementById('progressPhase');
|
||||||
|
|
||||||
|
if (progressBar) progressBar.style.width = percent + '%';
|
||||||
|
if (progressText) progressText.textContent = Math.round(percent) + '%';
|
||||||
|
if (phaseText) phaseText.textContent = phase;
|
||||||
|
},
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// INITIALIZATION HELPERS
|
// INITIALIZATION HELPERS
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -909,27 +1111,23 @@ const Stegasoo = {
|
|||||||
generateBtnId: 'channelKeyGenerate'
|
generateBtnId: 'channelKeyGenerate'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form submission with channel key validation
|
// Form submission with async progress tracking (v4.1.2)
|
||||||
const form = document.getElementById('encodeForm');
|
const form = document.getElementById('encodeForm');
|
||||||
const btn = document.getElementById('encodeBtn');
|
const btn = document.getElementById('encodeBtn');
|
||||||
form?.addEventListener('submit', (e) => {
|
form?.addEventListener('submit', (e) => {
|
||||||
if (!this.validateChannelKeyOnSubmit(form, 'channelSelect', 'channelKeyInput')) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!this.validateChannelKeyOnSubmit(form, 'channelSelect', 'channelKeyInput')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
const startTime = Date.now();
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting...';
|
||||||
const updateTimer = () => {
|
|
||||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
||||||
const mins = Math.floor(elapsed / 60);
|
|
||||||
const secs = elapsed % 60;
|
|
||||||
const timeStr = mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}s`;
|
|
||||||
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Encoding... ${timeStr}`;
|
|
||||||
};
|
|
||||||
updateTimer();
|
|
||||||
setInterval(updateTimer, 1000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use async submission with progress tracking
|
||||||
|
this.submitEncodeAsync(form, btn);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1442,3 +1442,260 @@ footer {
|
|||||||
padding: 0.35rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
background: rgba(0, 0, 0, 0.1);
|
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_output_format=params.get("dct_output_format", "png"),
|
||||||
dct_color_mode=params.get("dct_color_mode", "color"),
|
dct_color_mode=params.get("dct_color_mode", "color"),
|
||||||
channel_key=resolved_channel_key, # v4.0.0
|
channel_key=resolved_channel_key, # v4.0.0
|
||||||
|
progress_file=params.get("progress_file"), # v4.1.2
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build stats dict if available
|
# Build stats dict if available
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -233,6 +235,8 @@ class SubprocessStego:
|
|||||||
# Channel key (v4.0.0)
|
# Channel key (v4.0.0)
|
||||||
channel_key: str | None = "auto",
|
channel_key: str | None = "auto",
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
|
# Progress file (v4.1.2)
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> EncodeResult:
|
) -> EncodeResult:
|
||||||
"""
|
"""
|
||||||
Encode a message or file into an image.
|
Encode a message or file into an image.
|
||||||
@@ -268,6 +272,7 @@ class SubprocessStego:
|
|||||||
"dct_output_format": dct_output_format,
|
"dct_output_format": dct_output_format,
|
||||||
"dct_color_mode": dct_color_mode,
|
"dct_color_mode": dct_color_mode,
|
||||||
"channel_key": channel_key, # v4.0.0
|
"channel_key": channel_key, # v4.0.0
|
||||||
|
"progress_file": progress_file, # v4.1.2
|
||||||
}
|
}
|
||||||
|
|
||||||
if file_data:
|
if file_data:
|
||||||
@@ -496,3 +501,42 @@ def get_subprocess_stego() -> SubprocessStego:
|
|||||||
if _default_stego is None:
|
if _default_stego is None:
|
||||||
_default_stego = SubprocessStego()
|
_default_stego = SubprocessStego()
|
||||||
return _default_stego
|
return _default_stego
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Progress File Utilities (v4.1.2)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def generate_job_id() -> str:
|
||||||
|
"""Generate a unique job ID for tracking encode/decode operations."""
|
||||||
|
return str(uuid.uuid4())[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def get_progress_file_path(job_id: str) -> str:
|
||||||
|
"""Get the progress file path for a job ID."""
|
||||||
|
return str(Path(tempfile.gettempdir()) / f"stegasoo_progress_{job_id}.json")
|
||||||
|
|
||||||
|
|
||||||
|
def read_progress(job_id: str) -> dict | None:
|
||||||
|
"""
|
||||||
|
Read progress from file for a job ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Progress dict with current, total, percent, phase, or None if not found
|
||||||
|
"""
|
||||||
|
progress_file = get_progress_file_path(job_id)
|
||||||
|
try:
|
||||||
|
with open(progress_file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_progress_file(job_id: str) -> None:
|
||||||
|
"""Remove progress file for a completed job."""
|
||||||
|
progress_file = get_progress_file_path(job_id)
|
||||||
|
try:
|
||||||
|
Path(progress_file).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
<li><strong>Output:</strong> JPEG or PNG</li>
|
<li><strong>Output:</strong> JPEG or PNG</li>
|
||||||
<li><strong>Color:</strong> Color or grayscale</li>
|
<li><strong>Color:</strong> Color or grayscale</li>
|
||||||
<li><strong>Speed:</strong> ~2s</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>
|
</ul>
|
||||||
<hr>
|
<hr>
|
||||||
<div class="small">
|
<div class="small">
|
||||||
@@ -375,29 +375,42 @@
|
|||||||
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Version History</h5>
|
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Version History</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="table-responsive">
|
<!-- Current Version - Prominent -->
|
||||||
<table class="table table-dark table-sm small">
|
<div class="alert alert-success mb-4">
|
||||||
<thead>
|
<div class="d-flex align-items-center">
|
||||||
<tr>
|
<span class="badge bg-success fs-6 me-3">v4.1.2</span>
|
||||||
<th>Version</th>
|
<div>
|
||||||
<th>Changes</th>
|
<strong>Progress bars</strong> for encode operations,
|
||||||
</tr>
|
<strong>mobile-responsive polish</strong>,
|
||||||
</thead>
|
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>
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td width="80"><strong>4.1.1</strong></td>
|
||||||
|
<td>DCT RS format stability, Docker cleanup, first-boot wizard</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>4.1.0</strong></td>
|
<td><strong>4.1.0</strong></td>
|
||||||
<td>
|
<td>Reed-Solomon error correction for DCT, majority voting headers</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>
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>4.0.0</strong></td>
|
<td><strong>4.0.0</strong></td>
|
||||||
<td>
|
<td>Channel keys, DCT default, subprocess isolation</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>
|
||||||
<tr>
|
<tr>
|
||||||
<td>3.2.0</td>
|
<td>3.2.0</td>
|
||||||
@@ -408,16 +421,8 @@
|
|||||||
<td>DCT mode, JPEG output, color preservation</td>
|
<td>DCT mode, JPEG output, color preservation</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>2.2.0</td>
|
<td>2.x</td>
|
||||||
<td>QR code RSA key import/export</td>
|
<td>Web UI, REST API, RSA keys, QR codes, file embedding</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>
|
||||||
<tr>
|
<tr>
|
||||||
<td>1.0.0</td>
|
<td>1.0.0</td>
|
||||||
@@ -428,6 +433,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -553,9 +561,8 @@
|
|||||||
<div class="col-6 col-md-4 col-lg-2 mb-3">
|
<div class="col-6 col-md-4 col-lg-2 mb-3">
|
||||||
<div class="p-3 bg-dark rounded h-100">
|
<div class="p-3 bg-dark rounded h-100">
|
||||||
<i class="bi bi-bandaid text-info fs-3 d-block mb-2"></i>
|
<i class="bi bi-bandaid text-info fs-3 d-block mb-2"></i>
|
||||||
<div class="small text-muted">Error Correction</div>
|
<div class="small text-muted">DCT ECC</div>
|
||||||
<strong>Reed-Solomon</strong>
|
<strong>RS Code</strong>
|
||||||
<span class="badge bg-info ms-1">v4.1</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "stegasoo"
|
name = "stegasoo"
|
||||||
version = "4.1.1"
|
version = "4.1.2"
|
||||||
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -54,6 +54,7 @@ cli = [
|
|||||||
"click>=8.0.0",
|
"click>=8.0.0",
|
||||||
"qrcode>=7.30",
|
"qrcode>=7.30",
|
||||||
"piexif>=1.1.0",
|
"piexif>=1.1.0",
|
||||||
|
"rich>=13.0.0",
|
||||||
]
|
]
|
||||||
compression = [
|
compression = [
|
||||||
"lz4>=4.0.0",
|
"lz4>=4.0.0",
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ sudo apt-get update && sudo apt-get install -y git
|
|||||||
## Step 4: Clone & Run Setup
|
## Step 4: Clone & Run Setup
|
||||||
|
|
||||||
```bash
|
```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
|
cd stegasoo
|
||||||
./rpi/setup.sh
|
./rpi/setup.sh
|
||||||
```
|
```
|
||||||
@@ -102,10 +103,14 @@ Upload `.img.zst` to GitHub Releases.
|
|||||||
|
|
||||||
Users can flash with:
|
Users can flash with:
|
||||||
```bash
|
```bash
|
||||||
# Linux
|
# Option 1: rpi-imager CLI (supports .zst.zip directly)
|
||||||
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
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):
|
# On Pi (after SSH):
|
||||||
sudo chown admin:admin /opt
|
sudo chown admin:admin /opt
|
||||||
sudo apt-get update && sudo apt-get install -y git
|
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
|
cd stegasoo && ./rpi/setup.sh
|
||||||
sudo systemctl start stegasoo
|
sudo systemctl start stegasoo
|
||||||
curl -k https://localhost:5000
|
curl -k https://localhost:5000
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ sudo chown $USER:$USER /opt
|
|||||||
sudo apt-get update && sudo apt-get install -y git
|
sudo apt-get update && sudo apt-get install -y git
|
||||||
|
|
||||||
# Clone and run setup
|
# Clone and run setup
|
||||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git /opt/stegasoo
|
cd /opt
|
||||||
cd /opt/stegasoo
|
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||||
|
cd stegasoo
|
||||||
./rpi/setup.sh
|
./rpi/setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -138,8 +139,9 @@ sudo chown admin:admin /opt
|
|||||||
sudo apt-get update && sudo apt-get install -y git
|
sudo apt-get update && sudo apt-get install -y git
|
||||||
|
|
||||||
# Clone and run setup
|
# Clone and run setup
|
||||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git /opt/stegasoo
|
cd /opt
|
||||||
cd /opt/stegasoo
|
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||||
|
cd stegasoo
|
||||||
./rpi/setup.sh
|
./rpi/setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -196,7 +198,12 @@ Upload the `.img.zst` file to GitHub Releases.
|
|||||||
|
|
||||||
Users flash with:
|
Users flash with:
|
||||||
```bash
|
```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
|
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
|
# Welcome
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
gum style \
|
echo ""
|
||||||
--border double \
|
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||||
--border-foreground 212 \
|
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
||||||
--padding "1 2" \
|
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
||||||
--margin "1" \
|
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
|
||||||
--align center \
|
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"
|
||||||
"" \
|
|
||||||
" * . * . * . * . * . *" \
|
|
||||||
"" \
|
|
||||||
"First Boot Wizard"
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
gum style --foreground 245 "This wizard will help you configure your Stegasoo server."
|
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"
|
ACCESS_URL_LOCAL="http://$HOSTNAME.local:5000/setup"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
gum style \
|
echo ""
|
||||||
--border double \
|
echo ""
|
||||||
--border-foreground 82 \
|
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||||
--padding "1 2" \
|
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
||||||
--margin "1" \
|
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
||||||
--align center \
|
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"
|
||||||
" * . * . * . * . * . *" \
|
|
||||||
"" \
|
|
||||||
"Setup Complete!"
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
gum style --foreground 82 --bold "Create your admin account:"
|
gum style --foreground 82 --bold "Create your admin account:"
|
||||||
gum style --foreground 226 " $ACCESS_URL"
|
gum style --foreground 226 " $ACCESS_URL"
|
||||||
gum style --foreground 245 " $ACCESS_URL_LOCAL (if mDNS works)"
|
gum style --foreground 245 " $ACCESS_URL_LOCAL (if mDNS works)"
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ -n "$CHANNEL_KEY" ]; then
|
if [ -n "$CHANNEL_KEY" ]; then
|
||||||
gum style --foreground 82 --bold "Channel Key:"
|
|
||||||
gum style --foreground 226 " $CHANNEL_KEY"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
echo -e "\033[1;32mChannel Key:\033[0m \033[0;33m$CHANNEL_KEY\033[0m"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
gum style --foreground 82 --bold "First Steps:"
|
gum style --foreground 82 --bold "First Steps:"
|
||||||
gum style --foreground 255 \
|
gum style --foreground 255 " 1. Open URL → 2. Accept cert → 3. Create admin → 4. Encode!"
|
||||||
" 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 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 ""
|
echo ""
|
||||||
|
gum style --foreground 245 "Commands: systemctl {status|restart} stegasoo, journalctl -u stegasoo -f"
|
||||||
|
|
||||||
# Prompt for restart if overclock was enabled
|
# Prompt for restart if overclock was enabled
|
||||||
if [ "$NEEDS_RESTART" = "true" ]; then
|
if [ "$NEEDS_RESTART" = "true" ]; then
|
||||||
echo ""
|
echo ""
|
||||||
gum style \
|
gum style --foreground 226 --bold "⚠ Restart required for overclock settings"
|
||||||
--border rounded \
|
|
||||||
--border-foreground 226 \
|
|
||||||
--padding "1 2" \
|
|
||||||
--foreground 226 \
|
|
||||||
"Restart Required" \
|
|
||||||
"" \
|
|
||||||
"Overclock settings require a restart to take effect."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if gum confirm "Restart now?" --default=true; then
|
if gum confirm "Restart now?" --default=true; then
|
||||||
gum style --foreground 82 "Restarting in 3 seconds..."
|
gum style --foreground 82 "Restarting in 3 seconds..."
|
||||||
sleep 3
|
sleep 3
|
||||||
sudo reboot
|
sudo reboot
|
||||||
else
|
else
|
||||||
gum style --foreground 214 "Remember to restart later for overclock to take effect:"
|
gum style --foreground 214 "Run 'sudo reboot' later to apply overclock."
|
||||||
gum style --foreground 245 " sudo reboot"
|
|
||||||
echo ""
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
gum style --foreground 212 --bold "Enjoy Stegasoo!"
|
||||||
|
echo ""
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
#
|
#
|
||||||
# Flash Stegasoo image to SD card
|
# 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]
|
# Usage: ./flash-image.sh <image> [device]
|
||||||
# ./flash-image.sh <image.img> [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
|
set -e
|
||||||
@@ -19,13 +19,27 @@ BOLD='\033[1m'
|
|||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
# Check for required tools
|
# Check for required tools
|
||||||
for cmd in dd pv lsblk; do
|
for cmd in dd lsblk; do
|
||||||
if ! command -v $cmd &> /dev/null; then
|
if ! command -v $cmd &> /dev/null; then
|
||||||
echo -e "${RED}Error: $cmd is required but not installed.${NC}"
|
echo -e "${RED}Error: $cmd is required but not installed.${NC}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
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
|
# Check for root
|
||||||
if [ "$EUID" -ne 0 ]; then
|
if [ "$EUID" -ne 0 ]; then
|
||||||
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
||||||
@@ -34,11 +48,14 @@ fi
|
|||||||
|
|
||||||
# Check for image argument
|
# Check for image argument
|
||||||
if [ -z "$1" ]; then
|
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 ""
|
||||||
echo "Examples:"
|
echo "Examples:"
|
||||||
echo " $0 stegasoo-rpi-20260103.img.xz # auto-detect SD card"
|
echo " $0 stegasoo-rpi-4.1.1.img.zst # auto-detect SD card"
|
||||||
echo " $0 stegasoo-rpi-20260103.img.xz /dev/sdb # specify device"
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -50,6 +67,25 @@ if [ ! -f "$IMAGE" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
# Detect compression
|
||||||
COMPRESSED=false
|
COMPRESSED=false
|
||||||
COMP_TYPE=""
|
COMP_TYPE=""
|
||||||
@@ -202,14 +238,46 @@ echo ""
|
|||||||
echo -e "${GREEN}Flashing image to $SELECTED...${NC}"
|
echo -e "${GREEN}Flashing image to $SELECTED...${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if [ "$COMPRESSED" = true ]; then
|
# 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
|
||||||
|
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
|
case "$COMP_TYPE" in
|
||||||
xz) pv "$IMAGE" | xzcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
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 ;;
|
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 ;;
|
gz) pv "$IMAGE" | zcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
||||||
esac
|
esac
|
||||||
else
|
else
|
||||||
pv "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null
|
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
|
fi
|
||||||
|
|
||||||
echo ""
|
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..."
|
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
|
# Strategy 1: Try the standard patch file
|
||||||
if [ -f "$PATCH_FILE" ]; then
|
if [ -f "$PATCH_FILE" ]; then
|
||||||
echo " Trying patch file..."
|
echo " Trying patch file..."
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
--- a/setup.py
|
--- a/setup.py
|
||||||
+++ b/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')
|
largs.append('-mmacosx-version-min=10.9')
|
||||||
|
|
||||||
if arch == 'x64':
|
if arch == 'x64':
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
elif sys.platform == 'linux':
|
elif sys.platform == 'linux':
|
||||||
cargs.extend(['-w', '-fPIC'])
|
cargs.extend(['-w', '-fPIC'])
|
||||||
|
|
||||||
|
@@ -68,7 +68,7 @@ elif sys.platform == 'linux':
|
||||||
|
cargs.extend(['-w', '-fPIC'])
|
||||||
|
|
||||||
if arch == 'x64':
|
if arch == 'x64':
|
||||||
- cargs.append('-m64')
|
- cargs.append('-m64')
|
||||||
+ pass # ARM64: removed x86-specific -m64 flag
|
+ pass # ARM64: removed x86-specific -m64 flag
|
||||||
|
|||||||
@@ -71,19 +71,20 @@ fi
|
|||||||
|
|
||||||
clear
|
clear
|
||||||
echo ""
|
echo ""
|
||||||
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 "${CYAN} ___ _____ ___ ___ _ ___ ___ ___${NC}"
|
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
|
||||||
echo -e "${CYAN} / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\${NC}"
|
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
||||||
echo -e "${CYAN} \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |${NC}"
|
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\\033[0m"
|
||||||
echo -e "${CYAN} |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/${NC}"
|
echo -e "\033[38;5;220m \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |\033[0m"
|
||||||
echo ""
|
echo -e "\033[38;5;220m |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/\033[0m"
|
||||||
echo -e "${GRAY} * . * . * . * . * . *${NC}"
|
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"
|
||||||
if [ "$SOFT_RESET" = true ]; then
|
if [ "$SOFT_RESET" = true ]; then
|
||||||
echo -e "${CYAN} Soft Reset (Factory)${NC}"
|
echo -e "\033[1;37m Soft Reset (Factory)\033[0m"
|
||||||
else
|
else
|
||||||
echo -e "${CYAN} Sanitize for Imaging${NC}"
|
echo -e "\033[1;37m Sanitize for Imaging\033[0m"
|
||||||
fi
|
fi
|
||||||
|
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if [ "$SOFT_RESET" = true ]; then
|
if [ "$SOFT_RESET" = true ]; then
|
||||||
|
|||||||
57
rpi/setup.sh
57
rpi/setup.sh
@@ -81,15 +81,16 @@ done
|
|||||||
|
|
||||||
clear
|
clear
|
||||||
echo ""
|
echo ""
|
||||||
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 "${CYAN} ___ _____ ___ ___ _ ___ ___ ___${NC}"
|
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
|
||||||
echo -e "${CYAN} / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\${NC}"
|
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
||||||
echo -e "${CYAN} \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |${NC}"
|
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\\033[0m"
|
||||||
echo -e "${CYAN} |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/${NC}"
|
echo -e "\033[38;5;220m \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |\033[0m"
|
||||||
echo ""
|
echo -e "\033[38;5;220m |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/\033[0m"
|
||||||
echo -e "${GRAY} * . * . * . * . * . *${NC}"
|
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 "${CYAN} Raspberry Pi Setup${NC}"
|
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 ""
|
||||||
echo " This will install Stegasoo with full DCT support"
|
echo " This will install Stegasoo with full DCT support"
|
||||||
echo " Estimated time: 15-20 minutes on Pi 5"
|
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 daemon-reload
|
||||||
sudo systemctl enable stegasoo.service
|
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
|
# Add stegasoo venv and rpi scripts to PATH for all users
|
||||||
sudo tee /etc/profile.d/stegasoo-path.sh > /dev/null <<'PATHEOF'
|
sudo tee /etc/profile.d/stegasoo-path.sh > /dev/null <<'PATHEOF'
|
||||||
@@ -303,7 +304,18 @@ if [ -d /opt/stegasoo/rpi ]; then
|
|||||||
fi
|
fi
|
||||||
PATHEOF
|
PATHEOF
|
||||||
sudo chmod 644 /etc/profile.d/stegasoo-path.sh
|
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..."
|
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"
|
STEGASOO_URL="http://$PI_IP:5000"
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "\033[0;36m ___ _____ ___ ___ _ ___ ___ ___\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;36m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
|
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
||||||
echo -e "\033[0;36m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
|
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
||||||
echo -e "\033[0;36m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
|
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
|
||||||
echo ""
|
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;32m●\033[0m Stegasoo is running"
|
||||||
echo -e " \033[0;33m$STEGASOO_URL\033[0m"
|
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 ""
|
echo ""
|
||||||
else
|
else
|
||||||
echo ""
|
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
|
- encode() and decode() now accept channel_key parameter
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "4.0.1"
|
__version__ = "4.1.2"
|
||||||
|
|
||||||
# Core functionality
|
# Core functionality
|
||||||
# Channel key management (v4.0.0)
|
# Channel key management (v4.0.0)
|
||||||
@@ -45,6 +45,7 @@ from .image_utils import (
|
|||||||
|
|
||||||
# Steganography functions
|
# Steganography functions
|
||||||
from .steganography import (
|
from .steganography import (
|
||||||
|
calculate_capacity_by_mode,
|
||||||
compare_modes,
|
compare_modes,
|
||||||
has_dct_support,
|
has_dct_support,
|
||||||
will_fit_by_mode,
|
will_fit_by_mode,
|
||||||
@@ -92,6 +93,7 @@ from .constants import (
|
|||||||
EMBED_MODE_LSB,
|
EMBED_MODE_LSB,
|
||||||
FORMAT_VERSION,
|
FORMAT_VERSION,
|
||||||
LOSSLESS_FORMATS,
|
LOSSLESS_FORMATS,
|
||||||
|
MAX_FILE_PAYLOAD_SIZE,
|
||||||
MAX_IMAGE_PIXELS,
|
MAX_IMAGE_PIXELS,
|
||||||
MAX_MESSAGE_SIZE,
|
MAX_MESSAGE_SIZE,
|
||||||
MAX_PASSPHRASE_WORDS,
|
MAX_PASSPHRASE_WORDS,
|
||||||
@@ -112,12 +114,16 @@ from .exceptions import (
|
|||||||
ExtractionError,
|
ExtractionError,
|
||||||
ImageValidationError,
|
ImageValidationError,
|
||||||
InvalidHeaderError,
|
InvalidHeaderError,
|
||||||
|
InvalidMagicBytesError,
|
||||||
KeyDerivationError,
|
KeyDerivationError,
|
||||||
KeyGenerationError,
|
KeyGenerationError,
|
||||||
KeyPasswordError,
|
KeyPasswordError,
|
||||||
KeyValidationError,
|
KeyValidationError,
|
||||||
MessageValidationError,
|
MessageValidationError,
|
||||||
|
ModeMismatchError,
|
||||||
|
NoDataFoundError,
|
||||||
PinValidationError,
|
PinValidationError,
|
||||||
|
ReedSolomonError,
|
||||||
SecurityFactorError,
|
SecurityFactorError,
|
||||||
SteganographyError,
|
SteganographyError,
|
||||||
StegasooError,
|
StegasooError,
|
||||||
@@ -145,6 +151,7 @@ from .validation import (
|
|||||||
MIN_MESSAGE_LENGTH = 1
|
MIN_MESSAGE_LENGTH = 1
|
||||||
MAX_MESSAGE_LENGTH = MAX_MESSAGE_SIZE
|
MAX_MESSAGE_LENGTH = MAX_MESSAGE_SIZE
|
||||||
MAX_PAYLOAD_SIZE = MAX_MESSAGE_SIZE
|
MAX_PAYLOAD_SIZE = MAX_MESSAGE_SIZE
|
||||||
|
# MAX_FILE_PAYLOAD_SIZE imported from constants above
|
||||||
SUPPORTED_IMAGE_FORMATS = LOSSLESS_FORMATS
|
SUPPORTED_IMAGE_FORMATS = LOSSLESS_FORMATS
|
||||||
LSB_BYTES_PER_PIXEL = 3 / 8
|
LSB_BYTES_PER_PIXEL = 3 / 8
|
||||||
DCT_BYTES_PER_PIXEL = 0.125
|
DCT_BYTES_PER_PIXEL = 0.125
|
||||||
@@ -184,6 +191,7 @@ __all__ = [
|
|||||||
"has_argon2",
|
"has_argon2",
|
||||||
# Steganography
|
# Steganography
|
||||||
"has_dct_support",
|
"has_dct_support",
|
||||||
|
"calculate_capacity_by_mode",
|
||||||
"compare_modes",
|
"compare_modes",
|
||||||
"will_fit_by_mode",
|
"will_fit_by_mode",
|
||||||
# QR utilities
|
# QR utilities
|
||||||
@@ -232,6 +240,10 @@ __all__ = [
|
|||||||
"ExtractionError",
|
"ExtractionError",
|
||||||
"EmbeddingError",
|
"EmbeddingError",
|
||||||
"InvalidHeaderError",
|
"InvalidHeaderError",
|
||||||
|
"InvalidMagicBytesError",
|
||||||
|
"ReedSolomonError",
|
||||||
|
"NoDataFoundError",
|
||||||
|
"ModeMismatchError",
|
||||||
# Constants
|
# Constants
|
||||||
"FORMAT_VERSION",
|
"FORMAT_VERSION",
|
||||||
"MIN_PASSPHRASE_WORDS",
|
"MIN_PASSPHRASE_WORDS",
|
||||||
@@ -244,6 +256,7 @@ __all__ = [
|
|||||||
"MAX_MESSAGE_LENGTH",
|
"MAX_MESSAGE_LENGTH",
|
||||||
"MAX_MESSAGE_SIZE",
|
"MAX_MESSAGE_SIZE",
|
||||||
"MAX_PAYLOAD_SIZE",
|
"MAX_PAYLOAD_SIZE",
|
||||||
|
"MAX_FILE_PAYLOAD_SIZE",
|
||||||
"MIN_IMAGE_PIXELS",
|
"MIN_IMAGE_PIXELS",
|
||||||
"MAX_IMAGE_PIXELS",
|
"MAX_IMAGE_PIXELS",
|
||||||
"SUPPORTED_IMAGE_FORMATS",
|
"SUPPORTED_IMAGE_FORMATS",
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ def encode(
|
|||||||
stegasoo encode photo.png -r ref.jpg -f secret.pdf -o encoded.png
|
stegasoo encode photo.png -r ref.jpg -f secret.pdf -o encoded.png
|
||||||
"""
|
"""
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from .encode import encode as stegasoo_encode
|
from .encode import encode as stegasoo_encode
|
||||||
from .encode import encode_file as stegasoo_encode_file
|
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
|
stegasoo admin recover --db /path/to/stegasoo.db
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
|
|
||||||
from .recovery import verify_recovery_key
|
from .recovery import verify_recovery_key
|
||||||
|
|
||||||
# Try default paths if not specified
|
# Try default paths if not specified
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from pathlib import Path
|
|||||||
# VERSION
|
# VERSION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
__version__ = "4.1.0"
|
__version__ = "4.1.2"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# FILE FORMAT
|
# FILE FORMAT
|
||||||
|
|||||||
@@ -54,6 +54,34 @@ except ImportError:
|
|||||||
HAS_JPEGIO = False
|
HAS_JPEGIO = False
|
||||||
jio = None
|
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
|
# CONSTANTS
|
||||||
@@ -186,7 +214,7 @@ def has_jpegio_support() -> bool:
|
|||||||
|
|
||||||
# Check for reedsolo availability
|
# Check for reedsolo availability
|
||||||
try:
|
try:
|
||||||
from reedsolo import RSCodec, ReedSolomonError
|
from reedsolo import ReedSolomonError, RSCodec
|
||||||
|
|
||||||
HAS_REEDSOLO = True
|
HAS_REEDSOLO = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -214,7 +242,7 @@ def _rs_decode(data: bytes) -> bytes:
|
|||||||
pass # Errors were corrected
|
pass # Errors were corrected
|
||||||
return bytes(decoded)
|
return bytes(decoded)
|
||||||
except ReedSolomonError as e:
|
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)
|
magic, version, flags, length = struct.unpack(">4sBBI", header_bytes)
|
||||||
|
|
||||||
if magic != DCT_MAGIC:
|
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
|
return version, flags, length
|
||||||
|
|
||||||
@@ -461,7 +489,7 @@ def _jpegio_parse_header(header_bytes: bytes) -> tuple[int, int, int]:
|
|||||||
raise ValueError("Insufficient header data")
|
raise ValueError("Insufficient header data")
|
||||||
magic, version, flags, length = struct.unpack(">4sBBI", header_bytes[:HEADER_SIZE])
|
magic, version, flags, length = struct.unpack(">4sBBI", header_bytes[:HEADER_SIZE])
|
||||||
if magic != JPEGIO_MAGIC:
|
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
|
return version, flags, length
|
||||||
|
|
||||||
|
|
||||||
@@ -556,6 +584,7 @@ def embed_in_dct(
|
|||||||
seed: bytes,
|
seed: bytes,
|
||||||
output_format: str = OUTPUT_FORMAT_PNG,
|
output_format: str = OUTPUT_FORMAT_PNG,
|
||||||
color_mode: str = "color",
|
color_mode: str = "color",
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> tuple[bytes, DCTEmbedStats]:
|
) -> tuple[bytes, DCTEmbedStats]:
|
||||||
"""Embed data using DCT coefficient modification."""
|
"""Embed data using DCT coefficient modification."""
|
||||||
if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG):
|
if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG):
|
||||||
@@ -565,10 +594,12 @@ def embed_in_dct(
|
|||||||
color_mode = "color"
|
color_mode = "color"
|
||||||
|
|
||||||
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
|
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
|
||||||
return _embed_jpegio(data, carrier_image, seed, color_mode)
|
return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file)
|
||||||
|
|
||||||
_check_scipy()
|
_check_scipy()
|
||||||
return _embed_scipy_dct_safe(data, carrier_image, seed, output_format, color_mode)
|
return _embed_scipy_dct_safe(
|
||||||
|
data, carrier_image, seed, output_format, color_mode, progress_file
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _embed_scipy_dct_safe(
|
def _embed_scipy_dct_safe(
|
||||||
@@ -577,6 +608,7 @@ def _embed_scipy_dct_safe(
|
|||||||
seed: bytes,
|
seed: bytes,
|
||||||
output_format: str,
|
output_format: str,
|
||||||
color_mode: str = "color",
|
color_mode: str = "color",
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> tuple[bytes, DCTEmbedStats]:
|
) -> tuple[bytes, DCTEmbedStats]:
|
||||||
"""
|
"""
|
||||||
Embed using scipy DCT with safe memory handling.
|
Embed using scipy DCT with safe memory handling.
|
||||||
@@ -639,7 +671,7 @@ def _embed_scipy_dct_safe(
|
|||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
# Embed in Y channel
|
# Embed in Y channel
|
||||||
Y_embedded = _embed_in_channel_safe(Y_padded, bits, block_order, blocks_x)
|
Y_embedded = _embed_in_channel_safe(Y_padded, bits, block_order, blocks_x, progress_file)
|
||||||
del Y_padded
|
del Y_padded
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
@@ -663,7 +695,7 @@ def _embed_scipy_dct_safe(
|
|||||||
del image
|
del image
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
embedded = _embed_in_channel_safe(padded, bits, block_order, blocks_x)
|
embedded = _embed_in_channel_safe(padded, bits, block_order, blocks_x, progress_file)
|
||||||
del padded
|
del padded
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
@@ -696,6 +728,7 @@ def _embed_in_channel_safe(
|
|||||||
bits: list,
|
bits: list,
|
||||||
block_order: list,
|
block_order: list,
|
||||||
blocks_x: int,
|
blocks_x: int,
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""
|
"""
|
||||||
Embed bits in channel using safe DCT operations.
|
Embed bits in channel using safe DCT operations.
|
||||||
@@ -708,8 +741,9 @@ def _embed_in_channel_safe(
|
|||||||
result = np.array(channel, dtype=np.float64, copy=True, order="C")
|
result = np.array(channel, dtype=np.float64, copy=True, order="C")
|
||||||
|
|
||||||
bit_idx = 0
|
bit_idx = 0
|
||||||
|
total_blocks = len(block_order)
|
||||||
|
|
||||||
for block_num in block_order:
|
for block_idx, block_num in enumerate(block_order):
|
||||||
if bit_idx >= len(bits):
|
if bit_idx >= len(bits):
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -745,6 +779,14 @@ def _embed_in_channel_safe(
|
|||||||
# Clean up this iteration
|
# Clean up this iteration
|
||||||
del block, dct_block, modified_block
|
del block, dct_block, modified_block
|
||||||
|
|
||||||
|
# Report progress periodically
|
||||||
|
if progress_file and block_idx % PROGRESS_INTERVAL == 0:
|
||||||
|
_write_progress(progress_file, block_idx, total_blocks, "embedding")
|
||||||
|
|
||||||
|
# Final progress update
|
||||||
|
if progress_file:
|
||||||
|
_write_progress(progress_file, total_blocks, total_blocks, "finalizing")
|
||||||
|
|
||||||
# Force garbage collection
|
# Force garbage collection
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
@@ -801,6 +843,7 @@ def _embed_jpegio(
|
|||||||
carrier_image: bytes,
|
carrier_image: bytes,
|
||||||
seed: bytes,
|
seed: bytes,
|
||||||
color_mode: str = "color",
|
color_mode: str = "color",
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> tuple[bytes, DCTEmbedStats]:
|
) -> tuple[bytes, DCTEmbedStats]:
|
||||||
"""Embed using jpegio for proper JPEG coefficient modification."""
|
"""Embed using jpegio for proper JPEG coefficient modification."""
|
||||||
import os
|
import os
|
||||||
@@ -858,6 +901,9 @@ def _embed_jpegio(
|
|||||||
)
|
)
|
||||||
|
|
||||||
coefs_used = 0
|
coefs_used = 0
|
||||||
|
total_bits = len(bits)
|
||||||
|
progress_interval = max(total_bits // 20, 100) # Report ~20 times or every 100 bits
|
||||||
|
|
||||||
for bit_idx, pos_idx in enumerate(order):
|
for bit_idx, pos_idx in enumerate(order):
|
||||||
if bit_idx >= len(bits):
|
if bit_idx >= len(bits):
|
||||||
break
|
break
|
||||||
@@ -873,6 +919,14 @@ def _embed_jpegio(
|
|||||||
|
|
||||||
coefs_used += 1
|
coefs_used += 1
|
||||||
|
|
||||||
|
# Report progress periodically
|
||||||
|
if progress_file and bit_idx % progress_interval == 0:
|
||||||
|
_write_progress(progress_file, bit_idx, total_bits, "embedding")
|
||||||
|
|
||||||
|
# Final progress before save
|
||||||
|
if progress_file:
|
||||||
|
_write_progress(progress_file, total_bits, total_bits, "saving")
|
||||||
|
|
||||||
jio.write(jpeg, output_path)
|
jio.write(jpeg, output_path)
|
||||||
|
|
||||||
with open(output_path, "rb") as f:
|
with open(output_path, "rb") as f:
|
||||||
@@ -968,8 +1022,8 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
total_needed = (HEADER_SIZE + data_length) * 8
|
total_needed = (HEADER_SIZE + data_length) * 8
|
||||||
if len(all_bits) >= total_needed:
|
if len(all_bits) >= total_needed:
|
||||||
break
|
break
|
||||||
except ValueError:
|
except (ValueError, InvalidMagicBytesError):
|
||||||
pass
|
pass # RS-protected format has length prefix first, not magic bytes
|
||||||
|
|
||||||
del padded
|
del padded
|
||||||
gc.collect()
|
gc.collect()
|
||||||
@@ -994,6 +1048,7 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
|
|
||||||
# Count occurrences of each unique copy
|
# Count occurrences of each unique copy
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
counter = Counter(copies)
|
counter = Counter(copies)
|
||||||
best_header, count = counter.most_common(1)[0]
|
best_header, count = counter.most_common(1)[0]
|
||||||
|
|
||||||
@@ -1006,9 +1061,13 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
|
|
||||||
# Sanity check: both lengths should be reasonable
|
# Sanity check: both lengths should be reasonable
|
||||||
max_reasonable = (len(all_bits) // 8) - RS_LENGTH_PREFIX_SIZE
|
max_reasonable = (len(all_bits) // 8) - RS_LENGTH_PREFIX_SIZE
|
||||||
if (raw_payload_length > 0 and raw_payload_length <= max_reasonable and
|
if (
|
||||||
rs_encoded_length > 0 and rs_encoded_length <= max_reasonable and
|
raw_payload_length > 0
|
||||||
rs_encoded_length >= raw_payload_length):
|
and raw_payload_length <= max_reasonable
|
||||||
|
and rs_encoded_length > 0
|
||||||
|
and rs_encoded_length <= max_reasonable
|
||||||
|
and rs_encoded_length >= raw_payload_length
|
||||||
|
):
|
||||||
# This looks like RS-protected format
|
# This looks like RS-protected format
|
||||||
total_bits_needed = (RS_LENGTH_PREFIX_SIZE + rs_encoded_length) * 8
|
total_bits_needed = (RS_LENGTH_PREFIX_SIZE + rs_encoded_length) * 8
|
||||||
|
|
||||||
@@ -1085,6 +1144,7 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
|
|
||||||
# Extract 3 copies and use majority voting
|
# Extract 3 copies and use majority voting
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
copies = []
|
copies = []
|
||||||
for i in range(RS_LENGTH_COPIES):
|
for i in range(RS_LENGTH_COPIES):
|
||||||
start = i * RS_LENGTH_HEADER_SIZE
|
start = i * RS_LENGTH_HEADER_SIZE
|
||||||
@@ -1101,9 +1161,13 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
|||||||
|
|
||||||
# Sanity check
|
# Sanity check
|
||||||
max_reasonable = (len(all_positions) // 8) - RS_LENGTH_PREFIX_SIZE
|
max_reasonable = (len(all_positions) // 8) - RS_LENGTH_PREFIX_SIZE
|
||||||
if (raw_payload_length > 0 and raw_payload_length <= max_reasonable and
|
if (
|
||||||
rs_encoded_length > 0 and rs_encoded_length <= max_reasonable and
|
raw_payload_length > 0
|
||||||
rs_encoded_length >= raw_payload_length):
|
and raw_payload_length <= max_reasonable
|
||||||
|
and rs_encoded_length > 0
|
||||||
|
and rs_encoded_length <= max_reasonable
|
||||||
|
and rs_encoded_length >= raw_payload_length
|
||||||
|
):
|
||||||
total_bits_needed = (RS_LENGTH_PREFIX_SIZE + rs_encoded_length) * 8
|
total_bits_needed = (RS_LENGTH_PREFIX_SIZE + rs_encoded_length) * 8
|
||||||
|
|
||||||
if len(all_positions) >= total_bits_needed:
|
if len(all_positions) >= total_bits_needed:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ def encode(
|
|||||||
dct_output_format: str = "png",
|
dct_output_format: str = "png",
|
||||||
dct_color_mode: str = "color",
|
dct_color_mode: str = "color",
|
||||||
channel_key: str | bool | None = None,
|
channel_key: str | bool | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> EncodeResult:
|
) -> EncodeResult:
|
||||||
"""
|
"""
|
||||||
Encode a message or file into an image.
|
Encode a message or file into an image.
|
||||||
@@ -118,6 +119,7 @@ def encode(
|
|||||||
embed_mode=embed_mode,
|
embed_mode=embed_mode,
|
||||||
dct_output_format=dct_output_format,
|
dct_output_format=dct_output_format,
|
||||||
dct_color_mode=dct_color_mode,
|
dct_color_mode=dct_color_mode,
|
||||||
|
progress_file=progress_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate filename
|
# Generate filename
|
||||||
|
|||||||
@@ -133,6 +133,30 @@ class InvalidHeaderError(SteganographyError):
|
|||||||
pass
|
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
|
# FILE ERRORS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -39,6 +39,31 @@ from .debug import debug
|
|||||||
from .exceptions import CapacityError, EmbeddingError
|
from .exceptions import CapacityError, EmbeddingError
|
||||||
from .models import EmbedStats, FilePayload
|
from .models import EmbedStats, FilePayload
|
||||||
|
|
||||||
|
# Progress reporting interval
|
||||||
|
PROGRESS_INTERVAL = 1000 # Write every N pixels for LSB
|
||||||
|
|
||||||
|
|
||||||
|
def _write_progress(progress_file: str | None, current: int, total: int, phase: str = "embedding"):
|
||||||
|
"""Write progress to file for frontend polling."""
|
||||||
|
if progress_file is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
|
||||||
|
with open(progress_file, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"current": current,
|
||||||
|
"total": total,
|
||||||
|
"percent": round((current / total) * 100, 1) if total > 0 else 0,
|
||||||
|
"phase": phase,
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Don't let progress writing break encoding
|
||||||
|
|
||||||
|
|
||||||
# Lossless formats that preserve LSB data
|
# Lossless formats that preserve LSB data
|
||||||
LOSSLESS_FORMATS = {"PNG", "BMP", "TIFF"}
|
LOSSLESS_FORMATS = {"PNG", "BMP", "TIFF"}
|
||||||
|
|
||||||
@@ -526,6 +551,7 @@ def embed_in_image(
|
|||||||
embed_mode: str = EMBED_MODE_LSB,
|
embed_mode: str = EMBED_MODE_LSB,
|
||||||
dct_output_format: str = DCT_OUTPUT_PNG,
|
dct_output_format: str = DCT_OUTPUT_PNG,
|
||||||
dct_color_mode: str = "color",
|
dct_color_mode: str = "color",
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> tuple[bytes, Union[EmbedStats, "DCTEmbedStats"], str]:
|
) -> tuple[bytes, Union[EmbedStats, "DCTEmbedStats"], str]:
|
||||||
"""
|
"""
|
||||||
Embed data into an image using specified mode.
|
Embed data into an image using specified mode.
|
||||||
@@ -579,6 +605,7 @@ def embed_in_image(
|
|||||||
pixel_key,
|
pixel_key,
|
||||||
output_format=dct_output_format,
|
output_format=dct_output_format,
|
||||||
color_mode=dct_color_mode,
|
color_mode=dct_color_mode,
|
||||||
|
progress_file=progress_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Determine extension based on output format
|
# Determine extension based on output format
|
||||||
@@ -594,7 +621,7 @@ def embed_in_image(
|
|||||||
return stego_bytes, dct_stats, ext
|
return stego_bytes, dct_stats, ext
|
||||||
|
|
||||||
# LSB MODE
|
# LSB MODE
|
||||||
return _embed_lsb(data, image_data, pixel_key, bits_per_channel, output_format)
|
return _embed_lsb(data, image_data, pixel_key, bits_per_channel, output_format, progress_file)
|
||||||
|
|
||||||
|
|
||||||
def _embed_lsb(
|
def _embed_lsb(
|
||||||
@@ -603,6 +630,7 @@ def _embed_lsb(
|
|||||||
pixel_key: bytes,
|
pixel_key: bytes,
|
||||||
bits_per_channel: int = 1,
|
bits_per_channel: int = 1,
|
||||||
output_format: str | None = None,
|
output_format: str | None = None,
|
||||||
|
progress_file: str | None = None,
|
||||||
) -> tuple[bytes, EmbedStats, str]:
|
) -> tuple[bytes, EmbedStats, str]:
|
||||||
"""
|
"""
|
||||||
Embed data using LSB steganography (internal implementation).
|
Embed data using LSB steganography (internal implementation).
|
||||||
@@ -659,8 +687,9 @@ def _embed_lsb(
|
|||||||
|
|
||||||
bit_idx = 0
|
bit_idx = 0
|
||||||
modified_pixels = 0
|
modified_pixels = 0
|
||||||
|
total_pixels_to_process = len(selected_indices)
|
||||||
|
|
||||||
for pixel_idx in selected_indices:
|
for progress_idx, pixel_idx in enumerate(selected_indices):
|
||||||
if bit_idx >= len(binary_data):
|
if bit_idx >= len(binary_data):
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -690,6 +719,16 @@ def _embed_lsb(
|
|||||||
new_pixels[pixel_idx] = (r, g, b)
|
new_pixels[pixel_idx] = (r, g, b)
|
||||||
modified_pixels += 1
|
modified_pixels += 1
|
||||||
|
|
||||||
|
# Report progress periodically
|
||||||
|
if progress_file and progress_idx % PROGRESS_INTERVAL == 0:
|
||||||
|
_write_progress(progress_file, progress_idx, total_pixels_to_process, "embedding")
|
||||||
|
|
||||||
|
# Final progress before save
|
||||||
|
if progress_file:
|
||||||
|
_write_progress(
|
||||||
|
progress_file, total_pixels_to_process, total_pixels_to_process, "saving"
|
||||||
|
)
|
||||||
|
|
||||||
debug.print(f"Modified {modified_pixels} pixels (out of {len(selected_indices)} selected)")
|
debug.print(f"Modified {modified_pixels} pixels (out of {len(selected_indices)} selected)")
|
||||||
|
|
||||||
stego_img = Image.new("RGB", img.size)
|
stego_img = Image.new("RGB", img.size)
|
||||||
|
|||||||
@@ -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