Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65d9e6682 | ||
|
|
eeb44eae94 | ||
|
|
26d4b82c91 | ||
|
|
7efeaf02e8 | ||
|
|
925fb05cbd | ||
|
|
29a02265a1 | ||
|
|
d58f3c6fb6 | ||
|
|
cc46993d80 | ||
|
|
893a044eaa | ||
|
|
9f03b69408 | ||
|
|
cce2007c6e | ||
|
|
52f43d3a86 | ||
|
|
85a7092d55 | ||
|
|
4b37a81087 | ||
|
|
31941dc3f5 | ||
|
|
9a7e4ddce7 | ||
|
|
0424dd34d5 | ||
|
|
2127b916f3 | ||
|
|
f8e65890e5 | ||
|
|
5861ab0e1e | ||
|
|
5309a08aaf | ||
|
|
d8fb95b68e | ||
|
|
c0b6865790 | ||
|
|
6e7ae0d6f9 | ||
|
|
6a5b12f98e | ||
|
|
d8eb7b0160 | ||
|
|
962c04084b | ||
|
|
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 |
53
.dockerignore
Normal file
53
.dockerignore
Normal file
@@ -0,0 +1,53 @@
|
||||
# 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.xz
|
||||
*.img.zst
|
||||
*.img.zst.zip
|
||||
pishrink.sh
|
||||
|
||||
# Docs
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
*.tmp
|
||||
.DS_Store
|
||||
|
||||
# Dev scripts and old files
|
||||
scripts/
|
||||
old_files/
|
||||
*_old
|
||||
*_old.*
|
||||
*.bak
|
||||
*.orig
|
||||
|
||||
# Temp files
|
||||
frontends/web/temp_files/
|
||||
*.db
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -64,16 +64,32 @@ htmlcov/
|
||||
# Output test files.
|
||||
test_data/*.png
|
||||
|
||||
# Dev scripts (local convenience scripts)
|
||||
scripts/
|
||||
# Dev scripts (local convenience scripts - except validate-release.sh)
|
||||
scripts/*
|
||||
!scripts/validate-release.sh
|
||||
|
||||
# Web UI auth database and SSL certs
|
||||
frontends/web/instance/
|
||||
frontends/web/certs/
|
||||
rpi/inject-wifi.sh
|
||||
|
||||
# Tests (private)
|
||||
tests/
|
||||
|
||||
# RPi image build artifacts
|
||||
*.img
|
||||
*.img.xz
|
||||
*.img.zst
|
||||
pishrink.sh
|
||||
*.img.zst.zip
|
||||
|
||||
# Temp file storage
|
||||
frontends/web/temp_files/
|
||||
rpi/config.json
|
||||
|
||||
# Pre-built Pi tarballs and images (release assets, too large for git)
|
||||
rpi/stegasoo-pi-arm64.tar.zst
|
||||
rpi/stegasoo-pi-arm64.tar.zst.zip
|
||||
rpi/stegasoo-venv-pi-arm64.tar.zst
|
||||
rpi/*.img
|
||||
rpi/*.img.zst
|
||||
rpi/*.img.zst.zip
|
||||
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -5,6 +5,44 @@ All notable changes to Stegasoo will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [4.1.3] - 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
|
||||
- **Custom SSL Documentation**: Guide for replacing certs, Let's Encrypt setup
|
||||
|
||||
### 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
|
||||
- **SSL certificate generation**: Wizard and setup now generate certs when HTTPS enabled
|
||||
- DCT decode reliability improvements
|
||||
- Fixed `gum --inline` flag compatibility (not supported in all versions)
|
||||
- Wizard banner alignment and spacing issues
|
||||
- Better error handling in app.py for SSL failures
|
||||
|
||||
## [4.1.0] - 2026-01-04
|
||||
|
||||
### Added
|
||||
@@ -142,6 +180,8 @@ and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
- CLI interface
|
||||
- Basic PIN authentication
|
||||
|
||||
[4.1.3]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.3
|
||||
[4.1.0]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.2...v4.1.0
|
||||
[4.0.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.1...v4.0.2
|
||||
[4.0.1]: https://github.com/adlee-was-taken/stegasoo/compare/v4.0.0...v4.0.1
|
||||
[4.0.0]: https://github.com/adlee-was-taken/stegasoo/compare/v3.2.0...v4.0.0
|
||||
|
||||
@@ -6,7 +6,7 @@ Thank you for your interest in contributing to Stegasoo! This document provides
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.10 or higher
|
||||
- Python 3.10 - 3.12
|
||||
- Git
|
||||
- Docker (optional, for container testing)
|
||||
|
||||
|
||||
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,9 @@ COPY src/ src/
|
||||
COPY data/ data/
|
||||
COPY frontends/web/ frontends/web/
|
||||
|
||||
# Create upload directory
|
||||
RUN mkdir -p /tmp/stego_uploads
|
||||
# Create upload directory and instance directories (for volumes)
|
||||
# temp_files is for multi-worker temp file sharing
|
||||
RUN mkdir -p /tmp/stego_uploads /app/frontends/web/instance /app/frontends/web/certs /app/frontends/web/temp_files
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads
|
||||
|
||||
79
INSTALL.md
79
INSTALL.md
@@ -553,6 +553,85 @@ print(f'jpegio: {has_jpegio_support()}')
|
||||
|
||||
---
|
||||
|
||||
## Custom SSL Certificates
|
||||
|
||||
By default, Stegasoo generates a self-signed certificate for HTTPS. To use your own certificate (e.g., from Let's Encrypt or your organization's CA):
|
||||
|
||||
### Replace Self-Signed Certificates
|
||||
|
||||
```bash
|
||||
# Stop the service
|
||||
sudo systemctl stop stegasoo
|
||||
|
||||
# Backup existing certs (optional)
|
||||
mv /opt/stegasoo/frontends/web/certs /opt/stegasoo/frontends/web/certs.bak
|
||||
|
||||
# Create new certs directory
|
||||
mkdir -p /opt/stegasoo/frontends/web/certs
|
||||
|
||||
# Copy your certificates (adjust paths as needed)
|
||||
cp /path/to/your/certificate.crt /opt/stegasoo/frontends/web/certs/server.crt
|
||||
cp /path/to/your/private.key /opt/stegasoo/frontends/web/certs/server.key
|
||||
|
||||
# Set permissions (key must be readable by service user)
|
||||
chmod 600 /opt/stegasoo/frontends/web/certs/server.key
|
||||
chown -R $(whoami):$(whoami) /opt/stegasoo/frontends/web/certs
|
||||
|
||||
# Start the service
|
||||
sudo systemctl start stegasoo
|
||||
```
|
||||
|
||||
### Generate New Self-Signed Certificate
|
||||
|
||||
If your certificate expires or you need to regenerate:
|
||||
|
||||
```bash
|
||||
# Stop service
|
||||
sudo systemctl stop stegasoo
|
||||
|
||||
# Generate new cert with SANs
|
||||
CERT_DIR="/opt/stegasoo/frontends/web/certs"
|
||||
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
||||
HOSTNAME=$(hostname)
|
||||
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout "$CERT_DIR/server.key" \
|
||||
-out "$CERT_DIR/server.crt" \
|
||||
-days 365 -nodes \
|
||||
-subj "/O=Stegasoo/CN=$HOSTNAME" \
|
||||
-addext "subjectAltName=DNS:$HOSTNAME,DNS:$HOSTNAME.local,DNS:localhost,IP:$LOCAL_IP,IP:127.0.0.1"
|
||||
|
||||
chmod 600 "$CERT_DIR/server.key"
|
||||
|
||||
# Start service
|
||||
sudo systemctl start stegasoo
|
||||
```
|
||||
|
||||
### Let's Encrypt with Certbot
|
||||
|
||||
For publicly accessible servers:
|
||||
|
||||
```bash
|
||||
# Install certbot
|
||||
sudo apt install certbot
|
||||
|
||||
# Get certificate (standalone mode)
|
||||
sudo certbot certonly --standalone -d yourdomain.com
|
||||
|
||||
# Copy to Stegasoo
|
||||
sudo cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem /opt/stegasoo/frontends/web/certs/server.crt
|
||||
sudo cp /etc/letsencrypt/live/yourdomain.com/privkey.pem /opt/stegasoo/frontends/web/certs/server.key
|
||||
sudo chown $(whoami):$(whoami) /opt/stegasoo/frontends/web/certs/*
|
||||
sudo chmod 600 /opt/stegasoo/frontends/web/certs/server.key
|
||||
|
||||
# Restart
|
||||
sudo systemctl restart stegasoo
|
||||
```
|
||||
|
||||
**Note:** Set up a cron job or systemd timer to copy renewed certificates and restart Stegasoo.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Check Installation
|
||||
|
||||
165
PLAN-4.1.2.md
165
PLAN-4.1.2.md
@@ -7,7 +7,7 @@ Polish and UX improvements after the 4.1.1 stability release.
|
||||
|
||||
## 1. Real Progress Bar for Encode/Decode
|
||||
|
||||
**Status:** Planned
|
||||
**Status:** Done
|
||||
|
||||
**Problem:** Users see elapsed time but no indication of how far along the operation is. Long DCT encodes on Pi can take 2-3 minutes with no feedback.
|
||||
|
||||
@@ -50,69 +50,33 @@ Polish and UX improvements after the 4.1.1 stability release.
|
||||
|
||||
## 2. Granular Decode Error Messages
|
||||
|
||||
**Status:** Planned
|
||||
**Status:** Done
|
||||
|
||||
**Problem:** Decode failures show generic "Decryption failed" - users don't know if it's wrong photo, wrong passphrase, wrong PIN, corrupted image, or format mismatch.
|
||||
|
||||
**Solution:** Bubble up specific error types from library to UI
|
||||
|
||||
### Library Level (`src/stegasoo/`)
|
||||
### Implementation
|
||||
- Added new exceptions: InvalidMagicBytesError, ReedSolomonError, NoDataFoundError, ModeMismatchError
|
||||
- DCT decode now raises InvalidMagicBytesError for wrong magic bytes
|
||||
- DCT decode now raises ReedSolomonError (renamed from reedsolo's) for corruption
|
||||
- app.py catches specific exceptions with user-friendly messages:
|
||||
- Invalid magic → "Try a different mode (LSB/DCT)"
|
||||
- RS error → "Image too corrupted, may have been re-saved"
|
||||
- Invalid header → "Image may have been modified"
|
||||
- Decryption error → "Wrong credentials"
|
||||
|
||||
1. **Custom exception classes:**
|
||||
```python
|
||||
class StegasooError(Exception): pass
|
||||
class InvalidMagicBytesError(StegasooError): pass
|
||||
class DecryptionError(StegasooError): pass
|
||||
class ReedSolomonError(StegasooError): pass
|
||||
class PayloadTooLargeError(StegasooError): pass
|
||||
class InvalidHeaderError(StegasooError): pass
|
||||
class NoDataFoundError(StegasooError): pass
|
||||
```
|
||||
|
||||
2. **Raise specific exceptions** in decode paths:
|
||||
- Magic bytes mismatch → "Not a Stegasoo image or wrong mode (LSB/DCT)"
|
||||
- RS decode failure → "Image corrupted beyond repair"
|
||||
- AES-GCM auth fail → "Wrong credentials (photo/passphrase/PIN)"
|
||||
- Header parse fail → "Invalid or corrupted header"
|
||||
- No stego data → "No hidden data found in image"
|
||||
|
||||
3. **Error codes** for programmatic handling:
|
||||
```python
|
||||
class ErrorCode(Enum):
|
||||
INVALID_MAGIC = "invalid_magic"
|
||||
DECRYPTION_FAILED = "decryption_failed"
|
||||
RS_FAILED = "rs_failed"
|
||||
# etc.
|
||||
```
|
||||
|
||||
### Web UI Level (`frontends/web/`)
|
||||
|
||||
1. **app.py** - Catch specific exceptions, return error type:
|
||||
```python
|
||||
except InvalidMagicBytesError:
|
||||
flash("This doesn't appear to be a Stegasoo image, or mode mismatch", "danger")
|
||||
except DecryptionError:
|
||||
flash("Wrong credentials - check reference photo, passphrase, and PIN", "warning")
|
||||
```
|
||||
|
||||
2. **decode.html** - Error-specific help text:
|
||||
- Wrong credentials → "Double-check your reference photo matches exactly"
|
||||
- Corrupted → "Image may have been re-saved or compressed"
|
||||
- Mode mismatch → "Try switching between Auto/DCT/LSB"
|
||||
|
||||
### Files to Modify
|
||||
- `src/stegasoo/__init__.py` (export exceptions)
|
||||
- `src/stegasoo/exceptions.py` (new file)
|
||||
- `src/stegasoo/dct_steganography.py`
|
||||
- `src/stegasoo/steganography.py` (LSB)
|
||||
- `frontends/web/app.py`
|
||||
- `frontends/web/templates/decode.html`
|
||||
### Files Modified
|
||||
- `src/stegasoo/exceptions.py` (new exceptions)
|
||||
- `src/stegasoo/__init__.py` (exports)
|
||||
- `src/stegasoo/dct_steganography.py` (raise specific exceptions)
|
||||
- `frontends/web/app.py` (catch and display)
|
||||
|
||||
---
|
||||
|
||||
## 3. Mobile-Responsive Polish
|
||||
|
||||
**Status:** Planned
|
||||
**Status:** Done
|
||||
|
||||
**Problem:** UI works on mobile but has rough edges - cramped buttons, hard-to-tap targets, awkward layouts on small screens.
|
||||
|
||||
@@ -167,21 +131,24 @@ Polish and UX improvements after the 4.1.1 stability release.
|
||||
|
||||
## 4. Forced First-Login Setup
|
||||
|
||||
**Status:** Planned
|
||||
**Status:** Done
|
||||
|
||||
**Problem:** Users can navigate the app without creating an admin account first. Should force password setup before anything else.
|
||||
|
||||
**Solution:** Middleware/decorator that redirects to setup page if no users exist.
|
||||
|
||||
### Files to Modify
|
||||
- `frontends/web/app.py` (add before_request check)
|
||||
- `frontends/web/templates/setup.html` (ensure it blocks other nav)
|
||||
### Implementation
|
||||
- Added `@app.before_request` hook that redirects to /setup if no users exist
|
||||
- Skips redirect for static files and setup-related routes
|
||||
|
||||
### Files Modified
|
||||
- `frontends/web/app.py` (added require_setup before_request hook)
|
||||
|
||||
---
|
||||
|
||||
## 5. Dropzone UX Fixes
|
||||
|
||||
**Status:** Planned
|
||||
**Status:** Done
|
||||
|
||||
**Problem:** Dropzone has some interaction bugs:
|
||||
- Dropzone doesn't clear properly if first QR image fails
|
||||
@@ -189,33 +156,95 @@ Polish and UX improvements after the 4.1.1 stability release.
|
||||
|
||||
**Solution:** Fix JS event handling and state management
|
||||
|
||||
### Files to Modify
|
||||
### Implementation
|
||||
- Added click handler on preview images to trigger file input
|
||||
- Made entire drop zone clickable (not just label)
|
||||
- QR zone now resets after 2 seconds on error, allowing retry
|
||||
- Clear file input on QR error so same file can be re-selected
|
||||
|
||||
### Files Modified
|
||||
- `frontends/web/static/js/stegasoo.js`
|
||||
- `frontends/web/static/css/style.css` (clickable preview)
|
||||
|
||||
---
|
||||
|
||||
## 6. Smoke Test Benchmarking
|
||||
|
||||
**Status:** Planned
|
||||
**Status:** Done
|
||||
|
||||
**Problem:** No way to measure encode/decode performance or track regressions.
|
||||
|
||||
**Solution:** Add timing to smoke tests using `hyperfine` or `time`.
|
||||
|
||||
### Features
|
||||
- Benchmark encode/decode on test images
|
||||
- Output timing stats (min/max/avg)
|
||||
- Optional `--benchmark` flag for smoke-test.sh
|
||||
- Compare NVMe vs SD card, overclocked vs stock
|
||||
### Implementation
|
||||
- Added `--benchmark` flag to run encode/decode benchmarks after tests
|
||||
- Added `--runs=N` flag to customize number of benchmark runs (default: 5)
|
||||
- Uses hyperfine if available for precise timing with warmup
|
||||
- Falls back to manual timing with bc if hyperfine not installed
|
||||
- Outputs min/max/avg stats for both encode and decode operations
|
||||
|
||||
### Files to Modify
|
||||
### Files Modified
|
||||
- `tests/smoke-test.sh`
|
||||
|
||||
---
|
||||
|
||||
## 7. Docker Cleanup
|
||||
|
||||
**Status:** Done (4.1.1)
|
||||
|
||||
**Problem:** Docker build context is larger than needed (includes test images, rpi scripts, etc.)
|
||||
|
||||
**Solution:** Added `.dockerignore` and fixed volume permissions in Dockerfile
|
||||
|
||||
### Files Modified
|
||||
- `.dockerignore` (created)
|
||||
- `Dockerfile` (instance dir permissions)
|
||||
|
||||
---
|
||||
|
||||
## 8. Release Validation Script
|
||||
|
||||
**Status:** Done
|
||||
|
||||
**Problem:** Manual release checklist is error-prone. Need automated validation.
|
||||
|
||||
**Solution:** Script that runs through testable checklist items
|
||||
|
||||
### Features
|
||||
- Run pytest
|
||||
- Build and test Docker image
|
||||
- SSH to Pi and run smoke test (optional, if PI_IP provided)
|
||||
- Report pass/fail summary
|
||||
|
||||
### Files to Create
|
||||
- `scripts/validate-release.sh`
|
||||
|
||||
---
|
||||
|
||||
## 9. Smoke Test Docker Support
|
||||
|
||||
**Status:** Done
|
||||
|
||||
**Problem:** Smoke test expects systemd service, doesn't auto-create admin for Docker.
|
||||
|
||||
**Solution:** Make smoke test Docker-aware
|
||||
|
||||
### Features
|
||||
- Skip systemd checks if not on Pi/Linux with systemd
|
||||
- Auto-detect fresh Docker (no users) and create admin via /setup
|
||||
- Add `--docker` flag to skip Pi-specific checks
|
||||
|
||||
### Implementation
|
||||
- Added `--docker` flag that sets localhost and skips SSH/systemd checks
|
||||
- Docker health check verifies container responds with HTTP 200/302
|
||||
- Header shows "Docker Smoke Test" in Docker mode
|
||||
|
||||
### Files Modified
|
||||
- `rpi/smoke-test.sh`
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep 4.1.2 focused - 6 small features
|
||||
- Keep 4.1.2 focused - 9 features (9 done)
|
||||
- Don't break DCT compatibility (4.1.1 RS format is stable)
|
||||
- Test on Pi before release
|
||||
|
||||
165
PLAN-4.1.4.md
Normal file
165
PLAN-4.1.4.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Stegasoo 4.1.4 Plan
|
||||
|
||||
## Build / Deploy
|
||||
- [x] Pre-built Python 3.12 venv tarball for Pi (skip 20+ min compile) - see details below
|
||||
- [x] Fixed partition sizing in flash script (16GB rootfs for faster imaging)
|
||||
- [x] Rename `flash-pi.sh` → `flash-stock-img.sh` for clarity
|
||||
- [x] pip-audit integration in release validation
|
||||
|
||||
### Pi venv Tarball Approach
|
||||
1. Flash fresh Pi image, let it fully build (20+ min compile)
|
||||
2. Once running and working, SSH in and create optimized tarball:
|
||||
```bash
|
||||
cd /opt/stegasoo
|
||||
# Strip caches and tests (295MB → 208MB)
|
||||
find venv/ -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
|
||||
find venv/ -type d -name 'tests' -exec rm -rf {} + 2>/dev/null
|
||||
find venv/ -type d -name 'test' -exec rm -rf {} + 2>/dev/null
|
||||
# Compress with zstd (208MB → 39MB)
|
||||
tar -cf - venv/ | zstd -19 -T0 > /tmp/stegasoo-venv-pi-arm64.tar.zst
|
||||
```
|
||||
3. Pull tarball to host: `scp admin@pi:/tmp/stegasoo-venv-pi-arm64.tar.zst rpi/`
|
||||
4. setup.sh auto-detects and extracts tarball if present in rpi/
|
||||
5. Re-flash and test fresh build with pre-built venv (should be <2 min vs 20+)
|
||||
|
||||
## Features
|
||||
- [x] QR channel key sharing (see detailed plan below)
|
||||
- [ ] Role-based permissions: admin / mod / user
|
||||
- [x] `stegasoo info` fastfetch-style command (version, service status, channel, CPU, temp, etc.)
|
||||
- [ ] Better capacity estimates / pre-flight check before encode fails
|
||||
|
||||
---
|
||||
|
||||
## QR Channel Key Sharing - Implementation Plan
|
||||
|
||||
### Current State
|
||||
- ✅ **CLI**: `stegasoo channel qr` generates ASCII/PNG QR for server channel key
|
||||
- ✅ **Web UI (about.html)**: Client-side QR generator exists - input key, generate/show QR, download PNG
|
||||
- ✅ **Account page**: Shows saved channel keys with fingerprint, rename, delete
|
||||
- ❌ No role restrictions on QR sharing
|
||||
- ❌ No QR button for saved keys on account page
|
||||
- ❌ No QR scanning to import keys
|
||||
|
||||
### Design Decisions
|
||||
|
||||
**UI Placement** (avoiding encode/decode page crowding):
|
||||
- Keep QR generator in **about.html** (already exists, logical place for tools)
|
||||
- Add QR button to **account.html** saved keys (small icon, doesn't crowd)
|
||||
- Both should be admin-only
|
||||
|
||||
**Role Restriction** (per user request):
|
||||
- QR sharing = admin only (hide generator + saved key QR buttons from non-admins)
|
||||
- Prerequisite: Need role-based permissions feature first
|
||||
- Interim option: Just hide from non-admin users using existing `is_admin` flag
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
#### Phase 1: Admin-only restriction (quick win)
|
||||
1. **about.html**: Wrap QR generator section in `{% if is_admin %}` block
|
||||
2. **Account route**: Pass `is_admin` to template (if not already)
|
||||
3. **account.html**: Add small QR icon button to saved keys row (admin only)
|
||||
- Opens modal with QR canvas (reuse qrcode.js pattern from about.html)
|
||||
- Download PNG button in modal
|
||||
|
||||
#### Phase 2: QR Import (optional enhancement)
|
||||
1. Add "Import via QR" button to account.html key-add section
|
||||
2. Use device camera or file upload to scan QR
|
||||
3. Decode and populate channel_key input field
|
||||
4. Requires `pyzbar` on server OR client-side JS library like `jsQR`
|
||||
|
||||
### Files to Modify
|
||||
|
||||
```
|
||||
frontends/web/app.py
|
||||
- about() route: Add missing vars: is_admin, channel_configured,
|
||||
channel_fingerprint, channel_source (BUG: currently not passed!)
|
||||
- account() route: ✅ Already passes is_admin
|
||||
|
||||
frontends/web/templates/about.html
|
||||
- Wrap channel key QR section in {% if is_admin %}
|
||||
|
||||
frontends/web/templates/account.html
|
||||
- Add QR button to saved keys (admin only)
|
||||
- Add QR modal (copy pattern from about.html)
|
||||
- Include qrcode.min.js CDN script
|
||||
```
|
||||
|
||||
### Bug Found During Research
|
||||
The about.html template uses `channel_configured`, `channel_fingerprint`,
|
||||
`channel_source` but the route doesn't pass them - always shows "public mode".
|
||||
Fix this while implementing QR admin restriction.
|
||||
|
||||
### Exact Code Changes
|
||||
|
||||
**app.py - Fix about() route (around line 1564):**
|
||||
```python
|
||||
@app.route("/about")
|
||||
def about():
|
||||
from stegasoo.channel import get_channel_status
|
||||
channel_status = get_channel_status()
|
||||
|
||||
# Check if user is admin (for QR sharing)
|
||||
current_user = get_current_user()
|
||||
is_admin = current_user.is_admin if current_user else False
|
||||
|
||||
return render_template(
|
||||
"about.html",
|
||||
has_argon2=has_argon2(),
|
||||
has_qrcode_read=HAS_QRCODE_READ,
|
||||
# Channel info (bugfix)
|
||||
channel_configured=channel_status["configured"],
|
||||
channel_fingerprint=channel_status.get("fingerprint"),
|
||||
channel_source=channel_status.get("source"),
|
||||
# Admin check for QR sharing
|
||||
is_admin=is_admin,
|
||||
)
|
||||
```
|
||||
|
||||
### Template Changes Preview
|
||||
|
||||
**account.html - Add to saved key row:**
|
||||
```html
|
||||
{% if is_admin %}
|
||||
<button type="button" class="btn btn-outline-info btn-sm"
|
||||
onclick="showKeyQr('{{ key.channel_key }}')" title="Show QR">
|
||||
<i class="bi bi-qr-code"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
**about.html - Wrap existing section:**
|
||||
```html
|
||||
{% if is_admin %}
|
||||
<!-- Channel Key QR Generator -->
|
||||
<div class="card bg-dark border-secondary">
|
||||
...existing QR generator...
|
||||
</div>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### Testing Checklist (Phase 1 Implemented)
|
||||
- [ ] Non-admin users cannot see QR generator in about.html
|
||||
- [ ] Non-admin users cannot see QR buttons on account page
|
||||
- [ ] Admin users can generate QR for any saved key
|
||||
- [ ] QR downloads work correctly
|
||||
- [ ] QR scans correctly with phone camera
|
||||
|
||||
### Implementation Status
|
||||
**Phase 1: COMPLETE** - Admin-only QR sharing implemented:
|
||||
- `app.py`: Fixed about() route to pass channel status + is_admin
|
||||
- `about.html`: QR generator wrapped in `{% if is_admin %}` with Admin badge
|
||||
- `account.html`: QR button added to saved keys (admin only), modal + JS for generation/download
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
- [ ] Optional encryption for temp file storage (paranoid mode, config toggle)
|
||||
|
||||
## Docs
|
||||
- [x] Update UNDER_THE_HOOD.md (v4.1 changes, channel keys)
|
||||
- [ ] General docs refresh
|
||||
|
||||
## Ideas (maybe later)
|
||||
- [ ] Stego detection tool
|
||||
- [ ] Browser extension
|
||||
- [ ] Pi snapshot/backup feature
|
||||
106
PLAN-4.1.5.md
Normal file
106
PLAN-4.1.5.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Stegasoo 4.1.5 Plan
|
||||
|
||||
## Decode Progress Bar (Real Progress)
|
||||
|
||||
Mirror the encode async pattern for decode operations.
|
||||
|
||||
### Backend Changes
|
||||
|
||||
**1. Add async mode to `/decode` route (`app.py`)**
|
||||
- Check for `async=true` form param
|
||||
- Generate job_id, store job, submit to executor
|
||||
- Return `{"job_id": ..., "status": "pending"}` immediately
|
||||
|
||||
**2. Add decode status/progress endpoints (`app.py`)**
|
||||
```python
|
||||
@app.route("/decode/status/<job_id>")
|
||||
def decode_status(job_id):
|
||||
# Return {"status": "pending|running|complete|error", "result": {...}}
|
||||
|
||||
@app.route("/decode/progress/<job_id>")
|
||||
def decode_progress(job_id):
|
||||
# Read from /tmp/stegasoo_progress_{job_id}.json
|
||||
# Return {"percent": 0-100, "phase": "..."}
|
||||
```
|
||||
|
||||
**3. Add `_run_decode_job()` background worker (`app.py`)**
|
||||
- Similar to `_run_encode_job()`
|
||||
- Pass `progress_file` param to decode function
|
||||
- Store result/error in job dict
|
||||
|
||||
**4. Update decode functions to write progress (`lsb_steganography.py`, `dct_steganography.py`)**
|
||||
|
||||
Phases for decode:
|
||||
- `"starting"` (0%)
|
||||
- `"reading"` (10%) - reading stego image
|
||||
- `"extracting"` (30%) - extracting hidden data
|
||||
- `"decrypting"` (60%) - Argon2 + AES decryption
|
||||
- `"verifying"` (80%) - HMAC verification
|
||||
- `"finalizing"` (95%) - preparing output
|
||||
- `"complete"` (100%)
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
**5. Update decode form submission (`decode.html`)**
|
||||
- Add async form handler like encode
|
||||
- Call `Stegasoo.submitDecodeAsync(form, btn)`
|
||||
|
||||
**6. Add decode async methods (`stegasoo.js`)**
|
||||
```javascript
|
||||
submitDecodeAsync(form, btn) // POST with async=true, show modal
|
||||
pollDecodeProgress(jobId) // Poll /decode/status, /decode/progress
|
||||
```
|
||||
|
||||
Reuse existing:
|
||||
- `showProgressModal('Decoding')`
|
||||
- `updateProgress(percent, phase)`
|
||||
|
||||
**7. Handle decode result redirect**
|
||||
- On complete: redirect to `/decode/result/{file_id}` or display inline
|
||||
|
||||
### Files to Modify
|
||||
|
||||
```
|
||||
frontends/web/app.py
|
||||
- Add async handling to /decode route (~line 1300+)
|
||||
- Add /decode/status/<job_id> endpoint
|
||||
- Add /decode/progress/<job_id> endpoint
|
||||
- Add _run_decode_job() function
|
||||
|
||||
frontends/web/static/js/stegasoo.js
|
||||
- Add submitDecodeAsync()
|
||||
- Add pollDecodeProgress()
|
||||
|
||||
frontends/web/templates/decode.html
|
||||
- Update form submit to use async mode
|
||||
|
||||
src/stegasoo/lsb_steganography.py
|
||||
- Add progress_file param to decode()
|
||||
- Write progress at each phase
|
||||
|
||||
src/stegasoo/dct_steganography.py
|
||||
- Add progress_file param to decode()
|
||||
- Write progress at each phase
|
||||
```
|
||||
|
||||
### Testing Checklist
|
||||
|
||||
- [ ] Decode shows progress modal on submit
|
||||
- [ ] Progress bar animates through phases
|
||||
- [ ] Successful decode redirects to result
|
||||
- [ ] Failed decode shows error in modal
|
||||
- [ ] Works for both LSB and DCT modes
|
||||
- [ ] Works for message and file payloads
|
||||
- [ ] Progress file cleaned up after completion
|
||||
|
||||
---
|
||||
|
||||
## Other 4.1.5 Ideas (if time)
|
||||
|
||||
- [ ] Role-based permissions: admin / mod / user
|
||||
- [ ] Better capacity estimates / pre-flight check
|
||||
- [ ] Stego detection tool
|
||||
|
||||
## Bugs / Nice to Have
|
||||
|
||||
- [ ] **flash-stock-img.sh 16GB resize not working** - partition still full SD size after flash, makes dd pull slow. Investigate resize2fs/parted logic and test fix.
|
||||
31
README.md
31
README.md
@@ -102,9 +102,40 @@ black src/ tests/ frontends/
|
||||
ruff check src/ tests/ frontends/
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
docker-compose up -d
|
||||
|
||||
# Access
|
||||
# Web UI: http://localhost:5000
|
||||
# REST API: http://localhost:8000
|
||||
```
|
||||
|
||||
See [DOCKER.md](DOCKER.md) for full documentation.
|
||||
|
||||
## Raspberry Pi
|
||||
|
||||
Pre-built SD card images available for Pi 4/5:
|
||||
|
||||
```bash
|
||||
# Flash image (download from GitHub Releases)
|
||||
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
|
||||
# First boot runs interactive setup wizard:
|
||||
# - WiFi configuration
|
||||
# - HTTPS with port 443
|
||||
# - Channel key generation
|
||||
# - Optional overclocking
|
||||
```
|
||||
|
||||
See [rpi/README.md](rpi/README.md) for manual installation.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [INSTALL.md](INSTALL.md) - Installation guide
|
||||
- [DOCKER.md](DOCKER.md) - Docker deployment
|
||||
- [CLI.md](CLI.md) - Command-line reference
|
||||
- [API.md](API.md) - REST API documentation
|
||||
- [WEB_UI.md](WEB_UI.md) - Web interface guide
|
||||
|
||||
93
RELEASE-4.1.1.md
Normal file
93
RELEASE-4.1.1.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Stegasoo 4.1.1 Release Notes
|
||||
|
||||
**Release Date:** January 5, 2026
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Reed-Solomon Error Correction** - DCT steganography now includes RS error correction, making encoded images more resilient to minor corruption and compression artifacts
|
||||
- **Completely Rewritten Pi Setup** - Fresh install tested and validated, works reliably from scratch
|
||||
- **SSH Login Banner** - See your Stegasoo URL immediately on SSH login
|
||||
|
||||
## New Features
|
||||
|
||||
### Reed-Solomon Error Correction
|
||||
DCT-encoded images now include Reed-Solomon error correction codes, allowing recovery from minor image corruption. This significantly improves reliability when images are shared through platforms that may slightly modify them.
|
||||
|
||||
### SSH Login Banner (MOTD)
|
||||
When you SSH into your Stegasoo Pi, you'll now see:
|
||||
```
|
||||
___ _____ ___ ___ _ ___ ___ ___
|
||||
/ __||_ _|| __| / __| /_\ / __| / _ \ / _ \
|
||||
\__ \ | | | _| | (_ | / _ \ \__ \ | (_) || (_) |
|
||||
|___/ |_| |___| \___//_/ \_\|___/ \___/ \___/
|
||||
|
||||
● Stegasoo is running
|
||||
https://192.168.0.4
|
||||
```
|
||||
|
||||
### Elapsed Time Counter
|
||||
Encode/decode buttons now show elapsed time during operations.
|
||||
|
||||
### Click-to-Copy Decoded Message
|
||||
Click the decoded message box to copy to clipboard (no button needed).
|
||||
|
||||
### Overclock Wizard Option
|
||||
First-boot wizard now offers optional CPU overclocking for Pi 4/5 with active cooling.
|
||||
|
||||
## Improvements
|
||||
|
||||
### Setup Script (setup.sh)
|
||||
- Fixed pyenv Python path resolution (handles 3.12 → 3.12.12 mapping)
|
||||
- Changed default install location to `/opt/stegasoo`
|
||||
- Fixed jpegio build order (clone stegasoo first, then build jpegio into venv)
|
||||
- Added python3-dev to dependencies
|
||||
- Added btop for system monitoring
|
||||
- Shows `/setup` URL at completion for admin account creation
|
||||
|
||||
### Sanitize Script
|
||||
- Now clears port 443 iptables redirect (clean slate for wizard)
|
||||
- Removes overclock settings before imaging
|
||||
|
||||
### Documentation
|
||||
- Updated all docs to reference `/opt/stegasoo` path
|
||||
- Added pre-setup steps (chown /opt, install git)
|
||||
- Added Pi 4 performance baseline (~60s for 10MB JPEG)
|
||||
|
||||
### About Page
|
||||
- Redesigned "Limits & Specs" section with key stats cards and accordion
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Fixed DCT steganography for non-8-aligned images
|
||||
- Fixed MOTD port detection (was using iptables which requires root)
|
||||
- Fixed smoke test `--443` flag parsing
|
||||
|
||||
## Performance
|
||||
|
||||
On a Raspberry Pi 4 at 2GHz with USB 3.0 NVMe:
|
||||
- ~50 seconds to encode a 10MB JPEG
|
||||
- ~60 seconds to decode a 10MB JPEG
|
||||
- Full encryption: passphrase + PIN + reference photo
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
If upgrading from 4.1.0:
|
||||
```bash
|
||||
cd /opt/stegasoo # or ~/stegasoo
|
||||
git pull origin 4.1
|
||||
```
|
||||
|
||||
For fresh installs, see the [Pi README](rpi/README.md).
|
||||
|
||||
## Pre-built Images
|
||||
|
||||
- `stegasoo-rpi-4.1.1_20260105-2.img.zst` - Raspberry Pi 4/5 image
|
||||
|
||||
Flash with:
|
||||
```bash
|
||||
zstdcat stegasoo-rpi-4.1.1_20260105-2.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Full changelog: [v4.1.0...v4.1.1](https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.1)
|
||||
44
RELEASE_CHECKLIST.md
Normal file
44
RELEASE_CHECKLIST.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Stegasoo Release Checklist
|
||||
|
||||
Pre-release validation checklist. Complete all items before tagging a release.
|
||||
|
||||
## Code Quality
|
||||
|
||||
- [ ] All tests pass: `./venv/bin/pytest tests/ -v`
|
||||
- [ ] No lint errors: `./venv/bin/ruff check src/`
|
||||
- [ ] Version bumped in `pyproject.toml`
|
||||
- [ ] CHANGELOG.md updated
|
||||
|
||||
## Pi Image Validation
|
||||
|
||||
- [ ] Fresh Pi OS install with setup.sh works
|
||||
- [ ] First-boot wizard completes successfully
|
||||
- [ ] MOTD shows correct URL on SSH login
|
||||
- [ ] Smoke test passes: `./rpi/smoke-test.sh --443 <PI_IP>`
|
||||
- [ ] Encode/decode works on large image (10MB+)
|
||||
- [ ] Sanitize script runs cleanly
|
||||
- [ ] Image created and compressed
|
||||
|
||||
## Docker Validation
|
||||
|
||||
- [ ] Base image builds: `docker build -f Dockerfile.base -t stegasoo-base:latest .`
|
||||
- [ ] Web image builds: `docker-compose build web`
|
||||
- [ ] Container starts: `docker-compose up -d web`
|
||||
- [ ] Web UI accessible at http://localhost:5000
|
||||
- [ ] Encode/decode works in container
|
||||
- [ ] Container stops cleanly: `docker-compose down`
|
||||
|
||||
## Release Process
|
||||
|
||||
- [ ] Merge feature branch to main
|
||||
- [ ] Create annotated tag: `git tag -a vX.Y.Z -m "message"`
|
||||
- [ ] Push tag: `git push origin vX.Y.Z`
|
||||
- [ ] Create GitHub Release with release notes
|
||||
- [ ] Upload Pi image (.img.zst.zip)
|
||||
- [ ] Verify download links work
|
||||
|
||||
## Post-Release
|
||||
|
||||
- [ ] Delete old/obsolete releases if needed
|
||||
- [ ] Update any external documentation
|
||||
- [ ] Announce release (if applicable)
|
||||
34
RELEASE_NOTES.md
Normal file
34
RELEASE_NOTES.md
Normal file
@@ -0,0 +1,34 @@
|
||||
## Stegasoo v4.1.3
|
||||
|
||||
### Fixes
|
||||
- **SSL Certificate Generation**: First-boot wizard now properly generates self-signed certs when HTTPS is enabled
|
||||
- **Download Bug Fixed**: No more "File expired or not found" errors - fixed multi-worker temp file sharing
|
||||
- **Docker Build**: Reduced build context from 2.3GB to ~900KB
|
||||
|
||||
### Improvements
|
||||
- Docker memory limits increased to 2GB (prevents OOM on large DCT operations)
|
||||
- Decode button now shows loading spinner during processing
|
||||
- Headless Pi flash script with Trixie/NetworkManager support
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker-compose up -d web # Web UI on :5000
|
||||
docker-compose up -d api # REST API on :8000
|
||||
```
|
||||
|
||||
### Raspberry Pi Image
|
||||
Download `stegasoo-rpi-4.1.3.img.zst`, flash to SD card, and boot. The first-boot wizard will guide you through WiFi, HTTPS, and channel key setup.
|
||||
|
||||
```bash
|
||||
# Flash with included script
|
||||
./rpi/flash-image.sh stegasoo-rpi-4.1.3.img.zst /dev/sdX
|
||||
|
||||
# First time: save your WiFi credentials
|
||||
./rpi/inject-wifi.sh --setup
|
||||
|
||||
# Then inject WiFi after flashing
|
||||
sudo ./rpi/inject-wifi.sh /dev/sdX
|
||||
```
|
||||
|
||||
### Full Changelog
|
||||
See [CHANGELOG.md](CHANGELOG.md) for complete details.
|
||||
10
SECURITY.md
10
SECURITY.md
@@ -4,16 +4,16 @@
|
||||
|
||||
| Version | Supported | Notes |
|
||||
| ------- | ------------------ | ----- |
|
||||
| 4.x.x | ✅ Active | Current release |
|
||||
| 3.x.x | ⚠️ Security fixes only | Upgrade recommended |
|
||||
| 2.x.x | ❌ End of life | |
|
||||
| 1.x.x | ❌ End of life | |
|
||||
| 4.1.x | Current Version | What you SHOULD be using. |
|
||||
| 4.x.x | ⚠️ Security fixes only | Upgrade (EOL soon) |
|
||||
| <= 3.x.x | ❌ End of life | |
|
||||
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
Instead, please email: **security@example.com** (replace with your email)
|
||||
Instead, please email: **adlee-was-taken@proton.me**
|
||||
|
||||
Include:
|
||||
- Description of the vulnerability
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work under the hood.
|
||||
|
||||
**Version 4.0** - Updated for simplified authentication (no date dependency)
|
||||
**Version 4.1** - Updated for channel keys and deployment isolation
|
||||
|
||||
---
|
||||
|
||||
@@ -22,20 +22,20 @@ A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work unde
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ STEGASOO ARCHITECTURE (v4.0) │
|
||||
│ STEGASOO ARCHITECTURE (v4.1) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ INPUTS PROCESSING OUTPUT │
|
||||
│ ─────── ────────── ────── │
|
||||
│ │
|
||||
│ Reference Photo ─┐ │
|
||||
│ Passphrase ──────┼──► Argon2id KDF ──► AES-256 Key │
|
||||
│ PIN/RSA Key ─────┘ │ │
|
||||
│ ▼ │
|
||||
│ Message/File ────────────────────────► AES-256-GCM ──► Ciphertext │
|
||||
│ Passphrase ──────┼──► Argon2id KDF ──► AES-256 Key │
|
||||
│ PIN/RSA Key ─────┤ │ │
|
||||
│ Channel Key ─────┘ (v4.1) ▼ │
|
||||
│ Message/File ────────────────────────► AES-256-GCM ──► Ciphertext │
|
||||
│ Encryption │ │
|
||||
│ ▼ │
|
||||
│ Carrier Image ───────────────────────────────────────► Embedding ──► Stego│
|
||||
│ Carrier Image ───────────────────────────────────────► Embedding ─► Stego │
|
||||
│ (LSB/DCT) Image │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
@@ -50,11 +50,24 @@ A detailed breakdown of how Stegasoo's LSB and DCT steganography modes work unde
|
||||
| Header size | 75 bytes | 65 bytes (no date field) |
|
||||
| Python support | 3.10+ | 3.10-3.12 only |
|
||||
|
||||
### v4.1 Changes
|
||||
|
||||
| Change | v4.0 | v4.1 |
|
||||
|--------|------|------|
|
||||
| Channel keys | None | 32-byte deployment isolation |
|
||||
| Key derivation | passphrase + ref + pin | passphrase + ref + pin + channel |
|
||||
| Web auth | Session-based | Session + admin/user roles |
|
||||
| Raspberry Pi | Manual setup | First-boot wizard with gum |
|
||||
| Docker | Basic | Production-ready compose |
|
||||
|
||||
**Channel Keys** provide deployment isolation - messages encoded on one Stegasoo instance cannot be decoded by another instance with a different channel key, even with the same passphrase/PIN/reference photo.
|
||||
|
||||
### Module Responsibilities
|
||||
|
||||
| Module | File | Purpose |
|
||||
|--------|------|---------|
|
||||
| **Crypto** | `crypto.py` | Key derivation (Argon2id), AES-256-GCM encryption/decryption |
|
||||
| **Channel** | `channel.py` | Channel key management, deployment isolation (v4.1) |
|
||||
| **Steganography** | `steganography.py` | LSB pixel manipulation, capacity calculation |
|
||||
| **DCT Steganography** | `dct_steganography.py` | Frequency-domain embedding, jpegio integration |
|
||||
| **Compression** | `compression.py` | Optional LZ4 compression of payload |
|
||||
@@ -626,7 +639,7 @@ Factor 1: Reference Photo ─┐
|
||||
• 80-256 bits entropy │
|
||||
• "Something you have" │
|
||||
├──► Combined entropy: 133-400+ bits
|
||||
Factor 2: Passphrase │ (Beyond brute force)
|
||||
Factor 2: Passphrase │ (Beyond brute force)
|
||||
• 43-132 bits entropy │
|
||||
• "Something you know" │
|
||||
• 4 words default (v4.0) │
|
||||
@@ -688,7 +701,7 @@ AUTHENTICATED ENCRYPTION (AES-256-GCM)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ENCODE FLOW (v4.0) │
|
||||
│ ENCODE FLOW (v4.0) │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
User Inputs Processing Output
|
||||
@@ -714,14 +727,14 @@ Carrier Image ──────────────────────
|
||||
│ │
|
||||
┌───────────┴─────┴────────────┐
|
||||
│ │
|
||||
LSB Mode DCT Mode
|
||||
LSB Mode DCT Mode
|
||||
│ │
|
||||
▼ ▼
|
||||
embed_lsb() embed_in_dct()
|
||||
(pixel LSBs) (DCT coefficients)
|
||||
embed_lsb() embed_in_dct()
|
||||
(pixel LSBs) (DCT coefficients)
|
||||
│ │
|
||||
▼ ▼
|
||||
PNG Output PNG or JPEG
|
||||
PNG Output PNG or JPEG
|
||||
│ │
|
||||
└──────────┬───────────────────┘
|
||||
│
|
||||
@@ -793,8 +806,8 @@ Stego Image ──────────► detect_mode() ──────
|
||||
Both modes share the same cryptographic foundation (Argon2id + AES-256-GCM) and multi-factor authentication, ensuring security regardless of embedding method.
|
||||
|
||||
The choice comes down to your use case:
|
||||
- **Private channel?** → LSB (maximum capacity)
|
||||
- **Public platform?** → DCT (maximum compatibility)
|
||||
- **Private channel?** → LSB (maximum capacity)
|
||||
|
||||
### v4.0 Simplifications
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
# Shared environment variables
|
||||
x-common-env: &common-env
|
||||
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
|
||||
@@ -30,9 +28,9 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
memory: 2048M
|
||||
reservations:
|
||||
memory: 384M
|
||||
memory: 1024M
|
||||
|
||||
# ============================================================================
|
||||
# REST API (FastAPI)
|
||||
@@ -50,9 +48,9 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
memory: 2048M
|
||||
reservations:
|
||||
memory: 384M
|
||||
memory: 1024M
|
||||
|
||||
# Named volumes for persistent data
|
||||
volumes:
|
||||
|
||||
@@ -24,11 +24,31 @@ Usage:
|
||||
stegasoo channel [SUBCOMMAND]
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
# Rich progress bar (optional)
|
||||
try:
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
TaskProgressColumn,
|
||||
TextColumn,
|
||||
TimeElapsedColumn,
|
||||
)
|
||||
|
||||
HAS_RICH = True
|
||||
except ImportError:
|
||||
HAS_RICH = False
|
||||
|
||||
# Add parent to path for development
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
@@ -598,6 +618,73 @@ def channel_clear(project, clear_all, force):
|
||||
click.echo(" Mode is now: PUBLIC")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PROGRESS BAR UTILITIES (v4.1.2)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _generate_progress_job_id() -> str:
|
||||
"""Generate a unique job ID for progress tracking."""
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
def _get_progress_file_path(job_id: str) -> str:
|
||||
"""Get the progress file path for a job ID."""
|
||||
return str(Path(tempfile.gettempdir()) / f"stegasoo_progress_{job_id}.json")
|
||||
|
||||
|
||||
def _read_progress(job_id: str) -> dict | None:
|
||||
"""Read progress from file for a job ID."""
|
||||
progress_file = _get_progress_file_path(job_id)
|
||||
try:
|
||||
with open(progress_file) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _cleanup_progress_file(job_id: str) -> None:
|
||||
"""Remove progress file for a completed job."""
|
||||
progress_file = _get_progress_file_path(job_id)
|
||||
try:
|
||||
Path(progress_file).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _run_encode_with_progress(encode_func, encode_kwargs: dict, progress_file: str) -> tuple:
|
||||
"""
|
||||
Run encode in a thread and return result.
|
||||
|
||||
Returns:
|
||||
(success, result_or_error)
|
||||
"""
|
||||
result_holder = {"result": None, "error": None}
|
||||
|
||||
def run():
|
||||
try:
|
||||
result_holder["result"] = encode_func(**encode_kwargs, progress_file=progress_file)
|
||||
except Exception as e:
|
||||
result_holder["error"] = e
|
||||
|
||||
thread = threading.Thread(target=run)
|
||||
thread.start()
|
||||
return thread, result_holder
|
||||
|
||||
|
||||
def _format_phase(phase: str) -> str:
|
||||
"""Format phase name for display."""
|
||||
phases = {
|
||||
"starting": "Starting",
|
||||
"initializing": "Initializing",
|
||||
"embedding": "Embedding",
|
||||
"saving": "Saving",
|
||||
"finalizing": "Finalizing",
|
||||
"complete": "Complete",
|
||||
}
|
||||
return phases.get(phase, phase.capitalize())
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENCODE COMMAND
|
||||
# ============================================================================
|
||||
@@ -642,6 +729,7 @@ def channel_clear(project, clear_all, force):
|
||||
help="DCT color mode: grayscale (default) or color (preserves original colors)",
|
||||
)
|
||||
@click.option("--quiet", "-q", is_flag=True, help="Suppress output except errors")
|
||||
@click.option("--progress", is_flag=True, help="Show progress bar (requires rich)")
|
||||
def encode_cmd(
|
||||
ref,
|
||||
carrier,
|
||||
@@ -661,6 +749,7 @@ def encode_cmd(
|
||||
dct_output_format,
|
||||
dct_color_mode,
|
||||
quiet,
|
||||
progress,
|
||||
):
|
||||
"""
|
||||
Encode a secret message or file into an image.
|
||||
@@ -808,19 +897,63 @@ def encode_cmd(
|
||||
click.echo(channel_status)
|
||||
|
||||
# v4.0.0: Include channel_key parameter
|
||||
result = encode(
|
||||
message=payload,
|
||||
reference_photo=ref_photo,
|
||||
carrier_image=carrier_image,
|
||||
passphrase=passphrase,
|
||||
pin=pin or "",
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=effective_key_password,
|
||||
embed_mode=embed_mode,
|
||||
dct_output_format=dct_output_format,
|
||||
dct_color_mode=dct_color_mode,
|
||||
channel_key=resolved_channel_key,
|
||||
)
|
||||
# v4.1.2: Progress bar support
|
||||
encode_kwargs = {
|
||||
"message": payload,
|
||||
"reference_photo": ref_photo,
|
||||
"carrier_image": carrier_image,
|
||||
"passphrase": passphrase,
|
||||
"pin": pin or "",
|
||||
"rsa_key_data": rsa_key_data,
|
||||
"rsa_password": effective_key_password,
|
||||
"embed_mode": embed_mode,
|
||||
"dct_output_format": dct_output_format,
|
||||
"dct_color_mode": dct_color_mode,
|
||||
"channel_key": resolved_channel_key,
|
||||
}
|
||||
|
||||
if progress and HAS_RICH:
|
||||
# Run with progress bar
|
||||
job_id = _generate_progress_job_id()
|
||||
progress_file = _get_progress_file_path(job_id)
|
||||
|
||||
thread, result_holder = _run_encode_with_progress(encode, encode_kwargs, progress_file)
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TaskProgressColumn(),
|
||||
TimeElapsedColumn(),
|
||||
transient=True,
|
||||
) as progress_bar:
|
||||
task = progress_bar.add_task("Encoding...", total=100)
|
||||
|
||||
while thread.is_alive():
|
||||
prog = _read_progress(job_id)
|
||||
if prog:
|
||||
percent = prog.get("percent", 0)
|
||||
phase = _format_phase(prog.get("phase", "processing"))
|
||||
progress_bar.update(task, completed=percent, description=f"{phase}...")
|
||||
time.sleep(0.1)
|
||||
|
||||
# Final update
|
||||
progress_bar.update(task, completed=100, description="Complete!")
|
||||
|
||||
_cleanup_progress_file(job_id)
|
||||
|
||||
if result_holder["error"]:
|
||||
raise result_holder["error"]
|
||||
result = result_holder["result"]
|
||||
|
||||
elif progress and not HAS_RICH:
|
||||
click.secho(
|
||||
"Warning: --progress requires 'rich' package. Install with: pip install rich",
|
||||
fg="yellow",
|
||||
)
|
||||
result = encode(**encode_kwargs)
|
||||
else:
|
||||
result = encode(**encode_kwargs)
|
||||
|
||||
# Determine output path
|
||||
if output:
|
||||
|
||||
@@ -26,7 +26,9 @@ import mimetypes
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
from auth import (
|
||||
@@ -36,6 +38,7 @@ from auth import (
|
||||
can_create_user,
|
||||
can_save_channel_key,
|
||||
change_password,
|
||||
clear_recovery_key,
|
||||
create_admin_user,
|
||||
create_user,
|
||||
delete_channel_key,
|
||||
@@ -45,12 +48,11 @@ from auth import (
|
||||
get_channel_key_by_id,
|
||||
get_current_user,
|
||||
get_non_admin_count,
|
||||
get_recovery_key_hash,
|
||||
get_user_by_id,
|
||||
get_user_channel_keys,
|
||||
get_username,
|
||||
has_recovery_key,
|
||||
get_recovery_key_hash,
|
||||
clear_recovery_key,
|
||||
is_admin,
|
||||
is_authenticated,
|
||||
login_required,
|
||||
@@ -59,10 +61,10 @@ from auth import (
|
||||
reset_user_password,
|
||||
save_channel_key,
|
||||
set_recovery_key_hash,
|
||||
verify_and_reset_admin_password,
|
||||
update_channel_key_last_used,
|
||||
update_channel_key_name,
|
||||
user_exists,
|
||||
verify_and_reset_admin_password,
|
||||
verify_user_password,
|
||||
)
|
||||
from auth import (
|
||||
@@ -81,6 +83,7 @@ from flask import (
|
||||
)
|
||||
from PIL import Image
|
||||
from ssl_utils import ensure_certs
|
||||
import temp_storage
|
||||
|
||||
os.environ["NUMPY_MADVISE_HUGEPAGE"] = "0"
|
||||
os.environ["OMP_NUM_THREADS"] = "1"
|
||||
@@ -93,6 +96,9 @@ from stegasoo import (
|
||||
CapacityError,
|
||||
DecryptionError,
|
||||
FilePayload,
|
||||
InvalidHeaderError,
|
||||
InvalidMagicBytesError,
|
||||
ReedSolomonError,
|
||||
StegasooError,
|
||||
export_rsa_key_pem,
|
||||
generate_credentials,
|
||||
@@ -153,7 +159,13 @@ except ImportError:
|
||||
# ============================================================================
|
||||
# Runs encode/decode/compare in subprocesses to prevent jpegio/scipy crashes
|
||||
# from taking down the Flask server.
|
||||
from subprocess_stego import SubprocessStego
|
||||
from subprocess_stego import (
|
||||
SubprocessStego,
|
||||
cleanup_progress_file,
|
||||
generate_job_id,
|
||||
get_progress_file_path,
|
||||
read_progress,
|
||||
)
|
||||
|
||||
from stegasoo.qr_utils import (
|
||||
can_fit_in_qr,
|
||||
@@ -192,9 +204,64 @@ app.config["HTTPS_ENABLED"] = os.environ.get("STEGASOO_HTTPS_ENABLED", "false").
|
||||
# Initialize auth module
|
||||
init_auth(app)
|
||||
|
||||
# Temporary file storage for sharing (file_id -> {data, timestamp, filename})
|
||||
TEMP_FILES: dict[str, dict] = {}
|
||||
THUMBNAIL_FILES: dict[str, bytes] = {}
|
||||
# ============================================================================
|
||||
# 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
|
||||
|
||||
|
||||
# DEPRECATED: In-memory storage replaced by file-based temp_storage module
|
||||
# Kept for backwards compatibility during transition
|
||||
TEMP_FILES: dict[str, dict] = {} # Not used - see temp_storage.py
|
||||
THUMBNAIL_FILES: dict[str, bytes] = {} # Not used - see temp_storage.py
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -332,16 +399,7 @@ def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes
|
||||
|
||||
def cleanup_temp_files():
|
||||
"""Remove expired temporary files."""
|
||||
now = time.time()
|
||||
expired = [
|
||||
fid for fid, info in TEMP_FILES.items() if now - info["timestamp"] > TEMP_FILE_EXPIRY
|
||||
]
|
||||
|
||||
for fid in expired:
|
||||
TEMP_FILES.pop(fid, None)
|
||||
# Also clean up corresponding thumbnail
|
||||
thumb_id = f"{fid}_thumb"
|
||||
THUMBNAIL_FILES.pop(thumb_id, None)
|
||||
temp_storage.cleanup_expired(TEMP_FILE_EXPIRY)
|
||||
|
||||
|
||||
def allowed_image(filename: str) -> bool:
|
||||
@@ -498,13 +556,11 @@ def generate():
|
||||
if not qr_too_large:
|
||||
qr_token = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
TEMP_FILES[qr_token] = {
|
||||
"data": creds.rsa_key_pem.encode(),
|
||||
temp_storage.save_temp_file(qr_token, creds.rsa_key_pem.encode(), {
|
||||
"filename": "rsa_key.pem",
|
||||
"timestamp": time.time(),
|
||||
"type": "rsa_key",
|
||||
"compress": qr_needs_compression,
|
||||
}
|
||||
})
|
||||
|
||||
# v3.2.0: Single passphrase instead of daily phrases
|
||||
return render_template(
|
||||
@@ -541,10 +597,10 @@ def generate_qr(token):
|
||||
if not HAS_QRCODE:
|
||||
return "QR code support not available", 501
|
||||
|
||||
if token not in TEMP_FILES:
|
||||
file_info = temp_storage.get_temp_file(token)
|
||||
if not file_info:
|
||||
return "Token expired or invalid", 404
|
||||
|
||||
file_info = TEMP_FILES[token]
|
||||
if file_info.get("type") != "rsa_key":
|
||||
return "Invalid token type", 400
|
||||
|
||||
@@ -565,10 +621,10 @@ def generate_qr_download(token):
|
||||
if not HAS_QRCODE:
|
||||
return "QR code support not available", 501
|
||||
|
||||
if token not in TEMP_FILES:
|
||||
file_info = temp_storage.get_temp_file(token)
|
||||
if not file_info:
|
||||
return "Token expired or invalid", 404
|
||||
|
||||
file_info = TEMP_FILES[token]
|
||||
if file_info.get("type") != "rsa_key":
|
||||
return "Invalid token type", 400
|
||||
|
||||
@@ -796,10 +852,117 @@ 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_storage.save_temp_file(file_id, encode_result.stego_data, {
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||
"color_mode": dct_color_mode if embed_mode == "dct" else None,
|
||||
"mime_type": output_mime,
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
})
|
||||
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "complete",
|
||||
"file_id": file_id,
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
cleanup_progress_file(job_id)
|
||||
|
||||
|
||||
@app.route("/encode", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def encode_page():
|
||||
if request.method == "POST":
|
||||
# Check if async mode requested
|
||||
is_async = request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||
|
||||
try:
|
||||
# Get files
|
||||
ref_photo = request.files.get("reference_photo")
|
||||
@@ -935,7 +1098,9 @@ def encode_page():
|
||||
# Pre-check payload capacity BEFORE encode (fail fast)
|
||||
from stegasoo.steganography import will_fit_by_mode
|
||||
|
||||
payload_size = len(payload.data) if hasattr(payload, "data") else len(payload.encode("utf-8"))
|
||||
payload_size = (
|
||||
len(payload.data) if hasattr(payload, "data") else len(payload.encode("utf-8"))
|
||||
)
|
||||
fit_check = will_fit_by_mode(payload_size, carrier_data, embed_mode=embed_mode)
|
||||
if not fit_check.get("fits", True):
|
||||
error_msg = (
|
||||
@@ -951,8 +1116,35 @@ def encode_page():
|
||||
flash(error_msg, "error")
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
# v4.0.0: Include channel_key parameter
|
||||
# Use subprocess-isolated encode to prevent crashes
|
||||
# Build encode params for either sync or async
|
||||
encode_params = {
|
||||
"carrier_data": carrier_data,
|
||||
"ref_data": ref_data,
|
||||
"passphrase": passphrase,
|
||||
"pin": pin if pin else None,
|
||||
"rsa_key_data": rsa_key_data,
|
||||
"key_password": key_password,
|
||||
"embed_mode": embed_mode,
|
||||
"dct_output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||
"dct_color_mode": dct_color_mode if embed_mode == "dct" else "color",
|
||||
"channel_key": channel_key,
|
||||
}
|
||||
|
||||
if payload_type == "file" and payload_file and payload_file.filename:
|
||||
encode_params["file_data"] = payload.data
|
||||
encode_params["file_name"] = payload.filename
|
||||
encode_params["file_mime"] = payload.mime_type
|
||||
else:
|
||||
encode_params["message"] = payload
|
||||
|
||||
# ASYNC MODE: Start background job and return JSON
|
||||
if is_async:
|
||||
job_id = generate_job_id()
|
||||
_store_job(job_id, {"status": "pending", "created": time.time()})
|
||||
_executor.submit(_run_encode_job, job_id, encode_params)
|
||||
return jsonify({"job_id": job_id, "status": "pending"})
|
||||
|
||||
# SYNC MODE: Run inline (original behavior)
|
||||
if payload_type == "file" and payload_file and payload_file.filename:
|
||||
encode_result = subprocess_stego.encode(
|
||||
carrier_data=carrier_data,
|
||||
@@ -967,7 +1159,7 @@ def encode_page():
|
||||
embed_mode=embed_mode,
|
||||
dct_output_format=dct_output_format if embed_mode == "dct" else "png",
|
||||
dct_color_mode=dct_color_mode if embed_mode == "dct" else "color",
|
||||
channel_key=channel_key, # v4.0.0
|
||||
channel_key=channel_key,
|
||||
)
|
||||
else:
|
||||
encode_result = subprocess_stego.encode(
|
||||
@@ -981,7 +1173,7 @@ def encode_page():
|
||||
embed_mode=embed_mode,
|
||||
dct_output_format=dct_output_format if embed_mode == "dct" else "png",
|
||||
dct_color_mode=dct_color_mode if embed_mode == "dct" else "color",
|
||||
channel_key=channel_key, # v4.0.0
|
||||
channel_key=channel_key,
|
||||
)
|
||||
|
||||
# Check for subprocess errors
|
||||
@@ -1009,10 +1201,8 @@ def encode_page():
|
||||
# Store temporarily
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
TEMP_FILES[file_id] = {
|
||||
"data": encode_result.stego_data,
|
||||
temp_storage.save_temp_file(file_id, 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,
|
||||
@@ -1020,7 +1210,7 @@ def encode_page():
|
||||
# Channel info (v4.0.0)
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
}
|
||||
})
|
||||
|
||||
return redirect(url_for("encode_result", file_id=file_id))
|
||||
|
||||
@@ -1037,22 +1227,68 @@ def encode_page():
|
||||
return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENCODE PROGRESS ENDPOINTS (v4.1.2)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@app.route("/encode/status/<job_id>")
|
||||
@login_required
|
||||
def encode_status(job_id):
|
||||
"""Get the status of an async encode job."""
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
return jsonify({"error": "Job not found"}), 404
|
||||
|
||||
response = {"status": job.get("status", "unknown")}
|
||||
|
||||
if job["status"] == "complete":
|
||||
response["file_id"] = job.get("file_id")
|
||||
elif job["status"] == "error":
|
||||
response["error"] = job.get("error", "Unknown error")
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
|
||||
@app.route("/encode/progress/<job_id>")
|
||||
@login_required
|
||||
def encode_progress(job_id):
|
||||
"""Get the progress of an async encode job."""
|
||||
progress = read_progress(job_id)
|
||||
if progress:
|
||||
return jsonify(progress)
|
||||
|
||||
# No progress file yet - check job status
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
return jsonify({"error": "Job not found"}), 404
|
||||
|
||||
if job["status"] == "complete":
|
||||
return jsonify({"percent": 100, "phase": "complete"})
|
||||
elif job["status"] == "error":
|
||||
return jsonify({"percent": 0, "phase": "error", "error": job.get("error")})
|
||||
elif job["status"] == "pending":
|
||||
return jsonify({"percent": 0, "phase": "starting"})
|
||||
|
||||
# Running but no progress file yet
|
||||
return jsonify({"percent": 0, "phase": "initializing"})
|
||||
|
||||
|
||||
@app.route("/encode/result/<file_id>")
|
||||
@login_required
|
||||
def encode_result(file_id):
|
||||
if file_id not in TEMP_FILES:
|
||||
file_info = temp_storage.get_temp_file(file_id)
|
||||
if not file_info:
|
||||
flash("File expired or not found. Please encode again.", "error")
|
||||
return redirect(url_for("encode_page"))
|
||||
|
||||
file_info = TEMP_FILES[file_id]
|
||||
|
||||
# Generate thumbnail
|
||||
thumbnail_data = generate_thumbnail(file_info["data"])
|
||||
thumbnail_id = None
|
||||
|
||||
if thumbnail_data:
|
||||
thumbnail_id = f"{file_id}_thumb"
|
||||
THUMBNAIL_FILES[thumbnail_id] = thumbnail_data
|
||||
temp_storage.save_thumbnail(thumbnail_id, thumbnail_data)
|
||||
|
||||
return render_template(
|
||||
"encode_result.html",
|
||||
@@ -1072,22 +1308,23 @@ def encode_result(file_id):
|
||||
@login_required
|
||||
def encode_thumbnail(thumb_id):
|
||||
"""Serve thumbnail image."""
|
||||
if thumb_id not in THUMBNAIL_FILES:
|
||||
thumb_data = temp_storage.get_thumbnail(thumb_id)
|
||||
if not thumb_data:
|
||||
return "Thumbnail not found", 404
|
||||
|
||||
return send_file(
|
||||
io.BytesIO(THUMBNAIL_FILES[thumb_id]), mimetype="image/jpeg", as_attachment=False
|
||||
io.BytesIO(thumb_data), mimetype="image/jpeg", as_attachment=False
|
||||
)
|
||||
|
||||
|
||||
@app.route("/encode/download/<file_id>")
|
||||
@login_required
|
||||
def encode_download(file_id):
|
||||
if file_id not in TEMP_FILES:
|
||||
file_info = temp_storage.get_temp_file(file_id)
|
||||
if not file_info:
|
||||
flash("File expired or not found.", "error")
|
||||
return redirect(url_for("encode_page"))
|
||||
|
||||
file_info = TEMP_FILES[file_id]
|
||||
mime_type = file_info.get("mime_type", "image/png")
|
||||
|
||||
return send_file(
|
||||
@@ -1102,10 +1339,10 @@ def encode_download(file_id):
|
||||
@login_required
|
||||
def encode_file_route(file_id):
|
||||
"""Serve file for Web Share API."""
|
||||
if file_id not in TEMP_FILES:
|
||||
file_info = temp_storage.get_temp_file(file_id)
|
||||
if not file_info:
|
||||
return "Not found", 404
|
||||
|
||||
file_info = TEMP_FILES[file_id]
|
||||
mime_type = file_info.get("mime_type", "image/png")
|
||||
|
||||
return send_file(
|
||||
@@ -1120,11 +1357,11 @@ def encode_file_route(file_id):
|
||||
@login_required
|
||||
def encode_cleanup(file_id):
|
||||
"""Manually cleanup a file after sharing."""
|
||||
TEMP_FILES.pop(file_id, None)
|
||||
temp_storage.delete_temp_file(file_id)
|
||||
|
||||
# Also cleanup thumbnail if exists
|
||||
thumb_id = f"{file_id}_thumb"
|
||||
THUMBNAIL_FILES.pop(thumb_id, None)
|
||||
temp_storage.delete_thumbnail(thumb_id)
|
||||
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
@@ -1247,12 +1484,10 @@ def decode_page():
|
||||
cleanup_temp_files()
|
||||
|
||||
filename = decode_result.filename or "decoded_file"
|
||||
TEMP_FILES[file_id] = {
|
||||
"data": decode_result.file_data,
|
||||
temp_storage.save_temp_file(file_id, decode_result.file_data, {
|
||||
"filename": filename,
|
||||
"mime_type": decode_result.mime_type,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
})
|
||||
|
||||
return render_template(
|
||||
"decode.html",
|
||||
@@ -1271,10 +1506,28 @@ def decode_page():
|
||||
has_qrcode_read=HAS_QRCODE_READ,
|
||||
)
|
||||
|
||||
except InvalidMagicBytesError:
|
||||
flash(
|
||||
"This doesn't appear to be a Stegasoo image. Try a different mode (LSB/DCT).",
|
||||
"warning",
|
||||
)
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
except ReedSolomonError:
|
||||
flash(
|
||||
"Image too corrupted to decode. It may have been re-saved or compressed.",
|
||||
"error",
|
||||
)
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
except InvalidHeaderError:
|
||||
flash(
|
||||
"Invalid or corrupted header. The image may have been modified.",
|
||||
"error",
|
||||
)
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
except DecryptionError:
|
||||
flash(
|
||||
"Decryption failed. Check passphrase, PIN, RSA key, reference photo, and channel key.",
|
||||
"error",
|
||||
"Wrong credentials. Double-check your reference photo, passphrase, PIN, and channel key.",
|
||||
"warning",
|
||||
)
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
except StegasooError as e:
|
||||
@@ -1291,11 +1544,11 @@ def decode_page():
|
||||
@login_required
|
||||
def decode_download(file_id):
|
||||
"""Download decoded file."""
|
||||
if file_id not in TEMP_FILES:
|
||||
file_info = temp_storage.get_temp_file(file_id)
|
||||
if not file_info:
|
||||
flash("File expired or not found.", "error")
|
||||
return redirect(url_for("decode_page"))
|
||||
|
||||
file_info = TEMP_FILES[file_id]
|
||||
mime_type = file_info.get("mime_type", "application/octet-stream")
|
||||
|
||||
return send_file(
|
||||
@@ -1308,7 +1561,25 @@ def decode_download(file_id):
|
||||
|
||||
@app.route("/about")
|
||||
def about():
|
||||
return render_template("about.html", has_argon2=has_argon2(), has_qrcode_read=HAS_QRCODE_READ)
|
||||
from stegasoo.channel import get_channel_status
|
||||
|
||||
channel_status = get_channel_status()
|
||||
|
||||
# Check if user is admin (for QR sharing)
|
||||
current_user = get_current_user()
|
||||
is_admin = current_user.is_admin if current_user else False
|
||||
|
||||
return render_template(
|
||||
"about.html",
|
||||
has_argon2=has_argon2(),
|
||||
has_qrcode_read=HAS_QRCODE_READ,
|
||||
# Channel info (bugfix - was not being passed)
|
||||
channel_configured=channel_status["configured"],
|
||||
channel_fingerprint=channel_status.get("fingerprint"),
|
||||
channel_source=channel_status.get("source"),
|
||||
# Admin check for QR sharing
|
||||
is_admin=is_admin,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -1363,12 +1634,7 @@ def api_tools_strip_metadata():
|
||||
buffer = io.BytesIO(clean_data)
|
||||
filename = image_file.filename.rsplit(".", 1)[0] + "_clean.png"
|
||||
|
||||
return send_file(
|
||||
buffer,
|
||||
mimetype="image/png",
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
return send_file(buffer, mimetype="image/png", as_attachment=True, download_name=filename)
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
|
||||
@@ -1390,13 +1656,15 @@ def api_tools_exif():
|
||||
# Check if it's a JPEG (editable) or not
|
||||
is_jpeg = image_data[:2] == b"\xff\xd8"
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"filename": image_file.filename,
|
||||
"exif": exif,
|
||||
"editable": is_jpeg,
|
||||
"field_count": len(exif),
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"filename": image_file.filename,
|
||||
"exif": exif,
|
||||
"editable": is_jpeg,
|
||||
"field_count": len(exif),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
|
||||
@@ -1415,6 +1683,7 @@ def api_tools_exif_update():
|
||||
updates_json = request.form.get("updates", "{}")
|
||||
try:
|
||||
import json
|
||||
|
||||
updates = json.loads(updates_json)
|
||||
except json.JSONDecodeError:
|
||||
return jsonify({"success": False, "error": "Invalid updates JSON"}), 400
|
||||
@@ -1460,11 +1729,19 @@ def api_tools_exif_clear():
|
||||
clean_data = strip_image_metadata(image_data, output_format=output_format)
|
||||
|
||||
# Determine extension and mimetype
|
||||
ext_map = {"PNG": ("png", "image/png"), "JPEG": ("jpg", "image/jpeg"), "BMP": ("bmp", "image/bmp")}
|
||||
ext_map = {
|
||||
"PNG": ("png", "image/png"),
|
||||
"JPEG": ("jpg", "image/jpeg"),
|
||||
"BMP": ("bmp", "image/bmp"),
|
||||
}
|
||||
ext, mimetype = ext_map.get(output_format, ("png", "image/png"))
|
||||
|
||||
# Return as downloadable file
|
||||
stem = image_file.filename.rsplit(".", 1)[0] if "." in image_file.filename else image_file.filename
|
||||
stem = (
|
||||
image_file.filename.rsplit(".", 1)[0]
|
||||
if "." in image_file.filename
|
||||
else image_file.filename
|
||||
)
|
||||
buffer = io.BytesIO(clean_data)
|
||||
return send_file(
|
||||
buffer,
|
||||
@@ -1605,9 +1882,10 @@ def setup():
|
||||
@login_required
|
||||
def setup_recovery():
|
||||
"""Recovery key setup page (Step 2 of initial setup)."""
|
||||
from stegasoo.recovery import generate_recovery_key, hash_recovery_key, generate_recovery_qr
|
||||
import base64
|
||||
|
||||
from stegasoo.recovery import generate_recovery_key, generate_recovery_qr, hash_recovery_key
|
||||
|
||||
# Only allow during initial setup (no recovery key yet, first admin)
|
||||
if has_recovery_key():
|
||||
return redirect(url_for("index"))
|
||||
@@ -1685,9 +1963,10 @@ def recover():
|
||||
@admin_required
|
||||
def regenerate_recovery():
|
||||
"""Generate a new recovery key (replaces existing one)."""
|
||||
from stegasoo.recovery import generate_recovery_key, hash_recovery_key, generate_recovery_qr
|
||||
import base64
|
||||
|
||||
from stegasoo.recovery import generate_recovery_key, generate_recovery_qr, hash_recovery_key
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action")
|
||||
|
||||
@@ -1886,21 +2165,23 @@ def api_channel_keys():
|
||||
"""Get saved channel keys for current user (JSON API)."""
|
||||
current_user = get_current_user()
|
||||
keys = get_user_channel_keys(current_user.id)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"keys": [
|
||||
{
|
||||
"id": k.id,
|
||||
"name": k.name,
|
||||
"fingerprint": f"{k.channel_key[:4]}...{k.channel_key[-4:]}",
|
||||
"channel_key": k.channel_key,
|
||||
"last_used_at": k.last_used_at,
|
||||
}
|
||||
for k in keys
|
||||
],
|
||||
"can_save": can_save_channel_key(current_user.id),
|
||||
"max_keys": MAX_CHANNEL_KEYS,
|
||||
})
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"keys": [
|
||||
{
|
||||
"id": k.id,
|
||||
"name": k.name,
|
||||
"fingerprint": f"{k.channel_key[:4]}...{k.channel_key[-4:]}",
|
||||
"channel_key": k.channel_key,
|
||||
"last_used_at": k.last_used_at,
|
||||
}
|
||||
for k in keys
|
||||
],
|
||||
"can_save": can_save_channel_key(current_user.id),
|
||||
"max_keys": MAX_CHANNEL_KEYS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/channel/keys/<int:key_id>/use", methods=["POST"])
|
||||
@@ -2042,13 +2323,31 @@ def admin_user_password_reset():
|
||||
if __name__ == "__main__":
|
||||
base_dir = Path(__file__).parent
|
||||
|
||||
# Clean up any leftover temp files from previous runs
|
||||
temp_storage.init(base_dir / "temp_files")
|
||||
cleaned = temp_storage.cleanup_all()
|
||||
if cleaned > 0:
|
||||
print(f"Cleaned up {cleaned} leftover temp files from previous run")
|
||||
|
||||
# HTTPS configuration
|
||||
ssl_context = None
|
||||
if app.config.get("HTTPS_ENABLED", False):
|
||||
hostname = os.environ.get("STEGASOO_HOSTNAME", "localhost")
|
||||
cert_path, key_path = ensure_certs(base_dir, hostname)
|
||||
ssl_context = (str(cert_path), str(key_path))
|
||||
print(f"HTTPS enabled with self-signed certificate for {hostname}")
|
||||
try:
|
||||
cert_path, key_path = ensure_certs(base_dir, hostname)
|
||||
if cert_path.exists() and key_path.exists():
|
||||
ssl_context = (str(cert_path), str(key_path))
|
||||
print(f"HTTPS enabled with self-signed certificate for {hostname}")
|
||||
else:
|
||||
print("ERROR: SSL certificates not found after generation attempt")
|
||||
print(f" Expected: {cert_path}, {key_path}")
|
||||
print(" Falling back to HTTP (INSECURE)")
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to generate SSL certificates: {e}")
|
||||
print(" Falling back to HTTP (INSECURE)")
|
||||
print(" To fix: mkdir -p certs && openssl req -x509 -newkey rsa:2048 \\")
|
||||
print(" -keyout certs/server.key -out certs/server.crt -days 365 -nodes \\")
|
||||
print(" -subj '/CN=localhost'")
|
||||
|
||||
# Auth status
|
||||
if app.config.get("AUTH_ENABLED", True):
|
||||
|
||||
@@ -99,6 +99,23 @@ const Stegasoo = {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Make preview clickable to replace file
|
||||
if (preview) {
|
||||
preview.style.cursor = 'pointer';
|
||||
preview.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Make entire zone clickable (in case label/preview don't cover it)
|
||||
zone.addEventListener('click', (e) => {
|
||||
// Only trigger if not clicking directly on the input
|
||||
if (e.target !== input) {
|
||||
input.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -575,7 +592,7 @@ const Stegasoo = {
|
||||
console.log('QR crop/extract error:', err);
|
||||
container.classList.remove('loading', 'scanning');
|
||||
container.classList.add('error');
|
||||
|
||||
|
||||
// Update loader to show error
|
||||
const loader = container.querySelector('.qr-loader');
|
||||
if (loader) {
|
||||
@@ -584,6 +601,17 @@ const Stegasoo = {
|
||||
<span>No QR code detected</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// Reset after delay so user can try again
|
||||
setTimeout(() => {
|
||||
container.classList.remove('error');
|
||||
container.classList.add('d-none');
|
||||
label?.classList.remove('d-none');
|
||||
// Clear the file input so same file can be re-selected
|
||||
input.value = '';
|
||||
// Remove loader
|
||||
if (loader) loader.remove();
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -888,10 +916,184 @@ const Stegasoo = {
|
||||
});
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// ASYNC ENCODE WITH PROGRESS (v4.1.2)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Submit encode form asynchronously with progress tracking
|
||||
* @param {HTMLFormElement} form - The encode form
|
||||
* @param {HTMLElement} btn - The submit button
|
||||
*/
|
||||
async submitEncodeAsync(form, btn) {
|
||||
const formData = new FormData(form);
|
||||
formData.append('async', 'true');
|
||||
|
||||
// Show progress modal
|
||||
this.showProgressModal('Encoding');
|
||||
|
||||
try {
|
||||
// Start encode job
|
||||
const response = await fetch('/encode', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to start encode');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
const jobId = result.job_id;
|
||||
|
||||
// Poll for progress
|
||||
await this.pollEncodeProgress(jobId);
|
||||
|
||||
} catch (error) {
|
||||
this.hideProgressModal();
|
||||
alert('Encode failed: ' + error.message);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-lock-fill me-2"></i>Encode';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Poll encode progress until complete
|
||||
* @param {string} jobId - The job ID
|
||||
*/
|
||||
async pollEncodeProgress(jobId) {
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const phaseText = document.getElementById('progressPhase');
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
// Check status first
|
||||
const statusResponse = await fetch(`/encode/status/${jobId}`);
|
||||
const statusData = await statusResponse.json();
|
||||
|
||||
if (statusData.status === 'complete') {
|
||||
// Done - redirect to result
|
||||
this.updateProgress(100, 'Complete!');
|
||||
setTimeout(() => {
|
||||
window.location.href = `/encode/result/${statusData.file_id}`;
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusData.status === 'error') {
|
||||
throw new Error(statusData.error || 'Encode failed');
|
||||
}
|
||||
|
||||
// Get progress
|
||||
const progressResponse = await fetch(`/encode/progress/${jobId}`);
|
||||
const progressData = await progressResponse.json();
|
||||
|
||||
const percent = progressData.percent || 0;
|
||||
const phase = progressData.phase || 'processing';
|
||||
|
||||
this.updateProgress(percent, this.formatPhase(phase));
|
||||
|
||||
// Continue polling
|
||||
setTimeout(poll, 500);
|
||||
|
||||
} catch (error) {
|
||||
this.hideProgressModal();
|
||||
alert('Encode failed: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
await poll();
|
||||
},
|
||||
|
||||
/**
|
||||
* Format phase name for display
|
||||
*/
|
||||
formatPhase(phase) {
|
||||
const phases = {
|
||||
'starting': 'Starting...',
|
||||
'initializing': 'Initializing...',
|
||||
'embedding': 'Embedding data...',
|
||||
'saving': 'Saving image...',
|
||||
'finalizing': 'Finalizing...',
|
||||
'complete': 'Complete!',
|
||||
};
|
||||
return phases[phase] || phase;
|
||||
},
|
||||
|
||||
/**
|
||||
* Show progress modal
|
||||
*/
|
||||
showProgressModal(operation = 'Processing') {
|
||||
// Create modal if doesn't exist
|
||||
let modal = document.getElementById('progressModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'progressModal';
|
||||
modal.className = 'modal fade';
|
||||
modal.setAttribute('data-bs-backdrop', 'static');
|
||||
modal.setAttribute('data-bs-keyboard', 'false');
|
||||
modal.innerHTML = `
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-light">
|
||||
<div class="modal-body p-4">
|
||||
<h5 class="mb-3" id="progressTitle">${operation}...</h5>
|
||||
<div class="progress mb-2" style="height: 24px;">
|
||||
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated bg-success"
|
||||
role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between text-muted small">
|
||||
<span id="progressPhase">Initializing...</span>
|
||||
<span id="progressText">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Reset progress
|
||||
this.updateProgress(0, 'Initializing...');
|
||||
|
||||
// Show modal
|
||||
const bsModal = new bootstrap.Modal(modal);
|
||||
bsModal.show();
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide progress modal
|
||||
*/
|
||||
hideProgressModal() {
|
||||
const modal = document.getElementById('progressModal');
|
||||
if (modal) {
|
||||
const bsModal = bootstrap.Modal.getInstance(modal);
|
||||
bsModal?.hide();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update progress bar and text
|
||||
*/
|
||||
updateProgress(percent, phase) {
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const phaseText = document.getElementById('progressPhase');
|
||||
|
||||
if (progressBar) progressBar.style.width = percent + '%';
|
||||
if (progressText) progressText.textContent = Math.round(percent) + '%';
|
||||
if (phaseText) phaseText.textContent = phase;
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// INITIALIZATION HELPERS
|
||||
// ========================================================================
|
||||
|
||||
|
||||
initEncodePage() {
|
||||
this.initPasswordToggles();
|
||||
this.initRsaMethodToggle();
|
||||
@@ -909,27 +1111,23 @@ const Stegasoo = {
|
||||
generateBtnId: 'channelKeyGenerate'
|
||||
});
|
||||
|
||||
// Form submission with channel key validation
|
||||
// Form submission with async progress tracking (v4.1.2)
|
||||
const form = document.getElementById('encodeForm');
|
||||
const btn = document.getElementById('encodeBtn');
|
||||
form?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.validateChannelKeyOnSubmit(form, 'channelSelect', 'channelKeyInput')) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
const startTime = Date.now();
|
||||
const updateTimer = () => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
const mins = Math.floor(elapsed / 60);
|
||||
const secs = elapsed % 60;
|
||||
const timeStr = mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `${secs}s`;
|
||||
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>Encoding... ${timeStr}`;
|
||||
};
|
||||
updateTimer();
|
||||
setInterval(updateTimer, 1000);
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Starting...';
|
||||
}
|
||||
|
||||
// Use async submission with progress tracking
|
||||
this.submitEncodeAsync(form, btn);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -1442,3 +1442,260 @@ footer {
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
MOBILE RESPONSIVE IMPROVEMENTS
|
||||
============================================================================ */
|
||||
|
||||
/* Mobile-specific drop zone improvements */
|
||||
@media (max-width: 768px) {
|
||||
/* Larger drop zones on mobile for easier touch targets */
|
||||
.drop-zone {
|
||||
padding: 2rem 1.5rem;
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
/* Larger touch target for upload icons */
|
||||
.drop-zone-label i {
|
||||
font-size: 2.5rem !important;
|
||||
}
|
||||
|
||||
/* Touch feedback - active state */
|
||||
.drop-zone:active {
|
||||
border-color: var(--gradient-start);
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Mode buttons - stack vertically on very small screens */
|
||||
.d-flex.gap-2:has(.mode-btn) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 1rem;
|
||||
min-height: 56px; /* iOS touch target minimum */
|
||||
}
|
||||
|
||||
/* Full-width primary buttons */
|
||||
.btn-primary.btn-lg {
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
/* Security factor boxes - more padding for touch */
|
||||
.security-box {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
/* Form controls - larger for touch */
|
||||
.form-control,
|
||||
.form-select {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
/* Input groups - consistent sizing */
|
||||
.input-group .form-control {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.input-group .btn {
|
||||
min-width: 48px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* Password toggle button - easier to tap */
|
||||
[data-toggle-password] {
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
/* PIN input - larger on mobile */
|
||||
.pin-input-container .form-control {
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: 4px;
|
||||
padding: 0.875rem 1rem;
|
||||
}
|
||||
|
||||
/* Passphrase input - comfortable mobile size */
|
||||
.passphrase-input {
|
||||
font-size: 1rem !important;
|
||||
padding: 0.875rem 1rem !important;
|
||||
}
|
||||
|
||||
/* Card headers - compact on mobile */
|
||||
.card-header h5 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Alert info panel - readable text */
|
||||
.alert.small {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Bottom info icons - larger tap targets */
|
||||
.row.text-center .col-4 {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.row.text-center .col-4 i {
|
||||
font-size: 2rem !important;
|
||||
}
|
||||
|
||||
/* Capacity panel badges - easier to read */
|
||||
#capacityPanel .badge {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
|
||||
/* Payload type toggle - full width buttons */
|
||||
.btn-group[role="group"] {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Textarea - comfortable height */
|
||||
textarea.form-control {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
/* Channel select - full width */
|
||||
#channelSelect {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens (iPhone SE, etc.) */
|
||||
@media (max-width: 375px) {
|
||||
.drop-zone {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 0.875rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mode-btn .text-muted {
|
||||
display: none; /* Hide secondary text on tiny screens */
|
||||
}
|
||||
|
||||
.card-header h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Stack security factor row */
|
||||
.row:has(.security-box) > .col-md-6 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch device optimizations */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
/* Remove hover effects that don't work on touch */
|
||||
.btn-primary:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.card-link:hover .feature-card {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Add active states instead */
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.feature-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Drop zone active feedback */
|
||||
.drop-zone:active {
|
||||
border-color: var(--gradient-start);
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
/* Mode button active state */
|
||||
.mode-btn:active {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: var(--gradient-start);
|
||||
}
|
||||
}
|
||||
|
||||
/* Camera hint for mobile - shows on file inputs */
|
||||
@media (max-width: 768px) {
|
||||
.drop-zone-label span.text-muted {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Add camera icon hint on mobile */
|
||||
.drop-zone-label::after {
|
||||
content: "Tap to take photo or choose file";
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Hide the default text and show mobile version */
|
||||
.drop-zone-label > span.text-muted {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Navbar mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.navbar-brand img {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* Sticky header shouldn't eat too much space */
|
||||
.navbar.sticky-top {
|
||||
position: relative; /* Don't stick on mobile - saves screen space */
|
||||
}
|
||||
}
|
||||
|
||||
/* Results page mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
/* Download button - full width on mobile */
|
||||
.btn-success.btn-lg,
|
||||
a.btn-success.btn-lg {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* QR codes - appropriate sizing */
|
||||
.qr-scan-container {
|
||||
max-width: 280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Message display - readable on mobile */
|
||||
.alert-message {
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Result icon - slightly smaller on mobile */
|
||||
.result-icon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ def encode_operation(params: dict) -> dict:
|
||||
dct_output_format=params.get("dct_output_format", "png"),
|
||||
dct_color_mode=params.get("dct_color_mode", "color"),
|
||||
channel_key=resolved_channel_key, # v4.0.0
|
||||
progress_file=params.get("progress_file"), # v4.1.2
|
||||
)
|
||||
|
||||
# Build stats dict if available
|
||||
|
||||
@@ -47,6 +47,8 @@ import base64
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -233,6 +235,8 @@ class SubprocessStego:
|
||||
# Channel key (v4.0.0)
|
||||
channel_key: str | None = "auto",
|
||||
timeout: int | None = None,
|
||||
# Progress file (v4.1.2)
|
||||
progress_file: str | None = None,
|
||||
) -> EncodeResult:
|
||||
"""
|
||||
Encode a message or file into an image.
|
||||
@@ -268,6 +272,7 @@ class SubprocessStego:
|
||||
"dct_output_format": dct_output_format,
|
||||
"dct_color_mode": dct_color_mode,
|
||||
"channel_key": channel_key, # v4.0.0
|
||||
"progress_file": progress_file, # v4.1.2
|
||||
}
|
||||
|
||||
if file_data:
|
||||
@@ -496,3 +501,42 @@ def get_subprocess_stego() -> SubprocessStego:
|
||||
if _default_stego is None:
|
||||
_default_stego = SubprocessStego()
|
||||
return _default_stego
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Progress File Utilities (v4.1.2)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def generate_job_id() -> str:
|
||||
"""Generate a unique job ID for tracking encode/decode operations."""
|
||||
return str(uuid.uuid4())[:8]
|
||||
|
||||
|
||||
def get_progress_file_path(job_id: str) -> str:
|
||||
"""Get the progress file path for a job ID."""
|
||||
return str(Path(tempfile.gettempdir()) / f"stegasoo_progress_{job_id}.json")
|
||||
|
||||
|
||||
def read_progress(job_id: str) -> dict | None:
|
||||
"""
|
||||
Read progress from file for a job ID.
|
||||
|
||||
Returns:
|
||||
Progress dict with current, total, percent, phase, or None if not found
|
||||
"""
|
||||
progress_file = get_progress_file_path(job_id)
|
||||
try:
|
||||
with open(progress_file) as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def cleanup_progress_file(job_id: str) -> None:
|
||||
"""Remove progress file for a completed job."""
|
||||
progress_file = get_progress_file_path(job_id)
|
||||
try:
|
||||
Path(progress_file).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
214
frontends/web/temp_storage.py
Normal file
214
frontends/web/temp_storage.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
File-based Temporary Storage
|
||||
|
||||
Stores temp files on disk instead of in-memory dict.
|
||||
This allows multiple Gunicorn workers to share temp files
|
||||
and survives service restarts within the expiry window.
|
||||
|
||||
Files are stored in a temp directory with:
|
||||
- {file_id}.data - The actual file data
|
||||
- {file_id}.json - Metadata (filename, timestamp, mime_type, etc.)
|
||||
|
||||
IMPORTANT: This module ONLY manages files in the temp_files/ directory.
|
||||
It does NOT touch instance/ (auth database) or any other directories.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
# Default temp directory (can be overridden)
|
||||
DEFAULT_TEMP_DIR = Path(__file__).parent / "temp_files"
|
||||
|
||||
# Lock for thread-safe operations
|
||||
_lock = Lock()
|
||||
|
||||
# Module-level temp directory (set on init)
|
||||
_temp_dir: Path = DEFAULT_TEMP_DIR
|
||||
|
||||
|
||||
def init(temp_dir: Path | str | None = None):
|
||||
"""Initialize temp storage with optional custom directory."""
|
||||
global _temp_dir
|
||||
_temp_dir = Path(temp_dir) if temp_dir else DEFAULT_TEMP_DIR
|
||||
_temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _data_path(file_id: str) -> Path:
|
||||
"""Get path for file data."""
|
||||
return _temp_dir / f"{file_id}.data"
|
||||
|
||||
|
||||
def _meta_path(file_id: str) -> Path:
|
||||
"""Get path for file metadata."""
|
||||
return _temp_dir / f"{file_id}.json"
|
||||
|
||||
|
||||
def _thumb_path(thumb_id: str) -> Path:
|
||||
"""Get path for thumbnail data."""
|
||||
return _temp_dir / f"{thumb_id}.thumb"
|
||||
|
||||
|
||||
def save_temp_file(file_id: str, data: bytes, metadata: dict) -> None:
|
||||
"""
|
||||
Save a temp file with its metadata.
|
||||
|
||||
Args:
|
||||
file_id: Unique identifier for the file
|
||||
data: File contents as bytes
|
||||
metadata: Dict with filename, mime_type, timestamp, etc.
|
||||
"""
|
||||
init() # Ensure directory exists
|
||||
|
||||
with _lock:
|
||||
# Add timestamp if not present
|
||||
if "timestamp" not in metadata:
|
||||
metadata["timestamp"] = time.time()
|
||||
|
||||
# Write data file
|
||||
_data_path(file_id).write_bytes(data)
|
||||
|
||||
# Write metadata
|
||||
_meta_path(file_id).write_text(json.dumps(metadata))
|
||||
|
||||
|
||||
def get_temp_file(file_id: str) -> dict | None:
|
||||
"""
|
||||
Get a temp file and its metadata.
|
||||
|
||||
Returns:
|
||||
Dict with 'data' (bytes) and all metadata fields, or None if not found.
|
||||
"""
|
||||
init()
|
||||
|
||||
data_file = _data_path(file_id)
|
||||
meta_file = _meta_path(file_id)
|
||||
|
||||
if not data_file.exists() or not meta_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
data = data_file.read_bytes()
|
||||
metadata = json.loads(meta_file.read_text())
|
||||
return {"data": data, **metadata}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def has_temp_file(file_id: str) -> bool:
|
||||
"""Check if a temp file exists."""
|
||||
init()
|
||||
return _data_path(file_id).exists() and _meta_path(file_id).exists()
|
||||
|
||||
|
||||
def delete_temp_file(file_id: str) -> None:
|
||||
"""Delete a temp file and its metadata."""
|
||||
init()
|
||||
|
||||
with _lock:
|
||||
_data_path(file_id).unlink(missing_ok=True)
|
||||
_meta_path(file_id).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def save_thumbnail(thumb_id: str, data: bytes) -> None:
|
||||
"""Save a thumbnail."""
|
||||
init()
|
||||
|
||||
with _lock:
|
||||
_thumb_path(thumb_id).write_bytes(data)
|
||||
|
||||
|
||||
def get_thumbnail(thumb_id: str) -> bytes | None:
|
||||
"""Get thumbnail data."""
|
||||
init()
|
||||
|
||||
thumb_file = _thumb_path(thumb_id)
|
||||
if not thumb_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
return thumb_file.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def delete_thumbnail(thumb_id: str) -> None:
|
||||
"""Delete a thumbnail."""
|
||||
init()
|
||||
|
||||
with _lock:
|
||||
_thumb_path(thumb_id).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def cleanup_expired(max_age_seconds: float) -> int:
|
||||
"""
|
||||
Delete expired temp files.
|
||||
|
||||
Args:
|
||||
max_age_seconds: Maximum age in seconds before expiry
|
||||
|
||||
Returns:
|
||||
Number of files deleted
|
||||
"""
|
||||
init()
|
||||
|
||||
now = time.time()
|
||||
deleted = 0
|
||||
|
||||
with _lock:
|
||||
# Find all metadata files
|
||||
for meta_file in _temp_dir.glob("*.json"):
|
||||
try:
|
||||
metadata = json.loads(meta_file.read_text())
|
||||
timestamp = metadata.get("timestamp", 0)
|
||||
|
||||
if now - timestamp > max_age_seconds:
|
||||
file_id = meta_file.stem
|
||||
_data_path(file_id).unlink(missing_ok=True)
|
||||
meta_file.unlink(missing_ok=True)
|
||||
# Also delete thumbnail if exists
|
||||
_thumb_path(f"{file_id}_thumb").unlink(missing_ok=True)
|
||||
deleted += 1
|
||||
except (OSError, json.JSONDecodeError):
|
||||
# Remove corrupted files
|
||||
meta_file.unlink(missing_ok=True)
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
def cleanup_all() -> int:
|
||||
"""
|
||||
Delete all temp files. Call on service start/stop.
|
||||
|
||||
Returns:
|
||||
Number of files deleted
|
||||
"""
|
||||
init()
|
||||
|
||||
deleted = 0
|
||||
|
||||
with _lock:
|
||||
for f in _temp_dir.iterdir():
|
||||
if f.is_file():
|
||||
f.unlink(missing_ok=True)
|
||||
deleted += 1
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
def get_stats() -> dict:
|
||||
"""Get temp storage statistics."""
|
||||
init()
|
||||
|
||||
files = list(_temp_dir.glob("*.data"))
|
||||
total_size = sum(f.stat().st_size for f in files if f.exists())
|
||||
|
||||
return {
|
||||
"file_count": len(files),
|
||||
"total_size_bytes": total_size,
|
||||
"temp_dir": str(_temp_dir),
|
||||
}
|
||||
@@ -100,7 +100,7 @@
|
||||
<li><strong>Output:</strong> JPEG or PNG</li>
|
||||
<li><strong>Color:</strong> Color or grayscale</li>
|
||||
<li><strong>Speed:</strong> ~2s</li>
|
||||
<li><strong>Error Correction:</strong> Reed-Solomon <span class="badge bg-info ms-1">v4.1</span></li>
|
||||
<li><strong>Error Correction:</strong> Reed-Solomon</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<div class="small">
|
||||
@@ -331,10 +331,12 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Channel Key QR Generator -->
|
||||
<!-- Channel Key QR Generator (Admin only) -->
|
||||
{% if is_admin %}
|
||||
<div class="card bg-dark border-secondary">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-qr-code me-2"></i>Share Channel Key via QR
|
||||
<span class="badge bg-warning text-dark ms-2"><i class="bi bi-shield-check me-1"></i>Admin</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted mb-3">Generate a QR code to share a channel key with others.</p>
|
||||
@@ -366,6 +368,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -375,56 +378,64 @@
|
||||
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Version History</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-sm small">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Version</th>
|
||||
<th>Changes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>4.1.0</strong></td>
|
||||
<td>
|
||||
<strong>Reed-Solomon error correction</strong> for DCT mode (corrects up to 16 byte errors per 223-byte chunk),
|
||||
majority voting on length headers, improved robustness with problematic carrier images
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>4.0.0</strong></td>
|
||||
<td>
|
||||
<strong>Channel keys</strong> for group/deployment isolation,
|
||||
DCT default, simplified auth, passphrase replaces day_phrase,
|
||||
4-word default, JPEG fix, large image support, subprocess isolation, Python 3.10-3.12
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3.2.0</td>
|
||||
<td>Single passphrase, more default words</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3.0.0</td>
|
||||
<td>DCT mode, JPEG output, color preservation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2.2.0</td>
|
||||
<td>QR code RSA key import/export</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2.1.0</td>
|
||||
<td>File embedding, compression</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2.0.0</td>
|
||||
<td>Web UI, REST API, RSA keys</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>1.0.0</td>
|
||||
<td>Initial release, CLI only, LSB mode</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Current Version - Prominent -->
|
||||
<div class="alert alert-success mb-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge bg-success fs-6 me-3">v4.1.2</span>
|
||||
<div>
|
||||
<strong>Progress bars</strong> for encode operations,
|
||||
<strong>mobile-responsive polish</strong>,
|
||||
DCT decode bug fix, release validation script
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Previous Versions - Accordion -->
|
||||
<div class="accordion" id="versionAccordion">
|
||||
<div class="accordion-item bg-dark">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed bg-dark text-light py-2" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#olderVersions">
|
||||
<i class="bi bi-archive me-2"></i>Previous Versions
|
||||
</button>
|
||||
</h2>
|
||||
<div id="olderVersions" class="accordion-collapse collapse" data-bs-parent="#versionAccordion">
|
||||
<div class="accordion-body p-0">
|
||||
<table class="table table-dark table-sm small mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="80"><strong>4.1.1</strong></td>
|
||||
<td>DCT RS format stability, Docker cleanup, first-boot wizard</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>4.1.0</strong></td>
|
||||
<td>Reed-Solomon error correction for DCT, majority voting headers</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>4.0.0</strong></td>
|
||||
<td>Channel keys, DCT default, subprocess isolation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3.2.0</td>
|
||||
<td>Single passphrase, more default words</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3.0.0</td>
|
||||
<td>DCT mode, JPEG output, color preservation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2.x</td>
|
||||
<td>Web UI, REST API, RSA keys, QR codes, file embedding</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>1.0.0</td>
|
||||
<td>Initial release, CLI only, LSB mode</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -553,9 +564,8 @@
|
||||
<div class="col-6 col-md-4 col-lg-2 mb-3">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-bandaid text-info fs-3 d-block mb-2"></i>
|
||||
<div class="small text-muted">Error Correction</div>
|
||||
<strong>Reed-Solomon</strong>
|
||||
<span class="badge bg-info ms-1">v4.1</span>
|
||||
<div class="small text-muted">DCT ECC</div>
|
||||
<strong>RS Code</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,6 +140,13 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
{% if is_admin %}
|
||||
<button type="button" class="btn btn-outline-info"
|
||||
onclick="showKeyQr('{{ key.channel_key }}', '{{ key.name }}')"
|
||||
title="Show QR Code">
|
||||
<i class="bi bi-qr-code"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-outline-secondary"
|
||||
onclick="renameKey({{ key.id }}, '{{ key.name }}')"
|
||||
title="Rename">
|
||||
@@ -218,10 +225,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_admin %}
|
||||
<!-- QR Code Modal (Admin only) -->
|
||||
<div class="modal fade" id="qrModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title"><i class="bi bi-qr-code me-2"></i><span id="qrKeyName">Channel Key</span></h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<canvas id="qrCanvas" class="bg-white p-2 rounded"></canvas>
|
||||
<div class="mt-2">
|
||||
<code class="small" id="qrKeyDisplay"></code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-center">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="qrDownload">
|
||||
<i class="bi bi-download me-1"></i>Download PNG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
{% if is_admin %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
|
||||
{% endif %}
|
||||
<script>
|
||||
StegasooAuth.initPasswordConfirmation('accountForm', 'newPasswordInput', 'newPasswordConfirmInput');
|
||||
|
||||
@@ -230,5 +265,45 @@ function renameKey(keyId, currentName) {
|
||||
document.getElementById('renameForm').action = '/account/keys/' + keyId + '/rename';
|
||||
new bootstrap.Modal(document.getElementById('renameModal')).show();
|
||||
}
|
||||
|
||||
{% if is_admin %}
|
||||
function showKeyQr(channelKey, keyName) {
|
||||
// Format key with dashes if not already
|
||||
const clean = channelKey.replace(/-/g, '').toUpperCase();
|
||||
const formatted = clean.match(/.{4}/g)?.join('-') || clean;
|
||||
|
||||
// Update modal content
|
||||
document.getElementById('qrKeyName').textContent = keyName;
|
||||
document.getElementById('qrKeyDisplay').textContent = formatted;
|
||||
|
||||
// Generate QR code
|
||||
const canvas = document.getElementById('qrCanvas');
|
||||
if (typeof QRCode !== 'undefined' && canvas) {
|
||||
QRCode.toCanvas(canvas, formatted, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: { dark: '#000', light: '#fff' }
|
||||
}, function(error) {
|
||||
if (error) {
|
||||
console.error('QR generation error:', error);
|
||||
return;
|
||||
}
|
||||
new bootstrap.Modal(document.getElementById('qrModal')).show();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Download QR as PNG
|
||||
document.getElementById('qrDownload')?.addEventListener('click', function() {
|
||||
const canvas = document.getElementById('qrCanvas');
|
||||
const keyName = document.getElementById('qrKeyName').textContent;
|
||||
if (canvas) {
|
||||
const link = document.createElement('a');
|
||||
link.download = 'stegasoo-channel-key-' + keyName.toLowerCase().replace(/\s+/g, '-') + '.png';
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -518,5 +518,8 @@ advancedOptionsDec?.addEventListener('show.bs.collapse', () => {
|
||||
advancedOptionsDec?.addEventListener('hide.bs.collapse', () => {
|
||||
document.getElementById('advancedChevronDec')?.classList.replace('bi-chevron-up', 'bi-chevron-down');
|
||||
});
|
||||
|
||||
// Loading state for decode button
|
||||
Stegasoo.initFormLoading('decodeForm', 'decodeBtn', 'Decoding...');
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "stegasoo"
|
||||
version = "4.1.1"
|
||||
version = "4.1.2"
|
||||
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -54,6 +54,7 @@ cli = [
|
||||
"click>=8.0.0",
|
||||
"qrcode>=7.30",
|
||||
"piexif>=1.1.0",
|
||||
"rich>=13.0.0",
|
||||
]
|
||||
compression = [
|
||||
"lz4>=4.0.0",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Stegasoo Pi Image Build Workflow
|
||||
|
||||
> **Note:** This guide is for developers building custom Pi images.
|
||||
> **End users:** Just download the pre-built `.img.zst` from [Releases](https://github.com/adlee-was-taken/stegasoo/releases), flash it, and boot. No build process needed.
|
||||
|
||||
Quick reference for building a distributable SD card image.
|
||||
|
||||
## Step 1: Flash Fresh Raspbian
|
||||
@@ -26,25 +29,48 @@ ssh admin@stegasoo.local
|
||||
# Take ownership of /opt (for pyenv, jpegio builds)
|
||||
sudo chown admin:admin /opt
|
||||
|
||||
# Install git (not included in Lite image)
|
||||
sudo apt-get update && sudo apt-get install -y git
|
||||
# Install git and zstd (not included in Lite image)
|
||||
sudo apt-get update && sudo apt-get install -y git zstd jq
|
||||
```
|
||||
|
||||
## Step 4: Clone & Run Setup
|
||||
## Step 4: Clone Repo
|
||||
|
||||
```bash
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git
|
||||
cd stegasoo
|
||||
./rpi/setup.sh
|
||||
cd /opt
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
```
|
||||
|
||||
This takes ~15-20 minutes and installs:
|
||||
- Python 3.12 via pyenv
|
||||
- jpegio (patched for ARM)
|
||||
- Stegasoo with web UI
|
||||
- Systemd service
|
||||
## Step 5: Copy Pre-built Tarball (from host)
|
||||
|
||||
## Step 5: Test It Works
|
||||
> **Dev-only asset:** This tarball is for building Pi images, not for end users.
|
||||
> It's available on [Releases](https://github.com/adlee-was-taken/stegasoo/releases) for image builders.
|
||||
|
||||
```bash
|
||||
# On your host machine:
|
||||
scp rpi/stegasoo-pi-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
||||
```
|
||||
|
||||
This tarball contains:
|
||||
- pyenv with Python 3.12 (pre-compiled for ARM64)
|
||||
- venv with all dependencies (jpegio, scipy, etc.)
|
||||
|
||||
Install time: **~2 minutes** (vs 20+ min from source)
|
||||
|
||||
## Step 6: Run Setup
|
||||
|
||||
```bash
|
||||
cd /opt/stegasoo
|
||||
./rpi/setup.sh # Detects local tarball, skips download
|
||||
```
|
||||
|
||||
### From-Source Build (optional)
|
||||
|
||||
To build without the pre-built tarball:
|
||||
```bash
|
||||
./rpi/setup.sh --no-prebuilt # Takes 15-20 minutes
|
||||
```
|
||||
|
||||
## Step 7: Test It Works
|
||||
|
||||
```bash
|
||||
sudo systemctl start stegasoo
|
||||
@@ -52,7 +78,7 @@ curl -k https://localhost:5000
|
||||
# Should return HTML
|
||||
```
|
||||
|
||||
## Step 6: Sanitize for Distribution
|
||||
## Step 8: Sanitize for Distribution
|
||||
|
||||
```bash
|
||||
# Full sanitize (for final image - removes WiFi, shuts down)
|
||||
@@ -72,7 +98,7 @@ This removes:
|
||||
|
||||
The script validates all cleanup steps before finishing.
|
||||
|
||||
## Step 7: Copy the Image
|
||||
## Step 9: Copy the Image
|
||||
|
||||
Remove SD card, insert into your Linux machine:
|
||||
|
||||
@@ -84,7 +110,7 @@ lsblk
|
||||
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
|
||||
```
|
||||
|
||||
## Step 8: Shrink & Compress
|
||||
## Step 10: Shrink & Compress
|
||||
|
||||
```bash
|
||||
# Optional: Shrink image (saves space)
|
||||
@@ -96,16 +122,54 @@ sudo ./pishrink.sh stegasoo-rpi-*.img
|
||||
zstd -19 -T0 stegasoo-rpi-*.img
|
||||
```
|
||||
|
||||
## Step 9: Distribute
|
||||
## Step 11: Distribute
|
||||
|
||||
Upload `.img.zst` to GitHub Releases.
|
||||
|
||||
Users can flash with:
|
||||
```bash
|
||||
# Linux
|
||||
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
# Option 1: rpi-imager CLI (supports .zst.zip directly)
|
||||
sudo rpi-imager --cli --disable-verify stegasoo-rpi-*.img.zst.zip /dev/sdX
|
||||
|
||||
# Or use rpi-imager "Use custom" option
|
||||
# Option 2: flash-image.sh (auto-detects SD card, shows progress)
|
||||
sudo ./rpi/flash-image.sh stegasoo-rpi-*.img.zst.zip
|
||||
|
||||
# Option 3: Manual dd
|
||||
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating the Pre-built Tarball
|
||||
|
||||
After a successful from-source build, create the pre-built tarball for future installs:
|
||||
|
||||
```bash
|
||||
# On the Pi after successful setup:
|
||||
cd ~
|
||||
|
||||
# Strip caches and tests from venv (295MB → 208MB)
|
||||
find /opt/stegasoo/venv/ -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null
|
||||
find /opt/stegasoo/venv/ -type d -name 'tests' -exec rm -rf {} + 2>/dev/null
|
||||
find /opt/stegasoo/venv/ -type d -name 'test' -exec rm -rf {} + 2>/dev/null
|
||||
|
||||
# Create venv tarball
|
||||
cd /opt/stegasoo
|
||||
tar -cf - venv/ | zstd -19 -T0 > ~/stegasoo-venv.tar.zst
|
||||
|
||||
# Create combined tarball (pyenv + venv pointer)
|
||||
cd ~
|
||||
tar -cf - .pyenv stegasoo-venv.tar.zst | zstd -19 -T0 > /tmp/stegasoo-pi-arm64.tar.zst
|
||||
|
||||
# Check size (should be ~50-60MB)
|
||||
ls -lh /tmp/stegasoo-pi-arm64.tar.zst
|
||||
```
|
||||
|
||||
Pull to host and upload to GitHub releases:
|
||||
```bash
|
||||
# On host:
|
||||
scp admin@stegasoo.local:/tmp/stegasoo-pi-arm64.tar.zst ./
|
||||
# Upload to GitHub releases as stegasoo-pi-arm64.tar.zst
|
||||
```
|
||||
|
||||
---
|
||||
@@ -115,14 +179,19 @@ zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
```bash
|
||||
# On Pi (after SSH):
|
||||
sudo chown admin:admin /opt
|
||||
sudo apt-get update && sudo apt-get install -y git
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git
|
||||
cd stegasoo && ./rpi/setup.sh
|
||||
sudo apt-get update && sudo apt-get install -y git zstd jq
|
||||
cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
|
||||
# On host (copy tarball):
|
||||
scp rpi/stegasoo-pi-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
||||
|
||||
# On Pi (run setup):
|
||||
cd /opt/stegasoo && ./rpi/setup.sh
|
||||
sudo systemctl start stegasoo
|
||||
curl -k https://localhost:5000
|
||||
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
||||
|
||||
# On your machine:
|
||||
# On host (pull image):
|
||||
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
|
||||
zstd -19 -T0 stegasoo-rpi-*.img
|
||||
```
|
||||
|
||||
@@ -12,8 +12,9 @@ sudo chown $USER:$USER /opt
|
||||
sudo apt-get update && sudo apt-get install -y git
|
||||
|
||||
# Clone and run setup
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git /opt/stegasoo
|
||||
cd /opt/stegasoo
|
||||
cd /opt
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
cd stegasoo
|
||||
./rpi/setup.sh
|
||||
```
|
||||
|
||||
@@ -138,8 +139,9 @@ sudo chown admin:admin /opt
|
||||
sudo apt-get update && sudo apt-get install -y git
|
||||
|
||||
# Clone and run setup
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git /opt/stegasoo
|
||||
cd /opt/stegasoo
|
||||
cd /opt
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
cd stegasoo
|
||||
./rpi/setup.sh
|
||||
```
|
||||
|
||||
@@ -196,7 +198,12 @@ Upload the `.img.zst` file to GitHub Releases.
|
||||
|
||||
Users flash with:
|
||||
```bash
|
||||
# Option 1: rpi-imager CLI (supports .zst.zip directly)
|
||||
sudo rpi-imager --cli --disable-verify stegasoo-rpi-*.img.zst.zip /dev/sdX
|
||||
|
||||
# Option 2: flash-image.sh (auto-detects SD card, shows progress)
|
||||
sudo ./rpi/flash-image.sh stegasoo-rpi-*.img.zst.zip
|
||||
|
||||
# Option 3: Manual dd
|
||||
zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
```
|
||||
|
||||
Or use rpi-imager's "Use custom" option.
|
||||
|
||||
12
rpi/config.json.example
Normal file
12
rpi/config.json.example
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"hostname": "stegasoo",
|
||||
"username": "admin",
|
||||
"password": "stegasoo",
|
||||
"wifiSSID": "YourNetworkName",
|
||||
"wifiPassword": "YourWiFiPassword",
|
||||
"wifiCountry": "US",
|
||||
"locale": "en_US.UTF-8",
|
||||
"keyboardLayout": "us",
|
||||
"timezone": "America/New_York",
|
||||
"enableSSH": true
|
||||
}
|
||||
@@ -39,21 +39,17 @@ clear
|
||||
# Welcome
|
||||
# =============================================================================
|
||||
|
||||
gum style \
|
||||
--border double \
|
||||
--border-foreground 212 \
|
||||
--padding "1 2" \
|
||||
--margin "1" \
|
||||
--align center \
|
||||
" . * . . * . * . * . * ." \
|
||||
" ___ _____ ___ ___ _ ___ ___ ___ " \
|
||||
" / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\" \
|
||||
" \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |" \
|
||||
" |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/" \
|
||||
"" \
|
||||
" * . * . * . * . * . *" \
|
||||
"" \
|
||||
"First Boot Wizard"
|
||||
echo ""
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
||||
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
||||
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
|
||||
echo -e "\033[38;5;220m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
|
||||
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
|
||||
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
echo -e "\033[1;37m First Boot Wizard\033[0m"
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
|
||||
echo ""
|
||||
gum style --foreground 245 "This wizard will help you configure your Stegasoo server."
|
||||
@@ -283,6 +279,32 @@ EOF
|
||||
"
|
||||
gum style --foreground 82 "✓ Service configured"
|
||||
|
||||
# Generate SSL certificates if HTTPS enabled
|
||||
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||
gum spin --spinner dot --title "Generating SSL certificates..." -- bash -c "
|
||||
CERT_DIR='$INSTALL_DIR/frontends/web/certs'
|
||||
mkdir -p \"\$CERT_DIR\"
|
||||
|
||||
# Get local IP for SAN
|
||||
LOCAL_IP=\$(hostname -I | awk '{print \$1}')
|
||||
HOSTNAME=\$(hostname)
|
||||
|
||||
# Generate cert with SANs for IP, hostname, and localhost
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout \"\$CERT_DIR/server.key\" \
|
||||
-out \"\$CERT_DIR/server.crt\" \
|
||||
-days 365 -nodes \
|
||||
-subj \"/O=Stegasoo/CN=\$HOSTNAME\" \
|
||||
-addext \"subjectAltName=DNS:\$HOSTNAME,DNS:\$HOSTNAME.local,DNS:localhost,IP:\$LOCAL_IP,IP:127.0.0.1\" \
|
||||
2>/dev/null
|
||||
|
||||
# Fix permissions
|
||||
chmod 600 \"\$CERT_DIR/server.key\"
|
||||
chown -R $STEGASOO_USER:\$(id -gn $STEGASOO_USER) \"\$CERT_DIR\"
|
||||
"
|
||||
gum style --foreground 82 "✓ SSL certificates generated"
|
||||
fi
|
||||
|
||||
# Setup port 443 if requested
|
||||
if [ "$USE_PORT_443" = "true" ]; then
|
||||
gum spin --spinner dot --title "Setting up port 443 redirect..." -- bash -c "
|
||||
@@ -384,72 +406,49 @@ else
|
||||
ACCESS_URL_LOCAL="http://$HOSTNAME.local:5000/setup"
|
||||
fi
|
||||
|
||||
gum style \
|
||||
--border double \
|
||||
--border-foreground 82 \
|
||||
--padding "1 2" \
|
||||
--margin "1" \
|
||||
--align center \
|
||||
" . * . . * . * . * . * ." \
|
||||
" ___ _____ ___ ___ _ ___ ___ ___" \
|
||||
" / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\" \
|
||||
" \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |" \
|
||||
" |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/" \
|
||||
"" \
|
||||
" * . * . * . * . * . *" \
|
||||
"" \
|
||||
"Setup Complete!"
|
||||
echo ""
|
||||
echo ""
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
||||
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
||||
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
|
||||
echo -e "\033[38;5;220m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
|
||||
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
|
||||
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
echo -e "\033[1;32m Setup Complete!\033[0m"
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
|
||||
echo ""
|
||||
gum style --foreground 82 --bold "Create your admin account:"
|
||||
gum style --foreground 226 " $ACCESS_URL"
|
||||
gum style --foreground 245 " $ACCESS_URL_LOCAL (if mDNS works)"
|
||||
echo ""
|
||||
|
||||
if [ -n "$CHANNEL_KEY" ]; then
|
||||
gum style --foreground 82 --bold "Channel Key:"
|
||||
gum style --foreground 226 " $CHANNEL_KEY"
|
||||
echo ""
|
||||
echo -e "\033[1;32mChannel Key:\033[0m \033[0;33m$CHANNEL_KEY\033[0m"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
gum style --foreground 82 --bold "First Steps:"
|
||||
gum style --foreground 255 \
|
||||
" 1. Open the URL above in your browser" \
|
||||
" 2. Accept the security warning (self-signed cert)" \
|
||||
" 3. Create your admin account" \
|
||||
" 4. Start encoding secret messages!"
|
||||
echo ""
|
||||
gum style --foreground 255 " 1. Open URL → 2. Accept cert → 3. Create admin → 4. Encode!"
|
||||
|
||||
gum style --foreground 82 --bold "Useful Commands:"
|
||||
gum style --foreground 245 \
|
||||
" sudo systemctl status stegasoo # Check status" \
|
||||
" sudo systemctl restart stegasoo # Restart" \
|
||||
" journalctl -u stegasoo -f # View logs"
|
||||
echo ""
|
||||
|
||||
gum style --foreground 212 --bold "Enjoy Stegasoo!"
|
||||
echo ""
|
||||
gum style --foreground 245 "Commands: systemctl {status|restart} stegasoo, journalctl -u stegasoo -f"
|
||||
|
||||
# Prompt for restart if overclock was enabled
|
||||
if [ "$NEEDS_RESTART" = "true" ]; then
|
||||
echo ""
|
||||
gum style \
|
||||
--border rounded \
|
||||
--border-foreground 226 \
|
||||
--padding "1 2" \
|
||||
--foreground 226 \
|
||||
"Restart Required" \
|
||||
"" \
|
||||
"Overclock settings require a restart to take effect."
|
||||
echo ""
|
||||
|
||||
gum style --foreground 226 --bold "⚠ Restart required for overclock settings"
|
||||
if gum confirm "Restart now?" --default=true; then
|
||||
gum style --foreground 82 "Restarting in 3 seconds..."
|
||||
sleep 3
|
||||
sudo reboot
|
||||
else
|
||||
gum style --foreground 214 "Remember to restart later for overclock to take effect:"
|
||||
gum style --foreground 245 " sudo reboot"
|
||||
echo ""
|
||||
gum style --foreground 214 "Run 'sudo reboot' later to apply overclock."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
gum style --foreground 212 --bold "Enjoy Stegasoo!"
|
||||
echo ""
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Flash Stegasoo image to SD card
|
||||
# Auto-detects SD card, decompresses and writes with progress
|
||||
# Uses rpi-imager if available, falls back to dd
|
||||
#
|
||||
# Usage: ./flash-image.sh <image.img.xz> [device]
|
||||
# ./flash-image.sh <image.img> [device]
|
||||
# Usage: ./flash-image.sh <image> [device]
|
||||
#
|
||||
# If device is specified, skips auto-detection (useful for large drives)
|
||||
# Supports: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip (GitHub release format)
|
||||
# If device is specified, skips auto-detection (useful for NVMe/large drives)
|
||||
#
|
||||
# Optional: Place config.json in same directory for headless WiFi setup
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONFIG_FILE="$SCRIPT_DIR/config.json"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
@@ -18,14 +23,50 @@ BLUE='\033[0;34m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Load config if present (optional - for headless WiFi setup)
|
||||
HAS_CONFIG=false
|
||||
if [ -f "$CONFIG_FILE" ] && command -v jq &> /dev/null; then
|
||||
WIFI_SSID=$(jq -r '.wifiSSID // empty' "$CONFIG_FILE")
|
||||
WIFI_PASS=$(jq -r '.wifiPassword // empty' "$CONFIG_FILE")
|
||||
WIFI_COUNTRY=$(jq -r '.wifiCountry // "US"' "$CONFIG_FILE")
|
||||
PI_HOSTNAME=$(jq -r '.hostname // empty' "$CONFIG_FILE")
|
||||
if [ -n "$WIFI_SSID" ] && [ -n "$WIFI_PASS" ]; then
|
||||
HAS_CONFIG=true
|
||||
echo -e "${GREEN}Found config.json - will configure WiFi after flash${NC}"
|
||||
echo -e " WiFi: ${YELLOW}$WIFI_SSID${NC}"
|
||||
if [ -n "$PI_HOSTNAME" ]; then
|
||||
echo -e " Hostname: ${YELLOW}$PI_HOSTNAME${NC}"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
elif [ -f "$CONFIG_FILE" ]; then
|
||||
echo -e "${YELLOW}Note: config.json found but jq not installed (apt install jq)${NC}"
|
||||
echo -e "${YELLOW} WiFi will need to be configured manually after boot${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Check for required tools
|
||||
for cmd in dd pv lsblk; do
|
||||
for cmd in dd lsblk; do
|
||||
if ! command -v $cmd &> /dev/null; then
|
||||
echo -e "${RED}Error: $cmd is required but not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check for optional tools
|
||||
HAS_RPI_IMAGER=false
|
||||
HAS_PV=false
|
||||
if command -v rpi-imager &> /dev/null; then
|
||||
HAS_RPI_IMAGER=true
|
||||
fi
|
||||
if command -v pv &> /dev/null; then
|
||||
HAS_PV=true
|
||||
fi
|
||||
|
||||
if [ "$HAS_RPI_IMAGER" = false ] && [ "$HAS_PV" = false ]; then
|
||||
echo -e "${YELLOW}Warning: Neither rpi-imager nor pv found. Progress will not be shown.${NC}"
|
||||
fi
|
||||
|
||||
# Check for root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
||||
@@ -34,11 +75,14 @@ fi
|
||||
|
||||
# Check for image argument
|
||||
if [ -z "$1" ]; then
|
||||
echo -e "${RED}Usage: $0 <image.img.xz|image.img> [device]${NC}"
|
||||
echo -e "${RED}Usage: $0 <image> [device]${NC}"
|
||||
echo ""
|
||||
echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 stegasoo-rpi-20260103.img.xz # auto-detect SD card"
|
||||
echo " $0 stegasoo-rpi-20260103.img.xz /dev/sdb # specify device"
|
||||
echo " $0 stegasoo-rpi-4.1.1.img.zst # auto-detect SD card"
|
||||
echo " $0 stegasoo-rpi-4.1.1.img.zst.zip # from GitHub release"
|
||||
echo " $0 stegasoo-rpi-4.1.1.img.zst /dev/sdb # specify device"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -50,6 +94,25 @@ if [ ! -f "$IMAGE" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Handle .zst.zip wrapper (GitHub releases workaround)
|
||||
if [[ "$IMAGE" == *.zst.zip ]]; then
|
||||
echo -e "${YELLOW}Extracting .zst from zip wrapper...${NC}"
|
||||
if ! command -v unzip &> /dev/null; then
|
||||
echo -e "${RED}Error: unzip is required for .zst.zip files but not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
trap "rm -rf $TEMP_DIR" EXIT
|
||||
unzip -q "$IMAGE" -d "$TEMP_DIR"
|
||||
IMAGE=$(find "$TEMP_DIR" -name "*.zst" | head -1)
|
||||
if [ -z "$IMAGE" ]; then
|
||||
echo -e "${RED}Error: No .zst file found in zip archive${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}Found: $(basename "$IMAGE")${NC}"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Detect compression
|
||||
COMPRESSED=false
|
||||
COMP_TYPE=""
|
||||
@@ -186,6 +249,17 @@ if [ -n "$MOUNTED" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# Ask about wiping
|
||||
echo
|
||||
read -p "Wipe partition table first? (recommended if having issues) [y/N] " wipe_confirm
|
||||
if [[ "$wipe_confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Wiping partition table..."
|
||||
sudo wipefs -a "$SELECTED"
|
||||
sudo dd if=/dev/zero of="$SELECTED" bs=1M count=10 status=none
|
||||
sync
|
||||
echo " Wiped clean"
|
||||
fi
|
||||
|
||||
# Final confirmation
|
||||
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${RED}║ WARNING: ALL DATA ON THIS DEVICE WILL BE DESTROYED! ║${NC}"
|
||||
@@ -202,20 +276,155 @@ echo ""
|
||||
echo -e "${GREEN}Flashing image to $SELECTED...${NC}"
|
||||
echo ""
|
||||
|
||||
if [ "$COMPRESSED" = true ]; then
|
||||
case "$COMP_TYPE" in
|
||||
xz) pv "$IMAGE" | xzcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
||||
zst) pv "$IMAGE" | zstdcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
||||
gz) pv "$IMAGE" | zcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
||||
esac
|
||||
# Try rpi-imager first (faster, native support for compressed images)
|
||||
if command -v rpi-imager &> /dev/null; then
|
||||
echo -e "${YELLOW}Using rpi-imager...${NC}"
|
||||
if rpi-imager --cli --disable-verify "$IMAGE" "$SELECTED"; then
|
||||
# rpi-imager succeeded
|
||||
:
|
||||
else
|
||||
echo -e "${YELLOW}rpi-imager failed, falling back to dd...${NC}"
|
||||
# Fall through to dd
|
||||
USE_DD=true
|
||||
fi
|
||||
else
|
||||
pv "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null
|
||||
USE_DD=true
|
||||
fi
|
||||
|
||||
# Fallback to dd
|
||||
if [ "$USE_DD" = true ]; then
|
||||
if [ "$HAS_PV" = true ]; then
|
||||
echo -e "${YELLOW}Using dd with progress...${NC}"
|
||||
if [ "$COMPRESSED" = true ]; then
|
||||
case "$COMP_TYPE" in
|
||||
xz) pv "$IMAGE" | xzcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
||||
zst) pv "$IMAGE" | zstdcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
||||
gz) pv "$IMAGE" | zcat | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null ;;
|
||||
esac
|
||||
else
|
||||
pv "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync 2>/dev/null
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}Using dd (no progress - install pv for progress bar)...${NC}"
|
||||
if [ "$COMPRESSED" = true ]; then
|
||||
case "$COMP_TYPE" in
|
||||
xz) xzcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
|
||||
zst) zstdcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
|
||||
gz) zcat "$IMAGE" | dd of="$SELECTED" bs=4M conv=fsync status=progress ;;
|
||||
esac
|
||||
else
|
||||
dd if="$IMAGE" of="$SELECTED" bs=4M conv=fsync status=progress
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Syncing...${NC}"
|
||||
sync
|
||||
|
||||
# Inject WiFi config if config.json was loaded
|
||||
if [ "$HAS_CONFIG" = true ]; then
|
||||
echo ""
|
||||
echo -e "${GREEN}Configuring WiFi from config.json...${NC}"
|
||||
|
||||
# Wait for partitions to appear
|
||||
sleep 2
|
||||
partprobe "$SELECTED" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Determine boot partition
|
||||
if [[ "$SELECTED" == *"nvme"* ]] || [[ "$SELECTED" == *"mmcblk"* ]]; then
|
||||
BOOT_PART="${SELECTED}p1"
|
||||
else
|
||||
BOOT_PART="${SELECTED}1"
|
||||
fi
|
||||
|
||||
if [ -b "$BOOT_PART" ]; then
|
||||
MOUNT_DIR=$(mktemp -d)
|
||||
if mount "$BOOT_PART" "$MOUNT_DIR" 2>/dev/null; then
|
||||
# Create firstrun.sh for WiFi setup
|
||||
cat > "$MOUNT_DIR/firstrun.sh" << 'EOFSCRIPT'
|
||||
#!/bin/bash
|
||||
set +e
|
||||
|
||||
# Set hostname if provided
|
||||
if [ -n "PLACEHOLDER_HOSTNAME" ] && [ "PLACEHOLDER_HOSTNAME" != "" ]; then
|
||||
CURRENT_HOSTNAME=$(cat /etc/hostname | tr -d " \t\n\r")
|
||||
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
|
||||
/usr/lib/raspberrypi-sys-mods/imager_custom set_hostname PLACEHOLDER_HOSTNAME
|
||||
else
|
||||
echo PLACEHOLDER_HOSTNAME >/etc/hostname
|
||||
sed -i "s/127.0.1.1.*$CURRENT_HOSTNAME/127.0.1.1\tPLACEHOLDER_HOSTNAME/g" /etc/hosts
|
||||
fi
|
||||
fi
|
||||
|
||||
# Configure WiFi
|
||||
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
|
||||
/usr/lib/raspberrypi-sys-mods/imager_custom set_wlan 'PLACEHOLDER_SSID' 'PLACEHOLDER_WIFIPASS' 'PLACEHOLDER_COUNTRY'
|
||||
else
|
||||
# NetworkManager method (Trixie)
|
||||
cat >/etc/NetworkManager/system-connections/preconfigured.nmconnection <<'NMEOF'
|
||||
[connection]
|
||||
id=preconfigured
|
||||
type=wifi
|
||||
autoconnect=true
|
||||
|
||||
[wifi]
|
||||
mode=infrastructure
|
||||
ssid=PLACEHOLDER_SSID
|
||||
|
||||
[wifi-security]
|
||||
auth-alg=open
|
||||
key-mgmt=wpa-psk
|
||||
psk=PLACEHOLDER_WIFIPASS
|
||||
|
||||
[ipv4]
|
||||
method=auto
|
||||
|
||||
[ipv6]
|
||||
method=auto
|
||||
NMEOF
|
||||
chmod 600 /etc/NetworkManager/system-connections/preconfigured.nmconnection
|
||||
rfkill unblock wifi
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm -f /boot/firstrun.sh
|
||||
rm -f /boot/firmware/firstrun.sh
|
||||
sed -i 's| systemd.run.*||g' /boot/cmdline.txt 2>/dev/null
|
||||
sed -i 's| systemd.run.*||g' /boot/firmware/cmdline.txt 2>/dev/null
|
||||
exit 0
|
||||
EOFSCRIPT
|
||||
|
||||
# Replace placeholders
|
||||
sed -i "s/PLACEHOLDER_SSID/$WIFI_SSID/g" "$MOUNT_DIR/firstrun.sh"
|
||||
sed -i "s/PLACEHOLDER_WIFIPASS/$WIFI_PASS/g" "$MOUNT_DIR/firstrun.sh"
|
||||
sed -i "s/PLACEHOLDER_COUNTRY/$WIFI_COUNTRY/g" "$MOUNT_DIR/firstrun.sh"
|
||||
if [ -n "$PI_HOSTNAME" ]; then
|
||||
sed -i "s/PLACEHOLDER_HOSTNAME/$PI_HOSTNAME/g" "$MOUNT_DIR/firstrun.sh"
|
||||
else
|
||||
sed -i "s/PLACEHOLDER_HOSTNAME//g" "$MOUNT_DIR/firstrun.sh"
|
||||
fi
|
||||
chmod +x "$MOUNT_DIR/firstrun.sh"
|
||||
|
||||
# Update cmdline.txt to run firstrun.sh
|
||||
CMDLINE="$MOUNT_DIR/cmdline.txt"
|
||||
if [ -f "$CMDLINE" ]; then
|
||||
CURRENT=$(cat "$CMDLINE" | tr -d '\n' | sed 's| systemd.run.*||g')
|
||||
echo "$CURRENT systemd.run=/boot/firmware/firstrun.sh systemd.run_success_action=reboot systemd.unit=kernel-command-line.target" > "$CMDLINE"
|
||||
fi
|
||||
|
||||
umount "$MOUNT_DIR"
|
||||
echo -e " ${GREEN}✓${NC} WiFi configured for: $WIFI_SSID"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Could not mount boot partition"
|
||||
fi
|
||||
rmdir "$MOUNT_DIR" 2>/dev/null || true
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Boot partition not found"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Flash Complete! ║${NC}"
|
||||
@@ -223,5 +432,11 @@ echo -e "${GREEN}╚════════════════════
|
||||
echo ""
|
||||
echo -e "You can now remove the SD card and boot your Raspberry Pi."
|
||||
echo ""
|
||||
echo -e "${YELLOW}Tip:${NC} On first boot, SSH in and the setup wizard will run automatically."
|
||||
if [ "$HAS_CONFIG" = true ]; then
|
||||
echo -e "${GREEN}WiFi pre-configured${NC} - Pi will connect to $WIFI_SSID on boot"
|
||||
echo -e "SSH: ${YELLOW}ssh admin@${PI_HOSTNAME:-stegasoo}.local${NC} (password: stegasoo)"
|
||||
else
|
||||
echo -e "${YELLOW}Tip:${NC} On first boot, the setup wizard will help configure WiFi."
|
||||
echo -e "${YELLOW}Tip:${NC} Or place config.json in rpi/ for headless setup next time."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
285
rpi/flash-stock-img.sh
Executable file
285
rpi/flash-stock-img.sh
Executable file
@@ -0,0 +1,285 @@
|
||||
#!/bin/bash
|
||||
# Flash Raspberry Pi image with headless config (Trixie/Bookworm compatible)
|
||||
# Usage: ./flash-stock-img.sh <image.img.xz> <device>
|
||||
# Reads settings from config.json in same directory
|
||||
#
|
||||
# Uses the same firstrun.sh approach as rpi-imager for compatibility
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONFIG_FILE="$SCRIPT_DIR/config.json"
|
||||
|
||||
# ============================================================================
|
||||
# Load config
|
||||
# ============================================================================
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "Error: config.json not found at $CONFIG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PI_USER=$(jq -r '.username' "$CONFIG_FILE")
|
||||
PI_PASS=$(jq -r '.password' "$CONFIG_FILE")
|
||||
WIFI_SSID=$(jq -r '.wifiSSID' "$CONFIG_FILE")
|
||||
WIFI_PASS=$(jq -r '.wifiPassword' "$CONFIG_FILE")
|
||||
WIFI_COUNTRY=$(jq -r '.wifiCountry // "US"' "$CONFIG_FILE")
|
||||
PI_HOSTNAME=$(jq -r '.hostname' "$CONFIG_FILE")
|
||||
PI_TIMEZONE=$(jq -r '.timezone // "America/New_York"' "$CONFIG_FILE")
|
||||
PI_KEYMAP=$(jq -r '.keyboardLayout // "us"' "$CONFIG_FILE")
|
||||
|
||||
echo "Loaded config from $CONFIG_FILE"
|
||||
echo " Hostname: $PI_HOSTNAME"
|
||||
echo " User: $PI_USER"
|
||||
echo " WiFi: $WIFI_SSID"
|
||||
echo " Timezone: $PI_TIMEZONE"
|
||||
echo
|
||||
|
||||
# ============================================================================
|
||||
# Validate args
|
||||
# ============================================================================
|
||||
if [ $# -ne 2 ]; then
|
||||
echo "Usage: $0 <image.img.xz> <device>"
|
||||
echo "Example: $0 2025-12-04-raspios-trixie-arm64-lite.img.xz /dev/sdb"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE="$1"
|
||||
DEVICE="$2"
|
||||
|
||||
if [ ! -f "$IMAGE" ]; then
|
||||
echo "Error: Image file not found: $IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -b "$DEVICE" ]; then
|
||||
echo "Error: Device not found: $DEVICE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Safety check
|
||||
echo "WARNING: This will ERASE all data on $DEVICE"
|
||||
echo "Device info:"
|
||||
lsblk "$DEVICE"
|
||||
echo
|
||||
read -p "Type 'yes' to continue: " confirm
|
||||
if [ "$confirm" != "yes" ]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ask about wiping
|
||||
echo
|
||||
read -p "Wipe partition table first? (recommended if having issues) [y/N] " wipe_confirm
|
||||
if [[ "$wipe_confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Wiping partition table..."
|
||||
sudo wipefs -a "$DEVICE"
|
||||
sudo dd if=/dev/zero of="$DEVICE" bs=1M count=10 status=none
|
||||
sync
|
||||
echo " Wiped clean"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Flash image
|
||||
# ============================================================================
|
||||
echo "Flashing $IMAGE to $DEVICE..."
|
||||
if [[ "$IMAGE" == *.xz ]]; then
|
||||
xzcat "$IMAGE" | sudo dd of="$DEVICE" bs=4M status=progress conv=fsync
|
||||
elif [[ "$IMAGE" == *.zst ]]; then
|
||||
zstdcat "$IMAGE" | sudo dd of="$DEVICE" bs=4M status=progress conv=fsync
|
||||
else
|
||||
sudo dd if="$IMAGE" of="$DEVICE" bs=4M status=progress conv=fsync
|
||||
fi
|
||||
|
||||
echo "Syncing..."
|
||||
sync
|
||||
|
||||
# Wait for partitions
|
||||
sleep 2
|
||||
sudo partprobe "$DEVICE" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# ============================================================================
|
||||
# Find partitions
|
||||
# ============================================================================
|
||||
if [ -b "${DEVICE}1" ]; then
|
||||
BOOT_PART="${DEVICE}1"
|
||||
ROOT_PART="${DEVICE}2"
|
||||
elif [ -b "${DEVICE}p1" ]; then
|
||||
BOOT_PART="${DEVICE}p1"
|
||||
ROOT_PART="${DEVICE}p2"
|
||||
else
|
||||
echo "Error: Could not find boot partition"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Resize rootfs to 16GB (faster imaging)
|
||||
# ============================================================================
|
||||
echo
|
||||
read -p "Resize rootfs to 16GB for faster imaging? [Y/n] " resize_confirm
|
||||
if [[ ! "$resize_confirm" =~ ^[Nn]$ ]]; then
|
||||
echo "Resizing rootfs partition to 16GB..."
|
||||
|
||||
# Get boot partition end
|
||||
BOOT_END=$(sudo parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
||||
|
||||
# Calculate 16GB in sectors (512 byte sectors)
|
||||
# 16GB = 16 * 1024 * 1024 * 1024 / 512 = 33554432 sectors
|
||||
ROOT_SIZE_SECTORS=33554432
|
||||
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
||||
|
||||
# Delete and recreate partition 2 with fixed size
|
||||
sudo parted -s "$DEVICE" rm 2
|
||||
sudo parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||
|
||||
# Refresh partition table
|
||||
sudo partprobe "$DEVICE"
|
||||
sleep 1
|
||||
|
||||
# Check and resize filesystem
|
||||
echo "Checking filesystem..."
|
||||
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
|
||||
echo "Resizing filesystem to fit partition..."
|
||||
sudo resize2fs "$ROOT_PART"
|
||||
|
||||
# Disable Pi OS auto-expand on first boot
|
||||
echo "Disabling auto-expand..."
|
||||
TEMP_ROOT=$(mktemp -d)
|
||||
sudo mount "$ROOT_PART" "$TEMP_ROOT"
|
||||
|
||||
# Remove resize2fs_once service if it exists
|
||||
sudo rm -f "$TEMP_ROOT/etc/init.d/resize2fs_once"
|
||||
sudo rm -f "$TEMP_ROOT/etc/rc3.d/S01resize2fs_once"
|
||||
|
||||
# Disable the systemd resize service
|
||||
sudo rm -f "$TEMP_ROOT/etc/systemd/system/multi-user.target.wants/rpi-resizerootfs.service"
|
||||
|
||||
# Remove init= parameter from cmdline.txt on boot partition (handled later)
|
||||
|
||||
sudo umount "$TEMP_ROOT"
|
||||
rmdir "$TEMP_ROOT"
|
||||
|
||||
echo " Rootfs resized to 16GB (auto-expand disabled)"
|
||||
fi
|
||||
|
||||
MOUNT_DIR=$(mktemp -d)
|
||||
|
||||
# ============================================================================
|
||||
# Configure boot partition with firstrun.sh (rpi-imager method)
|
||||
# ============================================================================
|
||||
echo "Mounting boot partition..."
|
||||
sudo mount "$BOOT_PART" "$MOUNT_DIR"
|
||||
|
||||
# Enable SSH
|
||||
echo "Enabling SSH..."
|
||||
sudo touch "$MOUNT_DIR/ssh"
|
||||
|
||||
# Generate password hash
|
||||
PASS_HASH=$(echo "$PI_PASS" | openssl passwd -6 -stdin)
|
||||
|
||||
# Create firstrun.sh - this is exactly what rpi-imager generates
|
||||
echo "Creating firstrun.sh..."
|
||||
sudo tee "$MOUNT_DIR/firstrun.sh" > /dev/null << 'EOFSCRIPT'
|
||||
#!/bin/bash
|
||||
set +e
|
||||
|
||||
CURRENT_HOSTNAME=$(cat /etc/hostname | tr -d " \t\n\r")
|
||||
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
|
||||
/usr/lib/raspberrypi-sys-mods/imager_custom set_hostname PLACEHOLDER_HOSTNAME
|
||||
else
|
||||
echo PLACEHOLDER_HOSTNAME >/etc/hostname
|
||||
sed -i "s/127.0.1.1.*$CURRENT_HOSTNAME/127.0.1.1\tPLACEHOLDER_HOSTNAME/g" /etc/hosts
|
||||
fi
|
||||
|
||||
FIRSTUSER=$(getent passwd 1000 | cut -d: -f1)
|
||||
FIRSTUSERHOME=$(getent passwd 1000 | cut -d: -f6)
|
||||
|
||||
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
|
||||
/usr/lib/raspberrypi-sys-mods/imager_custom enable_ssh
|
||||
else
|
||||
systemctl enable ssh
|
||||
fi
|
||||
|
||||
if [ -f /usr/lib/userconf-pi/userconf ]; then
|
||||
/usr/lib/userconf-pi/userconf 'PLACEHOLDER_USER' 'PLACEHOLDER_HASH'
|
||||
else
|
||||
echo "$FIRSTUSER:"'PLACEHOLDER_HASH' | chpasswd -e
|
||||
if [ "$FIRSTUSER" != "PLACEHOLDER_USER" ]; then
|
||||
usermod -l "PLACEHOLDER_USER" "$FIRSTUSER"
|
||||
usermod -m -d "/home/PLACEHOLDER_USER" "PLACEHOLDER_USER"
|
||||
groupmod -n "PLACEHOLDER_USER" "$FIRSTUSER"
|
||||
if grep -q "^autologin-user=" /etc/lightdm/lightdm.conf 2>/dev/null; then
|
||||
sed -i "s/^autologin-user=.*/autologin-user=PLACEHOLDER_USER/" /etc/lightdm/lightdm.conf
|
||||
fi
|
||||
if [ -f /etc/systemd/system/getty@tty1.service.d/autologin.conf ]; then
|
||||
sed -i "s/$FIRSTUSER/PLACEHOLDER_USER/" /etc/systemd/system/getty@tty1.service.d/autologin.conf
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
|
||||
/usr/lib/raspberrypi-sys-mods/imager_custom set_keymap 'PLACEHOLDER_KEYMAP'
|
||||
/usr/lib/raspberrypi-sys-mods/imager_custom set_timezone 'PLACEHOLDER_TIMEZONE'
|
||||
fi
|
||||
|
||||
if [ -f /usr/lib/raspberrypi-sys-mods/imager_custom ]; then
|
||||
/usr/lib/raspberrypi-sys-mods/imager_custom set_wlan 'PLACEHOLDER_SSID' 'PLACEHOLDER_WIFIPASS' 'PLACEHOLDER_COUNTRY'
|
||||
else
|
||||
cat >/etc/wpa_supplicant/wpa_supplicant.conf <<'WPAEOF'
|
||||
country=PLACEHOLDER_COUNTRY
|
||||
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
|
||||
ap_scan=1
|
||||
update_config=1
|
||||
network={
|
||||
ssid="PLACEHOLDER_SSID"
|
||||
psk="PLACEHOLDER_WIFIPASS"
|
||||
}
|
||||
WPAEOF
|
||||
chmod 600 /etc/wpa_supplicant/wpa_supplicant.conf
|
||||
rfkill unblock wifi
|
||||
for filename in /var/lib/systemd/rfkill/*:wlan ; do
|
||||
echo 0 > "$filename"
|
||||
done
|
||||
fi
|
||||
|
||||
rm -f /boot/firstrun.sh
|
||||
rm -f /boot/firmware/firstrun.sh
|
||||
sed -i 's| systemd.run.*||g' /boot/cmdline.txt 2>/dev/null
|
||||
sed -i 's| systemd.run.*||g' /boot/firmware/cmdline.txt 2>/dev/null
|
||||
exit 0
|
||||
EOFSCRIPT
|
||||
|
||||
# Replace placeholders with actual values
|
||||
sudo sed -i "s/PLACEHOLDER_HOSTNAME/$PI_HOSTNAME/g" "$MOUNT_DIR/firstrun.sh"
|
||||
sudo sed -i "s/PLACEHOLDER_USER/$PI_USER/g" "$MOUNT_DIR/firstrun.sh"
|
||||
sudo sed -i "s|PLACEHOLDER_HASH|$PASS_HASH|g" "$MOUNT_DIR/firstrun.sh"
|
||||
sudo sed -i "s/PLACEHOLDER_KEYMAP/$PI_KEYMAP/g" "$MOUNT_DIR/firstrun.sh"
|
||||
sudo sed -i "s|PLACEHOLDER_TIMEZONE|$PI_TIMEZONE|g" "$MOUNT_DIR/firstrun.sh"
|
||||
sudo sed -i "s/PLACEHOLDER_SSID/$WIFI_SSID/g" "$MOUNT_DIR/firstrun.sh"
|
||||
sudo sed -i "s/PLACEHOLDER_WIFIPASS/$WIFI_PASS/g" "$MOUNT_DIR/firstrun.sh"
|
||||
sudo sed -i "s/PLACEHOLDER_COUNTRY/$WIFI_COUNTRY/g" "$MOUNT_DIR/firstrun.sh"
|
||||
|
||||
sudo chmod +x "$MOUNT_DIR/firstrun.sh"
|
||||
|
||||
# Update cmdline.txt to run firstrun.sh on boot
|
||||
echo "Updating cmdline.txt..."
|
||||
CMDLINE="$MOUNT_DIR/cmdline.txt"
|
||||
if [ -f "$CMDLINE" ]; then
|
||||
# Read current cmdline, strip existing systemd.run and init= (auto-expand)
|
||||
CURRENT=$(cat "$CMDLINE" | tr -d '\n' | sed 's| systemd.run.*||g' | sed 's| init=[^ ]*||g')
|
||||
echo "$CURRENT systemd.run=/boot/firmware/firstrun.sh systemd.run_success_action=reboot systemd.unit=kernel-command-line.target" | sudo tee "$CMDLINE" > /dev/null
|
||||
echo " cmdline.txt updated"
|
||||
fi
|
||||
|
||||
sudo umount "$MOUNT_DIR"
|
||||
rmdir "$MOUNT_DIR"
|
||||
|
||||
echo
|
||||
echo "Done! SD card is ready."
|
||||
echo " Hostname: $PI_HOSTNAME"
|
||||
echo " User: $PI_USER"
|
||||
echo " SSH: enabled"
|
||||
echo " WiFi: $WIFI_SSID"
|
||||
echo
|
||||
echo "Insert into Pi and boot. Find it with: ping $PI_HOSTNAME.local"
|
||||
200
rpi/inject-wifi.sh
Executable file
200
rpi/inject-wifi.sh
Executable file
@@ -0,0 +1,200 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Inject WiFi credentials into SD card for Raspberry Pi
|
||||
# Supports both Bookworm (NetworkManager) and older (wpa_supplicant)
|
||||
#
|
||||
# First-time setup:
|
||||
# ./inject-wifi.sh --setup
|
||||
#
|
||||
# Then after flashing:
|
||||
# sudo ./inject-wifi.sh # auto-detect partitions
|
||||
# sudo ./inject-wifi.sh /dev/sdb # specify device (finds partitions)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
CONFIG_DIR="$HOME/.config/stegasoo"
|
||||
CONFIG_FILE="$CONFIG_DIR/wifi.conf"
|
||||
|
||||
# Setup mode - save credentials
|
||||
if [ "$1" == "--setup" ]; then
|
||||
echo -e "${BLUE}Stegasoo WiFi Config Setup${NC}"
|
||||
echo ""
|
||||
|
||||
read -p "WiFi SSID: " WIFI_SSID
|
||||
read -s -p "WiFi Password: " WIFI_PASS
|
||||
echo ""
|
||||
read -p "Country code [US]: " WIFI_COUNTRY
|
||||
WIFI_COUNTRY=${WIFI_COUNTRY:-US}
|
||||
|
||||
# Generate hashed PSK for wpa_supplicant (legacy)
|
||||
if command -v wpa_passphrase &> /dev/null; then
|
||||
HASHED_PSK=$(wpa_passphrase "$WIFI_SSID" "$WIFI_PASS" | grep -E "^\s+psk=" | tr -d '\t' | cut -d= -f2)
|
||||
else
|
||||
HASHED_PSK=""
|
||||
echo -e "${YELLOW}Note: wpa_passphrase not found, legacy mode disabled${NC}"
|
||||
fi
|
||||
|
||||
# Save config (includes plaintext for NetworkManager)
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
chmod 700 "$CONFIG_DIR"
|
||||
|
||||
cat > "$CONFIG_FILE" << EOF
|
||||
# Stegasoo WiFi config
|
||||
WIFI_SSID="$WIFI_SSID"
|
||||
WIFI_PASS="$WIFI_PASS"
|
||||
WIFI_PSK_HASH="$HASHED_PSK"
|
||||
WIFI_COUNTRY="$WIFI_COUNTRY"
|
||||
EOF
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Config saved to $CONFIG_FILE${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Normal mode - inject credentials
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: Must run as root (sudo)${NC}"
|
||||
echo "Usage: sudo $0 [/dev/sdX]"
|
||||
echo ""
|
||||
echo "First-time setup (no sudo): $0 --setup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Load config
|
||||
if [ -n "$SUDO_USER" ]; then
|
||||
USER_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6)
|
||||
CONFIG_FILE="$USER_HOME/.config/stegasoo/wifi.conf"
|
||||
fi
|
||||
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo -e "${RED}Config not found: $CONFIG_FILE${NC}"
|
||||
echo ""
|
||||
echo "Run setup first (without sudo):"
|
||||
echo " ./inject-wifi.sh --setup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$CONFIG_FILE"
|
||||
|
||||
if [ -z "$WIFI_SSID" ] || [ -z "$WIFI_PASS" ]; then
|
||||
echo -e "${RED}Invalid config. Run --setup again.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find partitions
|
||||
MANUAL_DEV="$1"
|
||||
|
||||
if [ -n "$MANUAL_DEV" ]; then
|
||||
# Strip partition number if given (e.g., /dev/sdb1 -> /dev/sdb)
|
||||
BASE_DEV=$(echo "$MANUAL_DEV" | sed 's/[0-9]*$//')
|
||||
BOOT_DEV="${BASE_DEV}1"
|
||||
ROOT_DEV="${BASE_DEV}2"
|
||||
else
|
||||
# Auto-detect by label
|
||||
BOOT_PART=$(lsblk -o NAME,FSTYPE,LABEL -rn | grep -E "vfat.*(bootfs|boot)" | head -1 | awk '{print $1}')
|
||||
ROOT_PART=$(lsblk -o NAME,FSTYPE,LABEL -rn | grep -E "ext4.*rootfs" | head -1 | awk '{print $1}')
|
||||
|
||||
if [ -z "$BOOT_PART" ] || [ -z "$ROOT_PART" ]; then
|
||||
echo -e "${RED}Could not find boot/root partitions. Is the SD card inserted?${NC}"
|
||||
echo ""
|
||||
lsblk -o NAME,SIZE,FSTYPE,LABEL
|
||||
echo ""
|
||||
echo -e "${YELLOW}Tip: Specify device manually: sudo $0 /dev/sdX${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BOOT_DEV="/dev/$BOOT_PART"
|
||||
ROOT_DEV="/dev/$ROOT_PART"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Found partitions:${NC}"
|
||||
echo -e " Boot: ${YELLOW}$BOOT_DEV${NC}"
|
||||
echo -e " Root: ${YELLOW}$ROOT_DEV${NC}"
|
||||
|
||||
# Mount points
|
||||
BOOT_MNT="/tmp/stegasoo-boot-$$"
|
||||
ROOT_MNT="/tmp/stegasoo-root-$$"
|
||||
|
||||
cleanup() {
|
||||
umount "$BOOT_MNT" 2>/dev/null || true
|
||||
umount "$ROOT_MNT" 2>/dev/null || true
|
||||
rmdir "$BOOT_MNT" "$ROOT_MNT" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$BOOT_MNT" "$ROOT_MNT"
|
||||
|
||||
# Mount partitions
|
||||
mount "$BOOT_DEV" "$BOOT_MNT"
|
||||
mount "$ROOT_DEV" "$ROOT_MNT"
|
||||
|
||||
echo ""
|
||||
|
||||
# 1. Write NetworkManager config (Bookworm+)
|
||||
NM_DIR="$ROOT_MNT/etc/NetworkManager/system-connections"
|
||||
if [ -d "$ROOT_MNT/etc/NetworkManager" ]; then
|
||||
mkdir -p "$NM_DIR"
|
||||
|
||||
# NetworkManager connection file
|
||||
NM_FILE="$NM_DIR/stegasoo-wifi.nmconnection"
|
||||
cat > "$NM_FILE" << EOF
|
||||
[connection]
|
||||
id=$WIFI_SSID
|
||||
type=wifi
|
||||
autoconnect=true
|
||||
|
||||
[wifi]
|
||||
mode=infrastructure
|
||||
ssid=$WIFI_SSID
|
||||
|
||||
[wifi-security]
|
||||
auth-alg=open
|
||||
key-mgmt=wpa-psk
|
||||
psk=$WIFI_PASS
|
||||
|
||||
[ipv4]
|
||||
method=auto
|
||||
|
||||
[ipv6]
|
||||
method=auto
|
||||
EOF
|
||||
chmod 600 "$NM_FILE"
|
||||
echo -e "${GREEN}Created NetworkManager config (Bookworm)${NC}"
|
||||
fi
|
||||
|
||||
# 2. Write wpa_supplicant.conf (legacy, boot partition)
|
||||
if [ -n "$WIFI_PSK_HASH" ]; then
|
||||
cat > "$BOOT_MNT/wpa_supplicant.conf" << EOF
|
||||
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
|
||||
update_config=1
|
||||
country=$WIFI_COUNTRY
|
||||
|
||||
network={
|
||||
ssid="$WIFI_SSID"
|
||||
psk=$WIFI_PSK_HASH
|
||||
}
|
||||
EOF
|
||||
echo -e "${GREEN}Created wpa_supplicant.conf (legacy)${NC}"
|
||||
fi
|
||||
|
||||
# 3. Set WiFi country in boot config
|
||||
if [ -f "$BOOT_MNT/config.txt" ]; then
|
||||
if ! grep -q "^dtparam=cfg80211" "$BOOT_MNT/config.txt"; then
|
||||
echo "" >> "$BOOT_MNT/config.txt"
|
||||
echo "# WiFi country" >> "$BOOT_MNT/config.txt"
|
||||
echo "dtparam=cfg80211" >> "$BOOT_MNT/config.txt"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e " SSID: ${YELLOW}$WIFI_SSID${NC}"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Done! WiFi credentials injected for Bookworm + legacy.${NC}"
|
||||
@@ -21,6 +21,12 @@ cd "$JPEGIO_DIR"
|
||||
|
||||
echo "Applying ARM64 patch to jpegio..."
|
||||
|
||||
# Fix CRLF line endings (jpegio uses Windows line endings)
|
||||
if file setup.py | grep -q CRLF; then
|
||||
echo " Converting CRLF to LF..."
|
||||
sed -i 's/\r$//' setup.py
|
||||
fi
|
||||
|
||||
# Strategy 1: Try the standard patch file
|
||||
if [ -f "$PATCH_FILE" ]; then
|
||||
echo " Trying patch file..."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
--- a/setup.py
|
||||
+++ b/setup.py
|
||||
@@ -69,12 +69,12 @@
|
||||
@@ -64,7 +64,7 @@ elif sys.platform == 'darwin': # macOS
|
||||
largs.append('-mmacosx-version-min=10.9')
|
||||
|
||||
if arch == 'x64':
|
||||
@@ -9,6 +9,9 @@
|
||||
elif sys.platform == 'linux':
|
||||
cargs.extend(['-w', '-fPIC'])
|
||||
|
||||
@@ -68,7 +68,7 @@ elif sys.platform == 'linux':
|
||||
cargs.extend(['-w', '-fPIC'])
|
||||
|
||||
if arch == 'x64':
|
||||
- cargs.append('-m64')
|
||||
+ pass # ARM64: removed x86-specific -m64 flag
|
||||
|
||||
@@ -168,12 +168,41 @@ fi
|
||||
DEV_SIZE=$(blockdev --getsize64 "$SELECTED")
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}[1/3]${NC} Copying image from $SELECTED..."
|
||||
echo -e "${GREEN}[1/4]${NC} Copying image from $SELECTED..."
|
||||
dd if="$SELECTED" bs=4M status=none | pv -s "$DEV_SIZE" > "$IMG_FILE"
|
||||
sync
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}[2/3]${NC} Shrinking image..."
|
||||
echo -e "${GREEN}[2/4]${NC} Re-enabling auto-expand for distribution..."
|
||||
# Mount the image and restore auto-expand service (may have been disabled during build)
|
||||
LOOP_DEV=$(losetup -f --show -P "$IMG_FILE")
|
||||
if [ -n "$LOOP_DEV" ]; then
|
||||
TEMP_MOUNT=$(mktemp -d)
|
||||
if mount "${LOOP_DEV}p2" "$TEMP_MOUNT" 2>/dev/null; then
|
||||
# Re-enable the resize service if the service file exists
|
||||
SERVICE_FILE="$TEMP_MOUNT/lib/systemd/system/rpi-resizerootfs.service"
|
||||
SERVICE_LINK="$TEMP_MOUNT/etc/systemd/system/multi-user.target.wants/rpi-resizerootfs.service"
|
||||
if [ -f "$SERVICE_FILE" ] && [ ! -L "$SERVICE_LINK" ]; then
|
||||
mkdir -p "$(dirname "$SERVICE_LINK")"
|
||||
ln -sf /lib/systemd/system/rpi-resizerootfs.service "$SERVICE_LINK"
|
||||
echo -e " ${GREEN}✓${NC} Auto-expand service re-enabled"
|
||||
elif [ -L "$SERVICE_LINK" ]; then
|
||||
echo -e " ${GREEN}✓${NC} Auto-expand already enabled"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Could not find resize service file"
|
||||
fi
|
||||
umount "$TEMP_MOUNT"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Could not mount rootfs, skipping auto-expand fix"
|
||||
fi
|
||||
rmdir "$TEMP_MOUNT" 2>/dev/null || true
|
||||
losetup -d "$LOOP_DEV"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Could not create loop device, skipping auto-expand fix"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}[3/4]${NC} Shrinking image..."
|
||||
if command -v pishrink.sh &> /dev/null; then
|
||||
pishrink.sh "$IMG_FILE"
|
||||
elif [ -f "./pishrink.sh" ]; then
|
||||
@@ -187,11 +216,11 @@ fi
|
||||
|
||||
echo ""
|
||||
if [ "$SKIP_COMPRESS" = true ]; then
|
||||
echo -e "${GREEN}[3/3]${NC} Skipping compression (.img output)"
|
||||
echo -e "${GREEN}[4/4]${NC} Skipping compression (.img output)"
|
||||
FINAL_SIZE=$(du -h "$IMG_FILE" | awk '{print $1}')
|
||||
OUTPUT="$IMG_FILE"
|
||||
else
|
||||
echo -e "${GREEN}[3/3]${NC} Compressing with zstd..."
|
||||
echo -e "${GREEN}[4/4]${NC} Compressing with zstd..."
|
||||
pv "$IMG_FILE" | zstd -19 -T0 -q > "$OUTPUT"
|
||||
rm -f "$IMG_FILE"
|
||||
FINAL_SIZE=$(du -h "$OUTPUT" | awk '{print $1}')
|
||||
|
||||
@@ -71,19 +71,20 @@ fi
|
||||
|
||||
clear
|
||||
echo ""
|
||||
echo -e "${GRAY} . * . . * . * . * . * .${NC}"
|
||||
echo -e "${CYAN} ___ _____ ___ ___ _ ___ ___ ___${NC}"
|
||||
echo -e "${CYAN} / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\${NC}"
|
||||
echo -e "${CYAN} \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |${NC}"
|
||||
echo -e "${CYAN} |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/${NC}"
|
||||
echo ""
|
||||
echo -e "${GRAY} * . * . * . * . * . *${NC}"
|
||||
echo ""
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
|
||||
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
||||
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\\033[0m"
|
||||
echo -e "\033[38;5;220m \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |\033[0m"
|
||||
echo -e "\033[38;5;220m |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/\033[0m"
|
||||
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
if [ "$SOFT_RESET" = true ]; then
|
||||
echo -e "${CYAN} Soft Reset (Factory)${NC}"
|
||||
echo -e "\033[1;37m Soft Reset (Factory)\033[0m"
|
||||
else
|
||||
echo -e "${CYAN} Sanitize for Imaging${NC}"
|
||||
echo -e "\033[1;37m Sanitize for Imaging\033[0m"
|
||||
fi
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
echo ""
|
||||
|
||||
if [ "$SOFT_RESET" = true ]; then
|
||||
|
||||
321
rpi/setup.sh
321
rpi/setup.sh
@@ -36,7 +36,9 @@ show_help() {
|
||||
echo "Usage: $0 [options]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -h, --help Show this help message"
|
||||
echo " -h, --help Show this help message"
|
||||
echo " --no-prebuilt Build from source instead of using pre-built venv"
|
||||
echo " --from-source Same as --no-prebuilt"
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " Config files are loaded in order (later overrides earlier):"
|
||||
@@ -81,18 +83,19 @@ done
|
||||
|
||||
clear
|
||||
echo ""
|
||||
echo -e "${GRAY} . * . . * . * . * . * .${NC}"
|
||||
echo -e "${CYAN} ___ _____ ___ ___ _ ___ ___ ___${NC}"
|
||||
echo -e "${CYAN} / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\${NC}"
|
||||
echo -e "${CYAN} \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |${NC}"
|
||||
echo -e "${CYAN} |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/${NC}"
|
||||
echo ""
|
||||
echo -e "${GRAY} * . * . * . * . * . *${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN} Raspberry Pi Setup${NC}"
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
|
||||
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
||||
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\\\ / __| / _ \\\\ / _ \\\\\033[0m"
|
||||
echo -e "\033[38;5;220m \\\\__ \\\\ | | | _| | (_ | / _ \\\\ \\\\__ \\\\ | (_) || (_) |\033[0m"
|
||||
echo -e "\033[38;5;220m |___/ |_| |___| \\___|/_/ \\_\\\\|___/ \\\\___/ \\\\___/\033[0m"
|
||||
echo -e "${GRAY} · . · . * · . * · . * · . * · . * · . ·${NC}"
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
echo -e "\033[1;37m Raspberry Pi Setup\033[0m"
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
echo ""
|
||||
echo " This will install Stegasoo with full DCT support"
|
||||
echo " Estimated time: 15-20 minutes on Pi 5"
|
||||
echo " Estimated time: ~2 minutes (pre-built) or 15-20 min (from source)"
|
||||
echo ""
|
||||
|
||||
# Check if running on ARM
|
||||
@@ -134,6 +137,7 @@ sudo apt-get install -y \
|
||||
build-essential \
|
||||
git \
|
||||
curl \
|
||||
zstd \
|
||||
libssl-dev \
|
||||
zlib1g-dev \
|
||||
libbz2-dev \
|
||||
@@ -163,49 +167,9 @@ else
|
||||
echo " gum already installed"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[4/12]${NC} Installing pyenv and Python $PYTHON_VERSION..."
|
||||
echo -e "${GREEN}[4/12]${NC} Cloning Stegasoo..."
|
||||
|
||||
# Install pyenv if not present
|
||||
if [ ! -d "$HOME/.pyenv" ]; then
|
||||
curl https://pyenv.run | bash
|
||||
|
||||
# Add pyenv to current shell
|
||||
export PYENV_ROOT="$HOME/.pyenv"
|
||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
||||
eval "$(pyenv init -)"
|
||||
|
||||
# Add to .bashrc if not already there
|
||||
if ! grep -q 'PYENV_ROOT' ~/.bashrc; then
|
||||
echo '' >> ~/.bashrc
|
||||
echo '# pyenv' >> ~/.bashrc
|
||||
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
|
||||
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
|
||||
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
|
||||
fi
|
||||
else
|
||||
echo "pyenv already installed, skipping..."
|
||||
export PYENV_ROOT="$HOME/.pyenv"
|
||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
||||
eval "$(pyenv init -)"
|
||||
fi
|
||||
|
||||
# Install Python 3.12 if not present
|
||||
if ! pyenv versions | grep -q "$PYTHON_VERSION"; then
|
||||
echo "Building Python $PYTHON_VERSION (this takes ~10 minutes)..."
|
||||
pyenv install $PYTHON_VERSION
|
||||
fi
|
||||
pyenv global $PYTHON_VERSION
|
||||
|
||||
# Verify Python version
|
||||
INSTALLED_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
||||
if [ "$INSTALLED_PY" != "$PYTHON_VERSION" ]; then
|
||||
echo -e "${RED}Error: Python $PYTHON_VERSION not active. Got: $INSTALLED_PY${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[5/12]${NC} Cloning Stegasoo..."
|
||||
|
||||
# Clone Stegasoo first (needed for jpegio patch script)
|
||||
# Clone Stegasoo first (needed to check for pre-built tarball)
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
echo " Stegasoo directory exists, updating..."
|
||||
cd "$INSTALL_DIR"
|
||||
@@ -217,49 +181,166 @@ else
|
||||
cd "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[6/12]${NC} Creating Python virtual environment..."
|
||||
# Pre-built environment tarball (skips 20+ min compile time)
|
||||
# Includes both pyenv Python 3.12 AND venv with all dependencies
|
||||
PREBUILT_TARBALL="$INSTALL_DIR/rpi/stegasoo-pi-arm64.tar.zst"
|
||||
PREBUILT_URL="${PREBUILT_URL:-https://github.com/adlee-was-taken/stegasoo/releases/download/v4.1.3/stegasoo-pi-arm64.tar.zst}"
|
||||
USE_PREBUILT=true
|
||||
|
||||
# Create venv with pyenv Python (not system Python)
|
||||
# Use pyenv which to get actual path (handles 3.12 -> 3.12.12 mapping)
|
||||
PYENV_PYTHON=$(pyenv which python)
|
||||
echo " Using Python: $PYENV_PYTHON"
|
||||
if [ ! -d "venv" ]; then
|
||||
"$PYENV_PYTHON" -m venv venv
|
||||
fi
|
||||
source venv/bin/activate
|
||||
|
||||
# Verify we're using the right Python
|
||||
VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
||||
echo " venv Python: $VENV_PY"
|
||||
|
||||
echo -e "${GREEN}[7/12]${NC} Building jpegio for ARM..."
|
||||
|
||||
# Clone jpegio
|
||||
JPEGIO_DIR="/tmp/jpegio-build"
|
||||
rm -rf "$JPEGIO_DIR"
|
||||
git clone "$JPEGIO_REPO" "$JPEGIO_DIR"
|
||||
|
||||
# Apply ARM64 patch
|
||||
if [ -f "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
|
||||
bash "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
|
||||
# Use local tarball if present, otherwise will download
|
||||
if [ -f "$PREBUILT_TARBALL" ]; then
|
||||
echo -e "${GREEN}Found local pre-built environment - fast install mode${NC}"
|
||||
else
|
||||
echo " Applying inline ARM64 patch..."
|
||||
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
|
||||
echo -e "${GREEN}Will download pre-built environment - fast install mode${NC}"
|
||||
fi
|
||||
|
||||
cd "$JPEGIO_DIR"
|
||||
# Allow --no-prebuilt flag to force from-source build
|
||||
if [[ " $* " =~ " --no-prebuilt " ]] || [[ " $* " =~ " --from-source " ]]; then
|
||||
USE_PREBUILT=false
|
||||
echo -e "${YELLOW}Building from source (--no-prebuilt specified)${NC}"
|
||||
fi
|
||||
|
||||
# Build jpegio into venv
|
||||
pip install --upgrade pip setuptools wheel cython numpy
|
||||
pip install .
|
||||
# Fast path: use pre-built environment if available
|
||||
if [ "$USE_PREBUILT" = true ]; then
|
||||
echo -e "${GREEN}[5/8]${NC} Installing pre-built Python environment..."
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
rm -rf "$JPEGIO_DIR"
|
||||
# Download if local file doesn't exist
|
||||
if [ ! -f "$PREBUILT_TARBALL" ]; then
|
||||
echo " Downloading pre-built environment (~50MB)..."
|
||||
curl -L -o "$PREBUILT_TARBALL" "$PREBUILT_URL"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[8/12]${NC} Installing Stegasoo..."
|
||||
# Extract pre-built environment (includes pyenv Python + venv)
|
||||
echo " Extracting pre-built environment..."
|
||||
zstd -d "$PREBUILT_TARBALL" --stdout | tar -xf - -C "$HOME"
|
||||
|
||||
# Install dependencies (jpegio already in venv, won't re-download)
|
||||
pip install -e ".[web]"
|
||||
# Setup pyenv in current shell
|
||||
export PYENV_ROOT="$HOME/.pyenv"
|
||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
||||
eval "$(pyenv init -)"
|
||||
pyenv global $PYTHON_VERSION
|
||||
|
||||
# Add to .bashrc if not already there
|
||||
if ! grep -q 'PYENV_ROOT' ~/.bashrc; then
|
||||
echo '' >> ~/.bashrc
|
||||
echo '# pyenv' >> ~/.bashrc
|
||||
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
|
||||
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
|
||||
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
|
||||
fi
|
||||
|
||||
# Verify Python
|
||||
INSTALLED_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
||||
echo -e " ${GREEN}✓${NC} Python: $INSTALLED_PY"
|
||||
|
||||
# Extract venv to install dir
|
||||
echo -e "${GREEN}[6/8]${NC} Setting up virtual environment..."
|
||||
if [ -f "$HOME/stegasoo-venv.tar.zst" ]; then
|
||||
zstd -d "$HOME/stegasoo-venv.tar.zst" --stdout | tar -xf - -C "$INSTALL_DIR"
|
||||
rm "$HOME/stegasoo-venv.tar.zst"
|
||||
fi
|
||||
|
||||
# Activate and verify
|
||||
source "$INSTALL_DIR/venv/bin/activate"
|
||||
VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
||||
echo -e " ${GREEN}✓${NC} venv Python: $VENV_PY"
|
||||
|
||||
# Install stegasoo package in editable mode (quick, no compile)
|
||||
echo -e "${GREEN}[7/8]${NC} Installing Stegasoo package..."
|
||||
pip install -e "." --quiet
|
||||
|
||||
# Adjust step numbers for rest of script
|
||||
STEP_OFFSET=-4
|
||||
else
|
||||
echo -e "${GREEN}[5/12]${NC} Installing pyenv and Python $PYTHON_VERSION..."
|
||||
|
||||
# Install pyenv if not present
|
||||
if [ ! -d "$HOME/.pyenv" ]; then
|
||||
curl https://pyenv.run | bash
|
||||
|
||||
# Add pyenv to current shell
|
||||
export PYENV_ROOT="$HOME/.pyenv"
|
||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
||||
eval "$(pyenv init -)"
|
||||
|
||||
# Add to .bashrc if not already there
|
||||
if ! grep -q 'PYENV_ROOT' ~/.bashrc; then
|
||||
echo '' >> ~/.bashrc
|
||||
echo '# pyenv' >> ~/.bashrc
|
||||
echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc
|
||||
echo '[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc
|
||||
echo 'eval "$(pyenv init - bash)"' >> ~/.bashrc
|
||||
fi
|
||||
else
|
||||
echo " pyenv already installed"
|
||||
export PYENV_ROOT="$HOME/.pyenv"
|
||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
||||
eval "$(pyenv init -)"
|
||||
fi
|
||||
|
||||
# Install Python 3.12 if not present
|
||||
if ! pyenv versions | grep -q "$PYTHON_VERSION"; then
|
||||
echo " Building Python $PYTHON_VERSION (this takes ~10 minutes)..."
|
||||
pyenv install $PYTHON_VERSION
|
||||
else
|
||||
echo " Python $PYTHON_VERSION already installed"
|
||||
fi
|
||||
pyenv global $PYTHON_VERSION
|
||||
|
||||
# Verify Python version
|
||||
INSTALLED_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
||||
if [ "$INSTALLED_PY" != "$PYTHON_VERSION" ]; then
|
||||
echo -e "${RED}Error: Python $PYTHON_VERSION not active. Got: $INSTALLED_PY${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}[6/12]${NC} Creating Python virtual environment..."
|
||||
echo -e " ${YELLOW}Note: No pre-built venv found. Building from source (20+ min)${NC}"
|
||||
echo -e " ${YELLOW}To speed up future installs, add stegasoo-venv-pi-arm64.tar.gz to rpi/${NC}"
|
||||
|
||||
# Create venv with pyenv Python (not system Python)
|
||||
# Use pyenv which to get actual path (handles 3.12 -> 3.12.12 mapping)
|
||||
PYENV_PYTHON=$(pyenv which python)
|
||||
echo " Using Python: $PYENV_PYTHON"
|
||||
if [ ! -d "venv" ]; then
|
||||
"$PYENV_PYTHON" -m venv venv
|
||||
fi
|
||||
source venv/bin/activate
|
||||
|
||||
# Verify we're using the right Python
|
||||
VENV_PY=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2)
|
||||
echo " venv Python: $VENV_PY"
|
||||
|
||||
echo -e "${GREEN}[7/12]${NC} Building jpegio for ARM..."
|
||||
|
||||
# Clone jpegio
|
||||
JPEGIO_DIR="/tmp/jpegio-build"
|
||||
rm -rf "$JPEGIO_DIR"
|
||||
git clone "$JPEGIO_REPO" "$JPEGIO_DIR"
|
||||
|
||||
# Apply ARM64 patch
|
||||
if [ -f "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
|
||||
bash "$INSTALL_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
|
||||
else
|
||||
echo " Applying inline ARM64 patch..."
|
||||
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
|
||||
fi
|
||||
|
||||
cd "$JPEGIO_DIR"
|
||||
|
||||
# Build jpegio into venv
|
||||
pip install --upgrade pip setuptools wheel cython numpy
|
||||
pip install .
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
rm -rf "$JPEGIO_DIR"
|
||||
|
||||
echo -e "${GREEN}[8/12]${NC} Installing Stegasoo..."
|
||||
|
||||
# Install dependencies (jpegio already in venv, won't re-download)
|
||||
pip install -e ".[web]"
|
||||
|
||||
STEP_OFFSET=0
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[9/12]${NC} Creating systemd service..."
|
||||
|
||||
@@ -290,7 +371,7 @@ echo -e "${GREEN}[10/12]${NC} Enabling service..."
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable stegasoo.service
|
||||
|
||||
echo -e "${GREEN}[11/12]${NC} Adding stegasoo to PATH..."
|
||||
echo -e "${GREEN}[11/12]${NC} Setting up user environment..."
|
||||
|
||||
# Add stegasoo venv and rpi scripts to PATH for all users
|
||||
sudo tee /etc/profile.d/stegasoo-path.sh > /dev/null <<'PATHEOF'
|
||||
@@ -303,7 +384,18 @@ if [ -d /opt/stegasoo/rpi ]; then
|
||||
fi
|
||||
PATHEOF
|
||||
sudo chmod 644 /etc/profile.d/stegasoo-path.sh
|
||||
echo " Added /opt/stegasoo/venv/bin and /opt/stegasoo/rpi to PATH"
|
||||
echo " Added stegasoo to PATH"
|
||||
|
||||
# Install custom bashrc if not already customized
|
||||
if [ -f "$INSTALL_DIR/rpi/skel/.bashrc" ]; then
|
||||
if ! grep -q "Stegasoo Pi" ~/.bashrc 2>/dev/null; then
|
||||
cp "$INSTALL_DIR/rpi/skel/.bashrc" ~/.bashrc
|
||||
source ~/.bashrc 2>/dev/null || true
|
||||
echo " Installed custom .bashrc"
|
||||
else
|
||||
echo " Custom .bashrc already installed"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[12/12]${NC} Setting up login banner..."
|
||||
|
||||
@@ -324,13 +416,27 @@ if systemctl is-active --quiet stegasoo 2>/dev/null; then
|
||||
STEGASOO_URL="http://$PI_IP:5000"
|
||||
fi
|
||||
echo ""
|
||||
echo -e "\033[0;36m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
||||
echo -e "\033[0;36m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
|
||||
echo -e "\033[0;36m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
|
||||
echo -e "\033[0;36m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
|
||||
echo ""
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
||||
echo -e "\033[38;5;220m ___ _____ ___ ___ _ ___ ___ ___\033[0m"
|
||||
echo -e "\033[38;5;220m / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\\033[0m"
|
||||
echo -e "\033[38;5;220m \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |\033[0m"
|
||||
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
|
||||
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
echo -e " \033[0;32m●\033[0m Stegasoo is running"
|
||||
echo -e " \033[0;33m$STEGASOO_URL\033[0m"
|
||||
# Show CPU stats if overclocked (read configured freq, not current idle freq)
|
||||
CONFIG_FILE=""
|
||||
if [ -f /boot/firmware/config.txt ]; then CONFIG_FILE="/boot/firmware/config.txt"
|
||||
elif [ -f /boot/config.txt ]; then CONFIG_FILE="/boot/config.txt"; fi
|
||||
if [ -n "$CONFIG_FILE" ] && grep -qE "^arm_freq=" "$CONFIG_FILE" 2>/dev/null; then
|
||||
CPU_MHZ=$(grep "^arm_freq=" "$CONFIG_FILE" | cut -d= -f2)
|
||||
CPU_TEMP=$(vcgencmd measure_temp 2>/dev/null | cut -d= -f2)
|
||||
if [ -n "$CPU_MHZ" ] && [ -n "$CPU_TEMP" ]; then
|
||||
echo -e " \033[0;35m⚡\033[0m ${CPU_MHZ} MHz \033[0;35m🌡\033[0m ${CPU_TEMP}"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
else
|
||||
echo ""
|
||||
@@ -440,6 +546,31 @@ RestartSec=5
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Generate SSL certificates if HTTPS enabled
|
||||
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||
echo " Generating SSL certificates..."
|
||||
CERT_DIR="$INSTALL_DIR/frontends/web/certs"
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
# Get local IP for SAN
|
||||
LOCAL_IP=$(hostname -I | awk '{print $1}')
|
||||
PI_HOSTNAME=$(hostname)
|
||||
|
||||
# Generate cert with SANs for IP, hostname, and localhost
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout "$CERT_DIR/server.key" \
|
||||
-out "$CERT_DIR/server.crt" \
|
||||
-days 365 -nodes \
|
||||
-subj "/O=Stegasoo/CN=$PI_HOSTNAME" \
|
||||
-addext "subjectAltName=DNS:$PI_HOSTNAME,DNS:$PI_HOSTNAME.local,DNS:localhost,IP:$LOCAL_IP,IP:127.0.0.1" \
|
||||
2>/dev/null
|
||||
|
||||
# Fix permissions
|
||||
chmod 600 "$CERT_DIR/server.key"
|
||||
chown -R "$USER:$USER" "$CERT_DIR"
|
||||
echo -e " ${GREEN}✓${NC} SSL certificates generated"
|
||||
fi
|
||||
|
||||
# Setup port 443 redirect if requested
|
||||
if [ "$USE_PORT_443" = "true" ]; then
|
||||
echo " Setting up port 443 redirect..."
|
||||
|
||||
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
|
||||
334
scripts/validate-release.sh
Executable file
334
scripts/validate-release.sh
Executable file
@@ -0,0 +1,334 @@
|
||||
#!/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. Security Audit
|
||||
# =============================================================================
|
||||
section "Security Audit"
|
||||
|
||||
# pip-audit for known vulnerabilities
|
||||
if command -v ./venv/bin/pip-audit &> /dev/null; then
|
||||
echo -n "Running pip-audit... "
|
||||
if ./venv/bin/pip-audit --quiet 2>/dev/null; then
|
||||
pass "No known vulnerabilities"
|
||||
else
|
||||
fail "pip-audit found vulnerabilities (run: ./venv/bin/pip-audit)"
|
||||
fi
|
||||
else
|
||||
echo -n "Installing pip-audit... "
|
||||
if ./venv/bin/pip install pip-audit --quiet 2>/dev/null; then
|
||||
echo -n "Running pip-audit... "
|
||||
if ./venv/bin/pip-audit --quiet 2>/dev/null; then
|
||||
pass "No known vulnerabilities"
|
||||
else
|
||||
fail "pip-audit found vulnerabilities (run: ./venv/bin/pip-audit)"
|
||||
fi
|
||||
else
|
||||
skip "Could not install pip-audit"
|
||||
fi
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 3. 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
|
||||
|
||||
# =============================================================================
|
||||
# 4. 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
|
||||
|
||||
# =============================================================================
|
||||
# 5. 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
|
||||
|
||||
# =============================================================================
|
||||
# 6. 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
|
||||
|
||||
# =============================================================================
|
||||
# 7. Pi Smoke Test (optional)
|
||||
# =============================================================================
|
||||
if $INCLUDE_PI; then
|
||||
section "Pi Smoke Test"
|
||||
|
||||
echo -n "Testing SSH connectivity to $PI_USER@$PI_IP... "
|
||||
if ssh -o ConnectTimeout=5 -o BatchMode=yes "$PI_USER@$PI_IP" "echo OK" 2>/dev/null | grep -q OK; then
|
||||
pass "SSH connectivity"
|
||||
|
||||
echo -n "Checking stegasoo service status... "
|
||||
if ssh "$PI_USER@$PI_IP" "systemctl is-active stegasoo" 2>/dev/null | grep -q active; then
|
||||
pass "Stegasoo service running"
|
||||
|
||||
echo -n "Running smoke test on Pi... "
|
||||
SMOKE_RESULT=$(ssh "$PI_USER@$PI_IP" "cd /home/$PI_USER/stegasoo && bash tests/smoke-test.sh --quick 2>&1" || echo "FAILED")
|
||||
if echo "$SMOKE_RESULT" | grep -qE "All tests passed|PASS"; then
|
||||
pass "Pi smoke test"
|
||||
else
|
||||
fail "Pi smoke test"
|
||||
fi
|
||||
else
|
||||
fail "Stegasoo service not running"
|
||||
fi
|
||||
else
|
||||
fail "SSH connectivity to Pi"
|
||||
fi
|
||||
else
|
||||
skip "Pi smoke test (use --pi to enable)"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Summary
|
||||
# =============================================================================
|
||||
echo ""
|
||||
echo -e "${CYAN}━━━ Summary ━━━${NC}"
|
||||
echo ""
|
||||
echo -e "Tests run: ${TESTS_RUN}"
|
||||
echo -e "Passed: ${GREEN}${TESTS_PASSED}${NC}"
|
||||
echo -e "Failed: ${RED}${TESTS_FAILED}${NC}"
|
||||
|
||||
if [ ${#FAILED_TESTS[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo -e "${RED}Failed tests:${NC}"
|
||||
for test in "${FAILED_TESTS[@]}"; do
|
||||
echo -e " - $test"
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ $TESTS_FAILED -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ All validation checks passed!${NC}"
|
||||
echo -e " Ready to tag release ${VERSION}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}✗ Validation failed - fix issues before release${NC}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -7,7 +7,7 @@ Changes in v4.0.0:
|
||||
- encode() and decode() now accept channel_key parameter
|
||||
"""
|
||||
|
||||
__version__ = "4.0.1"
|
||||
__version__ = "4.1.3"
|
||||
|
||||
# Core functionality
|
||||
# Channel key management (v4.0.0)
|
||||
@@ -45,6 +45,7 @@ from .image_utils import (
|
||||
|
||||
# Steganography functions
|
||||
from .steganography import (
|
||||
calculate_capacity_by_mode,
|
||||
compare_modes,
|
||||
has_dct_support,
|
||||
will_fit_by_mode,
|
||||
@@ -92,6 +93,7 @@ from .constants import (
|
||||
EMBED_MODE_LSB,
|
||||
FORMAT_VERSION,
|
||||
LOSSLESS_FORMATS,
|
||||
MAX_FILE_PAYLOAD_SIZE,
|
||||
MAX_IMAGE_PIXELS,
|
||||
MAX_MESSAGE_SIZE,
|
||||
MAX_PASSPHRASE_WORDS,
|
||||
@@ -112,12 +114,16 @@ from .exceptions import (
|
||||
ExtractionError,
|
||||
ImageValidationError,
|
||||
InvalidHeaderError,
|
||||
InvalidMagicBytesError,
|
||||
KeyDerivationError,
|
||||
KeyGenerationError,
|
||||
KeyPasswordError,
|
||||
KeyValidationError,
|
||||
MessageValidationError,
|
||||
ModeMismatchError,
|
||||
NoDataFoundError,
|
||||
PinValidationError,
|
||||
ReedSolomonError,
|
||||
SecurityFactorError,
|
||||
SteganographyError,
|
||||
StegasooError,
|
||||
@@ -145,6 +151,7 @@ from .validation import (
|
||||
MIN_MESSAGE_LENGTH = 1
|
||||
MAX_MESSAGE_LENGTH = MAX_MESSAGE_SIZE
|
||||
MAX_PAYLOAD_SIZE = MAX_MESSAGE_SIZE
|
||||
# MAX_FILE_PAYLOAD_SIZE imported from constants above
|
||||
SUPPORTED_IMAGE_FORMATS = LOSSLESS_FORMATS
|
||||
LSB_BYTES_PER_PIXEL = 3 / 8
|
||||
DCT_BYTES_PER_PIXEL = 0.125
|
||||
@@ -184,6 +191,7 @@ __all__ = [
|
||||
"has_argon2",
|
||||
# Steganography
|
||||
"has_dct_support",
|
||||
"calculate_capacity_by_mode",
|
||||
"compare_modes",
|
||||
"will_fit_by_mode",
|
||||
# QR utilities
|
||||
@@ -232,6 +240,10 @@ __all__ = [
|
||||
"ExtractionError",
|
||||
"EmbeddingError",
|
||||
"InvalidHeaderError",
|
||||
"InvalidMagicBytesError",
|
||||
"ReedSolomonError",
|
||||
"NoDataFoundError",
|
||||
"ModeMismatchError",
|
||||
# Constants
|
||||
"FORMAT_VERSION",
|
||||
"MIN_PASSPHRASE_WORDS",
|
||||
@@ -244,6 +256,7 @@ __all__ = [
|
||||
"MAX_MESSAGE_LENGTH",
|
||||
"MAX_MESSAGE_SIZE",
|
||||
"MAX_PAYLOAD_SIZE",
|
||||
"MAX_FILE_PAYLOAD_SIZE",
|
||||
"MIN_IMAGE_PIXELS",
|
||||
"MAX_IMAGE_PIXELS",
|
||||
"SUPPORTED_IMAGE_FORMATS",
|
||||
|
||||
@@ -105,6 +105,7 @@ def encode(
|
||||
stegasoo encode photo.png -r ref.jpg -f secret.pdf -o encoded.png
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
from .encode import encode as stegasoo_encode
|
||||
from .encode import encode_file as stegasoo_encode_file
|
||||
|
||||
@@ -585,11 +586,113 @@ def generate(ctx, words, pin_length, channel_key):
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--full", is_flag=True, help="Show full system information (Pi stats)")
|
||||
@click.pass_context
|
||||
def info(ctx):
|
||||
"""Show version and feature information."""
|
||||
def info(ctx, full):
|
||||
"""Show version, features, and system information."""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# Check for DCT support
|
||||
try:
|
||||
from .dct_steganography import HAS_SCIPY, HAS_JPEGIO
|
||||
HAS_DCT = HAS_SCIPY and HAS_JPEGIO
|
||||
except ImportError:
|
||||
HAS_DCT = False
|
||||
|
||||
# Check service status
|
||||
service_status = "unknown"
|
||||
service_url = None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "is-active", "stegasoo"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
service_status = result.stdout.strip()
|
||||
if service_status == "active":
|
||||
# Try to get URL from service environment
|
||||
env_result = subprocess.run(
|
||||
["systemctl", "show", "stegasoo", "--property=Environment"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
https_enabled = "HTTPS_ENABLED=true" in env_result.stdout
|
||||
protocol = "https" if https_enabled else "http"
|
||||
# Get IP
|
||||
ip_result = subprocess.run(
|
||||
["hostname", "-I"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
ip = ip_result.stdout.strip().split()[0] if ip_result.stdout.strip() else "localhost"
|
||||
service_url = f"{protocol}://{ip}"
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, IndexError):
|
||||
pass
|
||||
|
||||
# Check channel key
|
||||
channel_fingerprint = None
|
||||
channel_source = None
|
||||
try:
|
||||
from .channel import get_channel_key, get_channel_fingerprint, get_channel_status
|
||||
key = get_channel_key()
|
||||
if key:
|
||||
channel_fingerprint = get_channel_fingerprint(key)
|
||||
status = get_channel_status()
|
||||
channel_source = status.get("source")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# System info (Pi-specific)
|
||||
cpu_freq = None
|
||||
cpu_temp = None
|
||||
disk_free = None
|
||||
uptime = None
|
||||
|
||||
if full:
|
||||
try:
|
||||
# CPU frequency
|
||||
with open("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq") as f:
|
||||
cpu_freq = int(f.read().strip()) // 1000 # MHz
|
||||
except (FileNotFoundError, ValueError):
|
||||
pass
|
||||
|
||||
try:
|
||||
# CPU temp
|
||||
with open("/sys/class/thermal/thermal_zone0/temp") as f:
|
||||
cpu_temp = int(f.read().strip()) / 1000 # Celsius
|
||||
except (FileNotFoundError, ValueError):
|
||||
pass
|
||||
|
||||
try:
|
||||
# Disk free
|
||||
st = os.statvfs("/")
|
||||
disk_free = (st.f_bavail * st.f_frsize) / (1024 ** 3) # GB
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Uptime
|
||||
with open("/proc/uptime") as f:
|
||||
uptime_secs = float(f.read().split()[0])
|
||||
days = int(uptime_secs // 86400)
|
||||
hours = int((uptime_secs % 86400) // 3600)
|
||||
uptime = f"{days}d {hours}h" if days else f"{hours}h"
|
||||
except (FileNotFoundError, ValueError):
|
||||
pass
|
||||
|
||||
info_data = {
|
||||
"version": __version__,
|
||||
"service": service_status,
|
||||
"url": service_url,
|
||||
"dct_support": HAS_DCT,
|
||||
"channel": {
|
||||
"fingerprint": channel_fingerprint,
|
||||
"source": channel_source,
|
||||
} if channel_fingerprint else None,
|
||||
"compression": {
|
||||
"available": [algorithm_name(a) for a in get_available_algorithms()],
|
||||
"lz4_installed": HAS_LZ4,
|
||||
@@ -598,20 +701,54 @@ def info(ctx):
|
||||
"max_message_bytes": MAX_MESSAGE_SIZE,
|
||||
"max_file_payload_bytes": MAX_FILE_PAYLOAD_SIZE,
|
||||
},
|
||||
"system": {
|
||||
"cpu_mhz": cpu_freq,
|
||||
"temp_c": cpu_temp,
|
||||
"disk_free_gb": round(disk_free, 1) if disk_free else None,
|
||||
"uptime": uptime,
|
||||
} if full else None,
|
||||
}
|
||||
|
||||
if ctx.obj.get("json"):
|
||||
click.echo(json.dumps(info_data, indent=2))
|
||||
else:
|
||||
click.echo(f"Stegasoo v{__version__}")
|
||||
click.echo("\nCompression algorithms:")
|
||||
for algo in get_available_algorithms():
|
||||
click.echo(f" • {algorithm_name(algo)}")
|
||||
if not HAS_LZ4:
|
||||
click.echo(" (install 'lz4' for LZ4 support)")
|
||||
click.echo("\nLimits:")
|
||||
click.echo(f" • Max message: {MAX_MESSAGE_SIZE:,} bytes")
|
||||
click.echo(f" • Max file payload: {MAX_FILE_PAYLOAD_SIZE:,} bytes")
|
||||
# Fastfetch-style output
|
||||
click.echo(f"\033[1mSTEGASOO\033[0m v{__version__}")
|
||||
click.echo("─" * 36)
|
||||
|
||||
# Service status
|
||||
if service_status == "active":
|
||||
click.echo(f" Service: \033[32m● running\033[0m")
|
||||
if service_url:
|
||||
click.echo(f" URL: {service_url}")
|
||||
elif service_status == "inactive":
|
||||
click.echo(f" Service: \033[31m○ stopped\033[0m")
|
||||
else:
|
||||
click.echo(f" Service: \033[33m? {service_status}\033[0m")
|
||||
|
||||
# Channel
|
||||
if channel_fingerprint:
|
||||
masked = f"{channel_fingerprint[:4]}••••••••{channel_fingerprint[-4:]}"
|
||||
click.echo(f" Channel: {masked}")
|
||||
else:
|
||||
click.echo(f" Channel: \033[33mpublic\033[0m")
|
||||
|
||||
# DCT
|
||||
dct_status = "\033[32m✓ enabled\033[0m" if HAS_DCT else "\033[31m✗ disabled\033[0m"
|
||||
click.echo(f" DCT: {dct_status}")
|
||||
|
||||
# System info (if --full)
|
||||
if full and any([cpu_freq, cpu_temp, disk_free, uptime]):
|
||||
click.echo("─" * 36)
|
||||
if cpu_freq:
|
||||
click.echo(f" CPU: {cpu_freq} MHz")
|
||||
if cpu_temp:
|
||||
temp_color = "\033[32m" if cpu_temp < 60 else "\033[33m" if cpu_temp < 75 else "\033[31m"
|
||||
click.echo(f" Temp: {temp_color}{cpu_temp:.1f}°C\033[0m")
|
||||
if uptime:
|
||||
click.echo(f" Uptime: {uptime}")
|
||||
if disk_free:
|
||||
click.echo(f" Disk: {disk_free:.1f} GB free")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1108,7 +1245,9 @@ def admin_recover(db_path, password):
|
||||
stegasoo admin recover --db /path/to/stegasoo.db
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
|
||||
from .recovery import verify_recovery_key
|
||||
|
||||
# Try default paths if not specified
|
||||
|
||||
@@ -25,7 +25,7 @@ from pathlib import Path
|
||||
# VERSION
|
||||
# ============================================================================
|
||||
|
||||
__version__ = "4.1.0"
|
||||
__version__ = "4.1.3"
|
||||
|
||||
# ============================================================================
|
||||
# FILE FORMAT
|
||||
|
||||
@@ -54,6 +54,34 @@ except ImportError:
|
||||
HAS_JPEGIO = False
|
||||
jio = None
|
||||
|
||||
# Import custom exceptions
|
||||
from .exceptions import InvalidMagicBytesError
|
||||
from .exceptions import ReedSolomonError as StegasooRSError
|
||||
|
||||
# Progress reporting interval (write every N blocks)
|
||||
PROGRESS_INTERVAL = 50
|
||||
|
||||
|
||||
def _write_progress(progress_file: str | None, current: int, total: int, phase: str = "embedding"):
|
||||
"""Write progress to file for frontend polling."""
|
||||
if progress_file is None:
|
||||
return
|
||||
try:
|
||||
import json
|
||||
|
||||
with open(progress_file, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"current": current,
|
||||
"total": total,
|
||||
"percent": round((current / total) * 100, 1) if total > 0 else 0,
|
||||
"phase": phase,
|
||||
},
|
||||
f,
|
||||
)
|
||||
except Exception:
|
||||
pass # Don't let progress writing break encoding
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CONSTANTS
|
||||
@@ -186,7 +214,7 @@ def has_jpegio_support() -> bool:
|
||||
|
||||
# Check for reedsolo availability
|
||||
try:
|
||||
from reedsolo import RSCodec, ReedSolomonError
|
||||
from reedsolo import ReedSolomonError, RSCodec
|
||||
|
||||
HAS_REEDSOLO = True
|
||||
except ImportError:
|
||||
@@ -214,7 +242,7 @@ def _rs_decode(data: bytes) -> bytes:
|
||||
pass # Errors were corrected
|
||||
return bytes(decoded)
|
||||
except ReedSolomonError as e:
|
||||
raise ValueError(f"Reed-Solomon decoding failed: {e}") from e
|
||||
raise StegasooRSError(f"Image corrupted beyond repair: {e}") from e
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -410,7 +438,7 @@ def _parse_header(header_bits: list) -> tuple[int, int, int]:
|
||||
magic, version, flags, length = struct.unpack(">4sBBI", header_bytes)
|
||||
|
||||
if magic != DCT_MAGIC:
|
||||
raise ValueError("Invalid DCT stego magic bytes")
|
||||
raise InvalidMagicBytesError("Not a Stegasoo image or wrong mode (try LSB instead of DCT)")
|
||||
|
||||
return version, flags, length
|
||||
|
||||
@@ -461,7 +489,7 @@ def _jpegio_parse_header(header_bytes: bytes) -> tuple[int, int, int]:
|
||||
raise ValueError("Insufficient header data")
|
||||
magic, version, flags, length = struct.unpack(">4sBBI", header_bytes[:HEADER_SIZE])
|
||||
if magic != JPEGIO_MAGIC:
|
||||
raise ValueError(f"Invalid JPEG stego magic: {magic}")
|
||||
raise InvalidMagicBytesError("Not a Stegasoo JPEG or wrong mode")
|
||||
return version, flags, length
|
||||
|
||||
|
||||
@@ -556,6 +584,7 @@ def embed_in_dct(
|
||||
seed: bytes,
|
||||
output_format: str = OUTPUT_FORMAT_PNG,
|
||||
color_mode: str = "color",
|
||||
progress_file: str | None = None,
|
||||
) -> tuple[bytes, DCTEmbedStats]:
|
||||
"""Embed data using DCT coefficient modification."""
|
||||
if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG):
|
||||
@@ -565,10 +594,12 @@ def embed_in_dct(
|
||||
color_mode = "color"
|
||||
|
||||
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
|
||||
return _embed_jpegio(data, carrier_image, seed, color_mode)
|
||||
return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file)
|
||||
|
||||
_check_scipy()
|
||||
return _embed_scipy_dct_safe(data, carrier_image, seed, output_format, color_mode)
|
||||
return _embed_scipy_dct_safe(
|
||||
data, carrier_image, seed, output_format, color_mode, progress_file
|
||||
)
|
||||
|
||||
|
||||
def _embed_scipy_dct_safe(
|
||||
@@ -577,6 +608,7 @@ def _embed_scipy_dct_safe(
|
||||
seed: bytes,
|
||||
output_format: str,
|
||||
color_mode: str = "color",
|
||||
progress_file: str | None = None,
|
||||
) -> tuple[bytes, DCTEmbedStats]:
|
||||
"""
|
||||
Embed using scipy DCT with safe memory handling.
|
||||
@@ -639,7 +671,7 @@ def _embed_scipy_dct_safe(
|
||||
gc.collect()
|
||||
|
||||
# Embed in Y channel
|
||||
Y_embedded = _embed_in_channel_safe(Y_padded, bits, block_order, blocks_x)
|
||||
Y_embedded = _embed_in_channel_safe(Y_padded, bits, block_order, blocks_x, progress_file)
|
||||
del Y_padded
|
||||
gc.collect()
|
||||
|
||||
@@ -663,7 +695,7 @@ def _embed_scipy_dct_safe(
|
||||
del image
|
||||
gc.collect()
|
||||
|
||||
embedded = _embed_in_channel_safe(padded, bits, block_order, blocks_x)
|
||||
embedded = _embed_in_channel_safe(padded, bits, block_order, blocks_x, progress_file)
|
||||
del padded
|
||||
gc.collect()
|
||||
|
||||
@@ -696,6 +728,7 @@ def _embed_in_channel_safe(
|
||||
bits: list,
|
||||
block_order: list,
|
||||
blocks_x: int,
|
||||
progress_file: str | None = None,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Embed bits in channel using safe DCT operations.
|
||||
@@ -708,8 +741,9 @@ def _embed_in_channel_safe(
|
||||
result = np.array(channel, dtype=np.float64, copy=True, order="C")
|
||||
|
||||
bit_idx = 0
|
||||
total_blocks = len(block_order)
|
||||
|
||||
for block_num in block_order:
|
||||
for block_idx, block_num in enumerate(block_order):
|
||||
if bit_idx >= len(bits):
|
||||
break
|
||||
|
||||
@@ -745,6 +779,14 @@ def _embed_in_channel_safe(
|
||||
# Clean up this iteration
|
||||
del block, dct_block, modified_block
|
||||
|
||||
# Report progress periodically
|
||||
if progress_file and block_idx % PROGRESS_INTERVAL == 0:
|
||||
_write_progress(progress_file, block_idx, total_blocks, "embedding")
|
||||
|
||||
# Final progress update
|
||||
if progress_file:
|
||||
_write_progress(progress_file, total_blocks, total_blocks, "finalizing")
|
||||
|
||||
# Force garbage collection
|
||||
gc.collect()
|
||||
|
||||
@@ -801,6 +843,7 @@ def _embed_jpegio(
|
||||
carrier_image: bytes,
|
||||
seed: bytes,
|
||||
color_mode: str = "color",
|
||||
progress_file: str | None = None,
|
||||
) -> tuple[bytes, DCTEmbedStats]:
|
||||
"""Embed using jpegio for proper JPEG coefficient modification."""
|
||||
import os
|
||||
@@ -858,6 +901,9 @@ def _embed_jpegio(
|
||||
)
|
||||
|
||||
coefs_used = 0
|
||||
total_bits = len(bits)
|
||||
progress_interval = max(total_bits // 20, 100) # Report ~20 times or every 100 bits
|
||||
|
||||
for bit_idx, pos_idx in enumerate(order):
|
||||
if bit_idx >= len(bits):
|
||||
break
|
||||
@@ -873,6 +919,14 @@ def _embed_jpegio(
|
||||
|
||||
coefs_used += 1
|
||||
|
||||
# Report progress periodically
|
||||
if progress_file and bit_idx % progress_interval == 0:
|
||||
_write_progress(progress_file, bit_idx, total_bits, "embedding")
|
||||
|
||||
# Final progress before save
|
||||
if progress_file:
|
||||
_write_progress(progress_file, total_bits, total_bits, "saving")
|
||||
|
||||
jio.write(jpeg, output_path)
|
||||
|
||||
with open(output_path, "rb") as f:
|
||||
@@ -968,8 +1022,8 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
||||
total_needed = (HEADER_SIZE + data_length) * 8
|
||||
if len(all_bits) >= total_needed:
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
except (ValueError, InvalidMagicBytesError):
|
||||
pass # RS-protected format has length prefix first, not magic bytes
|
||||
|
||||
del padded
|
||||
gc.collect()
|
||||
@@ -994,6 +1048,7 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
||||
|
||||
# Count occurrences of each unique copy
|
||||
from collections import Counter
|
||||
|
||||
counter = Counter(copies)
|
||||
best_header, count = counter.most_common(1)[0]
|
||||
|
||||
@@ -1006,9 +1061,13 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes:
|
||||
|
||||
# Sanity check: both lengths should be reasonable
|
||||
max_reasonable = (len(all_bits) // 8) - RS_LENGTH_PREFIX_SIZE
|
||||
if (raw_payload_length > 0 and raw_payload_length <= max_reasonable and
|
||||
rs_encoded_length > 0 and rs_encoded_length <= max_reasonable and
|
||||
rs_encoded_length >= raw_payload_length):
|
||||
if (
|
||||
raw_payload_length > 0
|
||||
and raw_payload_length <= max_reasonable
|
||||
and rs_encoded_length > 0
|
||||
and rs_encoded_length <= max_reasonable
|
||||
and rs_encoded_length >= raw_payload_length
|
||||
):
|
||||
# This looks like RS-protected format
|
||||
total_bits_needed = (RS_LENGTH_PREFIX_SIZE + rs_encoded_length) * 8
|
||||
|
||||
@@ -1085,6 +1144,7 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
||||
|
||||
# Extract 3 copies and use majority voting
|
||||
from collections import Counter
|
||||
|
||||
copies = []
|
||||
for i in range(RS_LENGTH_COPIES):
|
||||
start = i * RS_LENGTH_HEADER_SIZE
|
||||
@@ -1101,9 +1161,13 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes:
|
||||
|
||||
# Sanity check
|
||||
max_reasonable = (len(all_positions) // 8) - RS_LENGTH_PREFIX_SIZE
|
||||
if (raw_payload_length > 0 and raw_payload_length <= max_reasonable and
|
||||
rs_encoded_length > 0 and rs_encoded_length <= max_reasonable and
|
||||
rs_encoded_length >= raw_payload_length):
|
||||
if (
|
||||
raw_payload_length > 0
|
||||
and raw_payload_length <= max_reasonable
|
||||
and rs_encoded_length > 0
|
||||
and rs_encoded_length <= max_reasonable
|
||||
and rs_encoded_length >= raw_payload_length
|
||||
):
|
||||
total_bits_needed = (RS_LENGTH_PREFIX_SIZE + rs_encoded_length) * 8
|
||||
|
||||
if len(all_positions) >= total_bits_needed:
|
||||
|
||||
@@ -37,6 +37,7 @@ def encode(
|
||||
dct_output_format: str = "png",
|
||||
dct_color_mode: str = "color",
|
||||
channel_key: str | bool | None = None,
|
||||
progress_file: str | None = None,
|
||||
) -> EncodeResult:
|
||||
"""
|
||||
Encode a message or file into an image.
|
||||
@@ -118,6 +119,7 @@ def encode(
|
||||
embed_mode=embed_mode,
|
||||
dct_output_format=dct_output_format,
|
||||
dct_color_mode=dct_color_mode,
|
||||
progress_file=progress_file,
|
||||
)
|
||||
|
||||
# Generate filename
|
||||
|
||||
@@ -133,6 +133,30 @@ class InvalidHeaderError(SteganographyError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidMagicBytesError(SteganographyError):
|
||||
"""Magic bytes don't match - not a Stegasoo image or wrong mode."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ReedSolomonError(SteganographyError):
|
||||
"""Reed-Solomon error correction failed - image too corrupted."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NoDataFoundError(SteganographyError):
|
||||
"""No hidden data found in image."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ModeMismatchError(SteganographyError):
|
||||
"""Wrong steganography mode (LSB vs DCT)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FILE ERRORS
|
||||
# ============================================================================
|
||||
|
||||
@@ -39,6 +39,31 @@ from .debug import debug
|
||||
from .exceptions import CapacityError, EmbeddingError
|
||||
from .models import EmbedStats, FilePayload
|
||||
|
||||
# Progress reporting interval
|
||||
PROGRESS_INTERVAL = 1000 # Write every N pixels for LSB
|
||||
|
||||
|
||||
def _write_progress(progress_file: str | None, current: int, total: int, phase: str = "embedding"):
|
||||
"""Write progress to file for frontend polling."""
|
||||
if progress_file is None:
|
||||
return
|
||||
try:
|
||||
import json
|
||||
|
||||
with open(progress_file, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"current": current,
|
||||
"total": total,
|
||||
"percent": round((current / total) * 100, 1) if total > 0 else 0,
|
||||
"phase": phase,
|
||||
},
|
||||
f,
|
||||
)
|
||||
except Exception:
|
||||
pass # Don't let progress writing break encoding
|
||||
|
||||
|
||||
# Lossless formats that preserve LSB data
|
||||
LOSSLESS_FORMATS = {"PNG", "BMP", "TIFF"}
|
||||
|
||||
@@ -526,6 +551,7 @@ def embed_in_image(
|
||||
embed_mode: str = EMBED_MODE_LSB,
|
||||
dct_output_format: str = DCT_OUTPUT_PNG,
|
||||
dct_color_mode: str = "color",
|
||||
progress_file: str | None = None,
|
||||
) -> tuple[bytes, Union[EmbedStats, "DCTEmbedStats"], str]:
|
||||
"""
|
||||
Embed data into an image using specified mode.
|
||||
@@ -579,6 +605,7 @@ def embed_in_image(
|
||||
pixel_key,
|
||||
output_format=dct_output_format,
|
||||
color_mode=dct_color_mode,
|
||||
progress_file=progress_file,
|
||||
)
|
||||
|
||||
# Determine extension based on output format
|
||||
@@ -594,7 +621,7 @@ def embed_in_image(
|
||||
return stego_bytes, dct_stats, ext
|
||||
|
||||
# LSB MODE
|
||||
return _embed_lsb(data, image_data, pixel_key, bits_per_channel, output_format)
|
||||
return _embed_lsb(data, image_data, pixel_key, bits_per_channel, output_format, progress_file)
|
||||
|
||||
|
||||
def _embed_lsb(
|
||||
@@ -603,6 +630,7 @@ def _embed_lsb(
|
||||
pixel_key: bytes,
|
||||
bits_per_channel: int = 1,
|
||||
output_format: str | None = None,
|
||||
progress_file: str | None = None,
|
||||
) -> tuple[bytes, EmbedStats, str]:
|
||||
"""
|
||||
Embed data using LSB steganography (internal implementation).
|
||||
@@ -659,8 +687,9 @@ def _embed_lsb(
|
||||
|
||||
bit_idx = 0
|
||||
modified_pixels = 0
|
||||
total_pixels_to_process = len(selected_indices)
|
||||
|
||||
for pixel_idx in selected_indices:
|
||||
for progress_idx, pixel_idx in enumerate(selected_indices):
|
||||
if bit_idx >= len(binary_data):
|
||||
break
|
||||
|
||||
@@ -690,6 +719,16 @@ def _embed_lsb(
|
||||
new_pixels[pixel_idx] = (r, g, b)
|
||||
modified_pixels += 1
|
||||
|
||||
# Report progress periodically
|
||||
if progress_file and progress_idx % PROGRESS_INTERVAL == 0:
|
||||
_write_progress(progress_file, progress_idx, total_pixels_to_process, "embedding")
|
||||
|
||||
# Final progress before save
|
||||
if progress_file:
|
||||
_write_progress(
|
||||
progress_file, total_pixels_to_process, total_pixels_to_process, "saving"
|
||||
)
|
||||
|
||||
debug.print(f"Modified {modified_pixels} pixels (out of {len(selected_indices)} selected)")
|
||||
|
||||
stego_img = Image.new("RGB", img.size)
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
"""
|
||||
Tests for Stegasoo batch processing module (v4.0.0).
|
||||
|
||||
Updated for v4.0.0:
|
||||
- Uses 'passphrase' instead of 'phrase' in credentials dict
|
||||
- No date_str parameter
|
||||
- BatchCredentials.passphrase is a single string
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from stegasoo.batch import (
|
||||
BatchCredentials,
|
||||
BatchItem,
|
||||
BatchProcessor,
|
||||
BatchResult,
|
||||
BatchStatus,
|
||||
batch_capacity_check,
|
||||
print_batch_result,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir():
|
||||
"""Create a temporary directory for tests."""
|
||||
path = Path(tempfile.mkdtemp())
|
||||
yield path
|
||||
shutil.rmtree(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_images(temp_dir):
|
||||
"""Create sample PNG images for testing."""
|
||||
from PIL import Image
|
||||
|
||||
images = []
|
||||
for i in range(3):
|
||||
img_path = temp_dir / f"test_image_{i}.png"
|
||||
img = Image.new("RGB", (100, 100), color=(i * 50, i * 50, i * 50))
|
||||
img.save(img_path, "PNG")
|
||||
images.append(img_path)
|
||||
|
||||
return images
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_reference_photo():
|
||||
"""Create a sample reference photo as bytes."""
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
img = Image.new("RGB", (100, 100), color=(128, 128, 128))
|
||||
buf = BytesIO()
|
||||
img.save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_credentials(sample_reference_photo):
|
||||
"""Create sample v3.2.0 credentials dict."""
|
||||
return {
|
||||
"reference_photo": sample_reference_photo,
|
||||
"passphrase": "test phrase four words", # v3.2.0: single passphrase
|
||||
"pin": "123456",
|
||||
}
|
||||
|
||||
|
||||
class TestBatchItem:
|
||||
"""Tests for BatchItem dataclass."""
|
||||
|
||||
def test_duration_calculation(self):
|
||||
"""Duration should be calculated from start/end times."""
|
||||
item = BatchItem(input_path=Path("test.png"))
|
||||
item.start_time = 100.0
|
||||
item.end_time = 105.5
|
||||
assert item.duration == 5.5
|
||||
|
||||
def test_duration_none_without_times(self):
|
||||
"""Duration should be None if times not set."""
|
||||
item = BatchItem(input_path=Path("test.png"))
|
||||
assert item.duration is None
|
||||
|
||||
def test_to_dict(self):
|
||||
"""to_dict should serialize all fields."""
|
||||
item = BatchItem(
|
||||
input_path=Path("input.png"),
|
||||
output_path=Path("output.png"),
|
||||
status=BatchStatus.SUCCESS,
|
||||
message="Done",
|
||||
)
|
||||
result = item.to_dict()
|
||||
assert result["input_path"] == "input.png"
|
||||
assert result["output_path"] == "output.png"
|
||||
assert result["status"] == "success"
|
||||
|
||||
|
||||
class TestBatchResult:
|
||||
"""Tests for BatchResult dataclass."""
|
||||
|
||||
def test_to_json(self):
|
||||
"""Should serialize to valid JSON."""
|
||||
import json
|
||||
|
||||
result = BatchResult(operation="encode", total=5, succeeded=4, failed=1)
|
||||
json_str = result.to_json()
|
||||
parsed = json.loads(json_str)
|
||||
assert parsed["operation"] == "encode"
|
||||
assert parsed["summary"]["total"] == 5
|
||||
|
||||
def test_duration_with_end_time(self):
|
||||
"""Duration should work when end_time is set."""
|
||||
result = BatchResult(operation="test")
|
||||
result.start_time = 100.0
|
||||
result.end_time = 110.0
|
||||
assert result.duration == 10.0
|
||||
|
||||
|
||||
class TestBatchCredentials:
|
||||
"""Tests for BatchCredentials dataclass (v3.2.0)."""
|
||||
|
||||
def test_from_dict_new_format(self, sample_reference_photo):
|
||||
"""Should parse v3.2.0 format with 'passphrase' key."""
|
||||
data = {
|
||||
"reference_photo": sample_reference_photo,
|
||||
"passphrase": "test phrase four words",
|
||||
"pin": "123456",
|
||||
}
|
||||
creds = BatchCredentials.from_dict(data)
|
||||
assert creds.passphrase == "test phrase four words"
|
||||
assert creds.pin == "123456"
|
||||
|
||||
def test_from_dict_legacy_format(self, sample_reference_photo):
|
||||
"""Should parse legacy format with 'day_phrase' key for migration."""
|
||||
data = {
|
||||
"reference_photo": sample_reference_photo,
|
||||
"day_phrase": "legacy phrase here", # Old key name
|
||||
"pin": "123456",
|
||||
}
|
||||
creds = BatchCredentials.from_dict(data)
|
||||
# Should accept old key and map to passphrase
|
||||
assert creds.passphrase == "legacy phrase here"
|
||||
assert creds.pin == "123456"
|
||||
|
||||
def test_to_dict(self, sample_reference_photo):
|
||||
"""Should serialize to v3.2.0 format."""
|
||||
creds = BatchCredentials(
|
||||
reference_photo=sample_reference_photo,
|
||||
passphrase="test phrase four words",
|
||||
pin="123456",
|
||||
)
|
||||
result = creds.to_dict()
|
||||
assert result["passphrase"] == "test phrase four words"
|
||||
assert result["pin"] == "123456"
|
||||
assert "day_phrase" not in result # Old key should not be present
|
||||
|
||||
def test_passphrase_is_string(self, sample_reference_photo):
|
||||
"""Passphrase should be a string, not a dict."""
|
||||
creds = BatchCredentials(
|
||||
reference_photo=sample_reference_photo,
|
||||
passphrase="test phrase four words",
|
||||
pin="123456",
|
||||
)
|
||||
assert isinstance(creds.passphrase, str)
|
||||
|
||||
|
||||
class TestBatchProcessor:
|
||||
"""Tests for BatchProcessor class."""
|
||||
|
||||
def test_init_default_workers(self):
|
||||
"""Should default to 4 workers."""
|
||||
processor = BatchProcessor()
|
||||
assert processor.max_workers == 4
|
||||
|
||||
def test_init_custom_workers(self):
|
||||
"""Should accept custom worker count."""
|
||||
processor = BatchProcessor(max_workers=8)
|
||||
assert processor.max_workers == 8
|
||||
|
||||
def test_is_valid_image_png(self, temp_dir):
|
||||
"""Should recognize PNG as valid."""
|
||||
processor = BatchProcessor()
|
||||
png_path = temp_dir / "test.png"
|
||||
png_path.touch()
|
||||
assert processor._is_valid_image(png_path)
|
||||
|
||||
def test_is_valid_image_txt(self, temp_dir):
|
||||
"""Should reject non-image files."""
|
||||
processor = BatchProcessor()
|
||||
txt_path = temp_dir / "test.txt"
|
||||
txt_path.touch()
|
||||
assert not processor._is_valid_image(txt_path)
|
||||
|
||||
def test_find_images_file(self, sample_images):
|
||||
"""Should find single image file."""
|
||||
processor = BatchProcessor()
|
||||
results = list(processor.find_images([sample_images[0]]))
|
||||
assert len(results) == 1
|
||||
assert results[0] == sample_images[0]
|
||||
|
||||
def test_find_images_directory(self, sample_images, temp_dir):
|
||||
"""Should find images in directory."""
|
||||
processor = BatchProcessor()
|
||||
results = list(processor.find_images([temp_dir]))
|
||||
assert len(results) == 3
|
||||
|
||||
def test_find_images_recursive(self, temp_dir):
|
||||
"""Should find images recursively."""
|
||||
from PIL import Image
|
||||
|
||||
# Create nested directory
|
||||
nested = temp_dir / "nested"
|
||||
nested.mkdir()
|
||||
img_path = nested / "nested.png"
|
||||
img = Image.new("RGB", (50, 50))
|
||||
img.save(img_path)
|
||||
|
||||
processor = BatchProcessor()
|
||||
results = list(processor.find_images([temp_dir], recursive=True))
|
||||
assert any(p.name == "nested.png" for p in results)
|
||||
|
||||
def test_batch_encode_requires_message_or_file(self, sample_images, sample_credentials):
|
||||
"""Should raise if neither message nor file provided."""
|
||||
processor = BatchProcessor()
|
||||
with pytest.raises(ValueError, match="message or file_payload"):
|
||||
processor.batch_encode(
|
||||
images=sample_images,
|
||||
credentials=sample_credentials,
|
||||
)
|
||||
|
||||
def test_batch_encode_requires_credentials(self, sample_images):
|
||||
"""Should raise if credentials not provided."""
|
||||
processor = BatchProcessor()
|
||||
with pytest.raises(ValueError, match="Credentials"):
|
||||
processor.batch_encode(
|
||||
images=sample_images,
|
||||
message="test",
|
||||
)
|
||||
|
||||
def test_batch_encode_accepts_passphrase_credentials(
|
||||
self, sample_images, temp_dir, sample_credentials
|
||||
):
|
||||
"""Should accept v3.2.0 format credentials with passphrase."""
|
||||
processor = BatchProcessor()
|
||||
result = processor.batch_encode(
|
||||
images=sample_images,
|
||||
message="Test message",
|
||||
output_dir=temp_dir / "output",
|
||||
credentials=sample_credentials, # Uses 'passphrase' key
|
||||
)
|
||||
|
||||
assert isinstance(result, BatchResult)
|
||||
assert result.operation == "encode"
|
||||
assert result.total == 3
|
||||
|
||||
def test_batch_encode_creates_result(self, sample_images, temp_dir, sample_credentials):
|
||||
"""Should return BatchResult with correct structure."""
|
||||
processor = BatchProcessor()
|
||||
result = processor.batch_encode(
|
||||
images=sample_images,
|
||||
message="Test message",
|
||||
output_dir=temp_dir / "output",
|
||||
credentials=sample_credentials,
|
||||
)
|
||||
|
||||
assert isinstance(result, BatchResult)
|
||||
assert result.operation == "encode"
|
||||
assert result.total == 3
|
||||
assert len(result.items) == 3
|
||||
|
||||
def test_batch_decode_requires_credentials(self, sample_images):
|
||||
"""Should raise if credentials not provided."""
|
||||
processor = BatchProcessor()
|
||||
with pytest.raises(ValueError, match="Credentials"):
|
||||
processor.batch_decode(images=sample_images)
|
||||
|
||||
def test_batch_decode_accepts_passphrase_credentials(self, sample_images, sample_credentials):
|
||||
"""Should accept v3.2.0 format credentials with passphrase."""
|
||||
processor = BatchProcessor()
|
||||
result = processor.batch_decode(
|
||||
images=sample_images,
|
||||
credentials=sample_credentials, # Uses 'passphrase' key
|
||||
)
|
||||
|
||||
assert isinstance(result, BatchResult)
|
||||
assert result.operation == "decode"
|
||||
assert result.total == 3
|
||||
|
||||
def test_batch_decode_creates_result(self, sample_images, sample_credentials):
|
||||
"""Should return BatchResult with correct structure."""
|
||||
processor = BatchProcessor()
|
||||
result = processor.batch_decode(
|
||||
images=sample_images,
|
||||
credentials=sample_credentials,
|
||||
)
|
||||
|
||||
assert isinstance(result, BatchResult)
|
||||
assert result.operation == "decode"
|
||||
assert result.total == 3
|
||||
|
||||
def test_progress_callback_called(self, sample_images, sample_credentials):
|
||||
"""Progress callback should be called for each item."""
|
||||
processor = BatchProcessor()
|
||||
callback = Mock()
|
||||
|
||||
processor.batch_encode(
|
||||
images=sample_images,
|
||||
message="Test",
|
||||
credentials=sample_credentials,
|
||||
progress_callback=callback,
|
||||
)
|
||||
|
||||
assert callback.call_count == 3
|
||||
|
||||
def test_custom_encode_func(self, sample_images, temp_dir, sample_credentials):
|
||||
"""Should use custom encode function if provided."""
|
||||
processor = BatchProcessor()
|
||||
encode_mock = Mock()
|
||||
|
||||
processor.batch_encode(
|
||||
images=sample_images,
|
||||
message="Test",
|
||||
output_dir=temp_dir / "output",
|
||||
credentials=sample_credentials,
|
||||
encode_func=encode_mock,
|
||||
)
|
||||
|
||||
assert encode_mock.call_count == 3
|
||||
|
||||
|
||||
class TestBatchCapacityCheck:
|
||||
"""Tests for batch_capacity_check function."""
|
||||
|
||||
def test_returns_list(self, sample_images):
|
||||
"""Should return list of results."""
|
||||
results = batch_capacity_check(sample_images)
|
||||
assert isinstance(results, list)
|
||||
assert len(results) == 3
|
||||
|
||||
def test_includes_capacity(self, sample_images):
|
||||
"""Results should include capacity info."""
|
||||
results = batch_capacity_check(sample_images)
|
||||
for item in results:
|
||||
assert "capacity_bytes" in item
|
||||
assert "dimensions" in item
|
||||
assert "valid" in item
|
||||
|
||||
def test_handles_invalid_files(self, temp_dir):
|
||||
"""Should handle non-image files gracefully."""
|
||||
bad_file = temp_dir / "not_an_image.png"
|
||||
bad_file.write_bytes(b"not a png")
|
||||
|
||||
results = batch_capacity_check([bad_file])
|
||||
assert len(results) == 1
|
||||
assert "error" in results[0]
|
||||
|
||||
|
||||
class TestPrintBatchResult:
|
||||
"""Tests for print_batch_result function."""
|
||||
|
||||
def test_prints_summary(self, capsys, sample_images):
|
||||
"""Should print summary without errors."""
|
||||
result = BatchResult(
|
||||
operation="encode",
|
||||
total=3,
|
||||
succeeded=2,
|
||||
failed=1,
|
||||
)
|
||||
result.end_time = result.start_time + 5.0
|
||||
|
||||
print_batch_result(result)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "ENCODE" in captured.out
|
||||
assert "3" in captured.out # total
|
||||
assert "2" in captured.out # succeeded
|
||||
|
||||
def test_verbose_shows_items(self, capsys):
|
||||
"""Verbose mode should show individual items."""
|
||||
result = BatchResult(operation="decode", total=1, succeeded=1)
|
||||
result.items = [
|
||||
BatchItem(
|
||||
input_path=Path("test.png"),
|
||||
status=BatchStatus.SUCCESS,
|
||||
message="Decoded successfully",
|
||||
)
|
||||
]
|
||||
result.end_time = result.start_time + 1.0
|
||||
|
||||
print_batch_result(result, verbose=True)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "test.png" in captured.out
|
||||
|
||||
|
||||
class TestCredentialsMigration:
|
||||
"""Tests for v3.1.x to v3.2.0 credentials migration."""
|
||||
|
||||
def test_old_phrase_key_accepted(self, sample_reference_photo):
|
||||
"""Old 'phrase' key should be accepted for migration."""
|
||||
old_format = {
|
||||
"reference_photo": sample_reference_photo,
|
||||
"phrase": "old style phrase",
|
||||
"pin": "123456",
|
||||
}
|
||||
# Should not raise
|
||||
creds = BatchCredentials.from_dict(old_format)
|
||||
assert creds.passphrase == "old style phrase"
|
||||
|
||||
def test_old_day_phrase_key_accepted(self, sample_reference_photo):
|
||||
"""Old 'day_phrase' key should be accepted for migration."""
|
||||
old_format = {
|
||||
"reference_photo": sample_reference_photo,
|
||||
"day_phrase": "old day phrase",
|
||||
"pin": "123456",
|
||||
}
|
||||
creds = BatchCredentials.from_dict(old_format)
|
||||
assert creds.passphrase == "old day phrase"
|
||||
|
||||
def test_new_passphrase_key_preferred(self, sample_reference_photo):
|
||||
"""New 'passphrase' key should take precedence if both present."""
|
||||
mixed_format = {
|
||||
"reference_photo": sample_reference_photo,
|
||||
"passphrase": "new style passphrase",
|
||||
"day_phrase": "old day phrase",
|
||||
"pin": "123456",
|
||||
}
|
||||
creds = BatchCredentials.from_dict(mixed_format)
|
||||
assert creds.passphrase == "new style passphrase"
|
||||
@@ -1,181 +0,0 @@
|
||||
"""
|
||||
Tests for Stegasoo compression module.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from stegasoo.compression import (
|
||||
COMPRESSION_MAGIC,
|
||||
HAS_LZ4,
|
||||
MIN_COMPRESS_SIZE,
|
||||
CompressionAlgorithm,
|
||||
CompressionError,
|
||||
algorithm_name,
|
||||
compress,
|
||||
decompress,
|
||||
estimate_compressed_size,
|
||||
get_available_algorithms,
|
||||
get_compression_ratio,
|
||||
)
|
||||
|
||||
|
||||
class TestCompress:
|
||||
"""Tests for compress function."""
|
||||
|
||||
def test_compress_small_data_not_compressed(self):
|
||||
"""Small data should not be compressed (overhead not worth it)."""
|
||||
small_data = b"hello"
|
||||
result = compress(small_data)
|
||||
# Should have magic header but NONE algorithm
|
||||
assert result.startswith(COMPRESSION_MAGIC)
|
||||
assert result[4] == CompressionAlgorithm.NONE
|
||||
|
||||
def test_compress_zlib_reduces_size(self):
|
||||
"""Zlib should reduce size for compressible data."""
|
||||
# Highly compressible data
|
||||
data = b"A" * 1000
|
||||
result = compress(data, CompressionAlgorithm.ZLIB)
|
||||
assert len(result) < len(data)
|
||||
assert result.startswith(COMPRESSION_MAGIC)
|
||||
assert result[4] == CompressionAlgorithm.ZLIB
|
||||
|
||||
def test_compress_incompressible_data(self):
|
||||
"""Incompressible data should be stored uncompressed."""
|
||||
import os
|
||||
|
||||
# Random data doesn't compress well
|
||||
data = os.urandom(500)
|
||||
result = compress(data, CompressionAlgorithm.ZLIB)
|
||||
# Should fall back to NONE if compression didn't help
|
||||
assert result.startswith(COMPRESSION_MAGIC)
|
||||
|
||||
def test_compress_none_algorithm(self):
|
||||
"""NONE algorithm should just wrap data."""
|
||||
data = b"Test data" * 100
|
||||
result = compress(data, CompressionAlgorithm.NONE)
|
||||
assert result.startswith(COMPRESSION_MAGIC)
|
||||
assert result[4] == CompressionAlgorithm.NONE
|
||||
# Data should be after 9-byte header
|
||||
assert result[9:] == data
|
||||
|
||||
@pytest.mark.skipif(not HAS_LZ4, reason="LZ4 not installed")
|
||||
def test_compress_lz4(self):
|
||||
"""LZ4 compression should work if available."""
|
||||
data = b"B" * 1000
|
||||
result = compress(data, CompressionAlgorithm.LZ4)
|
||||
assert len(result) < len(data)
|
||||
assert result.startswith(COMPRESSION_MAGIC)
|
||||
assert result[4] == CompressionAlgorithm.LZ4
|
||||
|
||||
|
||||
class TestDecompress:
|
||||
"""Tests for decompress function."""
|
||||
|
||||
def test_decompress_zlib(self):
|
||||
"""Decompression should restore original data."""
|
||||
original = b"Hello, World! " * 100
|
||||
compressed = compress(original, CompressionAlgorithm.ZLIB)
|
||||
result = decompress(compressed)
|
||||
assert result == original
|
||||
|
||||
def test_decompress_none(self):
|
||||
"""Uncompressed wrapped data should decompress correctly."""
|
||||
original = b"Small data"
|
||||
wrapped = compress(original, CompressionAlgorithm.NONE)
|
||||
result = decompress(wrapped)
|
||||
assert result == original
|
||||
|
||||
def test_decompress_no_magic(self):
|
||||
"""Data without magic header should be returned as-is."""
|
||||
data = b"Not compressed at all"
|
||||
result = decompress(data)
|
||||
assert result == data
|
||||
|
||||
def test_decompress_truncated_header(self):
|
||||
"""Truncated header should raise CompressionError."""
|
||||
bad_data = COMPRESSION_MAGIC + b"\x01" # Too short
|
||||
with pytest.raises(CompressionError, match="Truncated"):
|
||||
decompress(bad_data)
|
||||
|
||||
@pytest.mark.skipif(not HAS_LZ4, reason="LZ4 not installed")
|
||||
def test_decompress_lz4(self):
|
||||
"""LZ4 decompression should work."""
|
||||
original = b"LZ4 test data " * 100
|
||||
compressed = compress(original, CompressionAlgorithm.LZ4)
|
||||
result = decompress(compressed)
|
||||
assert result == original
|
||||
|
||||
def test_roundtrip_large_data(self):
|
||||
"""Large data should survive compress/decompress roundtrip."""
|
||||
import os
|
||||
|
||||
original = os.urandom(50000)
|
||||
compressed = compress(original)
|
||||
result = decompress(compressed)
|
||||
assert result == original
|
||||
|
||||
|
||||
class TestUtilities:
|
||||
"""Tests for utility functions."""
|
||||
|
||||
def test_compression_ratio_compressed(self):
|
||||
"""Ratio should be < 1 for well-compressed data."""
|
||||
original = b"X" * 1000
|
||||
compressed = compress(original)
|
||||
ratio = get_compression_ratio(original, compressed)
|
||||
assert ratio < 1.0
|
||||
|
||||
def test_compression_ratio_empty(self):
|
||||
"""Empty data should return ratio of 1.0."""
|
||||
ratio = get_compression_ratio(b"", b"")
|
||||
assert ratio == 1.0
|
||||
|
||||
def test_estimate_compressed_size_small(self):
|
||||
"""Small data estimation should be accurate."""
|
||||
data = b"Test " * 100
|
||||
estimate = estimate_compressed_size(data)
|
||||
actual = len(compress(data))
|
||||
# Should be within 20% for small data
|
||||
assert abs(estimate - actual) / actual < 0.2
|
||||
|
||||
def test_available_algorithms(self):
|
||||
"""Should always include NONE and ZLIB."""
|
||||
algos = get_available_algorithms()
|
||||
assert CompressionAlgorithm.NONE in algos
|
||||
assert CompressionAlgorithm.ZLIB in algos
|
||||
|
||||
def test_algorithm_name(self):
|
||||
"""Algorithm names should be human-readable."""
|
||||
assert "Zlib" in algorithm_name(CompressionAlgorithm.ZLIB)
|
||||
assert "None" in algorithm_name(CompressionAlgorithm.NONE)
|
||||
assert "LZ4" in algorithm_name(CompressionAlgorithm.LZ4)
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Edge case tests."""
|
||||
|
||||
def test_empty_data(self):
|
||||
"""Empty data should be handled gracefully."""
|
||||
result = compress(b"")
|
||||
assert decompress(result) == b""
|
||||
|
||||
def test_exact_min_size(self):
|
||||
"""Data at exactly MIN_COMPRESS_SIZE should be compressed."""
|
||||
data = b"x" * MIN_COMPRESS_SIZE
|
||||
result = compress(data, CompressionAlgorithm.ZLIB)
|
||||
assert result.startswith(COMPRESSION_MAGIC)
|
||||
assert decompress(result) == data
|
||||
|
||||
def test_binary_data(self):
|
||||
"""Binary data with null bytes should work."""
|
||||
data = b"\x00\x01\x02\x03" * 500
|
||||
compressed = compress(data)
|
||||
assert decompress(compressed) == data
|
||||
|
||||
def test_unicode_after_encoding(self):
|
||||
"""UTF-8 encoded Unicode should compress correctly."""
|
||||
text = "Hello, 世界! 🎉 " * 100
|
||||
data = text.encode("utf-8")
|
||||
compressed = compress(data)
|
||||
result = decompress(compressed)
|
||||
assert result.decode("utf-8") == text
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user