Compare commits
201 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ebc42f2cd | ||
|
|
1e07630b49 | ||
|
|
67037ae196 | ||
|
|
5a68840725 | ||
|
|
ebc999b2b3 | ||
|
|
f46ef01f5f | ||
|
|
0d76780deb | ||
|
|
d34919e32f | ||
|
|
a4038589b0 | ||
|
|
db763f1464 | ||
|
|
27c5b08d41 | ||
|
|
28cb9bb9b3 | ||
|
|
889df881ba | ||
|
|
c058d116b8 | ||
|
|
fae86887e2 | ||
|
|
5e45b2c5c1 | ||
|
|
71088989f3 | ||
|
|
530e5debef | ||
|
|
3b062458e3 | ||
|
|
5e65035ca4 | ||
|
|
de9d1de881 | ||
|
|
8d90a888cf | ||
|
|
b0914778e3 | ||
|
|
7e5462ea6e | ||
|
|
e085a8ffe9 | ||
|
|
2d7fbd1e0d | ||
|
|
32842f6b73 | ||
|
|
3fd3204552 | ||
|
|
175362ce4c | ||
|
|
2ed108f3a0 | ||
|
|
167e1a6ff5 | ||
|
|
f2f3e2eefc | ||
|
|
5c685cba67 | ||
|
|
4e819b80cc | ||
|
|
ea86216648 | ||
|
|
8de5659fa6 | ||
|
|
de0bf2410d | ||
|
|
8b948d00a4 | ||
|
|
6d88453b69 | ||
|
|
ea57bdf302 | ||
|
|
55d54717f8 | ||
|
|
c0fe85ac83 | ||
|
|
e9e4d1aab9 | ||
|
|
1acb5a3dcc | ||
|
|
14a73c63ac | ||
|
|
3d53282738 | ||
|
|
e831ae4884 | ||
|
|
4751d05e9f | ||
|
|
d15bcb8df4 | ||
|
|
6ec7de5604 | ||
|
|
1cdb2aca91 | ||
|
|
46de371c42 | ||
|
|
11c0d45548 | ||
|
|
7bb1029c0f | ||
|
|
e3f7f36e5e | ||
|
|
f200737088 | ||
|
|
6def318ba7 | ||
|
|
e203af6a73 | ||
|
|
6ba135098b | ||
|
|
903739c055 | ||
|
|
30fbb5016e | ||
|
|
041148e8fe | ||
|
|
90bedce379 | ||
|
|
021265f3cf | ||
|
|
ff42398509 | ||
|
|
a30ec33b98 | ||
|
|
252efbec7e | ||
|
|
6e906d5981 | ||
|
|
df6125d098 | ||
|
|
3d4a340305 | ||
|
|
0decb39b17 | ||
|
|
4291dfad38 | ||
|
|
ddee3583e8 | ||
|
|
3e2307cbcf | ||
|
|
cc745fbdfa | ||
|
|
3027706d49 | ||
|
|
39fbd617e6 | ||
|
|
de4cb0b3be | ||
|
|
add3951003 | ||
|
|
3858e234da | ||
|
|
03e8e3a840 | ||
|
|
55e78d0503 | ||
|
|
b13a9fcd3f | ||
|
|
96b49c68ec | ||
|
|
be8744179d | ||
|
|
f971b75d7e | ||
|
|
455c6dfd01 | ||
|
|
a00a154a1a | ||
|
|
8b3b331843 | ||
|
|
10c874374f | ||
|
|
0c1e87c7c0 | ||
|
|
d517a4dc8b | ||
|
|
6d59f3edfc | ||
|
|
17d0406be2 | ||
|
|
ef73280015 | ||
|
|
6338d6aab4 | ||
|
|
b9d0fac535 | ||
|
|
5c0a5bbba7 | ||
|
|
ba1a77f00b | ||
|
|
5e587df545 | ||
|
|
23456ac1e4 | ||
|
|
8be512ad7b | ||
|
|
f129500202 | ||
|
|
c37d743b3e | ||
|
|
5bdb625059 | ||
|
|
231ba97fde | ||
|
|
a70e88625f | ||
|
|
b6770c46e5 | ||
|
|
9f4318cc0f | ||
|
|
91dc665a77 | ||
|
|
6066df391b | ||
|
|
be5c95b59d | ||
|
|
09b1abddc7 | ||
|
|
0c9ea0e3f2 | ||
|
|
aebfb20dfc | ||
|
|
b935c474af | ||
|
|
73b34ba8b5 | ||
|
|
89d8fee5da | ||
|
|
0e270dadb3 | ||
|
|
e2002b6026 | ||
|
|
66ed11fb97 | ||
|
|
9cbb4600f8 | ||
|
|
c1c850c593 | ||
|
|
e029f00d66 | ||
|
|
34e417fb55 | ||
|
|
e7954c63e4 | ||
|
|
446789a16f | ||
|
|
2538126573 | ||
|
|
a91d127ed7 | ||
|
|
a0781b1cf7 | ||
|
|
5e32ecb35a | ||
|
|
3e5de98f60 | ||
|
|
c8956b9e43 | ||
|
|
a8f15f87c6 | ||
|
|
8a64db9fcc | ||
|
|
ab450955fe | ||
|
|
afd502dbf3 | ||
|
|
3f02e55ffd | ||
|
|
2ee824b02b | ||
|
|
189620e4fb | ||
|
|
ecad88e859 | ||
|
|
62bd31d0aa | ||
|
|
241cdadd25 | ||
|
|
85309a2044 | ||
|
|
a81a20f8ee | ||
|
|
9c88f53cd0 | ||
|
|
3f8c2a6957 | ||
|
|
22cf27d7f6 | ||
|
|
4d8575ce33 | ||
|
|
28b539bcd9 | ||
|
|
6b82069dc8 | ||
|
|
52e1a3dfbf | ||
|
|
4a27d0c182 | ||
|
|
36931518ce | ||
|
|
f79c63428b | ||
|
|
cc29de4200 | ||
|
|
c14f3f75cb | ||
|
|
aa99a258f4 | ||
|
|
93420704e8 | ||
|
|
6e4eb5464e | ||
|
|
d04670e352 | ||
|
|
fda1cdad51 | ||
|
|
b48ccc5d16 | ||
|
|
15ed63cafa | ||
|
|
869d7ee8e3 | ||
|
|
3ee8c1d22a | ||
|
|
b96564358a | ||
|
|
01afb3da66 | ||
|
|
a98df5f9a0 | ||
|
|
70da348bce | ||
|
|
90ba8543a7 | ||
|
|
da3aea992c | ||
|
|
ae47ff4932 | ||
|
|
eb16eb1db2 | ||
|
|
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 |
@@ -22,6 +22,7 @@ tests/
|
||||
# Pi-specific
|
||||
rpi/
|
||||
*.img
|
||||
*.img.xz
|
||||
*.img.zst
|
||||
*.img.zst.zip
|
||||
|
||||
@@ -37,3 +38,15 @@ docs/
|
||||
*.log
|
||||
*.tmp
|
||||
.DS_Store
|
||||
|
||||
# Dev scripts and old files
|
||||
scripts/
|
||||
old_files/
|
||||
*_old
|
||||
*_old.*
|
||||
*.bak
|
||||
*.orig
|
||||
|
||||
# Temp files
|
||||
frontends/web/temp_files/
|
||||
*.db
|
||||
|
||||
26
.gitignore
vendored
@@ -64,11 +64,16 @@ htmlcov/
|
||||
# Output test files.
|
||||
test_data/*.png
|
||||
|
||||
# Dev scripts (local convenience scripts - except validate-release.sh)
|
||||
# Dev scripts (local convenience scripts - except these)
|
||||
scripts/*
|
||||
!scripts/validate-release.sh
|
||||
!scripts/smoke-test.sh
|
||||
!scripts/setup-trusted-certs.sh
|
||||
!scripts/screenshots.sh
|
||||
!scripts/build.sh
|
||||
|
||||
# Web UI auth database and SSL certs
|
||||
instance/
|
||||
frontends/web/instance/
|
||||
frontends/web/certs/
|
||||
|
||||
@@ -79,4 +84,21 @@ tests/
|
||||
*.img
|
||||
*.img.xz
|
||||
*.img.zst
|
||||
pishrink.sh
|
||||
*.img.zst.zip
|
||||
rpi/tools/pishrink.sh
|
||||
|
||||
# Temp file storage
|
||||
frontends/web/temp_files/
|
||||
rpi/config.json
|
||||
|
||||
# Pre-built Pi tarballs and images (release assets, too large for git)
|
||||
rpi/*.tar.zst
|
||||
rpi/*.tar.zst.zip
|
||||
rpi/*.img
|
||||
rpi/*.img.zst
|
||||
rpi/*.img.zst.zip
|
||||
|
||||
# AUR build artifacts
|
||||
aur-upload/
|
||||
aur/.SRCINFO
|
||||
aur/*.pkg.tar.zst
|
||||
|
||||
4
API.md
@@ -88,7 +88,7 @@ uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
|
||||
**Docker with channel key:**
|
||||
```bash
|
||||
STEGASOO_CHANNEL_KEY=XXXX-XXXX-... docker-compose up api
|
||||
STEGASOO_CHANNEL_KEY=XXXX-XXXX-... docker-compose -f docker/docker-compose.yml up api
|
||||
```
|
||||
|
||||
---
|
||||
@@ -843,7 +843,7 @@ curl -s -X POST "$BASE_URL/decode/multipart" \
|
||||
|
||||
## Docker Configuration
|
||||
|
||||
### docker-compose.yml
|
||||
### docker/docker-compose.yml
|
||||
|
||||
```yaml
|
||||
x-common-env: &common-env
|
||||
|
||||
29
CHANGELOG.md
@@ -5,7 +5,28 @@ All notable changes to Stegasoo will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [4.1.2] - 2026-01-05
|
||||
## [4.1.5] - 2026-01-07
|
||||
|
||||
### Added
|
||||
- **Developer Documentation**: Educational comments throughout core modules
|
||||
- DCT module: zig-zag diagrams, QIM explanation, Reed-Solomon deep dive
|
||||
- LSB module: visual bit embedding examples, ChaCha20 pixel selection
|
||||
- Crypto module: multi-factor KDF flow diagrams, Argon2id reasoning
|
||||
- CLI module: Click patterns (groups, JSON output, secure input)
|
||||
- Web UI module: Flask architecture, subprocess isolation, async jobs
|
||||
- **Pi Test Automation**: `rpi/kickoff-pi-test.sh` script
|
||||
- One command to flash, wait for boot, setup, and smoke test
|
||||
- Self-contained (no dotfile dependencies)
|
||||
- **v4.2 Wishlist**: `WISHLIST-4.2.md` for blue-sky ideas (GPU acceleration)
|
||||
|
||||
### Changed
|
||||
- **Pi MOTD Improvements**:
|
||||
- Dynamic temperature emoji (ice/cool/fire based on temp)
|
||||
- Rocket emoji for service status, globe emoji for URL
|
||||
- Shortened Debian boilerplate message
|
||||
- Fixed escaped variable syntax in heredoc
|
||||
|
||||
## [4.1.3] - 2026-01-05
|
||||
|
||||
### Added
|
||||
- **Docker Deployment**: Production-ready containerization
|
||||
@@ -28,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
- 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
|
||||
@@ -36,9 +58,11 @@ and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
- 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
|
||||
|
||||
@@ -177,7 +201,8 @@ and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
- CLI interface
|
||||
- Basic PIN authentication
|
||||
|
||||
[4.1.2]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.0...v4.1.2
|
||||
[4.1.5]: https://github.com/adlee-was-taken/stegasoo/compare/v4.1.3...v4.1.5
|
||||
[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
|
||||
|
||||
18
CLI.md
@@ -64,6 +64,18 @@ python -c "from stegasoo import has_dct_support; print('DCT:', 'available' if ha
|
||||
stegasoo channel show
|
||||
```
|
||||
|
||||
### Man Page
|
||||
|
||||
```bash
|
||||
# Install man page
|
||||
sudo mkdir -p /usr/local/share/man/man1
|
||||
sudo cp docs/stegasoo.1 /usr/local/share/man/man1/
|
||||
sudo mandb
|
||||
|
||||
# View
|
||||
man stegasoo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What's New in v4.1.0
|
||||
@@ -152,7 +164,7 @@ stegasoo generate [OPTIONS]
|
||||
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
|
||||
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
|
||||
| `--pin-length` | | 6-9 | 6 | PIN length in digits |
|
||||
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072, 4096) |
|
||||
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072) |
|
||||
| `--words` | | 3-12 | 4 | Words in passphrase |
|
||||
| `--output` | `-o` | path | | Save RSA key to file |
|
||||
| `--password` | `-p` | string | | Password for RSA key file |
|
||||
@@ -168,7 +180,7 @@ stegasoo generate
|
||||
stegasoo generate --words 6
|
||||
|
||||
# Generate with RSA key
|
||||
stegasoo generate --rsa --rsa-bits 4096
|
||||
stegasoo generate --rsa --rsa-bits 3072
|
||||
|
||||
# Save RSA key to encrypted file
|
||||
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword"
|
||||
@@ -798,7 +810,7 @@ stegasoo decode -r ref.jpg -s stego.png -p "phrase" --pin 123456
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
**docker-compose.yml:**
|
||||
**docker/docker-compose.yml:**
|
||||
```yaml
|
||||
x-common-env: &common-env
|
||||
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
39
DOCKER.md
@@ -6,14 +6,14 @@ Stegasoo provides Docker images for both the Web UI and REST API.
|
||||
|
||||
```bash
|
||||
# Build and start all services
|
||||
docker-compose up -d
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# Check status
|
||||
docker-compose ps
|
||||
docker-compose -f docker/docker-compose.yml ps
|
||||
```
|
||||
|
||||
Access:
|
||||
- **Web UI**: http://localhost:5000
|
||||
- **Web UI**: https://localhost:5000 (HTTPS with self-signed cert)
|
||||
- **REST API**: http://localhost:8000
|
||||
|
||||
## Services
|
||||
@@ -36,9 +36,12 @@ 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
|
||||
# HTTPS support (default: enabled, generates self-signed cert)
|
||||
STEGASOO_HTTPS_ENABLED=true
|
||||
STEGASOO_HOSTNAME=localhost
|
||||
|
||||
# To disable HTTPS:
|
||||
# STEGASOO_HTTPS_ENABLED=false
|
||||
```
|
||||
|
||||
### Volume Mounts
|
||||
@@ -58,10 +61,10 @@ 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 .
|
||||
docker build -f docker/Dockerfile.base -t stegasoo-base:latest .
|
||||
|
||||
# Build services (fast - only copies app code)
|
||||
docker-compose build
|
||||
docker-compose -f docker/docker-compose.yml build
|
||||
```
|
||||
|
||||
### Full Build (No Base Image)
|
||||
@@ -69,26 +72,26 @@ docker-compose build
|
||||
If you don't have the base image, the Dockerfile will build all dependencies (slower):
|
||||
|
||||
```bash
|
||||
docker-compose build
|
||||
docker-compose -f docker/docker-compose.yml build
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Start services
|
||||
docker-compose up -d
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
docker-compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Stop services
|
||||
docker-compose down
|
||||
docker-compose -f docker/docker-compose.yml down
|
||||
|
||||
# Rebuild after code changes
|
||||
docker-compose build && docker-compose up -d
|
||||
docker-compose -f docker/docker-compose.yml build && docker-compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# Full rebuild (no cache)
|
||||
docker-compose build --no-cache
|
||||
docker-compose -f docker/docker-compose.yml build --no-cache
|
||||
```
|
||||
|
||||
## Resource Limits
|
||||
@@ -109,7 +112,7 @@ Both services include health checks:
|
||||
|
||||
Check health status:
|
||||
```bash
|
||||
docker-compose ps
|
||||
docker-compose -f docker/docker-compose.yml ps
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
@@ -126,7 +129,7 @@ For production, consider:
|
||||
```bash
|
||||
# Don't commit .env files with secrets
|
||||
export STEGASOO_CHANNEL_KEY=your-key
|
||||
docker-compose up -d
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
3. **Reverse proxy**: Put behind nginx/traefik for TLS termination
|
||||
@@ -142,12 +145,12 @@ For production, consider:
|
||||
### Container won't start
|
||||
```bash
|
||||
# Check logs
|
||||
docker-compose logs web
|
||||
docker-compose logs api
|
||||
docker-compose -f docker/docker-compose.yml logs web
|
||||
docker-compose -f docker/docker-compose.yml logs api
|
||||
```
|
||||
|
||||
### Out of memory
|
||||
Increase Docker's memory allocation or reduce worker count in Dockerfile.
|
||||
Increase Docker's memory allocation or reduce worker count in `docker/Dockerfile`.
|
||||
|
||||
### Permission errors
|
||||
The containers run as non-root user `stego` (UID 1000). Ensure volume permissions match.
|
||||
|
||||
109
INSTALL.md
@@ -154,10 +154,10 @@ Build and run individual containers.
|
||||
#### Build Images
|
||||
|
||||
```bash
|
||||
# Build all targets
|
||||
docker build -t stegasoo-web --target web .
|
||||
docker build -t stegasoo-api --target api .
|
||||
docker build -t stegasoo-cli --target cli .
|
||||
# From project root - build all targets
|
||||
docker build -t stegasoo-web --target web -f docker/Dockerfile .
|
||||
docker build -t stegasoo-api --target api -f docker/Dockerfile .
|
||||
docker build -t stegasoo-cli --target cli -f docker/Dockerfile .
|
||||
```
|
||||
|
||||
#### Run Web UI
|
||||
@@ -214,17 +214,17 @@ The easiest way to run all services.
|
||||
|
||||
```bash
|
||||
# Start in background
|
||||
docker-compose up -d
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# Start specific service
|
||||
docker-compose up -d web
|
||||
docker-compose up -d api
|
||||
docker-compose -f docker/docker-compose.yml up -d web
|
||||
docker-compose -f docker/docker-compose.yml up -d api
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
docker-compose -f docker/docker-compose.yml logs -f
|
||||
|
||||
# Stop all
|
||||
docker-compose down
|
||||
docker-compose -f docker/docker-compose.yml down
|
||||
```
|
||||
|
||||
#### Authentication Configuration (v4.0.2)
|
||||
@@ -239,7 +239,7 @@ STEGASOO_HOSTNAME=localhost # Hostname for SSL cert
|
||||
STEGASOO_CHANNEL_KEY= # Optional channel key
|
||||
|
||||
# Then run
|
||||
docker-compose up -d web
|
||||
docker-compose -f docker/docker-compose.yml up -d web
|
||||
```
|
||||
|
||||
On first access, you'll be prompted to create an admin account. The database and SSL certs are persisted in Docker volumes.
|
||||
@@ -255,16 +255,16 @@ On first access, you'll be prompted to create an admin account. The database and
|
||||
|
||||
```bash
|
||||
# Build images and start
|
||||
docker-compose up -d --build
|
||||
docker-compose -f docker/docker-compose.yml up -d --build
|
||||
|
||||
# Force rebuild (no cache)
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
docker-compose -f docker/docker-compose.yml build --no-cache
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
#### Resource Configuration
|
||||
|
||||
The `docker-compose.yml` includes resource limits:
|
||||
The `docker/docker-compose.yml` includes resource limits:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -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
|
||||
@@ -773,7 +852,7 @@ Argon2 needs 256MB per operation. Increase container memory:
|
||||
# Docker run
|
||||
docker run --memory=768m ...
|
||||
|
||||
# Docker Compose - edit docker-compose.yml
|
||||
# Docker Compose - edit docker/docker-compose.yml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
538
PLAN-4.1.0.md
@@ -1,538 +0,0 @@
|
||||
# Stegasoo 4.1.0 Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Version 4.1.0 is a feature release focusing on small-group deployment improvements and new utilities.
|
||||
|
||||
## Goals
|
||||
|
||||
1. ~~**Multi-User Support** - Admin can create up to 16 users for shared deployments~~ ✅ DONE
|
||||
2. **Channel Key QR** - Easy visual sharing of channel keys via QR codes
|
||||
3. ~~**CLI Channel Commands** - Manage channel keys from command line~~ ✅ DONE
|
||||
4. **Advanced Tools** - Image/stego utilities (TBD)
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Multi-User Support ✅ COMPLETED
|
||||
|
||||
> Implemented in commit 7b33501. All requirements met.
|
||||
|
||||
### Requirements
|
||||
|
||||
- 16 users + 1 admin maximum (17 total)
|
||||
- First user created at setup is always admin
|
||||
- Admin can add/delete users, reset passwords
|
||||
- Regular users can only change their own password
|
||||
- No self-registration (admin-invite only)
|
||||
|
||||
### Database Changes
|
||||
|
||||
**Update User model in `frontends/web/models.py`:**
|
||||
|
||||
```python
|
||||
class User(db.Model):
|
||||
id = Column(Integer, primary_key=True)
|
||||
username = Column(String(80), unique=True, nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
role = Column(String(20), default='user') # 'admin' or 'user'
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
```
|
||||
|
||||
**Migration:** Add `role` and `created_at` columns. Existing users get `role='admin'`.
|
||||
|
||||
### New Routes
|
||||
|
||||
| Route | Method | Access | Description |
|
||||
|-------|--------|--------|-------------|
|
||||
| `/admin/users` | GET | admin | List all users |
|
||||
| `/admin/users/new` | GET, POST | admin | Create user form |
|
||||
| `/admin/users/<id>/delete` | POST | admin | Delete user |
|
||||
| `/admin/users/<id>/reset-password` | POST | admin | Generate temp password |
|
||||
|
||||
### New Decorator
|
||||
|
||||
```python
|
||||
# auth.py
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('login'))
|
||||
if current_user.role != 'admin':
|
||||
flash('Admin access required', 'error')
|
||||
return redirect(url_for('index'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
```
|
||||
|
||||
### UI Changes
|
||||
|
||||
**Navigation (for admin users):**
|
||||
- Add "Users" link in navbar (visible only to admin)
|
||||
|
||||
**Account page (`/account`):**
|
||||
- Admin sees link to user management
|
||||
- All users see their own password change form
|
||||
|
||||
**New template: `templates/admin/users.html`:**
|
||||
- Table: Username | Role | Created | Actions
|
||||
- Actions: Reset Password, Delete (disabled for self)
|
||||
- "Add User" button (disabled if at 16 user limit)
|
||||
- Show count: "3 of 16 users"
|
||||
|
||||
**New template: `templates/admin/user_new.html`:**
|
||||
- Username field (email-style allowed)
|
||||
- Password field (auto-populated with random 8-char, admin can override)
|
||||
- Submit → confirmation page shows password once with copy button
|
||||
|
||||
### Validation
|
||||
|
||||
- Username: 3-80 chars, alphanumeric + underscore/hyphen + @/. for email-style
|
||||
- Password: 8+ chars (same as current)
|
||||
- Can't delete yourself
|
||||
- Can't demote the last admin
|
||||
- Deleting user immediately invalidates their sessions
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Channel Key QR
|
||||
|
||||
### Web UI
|
||||
|
||||
**About page additions:**
|
||||
|
||||
If `STEGASOO_CHANNEL_KEY` environment variable is set:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Channel Key │
|
||||
│ │
|
||||
│ ██████████████ Your server uses a │
|
||||
│ ██ ██ private channel key. │
|
||||
│ ██ ██████ ██ Share this QR with │
|
||||
│ ██ ██████ ██ others to join. │
|
||||
│ ██ ██ │
|
||||
│ ██████████████ [Copy Key] [Download]│
|
||||
│ │
|
||||
│ Key: abc123...xyz │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- QR generated server-side using `qrcode` library
|
||||
- "Copy Key" copies text to clipboard
|
||||
- "Download QR" saves as PNG
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```python
|
||||
# about route addition
|
||||
@app.route('/about')
|
||||
def about():
|
||||
channel_key = os.environ.get('STEGASOO_CHANNEL_KEY', '')
|
||||
channel_qr_b64 = None
|
||||
if channel_key:
|
||||
# Generate QR as base64 PNG
|
||||
qr = qrcode.make(channel_key)
|
||||
buffer = BytesIO()
|
||||
qr.save(buffer, format='PNG')
|
||||
channel_qr_b64 = base64.b64encode(buffer.getvalue()).decode()
|
||||
return render_template('about.html',
|
||||
channel_key=channel_key,
|
||||
channel_qr=channel_qr_b64)
|
||||
```
|
||||
|
||||
### CLI Commands
|
||||
|
||||
**New command group: `stegasoo channel`**
|
||||
|
||||
```bash
|
||||
# Generate a new channel key
|
||||
stegasoo channel generate
|
||||
# Output:
|
||||
# Channel Key: stg_abc123...xyz789
|
||||
#
|
||||
# ██████████████████
|
||||
# ██ ██
|
||||
# ██ ██████████ ██
|
||||
# ...
|
||||
#
|
||||
# Set in environment: export STEGASOO_CHANNEL_KEY="stg_abc123..."
|
||||
|
||||
# Show current key (from env or argument)
|
||||
stegasoo channel show
|
||||
# Output:
|
||||
# Channel Key: stg_abc123...xyz789
|
||||
|
||||
# Display QR in terminal (ASCII)
|
||||
stegasoo channel qr
|
||||
# Output: ASCII QR code
|
||||
|
||||
# Save QR as PNG
|
||||
stegasoo channel qr -o channel-key.png
|
||||
# Output: Saved to channel-key.png
|
||||
|
||||
# Explicit format selection
|
||||
stegasoo channel qr --format ascii # Terminal (default)
|
||||
stegasoo channel qr --format png -o - # PNG to stdout
|
||||
```
|
||||
|
||||
**Implementation notes:**
|
||||
|
||||
- Use `qrcode[pil]` for PNG output
|
||||
- Use `qrcode` with `print_ascii()` for terminal
|
||||
- Read key from `--key` argument or `STEGASOO_CHANNEL_KEY` env var
|
||||
- `generate` uses existing `generate_channel_key()` from `stegasoo.channel`
|
||||
|
||||
---
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `frontends/web/templates/admin/users.html` | User management page |
|
||||
| `frontends/web/templates/admin/user_new.html` | Add user form |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `frontends/web/models.py` | Add `role`, `created_at` to User |
|
||||
| `frontends/web/auth.py` | Add `@admin_required`, user management routes |
|
||||
| `frontends/web/templates/base.html` | Add Users link for admins |
|
||||
| `frontends/web/templates/account.html` | Add admin link |
|
||||
| `frontends/web/templates/about.html` | Add channel key QR section |
|
||||
| `src/stegasoo/cli.py` | Add `channel` command group |
|
||||
|
||||
---
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Multi-User
|
||||
|
||||
1. Fresh install → first user is admin
|
||||
2. Admin can create users up to limit (16)
|
||||
3. Admin can't create 17th user (shows error)
|
||||
4. Regular user can log in, encode/decode
|
||||
5. Regular user can't access `/admin/users`
|
||||
6. Admin can reset user password
|
||||
7. Admin can delete user
|
||||
8. Admin can't delete self
|
||||
9. Existing 4.0.2 databases upgrade correctly (single user becomes admin)
|
||||
|
||||
### Channel Key QR
|
||||
|
||||
1. About page shows nothing if no channel key
|
||||
2. About page shows QR + key if channel key set
|
||||
3. Copy button works
|
||||
4. Download gives valid PNG
|
||||
5. QR scans correctly to key value
|
||||
|
||||
### CLI
|
||||
|
||||
1. `channel generate` creates valid key + shows QR
|
||||
2. `channel show` displays current key
|
||||
3. `channel qr` outputs ASCII to terminal
|
||||
4. `channel qr -o file.png` saves PNG
|
||||
5. Commands work with `--key` override
|
||||
6. Commands read from env var
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: Advanced Tools
|
||||
|
||||
### Included Tools
|
||||
|
||||
| Tool | Web | CLI | Description |
|
||||
|------|-----|-----|-------------|
|
||||
| **Capacity Calculator** | ✓ | ✓ | Upload image → show DCT/LSB capacity |
|
||||
| **Metadata Stripper** | ✓ | ✓ | Remove EXIF/metadata from image |
|
||||
| **Stego Detector** | ✓ | ✓ | Analyze image for signs of hidden data |
|
||||
| **Image Compare** | ✓ | - | Side-by-side before/after diff |
|
||||
| **Header Peek** | ✓ | ✓ | Check for Stegasoo header without decrypting |
|
||||
| **Batch Mode** | - | ✓ | Encode/decode multiple files |
|
||||
|
||||
### Web UI: `/tools` Page
|
||||
|
||||
New page with card-based layout:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 🛠️ Advanced Tools │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ 📏 Capacity │ │ 🧹 Metadata │ │
|
||||
│ │ Calculator │ │ Stripper │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Check how much │ │ Remove EXIF │ │
|
||||
│ │ data fits │ │ before encoding │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||
│ │ 🔍 Stego │ │ 🔎 Header │ │
|
||||
│ │ Detector │ │ Peek │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Analyze image │ │ Check for │ │
|
||||
│ │ for hidden data │ │ Stegasoo data │ │
|
||||
│ └─────────────────┘ └─────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────┐ │
|
||||
│ │ ⚖️ Image │ │
|
||||
│ │ Compare │ │
|
||||
│ │ │ │
|
||||
│ │ Before/after │ │
|
||||
│ │ diff view │ │
|
||||
│ └─────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Each card opens a modal or expands inline for the tool interface.
|
||||
|
||||
### CLI Structure
|
||||
|
||||
```bash
|
||||
# Capacity calculator
|
||||
stegasoo capacity image.jpg
|
||||
stegasoo capacity image.jpg --format json
|
||||
|
||||
# Metadata stripper
|
||||
stegasoo strip image.jpg # Output to image_stripped.jpg
|
||||
stegasoo strip image.jpg -o clean.jpg # Custom output
|
||||
stegasoo strip image.jpg --in-place # Overwrite original
|
||||
|
||||
# Stego detector
|
||||
stegasoo detect image.jpg
|
||||
stegasoo detect image.jpg --verbose # Detailed analysis
|
||||
|
||||
# Header peek
|
||||
stegasoo peek image.jpg
|
||||
# Output: "Stegasoo DCT header detected" or "No Stegasoo header found"
|
||||
|
||||
# Batch mode
|
||||
stegasoo encode --batch manifest.json # JSON with files + credentials
|
||||
stegasoo decode --batch input_dir/ --out output_dir/
|
||||
```
|
||||
|
||||
### Tool Details
|
||||
|
||||
#### Capacity Calculator
|
||||
- Input: Image file
|
||||
- Output: Dimensions, megapixels, DCT capacity, LSB capacity
|
||||
- Web: Upload zone + results panel
|
||||
- CLI: Table or JSON output
|
||||
|
||||
#### Metadata Stripper
|
||||
- Input: Image file
|
||||
- Output: Clean image (EXIF/metadata removed)
|
||||
- Show what was removed (camera model, GPS, etc.)
|
||||
- Preserve image quality
|
||||
|
||||
#### Stego Detector
|
||||
- Input: Image file
|
||||
- Analysis:
|
||||
- Chi-square analysis (LSB detection)
|
||||
- DCT coefficient histogram analysis
|
||||
- Visual inspection hints
|
||||
- Output: Likelihood score + findings
|
||||
- Note: Detection is probabilistic, not definitive
|
||||
|
||||
#### Image Compare
|
||||
- Input: Two images (original + stego)
|
||||
- Output:
|
||||
- Side-by-side view
|
||||
- Difference overlay (amplified)
|
||||
- Pixel-level stats (PSNR, SSIM)
|
||||
- Web only (visual tool)
|
||||
|
||||
#### Header Peek
|
||||
- Input: Image file
|
||||
- Output: Header found (yes/no), mode (DCT/LSB), embedded size estimate
|
||||
- Does NOT decrypt - just checks for valid header structure
|
||||
- Useful for "is this a stego image?" without credentials
|
||||
|
||||
#### Batch Mode
|
||||
- CLI only
|
||||
- Manifest file (JSON) or directory-based
|
||||
- Progress bar for multiple files
|
||||
- Error handling per-file (continue on failure)
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### Database Migration
|
||||
|
||||
For existing 4.0.2 installations:
|
||||
|
||||
```python
|
||||
# migrations/add_user_role.py
|
||||
def upgrade():
|
||||
# Add columns with defaults
|
||||
op.add_column('user', sa.Column('role', sa.String(20), default='user'))
|
||||
op.add_column('user', sa.Column('created_at', sa.DateTime))
|
||||
|
||||
# Set existing users as admin (they were the first user)
|
||||
op.execute("UPDATE user SET role = 'admin' WHERE role IS NULL")
|
||||
op.execute("UPDATE user SET created_at = datetime('now') WHERE created_at IS NULL")
|
||||
```
|
||||
|
||||
Or simpler: detect on startup, update schema automatically (current pattern).
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Per-user channel keys
|
||||
- User groups/teams
|
||||
- API authentication tokens
|
||||
- User activity logging
|
||||
- Password complexity rules beyond length
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
| Component | Complexity |
|
||||
|-----------|------------|
|
||||
| Database schema change | Low |
|
||||
| Admin routes + templates | Medium |
|
||||
| Access control decorator | Low |
|
||||
| About page QR | Low |
|
||||
| CLI channel commands | Medium |
|
||||
| Advanced Tools (TBD) | Medium-High |
|
||||
| Testing | Medium |
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Temp password flow:** Password field auto-populates with random 8-char password. Admin can override if desired. Show password once on confirmation page.
|
||||
|
||||
2. **Session handling:** Yes - deleting a user immediately invalidates their active sessions (ban hammer).
|
||||
|
||||
3. **Username rules:** Sane requirements, email-style allowed. Validation: 3-80 chars, alphanumeric, underscore, hyphen, @ and . for email-style.
|
||||
|
||||
---
|
||||
|
||||
## Approval
|
||||
|
||||
- [x] Plan reviewed
|
||||
- [x] Questions resolved
|
||||
- [x] Ready to implement
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Multi-User Support (commit 7b33501)
|
||||
- [x] Channel Key QR (Web UI) - added QR generator on About page
|
||||
- [x] CLI Channel Commands
|
||||
- [x] Saved Channel Keys (Web UI) - users can save/manage channel keys
|
||||
- [x] Advanced Tools - Image Security Toolkit
|
||||
- [x] CLI: `stegasoo tools capacity/strip/peek/exif`
|
||||
- [x] API: `/api/tools/capacity`, `/api/tools/peek`, `/api/tools/exif/*`
|
||||
- [x] WebUI: Tools page with tabbed interface
|
||||
- [x] EXIF Editor with inline editing, clear all, save/download
|
||||
|
||||
---
|
||||
|
||||
## Architectural Improvements (4.1.0)
|
||||
|
||||
### Consolidated Channel Key Resolution
|
||||
|
||||
Moved `resolve_channel_key()` from 3 duplicate implementations to single source of truth in `src/stegasoo/channel.py`:
|
||||
|
||||
```python
|
||||
# Library: src/stegasoo/channel.py
|
||||
def resolve_channel_key(value, *, file_path=None, no_channel=False) -> str | None:
|
||||
"""Unified channel key resolution - returns None (auto), "" (public), or key."""
|
||||
|
||||
def get_channel_response_info(channel_key) -> dict:
|
||||
"""Get channel info dict for API/WebUI responses."""
|
||||
```
|
||||
|
||||
Frontends now use thin wrappers that translate exceptions to their context (Click/HTTP).
|
||||
|
||||
### DCT Payload Pre-Check
|
||||
|
||||
Added `will_fit_by_mode()` pre-check to WebUI encode to fail fast with helpful error message instead of cryptic exception deep in DCT processing.
|
||||
|
||||
### EXIF Tools (Library Layer)
|
||||
|
||||
Added to `src/stegasoo/utils.py`:
|
||||
- `read_image_exif(image_data)` - Read EXIF metadata as dict
|
||||
- `write_image_exif(image_data, updates)` - Update EXIF fields (JPEG only)
|
||||
|
||||
Dependencies added: `piexif>=1.1.0`
|
||||
|
||||
---
|
||||
|
||||
## Action Item: Architectural Review ✅ DONE
|
||||
|
||||
Reviewed modules for consistency with Library → CLI → API → WebUI pattern:
|
||||
|
||||
| Module | Library | CLI | API | WebUI | Status |
|
||||
|--------|---------|-----|-----|-------|--------|
|
||||
| encode | ✓ | ✓ | ✓ | ✓ | Consistent |
|
||||
| decode | ✓ | ✓ | ✓ | ✓ | Consistent |
|
||||
| channel | ✓ | ✓ | ✓ | ✓ | Consolidated resolve_channel_key |
|
||||
| tools | ✓ | ✓ | ✓ | ✓ | Complete |
|
||||
| generate | ✓ | ✓ | - | ✓ | CLI has `stegasoo generate` |
|
||||
|
||||
Priority order: Developer/CLI → API integrator → WebUI end-user
|
||||
|
||||
---
|
||||
|
||||
## Admin Recovery System (4.1.0) ✅ DONE
|
||||
|
||||
Password reset capability for locked-out admins with multiple backup options.
|
||||
|
||||
### Library Layer (`src/stegasoo/recovery.py`)
|
||||
|
||||
```python
|
||||
# Key generation and validation
|
||||
generate_recovery_key() -> str # XXXX-XXXX-XXXX-... (32 chars)
|
||||
hash_recovery_key(key) -> str # SHA-256 for storage
|
||||
verify_recovery_key(key, hash) -> bool
|
||||
|
||||
# QR code (obfuscated - scans as gibberish)
|
||||
obfuscate_key(key) -> str # XOR with RECOVERY_OBFUSCATION_KEY
|
||||
deobfuscate_key(data) -> str | None
|
||||
generate_recovery_qr(key) -> bytes # PNG with obfuscated data
|
||||
extract_key_from_qr(image) -> str | None
|
||||
|
||||
# Stego backup (hide key in an image)
|
||||
create_stego_backup(key, carrier_image) -> bytes
|
||||
extract_stego_backup(stego_image, reference) -> str | None
|
||||
```
|
||||
|
||||
### Database (`app_settings` table)
|
||||
|
||||
- `recovery_key_hash` - SHA-256 of recovery key (or null if disabled)
|
||||
|
||||
### Web Routes
|
||||
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/setup/recovery` | GET, POST | Step 2 of initial setup |
|
||||
| `/recover` | GET, POST | Password reset page |
|
||||
| `/recover/stego` | POST | Extract key from stego backup |
|
||||
| `/account/recovery/regenerate` | GET, POST | Generate new key |
|
||||
| `/account/recovery/disable` | POST | Remove recovery option |
|
||||
| `/account/recovery/stego-backup` | POST | Create stego backup |
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
stegasoo admin recover --db path/to/stegasoo.db # Reset password
|
||||
stegasoo admin generate-key [--qr] # Generate key (reference)
|
||||
```
|
||||
|
||||
### Security Model
|
||||
|
||||
1. Recovery key shown once during setup - only hash stored
|
||||
2. QR codes XOR'd with `RECOVERY_OBFUSCATION_KEY` (fixed in constants.py)
|
||||
3. Stego backups use fixed internal passphrase/PIN - security is obscurity
|
||||
4. Instance-bound: recovery key hash must match in target database
|
||||
5. Options: text file, QR image, stego image, or no recovery (most secure)
|
||||
250
PLAN-4.1.2.md
@@ -1,250 +0,0 @@
|
||||
# Stegasoo 4.1.2 Plan
|
||||
|
||||
## Release Theme
|
||||
Polish and UX improvements after the 4.1.1 stability release.
|
||||
|
||||
---
|
||||
|
||||
## 1. Real Progress Bar for Encode/Decode
|
||||
|
||||
**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.
|
||||
|
||||
**Solution:** Polling + progress file approach
|
||||
|
||||
### Backend Changes
|
||||
|
||||
1. **dct_steganography.py** - Write progress during block loop:
|
||||
```python
|
||||
if progress_file and block_num % 50 == 0:
|
||||
with open(progress_file, 'w') as f:
|
||||
json.dump({"current": block_num, "total": total_blocks, "phase": "embedding"}, f)
|
||||
```
|
||||
|
||||
2. **app.py** - New endpoints:
|
||||
- `POST /encode` returns `job_id`, starts subprocess
|
||||
- `GET /encode/progress/<job_id>` returns progress JSON
|
||||
- `GET /encode/result/<job_id>` returns final result when done
|
||||
|
||||
3. **Subprocess wrapper** - Pass progress file path to encode/decode functions
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
1. **stegasoo.js** - After form submit:
|
||||
- Show progress bar (Bootstrap progress component)
|
||||
- Poll `/encode/progress/{job_id}` every 500ms
|
||||
- Update bar width and percentage text
|
||||
- Show phase (hashing, embedding, encoding, etc.)
|
||||
|
||||
2. **Templates** - Add progress bar markup to encode.html and decode.html
|
||||
|
||||
### Files to Modify
|
||||
- `src/stegasoo/dct_steganography.py`
|
||||
- `frontends/web/app.py`
|
||||
- `frontends/web/static/js/stegasoo.js`
|
||||
- `frontends/web/templates/encode.html`
|
||||
- `frontends/web/templates/decode.html`
|
||||
|
||||
---
|
||||
|
||||
## 2. Granular Decode Error Messages
|
||||
|
||||
**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
|
||||
|
||||
### 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"
|
||||
|
||||
### 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:** Done
|
||||
|
||||
**Problem:** UI works on mobile but has rough edges - cramped buttons, hard-to-tap targets, awkward layouts on small screens.
|
||||
|
||||
**Solution:** Targeted CSS/layout fixes for mobile breakpoints
|
||||
|
||||
### Areas to Improve
|
||||
|
||||
1. **Encode/Decode Forms:**
|
||||
- Stack image drop zones vertically on mobile (currently side-by-side)
|
||||
- Larger touch targets for file inputs
|
||||
- Full-width buttons on small screens
|
||||
- Passphrase input readable at smaller sizes
|
||||
|
||||
2. **Navigation:**
|
||||
- Hamburger menu for mobile navbar (if not already)
|
||||
- Sticky header doesn't eat too much screen
|
||||
- Easy thumb reach for main actions
|
||||
|
||||
3. **Results/Output:**
|
||||
- Download buttons full-width on mobile
|
||||
- QR codes sized appropriately
|
||||
- Click-to-copy message box works well with touch
|
||||
|
||||
4. **Drop Zones:**
|
||||
- Larger tap targets
|
||||
- Visual feedback for touch (not just hover)
|
||||
- Camera integration hint on mobile ("Tap to take photo or choose file")
|
||||
|
||||
### Testing Targets
|
||||
- iPhone SE (small)
|
||||
- iPhone 14 (medium)
|
||||
- iPad (tablet)
|
||||
- Android Chrome
|
||||
|
||||
### Files to Modify
|
||||
- `frontends/web/static/css/style.css` (or new mobile.css)
|
||||
- `frontends/web/templates/encode.html`
|
||||
- `frontends/web/templates/decode.html`
|
||||
- `frontends/web/templates/base.html` (navbar)
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Progress bar works on localhost
|
||||
- [ ] Progress bar works on Pi (slower, more visible)
|
||||
- [ ] Cancellation handling (what if user navigates away?)
|
||||
- [ ] Error states display correctly
|
||||
- [ ] Smoke test passes
|
||||
|
||||
---
|
||||
|
||||
## 4. Forced First-Login Setup
|
||||
|
||||
**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.
|
||||
|
||||
### 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:** Done
|
||||
|
||||
**Problem:** Dropzone has some interaction bugs:
|
||||
- Dropzone doesn't clear properly if first QR image fails
|
||||
- Can't click on image preview to replace file (have to click surrounding border)
|
||||
|
||||
**Solution:** Fix JS event handling and state management
|
||||
|
||||
### 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`
|
||||
|
||||
---
|
||||
|
||||
## 6. Smoke Test Benchmarking
|
||||
|
||||
**Status:** Done
|
||||
|
||||
**Problem:** No way to measure encode/decode performance or track regressions.
|
||||
|
||||
**Solution:** Add timing to smoke tests using `hyperfine` or `time`.
|
||||
|
||||
### 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 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 - 9 features (9 done)
|
||||
- Don't break DCT compatibility (4.1.1 RS format is stable)
|
||||
- Test on Pi before release
|
||||
@@ -1,42 +0,0 @@
|
||||
# Stegasoo 4.1.3 Plan
|
||||
|
||||
## Release Theme
|
||||
Performance and admin features.
|
||||
|
||||
---
|
||||
|
||||
## 1. DCT Performance Optimizations
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
**Problem:** DCT encode/decode can be slow on Pi, especially for large images.
|
||||
|
||||
**Ideas:**
|
||||
- Vectorize block processing with NumPy
|
||||
- Reduce Python loop overhead
|
||||
- Parallel block processing (multiprocessing?)
|
||||
- Profile and identify bottlenecks
|
||||
- Consider Cython for hot paths
|
||||
|
||||
---
|
||||
|
||||
## 2. User Management UI
|
||||
|
||||
**Status:** Planned
|
||||
|
||||
**Problem:** No way for admin to manage users via UI. Currently need direct DB access.
|
||||
|
||||
**Features:**
|
||||
- List all users
|
||||
- Create new user (admin only)
|
||||
- Delete user (admin only)
|
||||
- Reset user password
|
||||
- User activity/last login
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- These are heavier lifts than 4.1.2
|
||||
- Profile before optimizing
|
||||
- Consider security implications of user management
|
||||
97
PLAN-4.1.6.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Stegasoo v4.1.6 Planning
|
||||
|
||||
## UI Tweaks
|
||||
|
||||
### 1. Revamp Tron Lines Animation (Carrier/Stego Image)
|
||||
**Current state:**
|
||||
- 6-8 snake paths, each with 3-5 segments (~24-40 total lines)
|
||||
- 2px thick lines
|
||||
- 30-60px length per segment
|
||||
- Starting points spread across 80% of image area
|
||||
- Colors: yellow, cyan, purple, blue with glow
|
||||
|
||||
**Target improvements:**
|
||||
- [x] Thinner lines (1px instead of 2px)
|
||||
- [x] More numerous (20-40 paths via 5x4 grid, ~60-200 segments total)
|
||||
- [x] Better distribution across entire image (grid-based seeding)
|
||||
- [x] Shorter segments (12-30px) for denser "circuit board" look
|
||||
|
||||
**Files:**
|
||||
- `frontends/web/static/style.css` (~881-979) - `.embed-trace` styling
|
||||
- `frontends/web/static/js/stegasoo.js` (~333-390) - `generateEmbedTraces()`
|
||||
|
||||
---
|
||||
|
||||
## Tools Page Expansion
|
||||
|
||||
### Analysis Tools
|
||||
- [x] **JPEG Compression Tester** - Preview image at different quality levels (10-100%), show file size delta. Useful for understanding stego survivability.
|
||||
- [ ] **LSB Plane Viewer** - Visualize least significant bit plane(s) of RGB channels. Classic stego analysis tool.
|
||||
- [ ] **Histogram Viewer** - Color distribution graph per channel. Anomalies can indicate hidden data.
|
||||
- [ ] **Image Diff** - Compare two images side-by-side with pixel difference highlighting. Great for original vs stego comparison.
|
||||
- [ ] **Noise Analysis** - Chi-square or similar statistical analysis for detecting LSB embedding.
|
||||
|
||||
### Transform Tools
|
||||
- [x] **Rotate/Flip** - 90°/180°/270° rotation, horizontal/vertical flip
|
||||
- [ ] **Resize** - Scale with aspect ratio lock, common presets (50%, 25%, etc.)
|
||||
- [ ] **Crop** - Basic rectangular crop with preview
|
||||
- [x] **Format Convert** - PNG ↔ JPEG ↔ WebP with quality slider
|
||||
|
||||
### Existing Tools (already done)
|
||||
- [x] Capacity Calculator
|
||||
- [x] EXIF Viewer
|
||||
- [x] EXIF Strip
|
||||
- [x] Image Peek (header analysis)
|
||||
|
||||
### Tools UI/UX Overhaul
|
||||
|
||||
**Final Layout: Office-style Ribbon + Two-Panel**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 📏 📋 👁️ 📊 ┃ ✂️ 🔄 📐 🔀 Image Tools │ ← Icon toolbar
|
||||
├────────────────────────────────────────┬────────────────────┤
|
||||
│ [Format: PNG ▼] [Quality: 85] │ │
|
||||
├────────────────────────────────────────┤ Capacity │
|
||||
│ │ Calculator │
|
||||
│ ┌────────────────────────────┐ │ ────────────── │
|
||||
│ │ │ │ │
|
||||
│ │ Drop image here │ │ Dimensions: │
|
||||
│ │ or click │ │ 1920 × 1080 │
|
||||
│ │ │ │ │
|
||||
│ └────────────────────────────┘ │ LSB Capacity: │
|
||||
│ │ 245 KB │
|
||||
│ [image.jpg] │ │
|
||||
│ │ ────────────── │
|
||||
│ │ [Clear] [Export] │
|
||||
└────────────────────────────────────────┴────────────────────┘
|
||||
Options + dropzone/preview Results sidebar
|
||||
```
|
||||
|
||||
- Top ribbon: Icon buttons grouped by category (Analyze | Transform)
|
||||
- Left panel: Tool options + dropzone/preview (INPUT)
|
||||
- Right panel: Tool name + results/metadata + actions (OUTPUT)
|
||||
- Flow: Left → Right (input → output)
|
||||
|
||||
**Implementation Tasks:**
|
||||
- [x] Move inline CSS to style.css
|
||||
- [x] Build icon toolbar ribbon
|
||||
- [x] Build two-panel layout structure
|
||||
- [x] Migrate existing tools (Capacity, EXIF, Strip)
|
||||
- [x] Add new tools (Rotate, Compress, Convert)
|
||||
- [ ] Loading spinner on all async operations
|
||||
- [ ] Toast notifications instead of alerts
|
||||
- [ ] Consistent color coding (green=analysis, amber=transform)
|
||||
- [ ] Mobile: stack panels vertically
|
||||
|
||||
---
|
||||
|
||||
## CLI Improvements
|
||||
|
||||
### (Add items here)
|
||||
|
||||
---
|
||||
|
||||
## Other UI Tweaks
|
||||
|
||||
### (Add items here)
|
||||
|
||||
12
README.md
@@ -105,15 +105,18 @@ ruff check src/ tests/ frontends/
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
docker-compose up -d
|
||||
# Quick start (HTTPS enabled by default)
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# Access
|
||||
# Web UI: http://localhost:5000
|
||||
# Web UI: https://localhost:5000 (self-signed cert)
|
||||
# REST API: http://localhost:8000
|
||||
|
||||
# Disable HTTPS if needed:
|
||||
STEGASOO_HTTPS_ENABLED=false docker-compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
See [DOCKER.md](DOCKER.md) for full documentation.
|
||||
See [DOCKER.md](DOCKER.md) and [docs/DOCKER_QUICKSTART.md](docs/DOCKER_QUICKSTART.md) for full documentation.
|
||||
|
||||
## Raspberry Pi
|
||||
|
||||
@@ -143,6 +146,7 @@ See [rpi/README.md](rpi/README.md) for manual installation.
|
||||
- [UNDER_THE_HOOD.md](UNDER_THE_HOOD.md) - Technical deep-dive
|
||||
- [CHANGELOG.md](CHANGELOG.md) - Version history
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) - Contributor guide
|
||||
- `man stegasoo` - Man page (install: `sudo cp docs/stegasoo.1 /usr/local/share/man/man1/ && sudo mandb`)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
# 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)
|
||||
@@ -21,12 +21,12 @@ Pre-release validation checklist. Complete all items before tagging a release.
|
||||
|
||||
## 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`
|
||||
- [ ] Base image builds: `docker build -f docker/Dockerfile.base -t stegasoo-base:latest .`
|
||||
- [ ] Web image builds: `docker-compose -f docker/docker-compose.yml build web`
|
||||
- [ ] Container starts: `docker-compose -f docker/docker-compose.yml up -d web`
|
||||
- [ ] Web UI accessible at http://localhost:5000
|
||||
- [ ] Encode/decode works in container
|
||||
- [ ] Container stops cleanly: `docker-compose down`
|
||||
- [ ] Container stops cleanly: `docker-compose -f docker/docker-compose.yml down`
|
||||
|
||||
## Release Process
|
||||
|
||||
|
||||
86
RELEASE_NOTES.md
Normal file
@@ -0,0 +1,86 @@
|
||||
## Stegasoo v4.2.0
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
Major performance improvements for Raspberry Pi and resource-constrained deployments.
|
||||
|
||||
#### DCT Vectorization (~14x faster)
|
||||
- Batch DCT processing using `scipy.fft.dctn` with `axes=(1,2)`
|
||||
- Processes 500 blocks at once instead of one-by-one
|
||||
- Decode time reduced from ~2.6s to ~0.8s on 1MB images
|
||||
|
||||
#### Memory Optimization (50% reduction)
|
||||
- Switched from `float64` to `float32` for all DCT operations
|
||||
- Peak RAM: 211 MB → 107 MB for encode, 104 MB → 52 MB for decode
|
||||
- Critical for Pi 3/4 avoiding swap thrashing
|
||||
|
||||
#### Progress Callbacks for Decode
|
||||
- `progress_file` parameter added to `decode()` and extraction functions
|
||||
- UI can now show decode progress (phases: loading, extracting, decoding, complete)
|
||||
- JSON format: `{"current": 80, "total": 100, "percent": 80.0, "phase": "decoding"}`
|
||||
|
||||
#### Async API Endpoints
|
||||
- Encode/decode operations now run in thread pool via `asyncio.to_thread()`
|
||||
- API server can handle concurrent requests without blocking
|
||||
- Essential for multi-user Pi deployments
|
||||
|
||||
### Compression
|
||||
|
||||
#### Zstd Default Compression
|
||||
- `zstandard` is now a core dependency (always installed)
|
||||
- Better compression ratio than zlib for QR code RSA keys
|
||||
- New `STEGASOO-ZS:` prefix for zstd, backward compatible with `STEGASOO-Z:` (zlib)
|
||||
|
||||
### QR Code Generation
|
||||
|
||||
#### CLI Support
|
||||
- `stegasoo generate --rsa --qr key.png` - save RSA key as QR image (PNG/JPG)
|
||||
- `stegasoo generate --rsa --qr-ascii` - print ASCII QR to terminal
|
||||
|
||||
#### API Support
|
||||
- `POST /generate-key-qr` - generate QR from RSA key
|
||||
- Supports `png`, `jpg`, and `ascii` output formats
|
||||
- Uses zstd compression by default
|
||||
|
||||
### Other Changes
|
||||
|
||||
- RSA key size capped at 3072 bits (4096 too large for QR codes)
|
||||
- File auto-expire increased to 10 minutes
|
||||
- Progress bar "candy cane" animation during Argon2 key derivation
|
||||
- Optional API service in Pi setup (with security warning)
|
||||
|
||||
### Summary
|
||||
|
||||
| Metric | v4.1.7 | v4.2.0 | Improvement |
|
||||
|--------|--------|--------|-------------|
|
||||
| Decode (1MB) | ~2.6s | ~0.8s | **70% faster** |
|
||||
| Peak RAM | 211 MB | 107 MB | **50% less** |
|
||||
| Concurrent API | No | Yes | ✓ |
|
||||
| QR Compression | zlib | zstd | **~15% smaller** |
|
||||
|
||||
### Raspberry Pi Image
|
||||
Download `stegasoo-rpi-4.2.0_final.img.zst` from Releases.
|
||||
|
||||
```bash
|
||||
# Flash (auto-detects SD card)
|
||||
sudo ./rpi/flash-image.sh stegasoo-rpi-4.2.0_final.img.zst
|
||||
|
||||
# Or manual
|
||||
zstdcat stegasoo-rpi-4.2.0_final.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
```
|
||||
|
||||
Default login: `admin` / `stegasoo`
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
# Build and run
|
||||
docker build -f docker/Dockerfile.base -t stegasoo-base:latest .
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# Or individual services
|
||||
docker-compose -f docker/docker-compose.yml up -d web # Web UI on :5000
|
||||
docker-compose -f docker/docker-compose.yml up -d api # REST API on :8000
|
||||
```
|
||||
|
||||
### Full Changelog
|
||||
See [CHANGELOG.md](CHANGELOG.md) for complete version history.
|
||||
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
|
||||
|
||||
284
SECURITY_AUDIT_PLAN.md
Normal file
@@ -0,0 +1,284 @@
|
||||
# Stegasoo Security Audit Plan
|
||||
|
||||
> **Target Audience**: Developers, security reviewers, and deployment administrators
|
||||
> **Scope**: Web UI, REST API, CLI, and cryptographic core
|
||||
> **Deployment Model**: Air-gapped / private LAN (primary), Internet-facing (secondary)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Stegasoo is a steganography tool designed for **air-gapped deployments** on private networks. While the primary threat model assumes a trusted local network, this audit plan covers security best practices for both isolated and potentially exposed deployments.
|
||||
|
||||
### Known Limitations (By Design)
|
||||
|
||||
- **Self-signed certificates**: HTTPS uses self-signed certs; users must add exceptions or deploy their own CA
|
||||
- **No rate limiting**: Assumes trusted users on private network
|
||||
- **Single-node**: No distributed session store; sessions are per-instance
|
||||
- **Air-gap focus**: External security (firewalls, network isolation) is user's responsibility
|
||||
|
||||
---
|
||||
|
||||
## 1. Authentication & Authorization
|
||||
|
||||
### 1.1 Password Security
|
||||
- [ ] Passwords hashed with Argon2id (preferred) or PBKDF2 fallback
|
||||
- [ ] Minimum password length enforced (8+ characters)
|
||||
- [ ] Password not logged or exposed in error messages
|
||||
- [ ] Password change requires current password verification
|
||||
- [ ] Admin re-authentication required for sensitive operations (channel key export)
|
||||
|
||||
### 1.2 Session Management
|
||||
- [ ] Session tokens are cryptographically random
|
||||
- [ ] Session cookies have `HttpOnly` flag
|
||||
- [ ] Session cookies have `Secure` flag (when HTTPS enabled)
|
||||
- [ ] Session cookies have `SameSite` attribute
|
||||
- [ ] Sessions invalidated on logout
|
||||
- [ ] Sessions invalidated on password change
|
||||
- [ ] Session timeout configured appropriately
|
||||
|
||||
### 1.3 Authorization
|
||||
- [ ] Admin-only routes protected by `@admin_required` decorator
|
||||
- [ ] User-only routes protected by `@login_required` decorator
|
||||
- [ ] Users cannot access other users' saved channel keys
|
||||
- [ ] Users cannot modify other users' accounts
|
||||
- [ ] Role escalation not possible through API manipulation
|
||||
|
||||
---
|
||||
|
||||
## 2. Cryptographic Implementation
|
||||
|
||||
### 2.1 Key Derivation
|
||||
- [ ] KDF uses Argon2id with appropriate parameters (memory, iterations, parallelism)
|
||||
- [ ] PBKDF2 fallback uses sufficient iterations (600,000+)
|
||||
- [ ] Salt is cryptographically random and unique per operation
|
||||
- [ ] PIN/passphrase combined securely before KDF
|
||||
|
||||
### 2.2 Encryption
|
||||
- [ ] AES-256-GCM used for payload encryption
|
||||
- [ ] Nonce/IV is unique per encryption operation
|
||||
- [ ] Authentication tag verified before decryption
|
||||
- [ ] No padding oracle vulnerabilities
|
||||
|
||||
### 2.3 Channel Keys
|
||||
- [ ] Channel keys are 128-bit (32 hex chars)
|
||||
- [ ] Channel key derivation uses HKDF or similar
|
||||
- [ ] Channel isolation prevents cross-channel decryption
|
||||
- [ ] Fingerprint reveals no information about full key
|
||||
|
||||
### 2.4 Random Number Generation
|
||||
- [ ] All random values use `secrets` module or OS CSPRNG
|
||||
- [ ] No use of `random` module for security-sensitive operations
|
||||
|
||||
---
|
||||
|
||||
## 3. Input Validation & Injection Prevention
|
||||
|
||||
### 3.1 Web UI
|
||||
- [ ] All user input sanitized before rendering (XSS prevention)
|
||||
- [ ] Jinja2 auto-escaping enabled
|
||||
- [ ] No `| safe` filter on user-controlled content
|
||||
- [ ] Content-Security-Policy header configured
|
||||
- [ ] X-Content-Type-Options: nosniff
|
||||
|
||||
### 3.2 File Uploads
|
||||
- [ ] File size limits enforced server-side
|
||||
- [ ] File type validation (magic bytes, not just extension)
|
||||
- [ ] Uploaded files not executed
|
||||
- [ ] Filenames sanitized (path traversal prevention)
|
||||
- [ ] Temporary files cleaned up after processing
|
||||
|
||||
### 3.3 API Inputs
|
||||
- [ ] JSON schema validation on API endpoints
|
||||
- [ ] Integer overflow checks on size parameters
|
||||
- [ ] No SQL injection (parameterized queries only)
|
||||
- [ ] No command injection (no shell=True with user input)
|
||||
|
||||
---
|
||||
|
||||
## 4. Steganography-Specific Security
|
||||
|
||||
### 4.1 Carrier Image Handling
|
||||
- [ ] Malformed images don't crash the server (PIL/jpegio hardening)
|
||||
- [ ] DCT mode subprocess isolation for crash protection
|
||||
- [ ] Memory limits on image processing
|
||||
- [ ] No arbitrary code execution from image metadata
|
||||
|
||||
### 4.2 Payload Security
|
||||
- [ ] Payload size limits enforced
|
||||
- [ ] Encrypted payload indistinguishable from random noise
|
||||
- [ ] No metadata leakage in output images
|
||||
- [ ] Reference photo required (prevents dictionary attacks)
|
||||
|
||||
### 4.3 Capacity Reporting
|
||||
- [ ] Capacity calculation doesn't leak information about encoding method
|
||||
- [ ] Failed decodes don't reveal why (wrong key vs no data vs corrupted)
|
||||
|
||||
---
|
||||
|
||||
## 5. Network & Transport Security
|
||||
|
||||
### 5.1 HTTPS Configuration
|
||||
- [ ] TLS 1.2+ only (no SSLv3, TLS 1.0/1.1)
|
||||
- [ ] Strong cipher suites configured
|
||||
- [ ] Certificate generation uses 2048+ bit RSA or P-256 EC
|
||||
- [ ] Private key file permissions restricted (600)
|
||||
|
||||
### 5.2 Headers
|
||||
- [ ] X-Frame-Options: DENY (clickjacking prevention)
|
||||
- [ ] X-Content-Type-Options: nosniff
|
||||
- [ ] Referrer-Policy: same-origin
|
||||
- [ ] Permissions-Policy configured
|
||||
|
||||
### 5.3 CORS (if applicable)
|
||||
- [ ] CORS not enabled (or restricted to specific origins)
|
||||
- [ ] Credentials not allowed cross-origin
|
||||
|
||||
---
|
||||
|
||||
## 6. Error Handling & Logging
|
||||
|
||||
### 6.1 Error Messages
|
||||
- [ ] Stack traces not exposed to users in production
|
||||
- [ ] Error messages don't reveal sensitive paths or config
|
||||
- [ ] Failed login doesn't reveal if username exists
|
||||
|
||||
### 6.2 Logging
|
||||
- [ ] Passwords never logged
|
||||
- [ ] Channel keys never logged
|
||||
- [ ] Passphrases never logged
|
||||
- [ ] Log files have appropriate permissions
|
||||
- [ ] Sensitive operations logged for audit trail (optional)
|
||||
|
||||
---
|
||||
|
||||
## 7. Dependency Security
|
||||
|
||||
### 7.1 Python Dependencies
|
||||
- [ ] All dependencies pinned to specific versions
|
||||
- [ ] No known vulnerabilities in dependencies (run `pip-audit` or `safety`)
|
||||
- [ ] Dependencies from trusted sources only (PyPI)
|
||||
|
||||
### 7.2 Frontend Dependencies
|
||||
- [ ] All JS/CSS served locally (air-gap ready)
|
||||
- [ ] No CDN dependencies
|
||||
- [ ] Bootstrap and libraries are official releases
|
||||
- [ ] Subresource integrity considered for any external loads
|
||||
|
||||
---
|
||||
|
||||
## 8. Deployment Security
|
||||
|
||||
### 8.1 File Permissions
|
||||
- [ ] Database file not world-readable (600 or 640)
|
||||
- [ ] SSL certificates/keys not world-readable
|
||||
- [ ] Config files with secrets protected
|
||||
- [ ] Instance directory not in web root
|
||||
|
||||
### 8.2 Docker Deployment
|
||||
- [ ] Container runs as non-root user
|
||||
- [ ] No unnecessary capabilities
|
||||
- [ ] Resource limits configured
|
||||
- [ ] Health checks don't expose sensitive info
|
||||
|
||||
### 8.3 Raspberry Pi Deployment
|
||||
- [ ] Default passwords changed
|
||||
- [ ] SSH key-only authentication (recommended)
|
||||
- [ ] Unnecessary services disabled
|
||||
- [ ] Firewall configured (UFW/iptables)
|
||||
|
||||
---
|
||||
|
||||
## 9. Air-Gap Specific Considerations
|
||||
|
||||
### 9.1 Network Isolation
|
||||
- [ ] Document expected network topology
|
||||
- [ ] No phone-home or telemetry
|
||||
- [ ] No external API calls
|
||||
- [ ] Works fully offline after deployment
|
||||
|
||||
### 9.2 Key Distribution
|
||||
- [ ] QR code export for channel keys (offline transfer)
|
||||
- [ ] Print sheet for physical key backup
|
||||
- [ ] No cloud sync or external key servers
|
||||
|
||||
### 9.3 Updates
|
||||
- [ ] Document offline update procedure
|
||||
- [ ] Signed releases (future consideration)
|
||||
- [ ] Checksum verification for downloads
|
||||
|
||||
---
|
||||
|
||||
## 10. Penetration Testing Checklist
|
||||
|
||||
### 10.1 Authentication Attacks
|
||||
- [ ] Brute force login (note: no rate limiting by design)
|
||||
- [ ] Session fixation
|
||||
- [ ] Session hijacking
|
||||
- [ ] Password reset flow abuse
|
||||
|
||||
### 10.2 Injection Attacks
|
||||
- [ ] SQL injection on all inputs
|
||||
- [ ] XSS (stored, reflected, DOM-based)
|
||||
- [ ] Command injection
|
||||
- [ ] Path traversal
|
||||
- [ ] SSTI (Server-Side Template Injection)
|
||||
|
||||
### 10.3 Business Logic
|
||||
- [ ] Access control bypass
|
||||
- [ ] IDOR (Insecure Direct Object Reference)
|
||||
- [ ] Race conditions
|
||||
- [ ] Integer overflow in capacity calculations
|
||||
|
||||
### 10.4 Cryptographic Attacks
|
||||
- [ ] Known-plaintext attacks on stego output
|
||||
- [ ] Timing attacks on password verification
|
||||
- [ ] Padding oracle attacks
|
||||
- [ ] Key reuse vulnerabilities
|
||||
|
||||
---
|
||||
|
||||
## Tools for Automated Testing
|
||||
|
||||
```bash
|
||||
# Dependency vulnerability scan
|
||||
pip-audit
|
||||
safety check
|
||||
|
||||
# Static analysis
|
||||
bandit -r stegasoo/ frontends/
|
||||
|
||||
# Web security scan (if exposed)
|
||||
nikto -h https://localhost:5000
|
||||
OWASP ZAP (manual)
|
||||
|
||||
# SSL/TLS configuration
|
||||
testssl.sh https://localhost:5000
|
||||
|
||||
# Python code quality
|
||||
ruff check .
|
||||
mypy stegasoo/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Audit Schedule
|
||||
|
||||
| Phase | Focus Area | Priority |
|
||||
|-------|-----------|----------|
|
||||
| Pre-release | Crypto implementation, auth flow | Critical |
|
||||
| Post-release | Dependency scan, static analysis | High |
|
||||
| Quarterly | Full penetration test | Medium |
|
||||
| Ongoing | CVE monitoring for dependencies | High |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- This plan assumes **trusted users on a private network** as the primary deployment model
|
||||
- Internet-facing deployments should add rate limiting, fail2ban, and reverse proxy hardening
|
||||
- For high-security deployments, consider external security audit by professionals
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-01-07*
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ python app.py
|
||||
### Docker Configuration
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
# docker/docker-compose.yml
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
@@ -360,7 +360,7 @@ gunicorn --bind 0.0.0.0:5000 --workers 2 --threads 4 --timeout 60 app:app
|
||||
|
||||
**Docker:**
|
||||
```bash
|
||||
docker-compose up web
|
||||
docker-compose -f docker/docker-compose.yml up web
|
||||
```
|
||||
|
||||
### First-Time Setup
|
||||
@@ -411,7 +411,7 @@ Create a new set of credentials for steganography operations.
|
||||
| Use PIN | on/off | on | Generate a numeric PIN |
|
||||
| PIN length | 6-9 | 6 | Digits in the PIN |
|
||||
| Use RSA Key | on/off | off | Generate an RSA key pair |
|
||||
| RSA key size | 2048/3072/4096 | 2048 | Key size in bits |
|
||||
| RSA key size | 2048/3072 | 2048 | Key size in bits |
|
||||
|
||||
#### Entropy Calculator
|
||||
|
||||
@@ -1245,7 +1245,7 @@ volumes:
|
||||
```bash
|
||||
pip install scipy
|
||||
# Or rebuild Docker image
|
||||
docker-compose build --no-cache
|
||||
docker-compose -f docker/docker-compose.yml build --no-cache
|
||||
```
|
||||
|
||||
### Browser Compatibility
|
||||
|
||||
42
WISHLIST-4.2.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Stegasoo v4.2 Wishlist
|
||||
|
||||
Blue sky ideas for future development. No timeline - just capturing thoughts.
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### GPU-Accelerated DCT Encoding/Decoding
|
||||
- **Idea**: Leverage GPU for JPEG DCT coefficient manipulation
|
||||
- **Potential Approaches**:
|
||||
- OpenCL/CUDA for parallel DCT operations
|
||||
- Raspberry Pi VideoCore IV/VI GPU compute
|
||||
- WebGPU for browser-based acceleration
|
||||
- **Challenges**:
|
||||
- jpegio library is CPU-bound (C extension)
|
||||
- Would need custom DCT implementation
|
||||
- Memory transfer overhead may negate gains for small images
|
||||
- **Research**:
|
||||
- libjpeg-turbo uses SIMD but not GPU
|
||||
- nvJPEG (NVIDIA) does GPU-accelerated JPEG
|
||||
- Could potentially use GPU for the embedding math, not JPEG decode
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
(Add ideas here)
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
(Add ideas here)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- This is a living document - add ideas anytime
|
||||
- Not all ideas will be implemented
|
||||
- Feasibility research needed before committing to roadmap
|
||||
116
aur/PKGBUILD
Normal file
@@ -0,0 +1,116 @@
|
||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||
pkgname=stegasoo-git
|
||||
pkgver=4.2.0.r0.g530e5de
|
||||
pkgrel=1
|
||||
pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/adlee-was-taken/stegasoo"
|
||||
license=('MIT')
|
||||
|
||||
# Python 3.11-3.14 supported (uses jpeglib for modern Python compatibility)
|
||||
depends=(
|
||||
'python>=3.11'
|
||||
'zbar' # QR code reading for Web UI
|
||||
)
|
||||
makedepends=(
|
||||
'git'
|
||||
'python'
|
||||
'python-build'
|
||||
'python-hatchling'
|
||||
)
|
||||
provides=('stegasoo')
|
||||
conflicts=('stegasoo')
|
||||
install=stegasoo-git.install
|
||||
source=("${pkgname}::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||
printf "%s.r%s.g%s" "4.2.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "$pkgname"
|
||||
python -m build --wheel --no-isolation
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname"
|
||||
|
||||
# Detect Python version for site-packages path
|
||||
local pyver=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
|
||||
# Install to /opt/stegasoo with dedicated venv
|
||||
install -dm755 "$pkgdir/opt/stegasoo"
|
||||
|
||||
# Create fresh venv in package
|
||||
python -m venv "$pkgdir/opt/stegasoo/venv"
|
||||
|
||||
# Install the wheel with all extras
|
||||
local wheel=$(ls dist/*.whl | head -1)
|
||||
"$pkgdir/opt/stegasoo/venv/bin/pip" install --no-cache-dir "${wheel}[all]"
|
||||
|
||||
# Install frontends (not included in wheel)
|
||||
local site_packages="$pkgdir/opt/stegasoo/venv/lib/python${pyver}/site-packages"
|
||||
cp -r frontends "$site_packages/"
|
||||
|
||||
# Create writable directories for stegasoo user
|
||||
install -dm755 "$pkgdir/opt/stegasoo/venv/var/app-instance"
|
||||
install -dm755 "$site_packages/frontends/web/temp_files"
|
||||
install -dm755 "$site_packages/frontends/api/temp_files"
|
||||
|
||||
# Fix shebangs - replace build-time paths with installed paths
|
||||
find "$pkgdir/opt/stegasoo/venv/bin" -type f -exec \
|
||||
sed -i "s|$pkgdir/opt/stegasoo/venv|/opt/stegasoo/venv|g" {} \;
|
||||
|
||||
# Fix pyvenv.cfg
|
||||
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo/venv/pyvenv.cfg"
|
||||
|
||||
# Create symlinks to /usr/bin
|
||||
install -dm755 "$pkgdir/usr/bin"
|
||||
ln -s /opt/stegasoo/venv/bin/stegasoo "$pkgdir/usr/bin/stegasoo"
|
||||
|
||||
# Install license
|
||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
|
||||
# Install docs
|
||||
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
|
||||
|
||||
# Install systemd service files
|
||||
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-web.service" <<EOF
|
||||
[Unit]
|
||||
Description=Stegasoo Web UI
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=stegasoo
|
||||
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/web
|
||||
Environment="PATH=/opt/stegasoo/venv/bin"
|
||||
ExecStart=/opt/stegasoo/venv/bin/gunicorn -b 127.0.0.1:5000 app:app
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
|
||||
[Unit]
|
||||
Description=Stegasoo REST API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=stegasoo
|
||||
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/api
|
||||
Environment="PATH=/opt/stegasoo/venv/bin"
|
||||
ExecStart=/opt/stegasoo/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
}
|
||||
79
aur/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Stegasoo AUR Package
|
||||
|
||||
> **Note:** Uses Python 3.12 via `python312` AUR package (jpegio not yet compatible with 3.13)
|
||||
|
||||
## Installation
|
||||
|
||||
### From AUR (once published)
|
||||
```bash
|
||||
yay -S stegasoo-git
|
||||
# or
|
||||
paru -S stegasoo-git
|
||||
```
|
||||
|
||||
### Manual build
|
||||
```bash
|
||||
git clone https://aur.archlinux.org/stegasoo-git.git
|
||||
cd stegasoo-git
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## What Gets Installed
|
||||
|
||||
- `/opt/stegasoo/venv/` - Self-contained Python 3.12 venv with all dependencies
|
||||
- `/usr/bin/stegasoo` - CLI symlink
|
||||
- `/usr/lib/systemd/system/stegasoo-web.service` - Web UI service
|
||||
- `/usr/lib/systemd/system/stegasoo-api.service` - REST API service
|
||||
|
||||
## Optional Dependencies
|
||||
|
||||
```bash
|
||||
# QR code reading from webcam/images
|
||||
sudo pacman -S zbar
|
||||
```
|
||||
|
||||
All other dependencies are bundled in the venv.
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI
|
||||
```bash
|
||||
stegasoo --help
|
||||
stegasoo generate --rsa --qr-ascii
|
||||
stegasoo encode -i carrier.jpg -r reference.jpg -m "secret" -P passphrase -p 123456
|
||||
```
|
||||
|
||||
### Web UI (systemd)
|
||||
```bash
|
||||
# Create service user (first time)
|
||||
sudo useradd -r -s /usr/bin/nologin stegasoo
|
||||
|
||||
# Start service
|
||||
sudo systemctl enable --now stegasoo-web
|
||||
|
||||
# Access at http://localhost:5000
|
||||
```
|
||||
|
||||
### REST API (systemd)
|
||||
```bash
|
||||
# Start service
|
||||
sudo systemctl enable --now stegasoo-api
|
||||
|
||||
# Access at http://localhost:8000/docs
|
||||
```
|
||||
|
||||
### Manual run (without systemd)
|
||||
```bash
|
||||
# Web UI
|
||||
/opt/stegasoo/venv/bin/python -m gunicorn -b 0.0.0.0:5000 \
|
||||
--chdir /opt/stegasoo/venv/lib/python3.12/site-packages/frontends/web app:app
|
||||
|
||||
# REST API
|
||||
/opt/stegasoo/venv/bin/uvicorn \
|
||||
--app-dir /opt/stegasoo/venv/lib/python3.12/site-packages/frontends/api \
|
||||
main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## Maintainer
|
||||
|
||||
Aaron D. Lee
|
||||
40
aur/stegasoo-git.install
Normal file
@@ -0,0 +1,40 @@
|
||||
post_install() {
|
||||
# Create stegasoo system user if it doesn't exist
|
||||
if ! getent passwd stegasoo >/dev/null; then
|
||||
useradd -r -s /usr/bin/nologin -d /opt/stegasoo stegasoo
|
||||
echo "Created system user 'stegasoo'"
|
||||
fi
|
||||
|
||||
# Set ownership of instance directory for Flask
|
||||
chown -R stegasoo:stegasoo /opt/stegasoo/venv/var/app-instance 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "Stegasoo installed successfully!"
|
||||
echo ""
|
||||
echo "CLI usage:"
|
||||
echo " stegasoo --help"
|
||||
echo ""
|
||||
echo "To start the web UI:"
|
||||
echo " sudo systemctl start stegasoo-web"
|
||||
echo ""
|
||||
echo "To start the REST API:"
|
||||
echo " sudo systemctl start stegasoo-api"
|
||||
echo ""
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
post_install
|
||||
}
|
||||
|
||||
pre_remove() {
|
||||
# Stop services if running
|
||||
systemctl stop stegasoo-web 2>/dev/null || true
|
||||
systemctl stop stegasoo-api 2>/dev/null || true
|
||||
}
|
||||
|
||||
post_remove() {
|
||||
# Optionally remove the stegasoo user
|
||||
# userdel stegasoo 2>/dev/null || true
|
||||
echo "Stegasoo removed. User 'stegasoo' was not removed."
|
||||
echo "To remove: userdel stegasoo"
|
||||
}
|
||||
22
aur/test-build.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Test build the AUR package locally
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== Cleaning previous builds ==="
|
||||
rm -rf stegasoo-git pkg src *.pkg.tar.zst *.whl 2>/dev/null || true
|
||||
|
||||
echo "=== Generating .SRCINFO ==="
|
||||
makepkg --printsrcinfo > .SRCINFO
|
||||
|
||||
echo "=== Building package ==="
|
||||
makepkg -sf
|
||||
|
||||
echo "=== Package built ==="
|
||||
ls -la *.pkg.tar.zst
|
||||
|
||||
echo ""
|
||||
echo "To install: sudo pacman -U stegasoo-git-*.pkg.tar.zst"
|
||||
echo "To test: makepkg -si"
|
||||
BIN
data/WebUI.webp
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 16 KiB |
BIN
data/WebUI_Recover.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 16 KiB |
BIN
data/WebUI_Tools.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
@@ -35,12 +35,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libzbar0 \
|
||||
libjpeg-dev \
|
||||
zlib1g-dev \
|
||||
curl \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install ALL dependencies (slow path)
|
||||
RUN pip install --no-cache-dir \
|
||||
cython numpy scipy>=1.10.0 jpegio>=0.2.0 \
|
||||
argon2-cffi>=23.0.0 pillow>=10.0.0 cryptography>=41.0.0 \
|
||||
reedsolo>=1.7.0 \
|
||||
flask>=3.0.0 gunicorn>=21.0.0 \
|
||||
fastapi>=0.100.0 "uvicorn[standard]>=0.20.0" python-multipart>=0.0.6 \
|
||||
qrcode>=7.3.0 pyzbar>=0.1.9 click>=8.0.0 lz4>=4.0.0
|
||||
@@ -57,13 +60,24 @@ FROM base AS web
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies (curl for healthcheck, openssl for cert generation)
|
||||
USER root
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy application files (this is all that rebuilds normally!)
|
||||
COPY src/ src/
|
||||
COPY data/ data/
|
||||
COPY frontends/web/ frontends/web/
|
||||
|
||||
# Create upload directory and instance directories (for volumes)
|
||||
RUN mkdir -p /tmp/stego_uploads /app/frontends/web/instance /app/frontends/web/certs
|
||||
# temp_files is for multi-worker temp file sharing
|
||||
RUN mkdir -p /tmp/stego_uploads /app/frontends/web/instance /app/frontends/web/certs /app/frontends/web/temp_files
|
||||
|
||||
# Copy and set up entrypoint (before switching to non-root user)
|
||||
COPY frontends/web/docker-entrypoint.sh /app/frontends/web/
|
||||
RUN chmod +x /app/frontends/web/docker-entrypoint.sh
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads
|
||||
@@ -76,12 +90,12 @@ ENV PYTHONPATH=/app/src
|
||||
EXPOSE 5000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/')" || exit 1
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||
CMD curl -fsk https://localhost:5000/ || curl -fs http://localhost:5000/ || exit 1
|
||||
|
||||
# Run with gunicorn
|
||||
# Run with entrypoint (handles HTTPS/HTTP mode)
|
||||
WORKDIR /app/frontends/web
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "120", "app:app"]
|
||||
ENTRYPOINT ["/app/frontends/web/docker-entrypoint.sh"]
|
||||
|
||||
# ============================================================================
|
||||
# API stage - REST API
|
||||
@@ -32,7 +32,9 @@ RUN pip install --no-cache-dir \
|
||||
jpegio>=0.2.0 \
|
||||
argon2-cffi>=23.0.0 \
|
||||
pillow>=10.0.0 \
|
||||
cryptography>=41.0.0
|
||||
cryptography>=41.0.0 \
|
||||
reedsolo>=1.7.0 \
|
||||
zstandard>=0.22.0
|
||||
|
||||
# Install web/api framework packages (also stable)
|
||||
RUN pip install --no-cache-dir \
|
||||
@@ -47,9 +49,9 @@ RUN pip install --no-cache-dir \
|
||||
lz4>=4.0.0
|
||||
|
||||
# Verify key packages work
|
||||
RUN python -c "import jpegio; import scipy; import numpy; print('jpegio + scipy + numpy OK')"
|
||||
RUN python -c "import jpegio; import scipy; import numpy; import zstandard; print('jpegio + scipy + numpy + zstd OK')"
|
||||
|
||||
# Label for tracking
|
||||
LABEL org.opencontainers.image.title="Stegasoo Base"
|
||||
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
|
||||
LABEL org.opencontainers.image.version="4.0.0"
|
||||
LABEL org.opencontainers.image.version="4.2.0"
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
# Shared environment variables
|
||||
x-common-env: &common-env
|
||||
STEGASOO_CHANNEL_KEY: ${STEGASOO_CHANNEL_KEY:-}
|
||||
@@ -10,7 +8,8 @@ services:
|
||||
# ============================================================================
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
target: web
|
||||
container_name: stegasoo-web
|
||||
ports:
|
||||
@@ -20,7 +19,9 @@ services:
|
||||
FLASK_ENV: production
|
||||
# Authentication (v4.0.2)
|
||||
STEGASOO_AUTH_ENABLED: ${STEGASOO_AUTH_ENABLED:-true}
|
||||
STEGASOO_HTTPS_ENABLED: ${STEGASOO_HTTPS_ENABLED:-false}
|
||||
# HTTPS enabled by default - generates self-signed cert if none provided
|
||||
# To disable: STEGASOO_HTTPS_ENABLED=false docker-compose up
|
||||
STEGASOO_HTTPS_ENABLED: ${STEGASOO_HTTPS_ENABLED:-true}
|
||||
STEGASOO_HOSTNAME: ${STEGASOO_HOSTNAME:-localhost}
|
||||
volumes:
|
||||
# Persist auth database and SSL certs (v4.0.2)
|
||||
@@ -30,16 +31,17 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
memory: 2048M
|
||||
reservations:
|
||||
memory: 384M
|
||||
memory: 1024M
|
||||
|
||||
# ============================================================================
|
||||
# REST API (FastAPI)
|
||||
# ============================================================================
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
target: api
|
||||
container_name: stegasoo-api
|
||||
ports:
|
||||
@@ -50,9 +52,9 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
memory: 2048M
|
||||
reservations:
|
||||
memory: 384M
|
||||
memory: 1024M
|
||||
|
||||
# Named volumes for persistent data
|
||||
volumes:
|
||||
162
docs/DOCKER_QUICKSTART.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Docker Quickstart
|
||||
|
||||
Get Stegasoo running in Docker in under 5 minutes.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# From project root:
|
||||
|
||||
# Build web UI image
|
||||
sudo docker build -t stegasoo-web --target web -f docker/Dockerfile .
|
||||
|
||||
# Or build all targets
|
||||
sudo docker build -t stegasoo-api --target api -f docker/Dockerfile .
|
||||
sudo docker build -t stegasoo-cli --target cli -f docker/Dockerfile .
|
||||
|
||||
# Or use docker-compose
|
||||
sudo docker-compose -f docker/docker-compose.yml build
|
||||
```
|
||||
|
||||
## Run (Basic)
|
||||
|
||||
```bash
|
||||
# HTTP only, no auth
|
||||
sudo docker run -d \
|
||||
-p 5000:5000 \
|
||||
-e STEGASOO_AUTH_ENABLED=false \
|
||||
--name stegasoo \
|
||||
stegasoo-web
|
||||
```
|
||||
|
||||
Visit http://localhost:5000
|
||||
|
||||
## Run (Production)
|
||||
|
||||
```bash
|
||||
# HTTPS + Auth + Channel Key
|
||||
sudo docker run -d \
|
||||
-p 5000:5000 \
|
||||
-e STEGASOO_AUTH_ENABLED=true \
|
||||
-e STEGASOO_HTTPS_ENABLED=true \
|
||||
-e STEGASOO_HOSTNAME=stegasoo.local \
|
||||
-e STEGASOO_CHANNEL_KEY=ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456 \
|
||||
-v stegasoo-data:/opt/stegasoo/frontends/web/instance \
|
||||
-v stegasoo-certs:/opt/stegasoo/frontends/web/certs \
|
||||
--name stegasoo \
|
||||
stegasoo-web
|
||||
```
|
||||
|
||||
Visit https://localhost:5000 (accept self-signed cert warning)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `STEGASOO_AUTH_ENABLED` | `true` | Require login |
|
||||
| `STEGASOO_HTTPS_ENABLED` | `false` | Enable HTTPS |
|
||||
| `STEGASOO_HOSTNAME` | `localhost` | Hostname for SSL cert |
|
||||
| `STEGASOO_CHANNEL_KEY` | *(none)* | Shared channel key (32 alphanumeric chars with dashes) |
|
||||
|
||||
## Docker Compose
|
||||
|
||||
Create `.env` file in project root:
|
||||
```bash
|
||||
STEGASOO_AUTH_ENABLED=true
|
||||
STEGASOO_HTTPS_ENABLED=true
|
||||
STEGASOO_HOSTNAME=stegasoo.local
|
||||
STEGASOO_CHANNEL_KEY=
|
||||
```
|
||||
|
||||
Run:
|
||||
```bash
|
||||
sudo docker-compose -f docker/docker-compose.yml up -d web
|
||||
```
|
||||
|
||||
## Custom SSL Certificates
|
||||
|
||||
### Use Your Own Certs
|
||||
|
||||
```bash
|
||||
# Stop container
|
||||
sudo docker stop stegasoo
|
||||
|
||||
# Copy certs to volume
|
||||
sudo docker run --rm -v stegasoo-certs:/certs -v $(pwd):/src alpine \
|
||||
sh -c "cp /src/your-cert.crt /certs/server.crt && cp /src/your-key.key /certs/server.key && chmod 600 /certs/server.key"
|
||||
|
||||
# Start container
|
||||
sudo docker start stegasoo
|
||||
```
|
||||
|
||||
### Use mkcert (Local Development)
|
||||
|
||||
```bash
|
||||
# Install mkcert
|
||||
brew install mkcert # macOS
|
||||
# or: sudo apt install mkcert # Debian/Ubuntu
|
||||
|
||||
# Create local CA and certs
|
||||
mkcert -install
|
||||
mkcert -cert-file server.crt -key-file server.key localhost 127.0.0.1 stegasoo.local
|
||||
|
||||
# Copy to Docker volume (see above)
|
||||
```
|
||||
|
||||
### Use Let's Encrypt (Public Server)
|
||||
|
||||
```bash
|
||||
# Get cert
|
||||
sudo certbot certonly --standalone -d yourdomain.com
|
||||
|
||||
# Copy to Docker volume
|
||||
sudo docker run --rm -v stegasoo-certs:/certs alpine \
|
||||
sh -c "cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem /certs/server.crt && \
|
||||
cp /etc/letsencrypt/live/yourdomain.com/privkey.pem /certs/server.key && \
|
||||
chmod 600 /certs/server.key"
|
||||
```
|
||||
|
||||
## Volumes
|
||||
|
||||
| Volume | Purpose |
|
||||
|--------|---------|
|
||||
| `stegasoo-data` | User database, settings |
|
||||
| `stegasoo-certs` | SSL certificates |
|
||||
|
||||
## Smoke Test
|
||||
|
||||
```bash
|
||||
# Check container logs
|
||||
sudo docker logs stegasoo
|
||||
|
||||
# Test HTTP endpoint
|
||||
curl -k https://localhost:5000/health
|
||||
|
||||
# Expected: {"status":"ok","version":"4.1.7",...}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Container won't start:**
|
||||
```bash
|
||||
sudo docker logs stegasoo
|
||||
```
|
||||
|
||||
**Out of memory:**
|
||||
```bash
|
||||
# Argon2 needs 256MB+ per operation
|
||||
sudo docker run --memory=768m ...
|
||||
```
|
||||
|
||||
**Certificate errors:**
|
||||
```bash
|
||||
# Regenerate self-signed cert
|
||||
sudo docker exec stegasoo rm -rf /opt/stegasoo/frontends/web/certs/*
|
||||
sudo docker restart stegasoo
|
||||
```
|
||||
|
||||
**Reset everything:**
|
||||
```bash
|
||||
sudo docker stop stegasoo && sudo docker rm stegasoo
|
||||
sudo docker volume rm stegasoo-data stegasoo-certs
|
||||
```
|
||||
@@ -126,7 +126,7 @@ Quick reference for all Jinja2 templates in `frontends/web/templates/`.
|
||||
- `use_pin` - checkbox
|
||||
- `pin_length` - PIN digits (6-9)
|
||||
- `use_rsa` - checkbox
|
||||
- `rsa_bits` - key size (2048/3072/4096)
|
||||
- `rsa_bits` - key size (2048/3072)
|
||||
|
||||
**Output panels:**
|
||||
- Passphrase display
|
||||
|
||||
340
docs/stegasoo.1
Normal file
@@ -0,0 +1,340 @@
|
||||
.\" Stegasoo man page
|
||||
.\" Generate with: groff -man -Tascii stegasoo.1
|
||||
.TH STEGASOO 1 "January 2026" "Stegasoo 4.1.7" "User Commands"
|
||||
.SH NAME
|
||||
stegasoo \- steganography with hybrid authentication
|
||||
.SH SYNOPSIS
|
||||
.B stegasoo
|
||||
[\fB\-v\fR|\fB\-\-version\fR]
|
||||
[\fB\-\-json\fR]
|
||||
[\fB\-h\fR|\fB\-\-help\fR]
|
||||
.I command
|
||||
[\fIargs\fR]
|
||||
.SH DESCRIPTION
|
||||
.B stegasoo
|
||||
hides messages and files in images using PIN + passphrase security.
|
||||
It uses LSB (Least Significant Bit) steganography with optional DCT
|
||||
(Discrete Cosine Transform) encoding for JPEG resilience.
|
||||
.PP
|
||||
Messages are encrypted using a hybrid authentication scheme that combines
|
||||
a reference photo (shared secret), passphrase, and PIN code.
|
||||
.SH GLOBAL OPTIONS
|
||||
.TP
|
||||
.BR \-v ", " \-\-version
|
||||
Show version and exit.
|
||||
.TP
|
||||
.B \-\-json
|
||||
Output results as JSON (where supported).
|
||||
.TP
|
||||
.BR \-h ", " \-\-help
|
||||
Show help message and exit.
|
||||
.SH COMMANDS
|
||||
.SS encode
|
||||
Encode a message or file into an image.
|
||||
.PP
|
||||
.B stegasoo encode
|
||||
.I carrier
|
||||
.B \-r
|
||||
.I reference
|
||||
[\fB\-m\fR \fImessage\fR | \fB\-f\fR \fIfile\fR]
|
||||
[\fIoptions\fR]
|
||||
.TP
|
||||
.BR \-r ", " \-\-reference " " \fIPATH\fR
|
||||
Reference photo (shared secret). Required.
|
||||
.TP
|
||||
.BR \-m ", " \-\-message " " \fITEXT\fR
|
||||
Message to encode.
|
||||
.TP
|
||||
.BR \-f ", " \-\-file " " \fIPATH\fR
|
||||
File to embed instead of message.
|
||||
.TP
|
||||
.BR \-o ", " \-\-output " " \fIPATH\fR
|
||||
Output image path.
|
||||
.TP
|
||||
.B \-\-passphrase " " \fITEXT\fR
|
||||
Passphrase (recommend 4+ words). Prompts if not provided.
|
||||
.TP
|
||||
.B \-\-pin " " \fITEXT\fR
|
||||
PIN code. Prompts if not provided.
|
||||
.TP
|
||||
.B \-\-dry\-run
|
||||
Show capacity usage without encoding.
|
||||
.PP
|
||||
.B Examples:
|
||||
.nf
|
||||
stegasoo encode photo.png -r ref.jpg -m "Secret" --passphrase --pin
|
||||
stegasoo encode photo.png -r ref.jpg -f doc.pdf -o encoded.png
|
||||
.fi
|
||||
.SS decode
|
||||
Decode a message or file from an image.
|
||||
.PP
|
||||
.B stegasoo decode
|
||||
.I image
|
||||
.B \-r
|
||||
.I reference
|
||||
[\fIoptions\fR]
|
||||
.TP
|
||||
.BR \-r ", " \-\-reference " " \fIPATH\fR
|
||||
Reference photo (shared secret). Required.
|
||||
.TP
|
||||
.B \-\-passphrase " " \fITEXT\fR
|
||||
Passphrase. Prompts if not provided.
|
||||
.TP
|
||||
.B \-\-pin " " \fITEXT\fR
|
||||
PIN code. Prompts if not provided.
|
||||
.TP
|
||||
.BR \-o ", " \-\-output " " \fIPATH\fR
|
||||
Output path for file payloads.
|
||||
.PP
|
||||
.B Examples:
|
||||
.nf
|
||||
stegasoo decode encoded.png -r ref.jpg --passphrase --pin
|
||||
stegasoo decode encoded.png -r ref.jpg -o ./extracted/
|
||||
.fi
|
||||
.SS generate
|
||||
Generate random credentials (passphrase + PIN + optional channel key).
|
||||
.PP
|
||||
.B stegasoo generate
|
||||
[\fIoptions\fR]
|
||||
.TP
|
||||
.B \-\-words " " \fIINTEGER\fR
|
||||
Number of words in passphrase (default: 4).
|
||||
.TP
|
||||
.B \-\-pin\-length " " \fIINTEGER\fR
|
||||
PIN length (default: 6).
|
||||
.TP
|
||||
.B \-\-channel\-key
|
||||
Also generate a 256-bit channel key.
|
||||
.PP
|
||||
.B Examples:
|
||||
.nf
|
||||
stegasoo generate
|
||||
stegasoo generate --words 6 --pin-length 8
|
||||
stegasoo generate --channel-key
|
||||
.fi
|
||||
.SS info
|
||||
Show version, features, and system information.
|
||||
.PP
|
||||
.B stegasoo info
|
||||
[\fB\-\-full\fR]
|
||||
.TP
|
||||
.B \-\-full
|
||||
Show full system information (CPU, temperature, disk on Pi).
|
||||
.SS batch
|
||||
Batch operations on multiple images.
|
||||
.PP
|
||||
.B stegasoo batch
|
||||
.I subcommand
|
||||
[\fIargs\fR]
|
||||
.TP
|
||||
.B batch encode
|
||||
Encode message into multiple images.
|
||||
.RS
|
||||
.PP
|
||||
.B stegasoo batch encode
|
||||
.I images...
|
||||
[\fB\-m\fR \fImessage\fR | \fB\-f\fR \fIfile\fR]
|
||||
[\fIoptions\fR]
|
||||
.PP
|
||||
Options: \fB\-m\fR, \fB\-f\fR, \fB\-o\fR/\fB\-\-output\-dir\fR, \fB\-\-suffix\fR, \fB\-\-passphrase\fR, \fB\-\-pin\fR,
|
||||
\fB\-r\fR/\fB\-\-recursive\fR, \fB\-j\fR/\fB\-\-jobs\fR, \fB\-v\fR/\fB\-\-verbose\fR.
|
||||
.RE
|
||||
.TP
|
||||
.B batch decode
|
||||
Decode messages from multiple images.
|
||||
.RS
|
||||
.PP
|
||||
.B stegasoo batch decode
|
||||
.I images...
|
||||
[\fIoptions\fR]
|
||||
.PP
|
||||
Options: \fB\-o\fR/\fB\-\-output\-dir\fR, \fB\-\-passphrase\fR, \fB\-\-pin\fR, \fB\-r\fR/\fB\-\-recursive\fR,
|
||||
\fB\-j\fR/\fB\-\-jobs\fR, \fB\-v\fR/\fB\-\-verbose\fR.
|
||||
.RE
|
||||
.TP
|
||||
.B batch check
|
||||
Check capacity of multiple images.
|
||||
.RS
|
||||
.PP
|
||||
.B stegasoo batch check
|
||||
.I images...
|
||||
[\fB\-r\fR/\fB\-\-recursive\fR]
|
||||
.RE
|
||||
.SS channel
|
||||
Manage channel keys for deployment isolation.
|
||||
.PP
|
||||
Channel keys bind encode/decode operations to a specific group or deployment.
|
||||
Messages encoded with one channel key can only be decoded by systems with
|
||||
the same channel key.
|
||||
.PP
|
||||
.B stegasoo channel
|
||||
.I subcommand
|
||||
[\fIargs\fR]
|
||||
.TP
|
||||
.B channel generate
|
||||
Generate a new random channel key.
|
||||
.RS
|
||||
.PP
|
||||
Options: \fB\-\-save\fR (project config), \fB\-\-save\-user\fR (user config).
|
||||
.RE
|
||||
.TP
|
||||
.B channel show
|
||||
Show the current channel key.
|
||||
.RS
|
||||
.PP
|
||||
Options: \fB\-\-key\fR \fITEXT\fR (show specific key instead).
|
||||
.RE
|
||||
.TP
|
||||
.B channel qr
|
||||
Display channel key as QR code.
|
||||
.RS
|
||||
.PP
|
||||
Options: \fB\-\-key\fR \fITEXT\fR, \fB\-\-format\fR [\fIascii\fR|\fIpng\fR], \fB\-o\fR/\fB\-\-output\fR \fIPATH\fR.
|
||||
.RE
|
||||
.TP
|
||||
.B channel status
|
||||
Show channel key status and configuration.
|
||||
.TP
|
||||
.B channel clear
|
||||
Remove channel key configuration.
|
||||
.RS
|
||||
.PP
|
||||
Options: \fB\-\-project\fR, \fB\-\-user\fR.
|
||||
.RE
|
||||
.SS admin
|
||||
Web UI administration commands.
|
||||
.PP
|
||||
.B stegasoo admin
|
||||
.I subcommand
|
||||
[\fIargs\fR]
|
||||
.TP
|
||||
.B admin generate\-key
|
||||
Generate a new recovery key (for reference only).
|
||||
.RS
|
||||
.PP
|
||||
Options: \fB\-\-qr\fR (show QR code in terminal).
|
||||
.RE
|
||||
.TP
|
||||
.B admin recover
|
||||
Reset admin password using recovery key.
|
||||
.RS
|
||||
.PP
|
||||
Options: \fB\-\-db\fR \fIPATH\fR (path to stegasoo.db), \fB\-\-password\fR \fITEXT\fR.
|
||||
.RE
|
||||
.SS tools
|
||||
Image security tools.
|
||||
.PP
|
||||
.B stegasoo tools
|
||||
.I subcommand
|
||||
[\fIargs\fR]
|
||||
.TP
|
||||
.B tools capacity
|
||||
Show steganography capacity for an image.
|
||||
.RS
|
||||
.PP
|
||||
.B stegasoo tools capacity
|
||||
.I image
|
||||
[\fB\-\-json\fR]
|
||||
.RE
|
||||
.TP
|
||||
.B tools exif
|
||||
View or edit EXIF metadata.
|
||||
.RS
|
||||
.PP
|
||||
.B stegasoo tools exif
|
||||
.I image
|
||||
[\fB\-\-clear\fR] [\fB\-\-set\fR \fIFIELD=VALUE\fR] [\fB\-o\fR \fIPATH\fR] [\fB\-\-json\fR]
|
||||
.RE
|
||||
.TP
|
||||
.B tools peek
|
||||
Check if image contains Stegasoo hidden data.
|
||||
.RS
|
||||
.PP
|
||||
.B stegasoo tools peek
|
||||
.I image
|
||||
[\fB\-\-json\fR]
|
||||
.RE
|
||||
.TP
|
||||
.B tools strip
|
||||
Strip EXIF/metadata from an image.
|
||||
.RS
|
||||
.PP
|
||||
.B stegasoo tools strip
|
||||
.I image
|
||||
[\fB\-o\fR \fIPATH\fR] [\fB\-\-format\fR [\fIpng\fR|\fIbmp\fR]]
|
||||
.RE
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
.B STEGASOO_CHANNEL_KEY
|
||||
Channel key for encode/decode operations. Overrides config file settings.
|
||||
.TP
|
||||
.B STEGASOO_HTTPS_ENABLED
|
||||
Enable HTTPS for web UI (Docker/service mode).
|
||||
.TP
|
||||
.B STEGASOO_HOSTNAME
|
||||
Hostname for SSL certificate generation.
|
||||
.SH FILES
|
||||
.TP
|
||||
.I ~/.stegasoo/channel.key
|
||||
User's channel key configuration (encrypted).
|
||||
.TP
|
||||
.I .stegasoo.toml
|
||||
Project-level configuration file.
|
||||
.TP
|
||||
.I frontends/web/instance/stegasoo.db
|
||||
Web UI SQLite database (accounts, settings).
|
||||
.SH EXAMPLES
|
||||
.SS Basic encode/decode workflow
|
||||
.nf
|
||||
# Generate credentials
|
||||
stegasoo generate
|
||||
|
||||
# Encode a secret message
|
||||
stegasoo encode vacation.png -r selfie.jpg -m "Meet at noon"
|
||||
|
||||
# Decode the message (on another system with same reference photo)
|
||||
stegasoo decode vacation_steg.png -r selfie.jpg
|
||||
.fi
|
||||
.SS Using channel keys for team isolation
|
||||
.nf
|
||||
# Generate and save a channel key
|
||||
stegasoo channel generate --save-user
|
||||
|
||||
# Share the key with your team
|
||||
stegasoo channel qr -o team-key.png
|
||||
|
||||
# Now all encode/decode operations use this channel
|
||||
stegasoo encode photo.png -r ref.jpg -m "Team secret"
|
||||
.fi
|
||||
.SS Batch processing
|
||||
.nf
|
||||
# Check capacity of all PNGs in a directory
|
||||
stegasoo batch check ./photos/*.png
|
||||
|
||||
# Encode same message into multiple images
|
||||
stegasoo batch encode ./photos/ -r ref.jpg -m "Secret" -o ./encoded/
|
||||
.fi
|
||||
.SH SECURITY
|
||||
Stegasoo uses multiple layers of security:
|
||||
.IP \(bu 2
|
||||
Reference photo provides a visual shared secret
|
||||
.IP \(bu 2
|
||||
Passphrase (recommend 4+ words) for strong encryption
|
||||
.IP \(bu 2
|
||||
PIN code adds additional entropy
|
||||
.IP \(bu 2
|
||||
Channel keys isolate different deployments
|
||||
.IP \(bu 2
|
||||
AES-256 encryption for payload data
|
||||
.PP
|
||||
For maximum security, share the reference photo out-of-band (in person,
|
||||
secure messenger) and use a strong passphrase.
|
||||
.SH SEE ALSO
|
||||
.BR openssl (1),
|
||||
.BR qrencode (1)
|
||||
.SH BUGS
|
||||
Report bugs at: https://github.com/adlee-was-taken/stegasoo/issues
|
||||
.SH AUTHOR
|
||||
Written by the Stegasoo contributors.
|
||||
.SH COPYRIGHT
|
||||
Copyright \(co 2024-2026. MIT License.
|
||||
0
frontends/__init__.py
Normal file
0
frontends/api/__init__.py
Normal file
@@ -1,10 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stegasoo REST API (v4.0.0)
|
||||
Stegasoo REST API (v4.2.0)
|
||||
|
||||
FastAPI-based REST API for steganography operations.
|
||||
Supports both text messages and file embedding.
|
||||
|
||||
CHANGES in v4.2.0:
|
||||
- Async encode/decode operations (run in thread pool)
|
||||
- Server can handle concurrent requests without blocking
|
||||
|
||||
CHANGES in v4.0.0:
|
||||
- Added channel key support for deployment/group isolation
|
||||
- New /channel endpoints for key management
|
||||
@@ -21,8 +25,10 @@ NEW in v3.0: LSB and DCT embedding modes.
|
||||
NEW in v3.0.1: DCT color mode and JPEG output format.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import sys
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
@@ -68,13 +74,20 @@ from stegasoo.constants import (
|
||||
try:
|
||||
from stegasoo.qr_utils import (
|
||||
extract_key_from_qr,
|
||||
generate_qr_ascii,
|
||||
generate_qr_code,
|
||||
has_qr_read,
|
||||
has_qr_write,
|
||||
)
|
||||
|
||||
HAS_QR_READ = has_qr_read()
|
||||
HAS_QR_WRITE = has_qr_write()
|
||||
except ImportError:
|
||||
HAS_QR_READ = False
|
||||
HAS_QR_WRITE = False
|
||||
extract_key_from_qr = None
|
||||
generate_qr_code = None
|
||||
generate_qr_ascii = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -357,6 +370,7 @@ class StatusResponse(BaseModel):
|
||||
version: str
|
||||
has_argon2: bool
|
||||
has_qrcode_read: bool
|
||||
has_qrcode_write: bool # v4.2.0: QR generation capability
|
||||
has_dct: bool
|
||||
max_payload_kb: int
|
||||
available_modes: list[str]
|
||||
@@ -372,6 +386,32 @@ class QrExtractResponse(BaseModel):
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class QrGenerateRequest(BaseModel):
|
||||
"""Request to generate QR code from RSA key."""
|
||||
|
||||
key_pem: str = Field(..., description="RSA private key in PEM format")
|
||||
output_format: str = Field(
|
||||
default="png",
|
||||
description="Output format: 'png', 'jpg', or 'ascii'",
|
||||
)
|
||||
compress: bool = Field(
|
||||
default=True,
|
||||
description="Compress key data with zstd (recommended for larger keys)",
|
||||
)
|
||||
|
||||
|
||||
class QrGenerateResponse(BaseModel):
|
||||
"""Response containing generated QR code."""
|
||||
|
||||
success: bool
|
||||
format: str | None = None
|
||||
qr_data: str | None = Field(
|
||||
default=None,
|
||||
description="Base64-encoded image data (for png/jpg) or ASCII string",
|
||||
)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class WillFitRequest(BaseModel):
|
||||
"""Request to check if payload will fit."""
|
||||
|
||||
@@ -436,6 +476,27 @@ def _get_channel_info(channel_key: str | None) -> tuple[str, str | None]:
|
||||
return info["mode"], info.get("fingerprint")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HELPER: ASYNC EXECUTION
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def run_in_thread(func, *args, **kwargs):
|
||||
"""
|
||||
Run a CPU-bound function in a thread pool.
|
||||
|
||||
This allows the FastAPI server to handle other requests while
|
||||
encode/decode operations are running. Essential for Pi deployments
|
||||
where operations can take several seconds.
|
||||
|
||||
Usage:
|
||||
result = await run_in_thread(encode, message=msg, carrier_image=carrier, ...)
|
||||
"""
|
||||
if kwargs:
|
||||
func = partial(func, **kwargs)
|
||||
return await asyncio.to_thread(func, *args)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ROUTES - STATUS & INFO
|
||||
# ============================================================================
|
||||
@@ -469,6 +530,7 @@ async def root():
|
||||
version=__version__,
|
||||
has_argon2=has_argon2(),
|
||||
has_qrcode_read=HAS_QR_READ,
|
||||
has_qrcode_write=HAS_QR_WRITE,
|
||||
has_dct=has_dct_support(),
|
||||
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
|
||||
available_modes=available_modes,
|
||||
@@ -760,6 +822,51 @@ async def api_extract_key_from_qr(
|
||||
return QrExtractResponse(success=False, error=str(e))
|
||||
|
||||
|
||||
@app.post("/generate-key-qr", response_model=QrGenerateResponse)
|
||||
async def api_generate_key_qr(request: QrGenerateRequest):
|
||||
"""
|
||||
Generate QR code from an RSA private key.
|
||||
|
||||
Supports PNG, JPG, and ASCII output formats.
|
||||
Uses zstd compression by default for better QR code density.
|
||||
"""
|
||||
if not HAS_QR_WRITE:
|
||||
raise HTTPException(501, "QR code generation not available. Install qrcode library.")
|
||||
|
||||
try:
|
||||
fmt = request.output_format.lower()
|
||||
|
||||
if fmt == "ascii":
|
||||
ascii_qr = generate_qr_ascii(
|
||||
request.key_pem,
|
||||
compress=request.compress,
|
||||
invert=False,
|
||||
)
|
||||
return QrGenerateResponse(success=True, format="ascii", qr_data=ascii_qr)
|
||||
|
||||
elif fmt in ("png", "jpg", "jpeg"):
|
||||
import base64
|
||||
|
||||
qr_bytes = generate_qr_code(
|
||||
request.key_pem,
|
||||
compress=request.compress,
|
||||
output_format=fmt,
|
||||
)
|
||||
qr_b64 = base64.b64encode(qr_bytes).decode("ascii")
|
||||
return QrGenerateResponse(success=True, format=fmt, qr_data=qr_b64)
|
||||
|
||||
else:
|
||||
return QrGenerateResponse(
|
||||
success=False,
|
||||
error=f"Unsupported format: {fmt}. Use 'png', 'jpg', or 'ascii'",
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
return QrGenerateResponse(success=False, error=str(e))
|
||||
except Exception as e:
|
||||
return QrGenerateResponse(success=False, error=f"QR generation failed: {e}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ROUTES - GENERATE
|
||||
# ============================================================================
|
||||
@@ -874,8 +981,9 @@ async def api_encode(request: EncodeRequest):
|
||||
request.embed_mode, request.dct_output_format, request.dct_color_mode
|
||||
)
|
||||
|
||||
# v4.0.0: Include channel_key
|
||||
result = encode(
|
||||
# v4.2.0: Run CPU-bound encode in thread pool
|
||||
result = await run_in_thread(
|
||||
encode,
|
||||
message=request.message,
|
||||
reference_photo=ref_photo,
|
||||
carrier_image=carrier,
|
||||
@@ -950,8 +1058,9 @@ async def api_encode_file(request: EncodeFileRequest):
|
||||
request.embed_mode, request.dct_output_format, request.dct_color_mode
|
||||
)
|
||||
|
||||
# v4.0.0: Include channel_key
|
||||
result = encode(
|
||||
# v4.2.0: Run CPU-bound encode in thread pool
|
||||
result = await run_in_thread(
|
||||
encode,
|
||||
message=payload,
|
||||
reference_photo=ref_photo,
|
||||
carrier_image=carrier,
|
||||
@@ -1021,8 +1130,9 @@ async def api_decode(request: DecodeRequest):
|
||||
ref_photo = base64.b64decode(request.reference_photo_base64)
|
||||
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
|
||||
|
||||
# v4.0.0: Include channel_key
|
||||
result = decode(
|
||||
# v4.2.0: Run CPU-bound decode in thread pool
|
||||
result = await run_in_thread(
|
||||
decode,
|
||||
stego_image=stego,
|
||||
reference_photo=ref_photo,
|
||||
passphrase=request.passphrase,
|
||||
@@ -1150,8 +1260,9 @@ async def api_encode_multipart(
|
||||
# Get DCT parameters
|
||||
dct_params = _get_dct_params(embed_mode, dct_output_format, dct_color_mode)
|
||||
|
||||
# v4.0.0: Include channel_key
|
||||
result = encode(
|
||||
# v4.2.0: Run CPU-bound encode in thread pool
|
||||
result = await run_in_thread(
|
||||
encode,
|
||||
message=payload,
|
||||
reference_photo=ref_data,
|
||||
carrier_image=carrier_data,
|
||||
@@ -1264,8 +1375,9 @@ async def api_decode_multipart(
|
||||
# QR code keys are never password-protected
|
||||
effective_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
|
||||
|
||||
# v4.0.0: Include channel_key
|
||||
result = decode(
|
||||
# v4.2.0: Run CPU-bound decode in thread pool
|
||||
result = await run_in_thread(
|
||||
decode,
|
||||
stego_image=stego_data,
|
||||
reference_photo=ref_data,
|
||||
passphrase=passphrase,
|
||||
|
||||
0
frontends/cli/__init__.py
Normal file
@@ -120,6 +120,7 @@ try:
|
||||
from stegasoo.qr_utils import ( # noqa: F401
|
||||
can_fit_in_qr,
|
||||
extract_key_from_qr_file,
|
||||
generate_qr_ascii,
|
||||
generate_qr_code,
|
||||
has_qr_read,
|
||||
has_qr_write,
|
||||
@@ -136,6 +137,9 @@ except ImportError:
|
||||
def has_qr_write() -> bool:
|
||||
return False
|
||||
|
||||
def generate_qr_ascii(*args, **kwargs):
|
||||
raise RuntimeError("QR code generation not available")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CLI SETUP
|
||||
@@ -236,7 +240,7 @@ def format_channel_status_line(quiet: bool = False) -> str | None:
|
||||
help=f"PIN length (6-9, default: {DEFAULT_PIN_LENGTH})",
|
||||
)
|
||||
@click.option(
|
||||
"--rsa-bits", type=click.Choice(["2048", "3072", "4096"]), default="2048", help="RSA key size"
|
||||
"--rsa-bits", type=click.Choice(["2048", "3072"]), default="2048", help="RSA key size"
|
||||
)
|
||||
@click.option(
|
||||
"--words",
|
||||
@@ -247,7 +251,13 @@ def format_channel_status_line(quiet: bool = False) -> str | None:
|
||||
@click.option("--output", "-o", type=click.Path(), help="Save RSA key to file (requires password)")
|
||||
@click.option("--password", "-p", help="Password for RSA key file")
|
||||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
||||
def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
||||
@click.option(
|
||||
"--qr",
|
||||
type=click.Path(),
|
||||
help="Save RSA key QR code to file (png/jpg, uses zstd compression)",
|
||||
)
|
||||
@click.option("--qr-ascii", is_flag=True, help="Print RSA key as ASCII QR code to terminal")
|
||||
def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json, qr, qr_ascii):
|
||||
"""
|
||||
Generate credentials for encoding/decoding.
|
||||
|
||||
@@ -261,13 +271,18 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
||||
Examples:
|
||||
stegasoo generate
|
||||
stegasoo generate --words 5
|
||||
stegasoo generate --rsa --rsa-bits 4096
|
||||
stegasoo generate --rsa --rsa-bits 3072
|
||||
stegasoo generate --rsa -o mykey.pem -p "secretpassword"
|
||||
stegasoo generate --rsa --qr key.png
|
||||
stegasoo generate --rsa --qr-ascii
|
||||
stegasoo generate --no-pin --rsa
|
||||
"""
|
||||
if not pin and not rsa:
|
||||
raise click.UsageError("Must enable at least one of --pin or --rsa")
|
||||
|
||||
if (qr or qr_ascii) and not rsa:
|
||||
raise click.UsageError("QR output requires --rsa to generate an RSA key")
|
||||
|
||||
if output and not password:
|
||||
raise click.UsageError("--password is required when saving RSA key to file")
|
||||
|
||||
@@ -334,6 +349,33 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
||||
click.echo(creds.rsa_key_pem)
|
||||
click.echo()
|
||||
|
||||
# QR code output (v4.2.0)
|
||||
if qr:
|
||||
if not HAS_QR:
|
||||
click.secho(" ⚠️ QR code library not available", fg="yellow")
|
||||
else:
|
||||
# Determine format from extension
|
||||
qr_path = Path(qr)
|
||||
ext = qr_path.suffix.lower()
|
||||
fmt = "jpeg" if ext in (".jpg", ".jpeg") else "png"
|
||||
|
||||
qr_bytes = generate_qr_code(creds.rsa_key_pem, compress=True, output_format=fmt)
|
||||
qr_path.write_bytes(qr_bytes)
|
||||
click.secho(f"─── RSA KEY QR CODE ───", fg="green")
|
||||
click.secho(f" Saved to: {qr}", fg="bright_white")
|
||||
click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow")
|
||||
click.echo()
|
||||
|
||||
if qr_ascii:
|
||||
if not HAS_QR:
|
||||
click.secho(" ⚠️ QR code library not available", fg="yellow")
|
||||
else:
|
||||
click.secho("─── RSA KEY QR CODE (ASCII) ───", fg="green")
|
||||
click.secho(" ⚠️ Contains unencrypted private key!", fg="yellow")
|
||||
click.echo()
|
||||
ascii_qr = generate_qr_ascii(creds.rsa_key_pem, compress=True, invert=True)
|
||||
click.echo(ascii_qr)
|
||||
|
||||
click.secho("─── SECURITY ───", fg="green")
|
||||
click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)")
|
||||
if creds.pin:
|
||||
|
||||
0
frontends/web/__init__.py
Normal file
52
frontends/web/dev_run.sh
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# Stegasoo Web Frontend - Development Runner
|
||||
# Press 'r' to restart, 'q' to quit (single keypress, no Enter needed)
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
PID=""
|
||||
|
||||
cleanup() {
|
||||
echo -e "\n\033[33mShutting down...\033[0m"
|
||||
[[ -n "$PID" ]] && kill "$PID" 2>/dev/null
|
||||
stty sane 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGINT SIGTERM EXIT
|
||||
|
||||
start_server() {
|
||||
clear
|
||||
echo -e "\033[36m┌──────────────────────────────────────┐\033[0m"
|
||||
echo -e "\033[36m│ Stegasoo Dev Server │\033[0m"
|
||||
echo -e "\033[36m│ \033[0m[r] restart [q] quit\033[36m │\033[0m"
|
||||
echo -e "\033[36m└──────────────────────────────────────┘\033[0m"
|
||||
|
||||
pkill -f "python app.py" 2>/dev/null
|
||||
sleep 0.3
|
||||
|
||||
python app.py 2>&1 &
|
||||
PID=$!
|
||||
echo -e "\033[32m✓ Running on http://localhost:5000 (PID: $PID)\033[0m\n"
|
||||
}
|
||||
|
||||
start_server
|
||||
|
||||
# Single keypress mode
|
||||
stty -echo -icanon time 0 min 0
|
||||
|
||||
while true; do
|
||||
key=$(dd bs=1 count=1 2>/dev/null)
|
||||
case "$key" in
|
||||
r|R) start_server ;;
|
||||
q|Q) cleanup ;;
|
||||
esac
|
||||
|
||||
# Check if crashed
|
||||
if [[ -n "$PID" ]] && ! kill -0 "$PID" 2>/dev/null; then
|
||||
echo -e "\033[31m✗ Crashed! Press 'r' to restart\033[0m"
|
||||
PID=""
|
||||
fi
|
||||
|
||||
sleep 0.1
|
||||
done
|
||||
75
frontends/web/docker-entrypoint.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Docker entrypoint for Stegasoo Web UI
|
||||
# Handles SSL certificate generation and gunicorn startup
|
||||
#
|
||||
# Supports mkcert for browser-trusted certificates (no warning screen)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
CERT_DIR="/app/frontends/web/certs"
|
||||
CERT_FILE="$CERT_DIR/cert.pem"
|
||||
KEY_FILE="$CERT_DIR/key.pem"
|
||||
HOSTNAME="${STEGASOO_HOSTNAME:-localhost}"
|
||||
|
||||
# Generate SSL certificates
|
||||
# Priority: 1) Existing certs, 2) mkcert (trusted), 3) openssl (self-signed)
|
||||
generate_certs() {
|
||||
if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ]; then
|
||||
echo "Using existing SSL certificates."
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
# Try mkcert first (creates browser-trusted certs)
|
||||
if command -v mkcert &> /dev/null; then
|
||||
echo "Generating trusted certificate with mkcert for $HOSTNAME..."
|
||||
cd "$CERT_DIR"
|
||||
mkcert -key-file key.pem -cert-file cert.pem "$HOSTNAME" localhost 127.0.0.1 ::1
|
||||
echo "Trusted certificate generated."
|
||||
echo ""
|
||||
echo " To trust on other devices, install the CA cert from:"
|
||||
echo " $(mkcert -CAROOT)/rootCA.pem"
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback to self-signed (shows browser warning)
|
||||
echo "Generating self-signed SSL certificate for $HOSTNAME..."
|
||||
echo "(Install mkcert for browser-trusted certs without warnings)"
|
||||
|
||||
openssl req -x509 -newkey rsa:2048 \
|
||||
-keyout "$KEY_FILE" \
|
||||
-out "$CERT_FILE" \
|
||||
-sha256 -days 365 -nodes \
|
||||
-subj "/CN=$HOSTNAME" \
|
||||
-addext "subjectAltName=DNS:$HOSTNAME,DNS:localhost,IP:127.0.0.1" \
|
||||
2>/dev/null
|
||||
|
||||
echo "Self-signed certificate generated."
|
||||
}
|
||||
|
||||
# Start gunicorn with appropriate settings
|
||||
if [ "${STEGASOO_HTTPS_ENABLED:-false}" = "true" ]; then
|
||||
echo "HTTPS mode enabled"
|
||||
generate_certs
|
||||
|
||||
exec gunicorn \
|
||||
--bind 0.0.0.0:5000 \
|
||||
--workers 2 \
|
||||
--threads 4 \
|
||||
--timeout 120 \
|
||||
--certfile "$CERT_FILE" \
|
||||
--keyfile "$KEY_FILE" \
|
||||
app:app
|
||||
else
|
||||
echo "HTTP mode (HTTPS disabled)"
|
||||
exec gunicorn \
|
||||
--bind 0.0.0.0:5000 \
|
||||
--workers 2 \
|
||||
--threads 4 \
|
||||
--timeout 120 \
|
||||
app:app
|
||||
fi
|
||||
@@ -7,6 +7,7 @@ Uses cryptography library (already a dependency).
|
||||
|
||||
import datetime
|
||||
import ipaddress
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography import x509
|
||||
@@ -15,6 +16,33 @@ from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
|
||||
def _get_local_ips() -> list[str]:
|
||||
"""Get local IP addresses for this machine."""
|
||||
ips = []
|
||||
try:
|
||||
# Get hostname and resolve to IP
|
||||
hostname = socket.gethostname()
|
||||
for addr_info in socket.getaddrinfo(hostname, None, socket.AF_INET):
|
||||
ip = addr_info[4][0]
|
||||
if ip not in ips and not ip.startswith("127."):
|
||||
ips.append(ip)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also try connecting to external to get primary interface IP
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
if ip not in ips:
|
||||
ips.append(ip)
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ips
|
||||
|
||||
|
||||
def get_cert_paths(base_dir: Path) -> tuple[Path, Path]:
|
||||
"""Get paths for cert and key files."""
|
||||
cert_dir = base_dir / "certs"
|
||||
@@ -64,12 +92,26 @@ def generate_self_signed_cert(
|
||||
x509.DNSName("localhost"),
|
||||
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
|
||||
]
|
||||
|
||||
# Add hostname.local for mDNS access
|
||||
if not hostname.endswith(".local"):
|
||||
san_list.append(x509.DNSName(f"{hostname}.local"))
|
||||
|
||||
# Add the hostname as IP if it looks like one
|
||||
try:
|
||||
san_list.append(x509.IPAddress(ipaddress.IPv4Address(hostname)))
|
||||
except ipaddress.AddressValueError:
|
||||
pass
|
||||
|
||||
# Add local network IPs
|
||||
for local_ip in _get_local_ips():
|
||||
try:
|
||||
ip_addr = ipaddress.IPv4Address(local_ip)
|
||||
if x509.IPAddress(ip_addr) not in san_list:
|
||||
san_list.append(x509.IPAddress(ip_addr))
|
||||
except (ipaddress.AddressValueError, ValueError):
|
||||
pass
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
|
||||
@@ -231,20 +231,14 @@ const StegasooGenerate = {
|
||||
printWindow.document.write(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Stegasoo RSA Key QR Code</title>
|
||||
<title>QR Code</title>
|
||||
<style>
|
||||
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; font-family: sans-serif; }
|
||||
body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
||||
img { max-width: 400px; }
|
||||
.warning { margin-top: 20px; padding: 10px; border: 2px solid #ff9800; background: #fff3e0; max-width: 400px; text-align: center; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Stegasoo RSA Private Key</h2>
|
||||
<img src="${qrImg.src}" alt="RSA Key QR Code">
|
||||
<div class="warning">
|
||||
<strong>Warning:</strong> This QR code contains your unencrypted RSA private key.
|
||||
Store securely and destroy after use.
|
||||
</div>
|
||||
<img src="${qrImg.src}" alt="QR Code">
|
||||
<script>window.onload = function() { window.print(); }<\/script>
|
||||
</body>
|
||||
</html>`);
|
||||
|
||||
6
frontends/web/static/js/qrcode.min.js
vendored
Normal file
@@ -333,56 +333,68 @@ const Stegasoo = {
|
||||
generateEmbedTraces(container, width, height) {
|
||||
// Color classes for variety
|
||||
const colors = ['color-yellow', 'color-cyan', 'color-purple', 'color-blue'];
|
||||
|
||||
// Generate 6-8 snake paths spread across the whole image
|
||||
const numPaths = 6 + Math.floor(Math.random() * 3);
|
||||
|
||||
for (let p = 0; p < numPaths; p++) {
|
||||
// Each path gets a random color
|
||||
const pathColor = colors[Math.floor(Math.random() * colors.length)];
|
||||
|
||||
// Distribute starting points across the image
|
||||
let x = (width * 0.1) + (Math.random() * width * 0.8);
|
||||
let y = (height * 0.1) + (Math.random() * height * 0.8);
|
||||
let delay = p * 40;
|
||||
|
||||
// Each path has 3-5 segments for more coverage
|
||||
const numSegments = 3 + Math.floor(Math.random() * 3);
|
||||
let horizontal = Math.random() > 0.5;
|
||||
|
||||
for (let s = 0; s < numSegments; s++) {
|
||||
const trace = document.createElement('div');
|
||||
trace.className = 'embed-trace ' + (horizontal ? 'h' : 'v') + ' ' + pathColor;
|
||||
|
||||
const length = 30 + Math.random() * 60;
|
||||
trace.style.left = x + 'px';
|
||||
trace.style.top = y + 'px';
|
||||
trace.style.animationDelay = delay + 'ms';
|
||||
|
||||
if (horizontal) {
|
||||
trace.style.width = length + 'px';
|
||||
} else {
|
||||
trace.style.height = length + 'px';
|
||||
|
||||
// Grid-based distribution: divide image into cells for even coverage
|
||||
const gridCols = 5;
|
||||
const gridRows = 4;
|
||||
const cellWidth = width / gridCols;
|
||||
const cellHeight = height / gridRows;
|
||||
|
||||
let pathIndex = 0;
|
||||
|
||||
// Spawn 1-2 paths from each grid cell for even distribution
|
||||
for (let row = 0; row < gridRows; row++) {
|
||||
for (let col = 0; col < gridCols; col++) {
|
||||
// 1-2 paths per cell
|
||||
const pathsInCell = 1 + Math.floor(Math.random() * 2);
|
||||
|
||||
for (let p = 0; p < pathsInCell; p++) {
|
||||
const pathColor = colors[Math.floor(Math.random() * colors.length)];
|
||||
|
||||
// Start within this grid cell (with padding)
|
||||
let x = (col * cellWidth) + (cellWidth * 0.15) + (Math.random() * cellWidth * 0.7);
|
||||
let y = (row * cellHeight) + (cellHeight * 0.15) + (Math.random() * cellHeight * 0.7);
|
||||
let delay = pathIndex * 15;
|
||||
|
||||
// Each path has 3-5 short segments
|
||||
const numSegments = 3 + Math.floor(Math.random() * 3);
|
||||
let horizontal = Math.random() > 0.5;
|
||||
|
||||
for (let s = 0; s < numSegments; s++) {
|
||||
const trace = document.createElement('div');
|
||||
trace.className = 'embed-trace ' + (horizontal ? 'h' : 'v') + ' ' + pathColor;
|
||||
|
||||
// Shorter segments: 12-30px for denser circuit look
|
||||
const length = 12 + Math.random() * 18;
|
||||
trace.style.left = Math.max(0, Math.min(x, width - length)) + 'px';
|
||||
trace.style.top = Math.max(0, Math.min(y, height - length)) + 'px';
|
||||
trace.style.animationDelay = delay + 'ms';
|
||||
|
||||
if (horizontal) {
|
||||
trace.style.width = length + 'px';
|
||||
} else {
|
||||
trace.style.height = length + 'px';
|
||||
}
|
||||
|
||||
container.appendChild(trace);
|
||||
|
||||
// Move position for next segment
|
||||
if (horizontal) {
|
||||
x += length * (Math.random() > 0.5 ? 1 : -1);
|
||||
} else {
|
||||
y += length * (Math.random() > 0.5 ? 1 : -1);
|
||||
}
|
||||
|
||||
// Keep within bounds
|
||||
x = Math.max(5, Math.min(x, width - 20));
|
||||
y = Math.max(5, Math.min(y, height - 20));
|
||||
|
||||
// Alternate direction (90 degree turn)
|
||||
horizontal = !horizontal;
|
||||
delay += 20;
|
||||
}
|
||||
pathIndex++;
|
||||
}
|
||||
|
||||
container.appendChild(trace);
|
||||
|
||||
// Move position for next segment
|
||||
if (horizontal) {
|
||||
x += length;
|
||||
} else {
|
||||
y += length;
|
||||
}
|
||||
|
||||
// Wrap around if out of bounds to keep traces in view
|
||||
if (x > width - 20) x = 10 + Math.random() * 40;
|
||||
if (y > height - 20) y = 10 + Math.random() * 40;
|
||||
if (x < 10) x = width - 60 + Math.random() * 40;
|
||||
if (y < 10) y = height - 60 + Math.random() * 40;
|
||||
|
||||
// Alternate direction (90 degree turn)
|
||||
horizontal = !horizontal;
|
||||
delay += 30;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -997,7 +1009,9 @@ const Stegasoo = {
|
||||
const percent = progressData.percent || 0;
|
||||
const phase = progressData.phase || 'processing';
|
||||
|
||||
this.updateProgress(percent, this.formatPhase(phase));
|
||||
// Use indeterminate mode for initializing/starting phases
|
||||
const isIndeterminate = (phase === 'initializing' || phase === 'starting');
|
||||
this.updateProgress(percent, this.formatPhase(phase), isIndeterminate);
|
||||
|
||||
// Continue polling
|
||||
setTimeout(poll, 500);
|
||||
@@ -1017,7 +1031,7 @@ const Stegasoo = {
|
||||
formatPhase(phase) {
|
||||
const phases = {
|
||||
'starting': 'Starting...',
|
||||
'initializing': 'Initializing...',
|
||||
'initializing': 'Deriving keys (may take a moment)...',
|
||||
'embedding': 'Embedding data...',
|
||||
'saving': 'Saving image...',
|
||||
'finalizing': 'Finalizing...',
|
||||
@@ -1058,8 +1072,9 @@ const Stegasoo = {
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
// Reset progress
|
||||
this.updateProgress(0, 'Initializing...');
|
||||
// Reset progress tracking and start with indeterminate state
|
||||
this.resetProgressTracking();
|
||||
this.updateProgress(0, 'Initializing...', true);
|
||||
|
||||
// Show modal
|
||||
const bsModal = new bootstrap.Modal(modal);
|
||||
@@ -1078,16 +1093,446 @@ const Stegasoo = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Update progress bar and text
|
||||
* Track max progress to prevent backwards jumps
|
||||
*/
|
||||
updateProgress(percent, phase) {
|
||||
_maxProgress: 0,
|
||||
|
||||
/**
|
||||
* Reset progress tracking (call when starting new operation)
|
||||
*/
|
||||
resetProgressTracking() {
|
||||
this._maxProgress = 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update progress bar and text
|
||||
* Supports indeterminate mode for initializing phase (barber pole at full width)
|
||||
*/
|
||||
updateProgress(percent, phase, indeterminate = false) {
|
||||
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;
|
||||
if (indeterminate) {
|
||||
// Barber pole animation at full width, no percentage
|
||||
if (progressBar) {
|
||||
progressBar.style.width = '100%';
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
}
|
||||
if (progressText) progressText.textContent = '';
|
||||
if (phaseText) phaseText.textContent = phase;
|
||||
} else {
|
||||
// Determinate progress - never go backwards
|
||||
const safePercent = Math.max(percent, this._maxProgress);
|
||||
this._maxProgress = safePercent;
|
||||
|
||||
if (progressBar) {
|
||||
progressBar.style.width = safePercent + '%';
|
||||
// Keep animation but show actual progress
|
||||
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
|
||||
}
|
||||
if (progressText) progressText.textContent = Math.round(safePercent) + '%';
|
||||
if (phaseText) phaseText.textContent = phase;
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// ASYNC DECODE WITH PROGRESS (v4.1.5)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Submit decode form asynchronously with progress tracking
|
||||
* @param {HTMLFormElement} form - The decode form
|
||||
* @param {HTMLElement} btn - The submit button
|
||||
*/
|
||||
async submitDecodeAsync(form, btn) {
|
||||
const formData = new FormData(form);
|
||||
formData.append('async', 'true');
|
||||
|
||||
// Show progress modal
|
||||
this.showProgressModal('Decoding');
|
||||
|
||||
try {
|
||||
// Start decode job
|
||||
const response = await fetch('/decode', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to start decode');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
const jobId = result.job_id;
|
||||
|
||||
// Poll for progress
|
||||
await this.pollDecodeProgress(jobId);
|
||||
|
||||
} catch (error) {
|
||||
this.hideProgressModal();
|
||||
alert('Decode failed: ' + error.message);
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-unlock-fill me-2"></i>Decode';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Poll decode progress until complete
|
||||
* @param {string} jobId - The job ID
|
||||
*/
|
||||
async pollDecodeProgress(jobId) {
|
||||
const poll = async () => {
|
||||
try {
|
||||
// Check status first
|
||||
const statusResponse = await fetch(`/decode/status/${jobId}`);
|
||||
const statusData = await statusResponse.json();
|
||||
|
||||
if (statusData.status === 'complete') {
|
||||
// Done - redirect to result page
|
||||
this.updateProgress(100, 'Complete!');
|
||||
setTimeout(() => {
|
||||
window.location.href = `/decode/result/${jobId}`;
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusData.status === 'error') {
|
||||
// Handle specific error types
|
||||
const errorType = statusData.error_type;
|
||||
let errorMsg = statusData.error || 'Decode failed';
|
||||
|
||||
if (errorType === 'DecryptionError' || errorMsg.toLowerCase().includes('decrypt')) {
|
||||
errorMsg = 'Wrong credentials. Double-check your reference photo, passphrase, PIN, and channel key.';
|
||||
}
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
// Get progress
|
||||
const progressResponse = await fetch(`/decode/progress/${jobId}`);
|
||||
const progressData = await progressResponse.json();
|
||||
|
||||
const percent = progressData.percent || 0;
|
||||
const phase = progressData.phase || 'processing';
|
||||
|
||||
// Use indeterminate mode for initializing/starting/loading phases
|
||||
const isIndeterminate = (phase === 'initializing' || phase === 'starting' || phase === 'loading');
|
||||
this.updateProgress(percent, this.formatDecodePhase(phase), isIndeterminate);
|
||||
|
||||
// Continue polling
|
||||
setTimeout(poll, 500);
|
||||
|
||||
} catch (error) {
|
||||
this.hideProgressModal();
|
||||
alert(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
await poll();
|
||||
},
|
||||
|
||||
/**
|
||||
* Format decode phase name for display
|
||||
*/
|
||||
formatDecodePhase(phase) {
|
||||
const phases = {
|
||||
'starting': 'Starting...',
|
||||
'initializing': 'Deriving keys (may take a moment)...',
|
||||
'loading': 'Deriving keys (may take a moment)...',
|
||||
'reading': 'Reading image...',
|
||||
'extracting': 'Extracting data...',
|
||||
'decoding': 'Decoding data...',
|
||||
'decrypting': 'Decrypting...',
|
||||
'verifying': 'Verifying...',
|
||||
'finalizing': 'Finalizing...',
|
||||
'complete': 'Complete!',
|
||||
};
|
||||
return phases[phase] || phase;
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// WEBCAM QR SCANNING (v4.1.5)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Active scanner instance
|
||||
*/
|
||||
_qrScanner: null,
|
||||
_qrScannerModal: null,
|
||||
_qrScannerCallback: null,
|
||||
|
||||
/**
|
||||
* Show webcam QR scanner modal
|
||||
* @param {Function} onSuccess - Callback with decoded QR text
|
||||
* @param {string} title - Modal title
|
||||
*/
|
||||
showQrScanner(onSuccess, title = 'Scan QR Code') {
|
||||
this._qrScannerCallback = onSuccess;
|
||||
|
||||
// Create modal if doesn't exist
|
||||
let modal = document.getElementById('qrScannerModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'qrScannerModal';
|
||||
modal.className = 'modal fade';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-dark text-light">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-camera-video me-2"></i>
|
||||
<span id="qrScannerTitle">${title}</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<div id="qrScannerReader" style="width: 100%;"></div>
|
||||
<div id="qrScannerStatus" class="text-center py-3 text-muted">
|
||||
<i class="bi bi-qr-code-scan me-2"></i>
|
||||
Point camera at QR code
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button type="button" class="btn btn-primary" id="qrCaptureBtn">
|
||||
<i class="bi bi-camera me-1"></i>Capture
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Clean up scanner when modal hides
|
||||
modal.addEventListener('hidden.bs.modal', () => {
|
||||
this.stopQrScanner();
|
||||
});
|
||||
|
||||
// Manual capture button
|
||||
modal.querySelector('#qrCaptureBtn')?.addEventListener('click', () => {
|
||||
this.captureQrFrame();
|
||||
});
|
||||
}
|
||||
|
||||
// Update title
|
||||
const titleEl = modal.querySelector('#qrScannerTitle');
|
||||
if (titleEl) titleEl.textContent = title;
|
||||
|
||||
// Reset status
|
||||
const statusEl = modal.querySelector('#qrScannerStatus');
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = '<i class="bi bi-qr-code-scan me-2"></i>Point camera at QR code';
|
||||
statusEl.className = 'text-center py-3 text-muted';
|
||||
}
|
||||
|
||||
// Show modal
|
||||
this._qrScannerModal = new bootstrap.Modal(modal);
|
||||
this._qrScannerModal.show();
|
||||
|
||||
// Start scanner after modal is shown
|
||||
modal.addEventListener('shown.bs.modal', () => {
|
||||
this.startQrScanner();
|
||||
}, { once: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Start the QR scanner
|
||||
*/
|
||||
startQrScanner() {
|
||||
const readerEl = document.getElementById('qrScannerReader');
|
||||
if (!readerEl) return;
|
||||
|
||||
// Check if Html5Qrcode is available
|
||||
if (typeof Html5Qrcode === 'undefined') {
|
||||
console.error('Html5Qrcode library not loaded');
|
||||
const statusEl = document.getElementById('qrScannerStatus');
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = '<i class="bi bi-exclamation-triangle text-warning me-2"></i>QR scanner not available';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._qrScanner = new Html5Qrcode('qrScannerReader');
|
||||
|
||||
const config = {
|
||||
fps: 10,
|
||||
qrbox: { width: 250, height: 250 },
|
||||
aspectRatio: 1.0,
|
||||
};
|
||||
|
||||
this._qrScanner.start(
|
||||
{ facingMode: 'environment' }, // Prefer back camera
|
||||
config,
|
||||
(decodedText, decodedResult) => {
|
||||
// QR code detected
|
||||
this.onQrCodeDetected(decodedText);
|
||||
},
|
||||
(errorMessage) => {
|
||||
// Scan error (ignore, keep scanning)
|
||||
}
|
||||
).catch((err) => {
|
||||
console.error('Failed to start scanner:', err);
|
||||
const statusEl = document.getElementById('qrScannerStatus');
|
||||
if (statusEl) {
|
||||
if (err.toString().includes('Permission')) {
|
||||
statusEl.innerHTML = '<i class="bi bi-camera-video-off text-danger me-2"></i>Camera permission denied';
|
||||
} else {
|
||||
statusEl.innerHTML = '<i class="bi bi-exclamation-triangle text-warning me-2"></i>Could not access camera';
|
||||
}
|
||||
statusEl.className = 'text-center py-3';
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Capture a frame with countdown and try to decode
|
||||
*/
|
||||
captureQrFrame() {
|
||||
const statusEl = document.getElementById('qrScannerStatus');
|
||||
const captureBtn = document.getElementById('qrCaptureBtn');
|
||||
if (!statusEl || !this._qrScanner) return;
|
||||
|
||||
// Disable button during countdown
|
||||
if (captureBtn) captureBtn.disabled = true;
|
||||
|
||||
let count = 3;
|
||||
const countdown = () => {
|
||||
if (count > 0) {
|
||||
statusEl.innerHTML = `<i class="bi bi-camera me-2"></i><span style="font-size: 1.5rem; font-weight: bold;">${count}</span>`;
|
||||
statusEl.className = 'text-center py-3 text-warning';
|
||||
count--;
|
||||
setTimeout(countdown, 1000);
|
||||
} else {
|
||||
// Capture!
|
||||
statusEl.innerHTML = '<i class="bi bi-hourglass-split me-2"></i>Analyzing...';
|
||||
statusEl.className = 'text-center py-3 text-info';
|
||||
|
||||
// Get video element and capture frame
|
||||
const video = document.querySelector('#qrScannerReader video');
|
||||
if (video) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(video, 0, 0);
|
||||
|
||||
// Stop the scanner before file scan (prevents conflicts)
|
||||
const scanner = this._qrScanner;
|
||||
scanner.stop().then(() => {
|
||||
canvas.toBlob((blob) => {
|
||||
const file = new File([blob], 'capture.png', { type: 'image/png' });
|
||||
scanner.scanFile(file, true)
|
||||
.then((decodedText) => {
|
||||
this.onQrCodeDetected(decodedText);
|
||||
})
|
||||
.catch((err) => {
|
||||
statusEl.innerHTML = '<i class="bi bi-x-circle text-danger me-2"></i>No QR code found. Try again.';
|
||||
statusEl.className = 'text-center py-3 text-danger';
|
||||
if (captureBtn) captureBtn.disabled = false;
|
||||
// Restart the scanner
|
||||
this.startQrScanner();
|
||||
});
|
||||
}, 'image/png');
|
||||
}).catch(() => {
|
||||
statusEl.innerHTML = '<i class="bi bi-x-circle text-danger me-2"></i>Scanner error';
|
||||
statusEl.className = 'text-center py-3 text-danger';
|
||||
if (captureBtn) captureBtn.disabled = false;
|
||||
});
|
||||
} else {
|
||||
statusEl.innerHTML = '<i class="bi bi-x-circle text-danger me-2"></i>Camera not ready';
|
||||
statusEl.className = 'text-center py-3 text-danger';
|
||||
if (captureBtn) captureBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
countdown();
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop the QR scanner
|
||||
*/
|
||||
stopQrScanner() {
|
||||
if (this._qrScanner) {
|
||||
this._qrScanner.stop().then(() => {
|
||||
this._qrScanner.clear();
|
||||
this._qrScanner = null;
|
||||
}).catch((err) => {
|
||||
console.log('Scanner stop error:', err);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle detected QR code
|
||||
* @param {string} text - Decoded QR text
|
||||
*/
|
||||
onQrCodeDetected(text) {
|
||||
// Update status
|
||||
const statusEl = document.getElementById('qrScannerStatus');
|
||||
if (statusEl) {
|
||||
statusEl.innerHTML = '<i class="bi bi-check-circle text-success me-2"></i>QR code detected!';
|
||||
statusEl.className = 'text-center py-3 text-success';
|
||||
}
|
||||
|
||||
// Close modal after brief delay
|
||||
setTimeout(() => {
|
||||
this._qrScannerModal?.hide();
|
||||
|
||||
// Call callback
|
||||
if (this._qrScannerCallback) {
|
||||
this._qrScannerCallback(text);
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add camera scan button to an input field
|
||||
* @param {string} inputId - ID of the input field
|
||||
* @param {string} title - Modal title
|
||||
* @param {Function} validator - Optional validation function for scanned text
|
||||
*/
|
||||
addCameraScanButton(inputId, title = 'Scan QR Code', validator = null) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) return;
|
||||
|
||||
// Create button
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-outline-secondary';
|
||||
btn.innerHTML = '<i class="bi bi-camera"></i>';
|
||||
btn.title = 'Scan QR code with camera';
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
this.showQrScanner((text) => {
|
||||
// Validate if validator provided
|
||||
if (validator && !validator(text)) {
|
||||
alert('Invalid QR code format');
|
||||
return;
|
||||
}
|
||||
// Set input value
|
||||
input.value = text;
|
||||
// Trigger input event for formatting
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}, title);
|
||||
});
|
||||
|
||||
// Wrap input in input-group if not already
|
||||
const parent = input.parentElement;
|
||||
if (!parent.classList.contains('input-group')) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'input-group';
|
||||
parent.insertBefore(wrapper, input);
|
||||
wrapper.appendChild(input);
|
||||
wrapper.appendChild(btn);
|
||||
} else {
|
||||
parent.appendChild(btn);
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
@@ -1111,6 +1556,39 @@ const Stegasoo = {
|
||||
generateBtnId: 'channelKeyGenerate'
|
||||
});
|
||||
|
||||
// Webcam QR scanning for channel key (v4.1.5)
|
||||
document.getElementById('channelKeyScan')?.addEventListener('click', () => {
|
||||
this.showQrScanner((text) => {
|
||||
const input = document.getElementById('channelKeyInput');
|
||||
if (input) {
|
||||
const clean = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
|
||||
input.value = clean.length === 32 ? clean.match(/.{4}/g).join('-') : text.toUpperCase();
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}, 'Scan Channel Key');
|
||||
});
|
||||
|
||||
// Webcam QR scanning for RSA key (v4.1.5)
|
||||
document.getElementById('rsaQrWebcam')?.addEventListener('click', () => {
|
||||
this.showQrScanner((text) => {
|
||||
// Check for raw PEM or compressed format (STEGASOO-Z: prefix)
|
||||
const isRawPem = text.includes('-----BEGIN') && text.includes('KEY-----');
|
||||
const isCompressed = text.startsWith('STEGASOO-Z:');
|
||||
if (isRawPem || isCompressed) {
|
||||
// Valid RSA key data scanned
|
||||
document.getElementById('rsaKeyPem').value = text;
|
||||
// Show success in drop zone
|
||||
const dropZone = document.getElementById('qrDropZone');
|
||||
const label = dropZone?.querySelector('.drop-zone-label');
|
||||
if (label) {
|
||||
label.innerHTML = '<i class="bi bi-check-circle text-success fs-4 d-block mb-1"></i><span class="text-success small">RSA Key scanned successfully</span>';
|
||||
}
|
||||
} else {
|
||||
alert('QR code does not contain a valid RSA key');
|
||||
}
|
||||
}, 'Scan RSA Key QR');
|
||||
});
|
||||
|
||||
// Form submission with async progress tracking (v4.1.2)
|
||||
const form = document.getElementById('encodeForm');
|
||||
const btn = document.getElementById('encodeBtn');
|
||||
@@ -1136,7 +1614,7 @@ const Stegasoo = {
|
||||
this.initRsaMethodToggle();
|
||||
this.initDropZones();
|
||||
this.initClipboardPaste(['input[name="stego_image"]', 'input[name="reference_photo"]']);
|
||||
this.initQrCropAnimation('rsaKeyQrInput');
|
||||
this.initQrCropAnimation('rsaQrInput');
|
||||
this.initCollapseChevrons();
|
||||
this.initPassphraseFontResize();
|
||||
|
||||
@@ -1148,28 +1626,56 @@ const Stegasoo = {
|
||||
serverInfoId: 'channelServerInfoDec'
|
||||
});
|
||||
|
||||
// Form submission with channel key validation and mode display
|
||||
// Webcam QR scanning for channel key (v4.1.5)
|
||||
document.getElementById('channelKeyScanDec')?.addEventListener('click', () => {
|
||||
this.showQrScanner((text) => {
|
||||
const input = document.getElementById('channelKeyInputDec');
|
||||
if (input) {
|
||||
const clean = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
|
||||
input.value = clean.length === 32 ? clean.match(/.{4}/g).join('-') : text.toUpperCase();
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}, 'Scan Channel Key');
|
||||
});
|
||||
|
||||
// Webcam QR scanning for RSA key (v4.1.5)
|
||||
document.getElementById('rsaQrWebcam')?.addEventListener('click', () => {
|
||||
this.showQrScanner((text) => {
|
||||
// Check for raw PEM or compressed format (STEGASOO-Z: prefix)
|
||||
const isRawPem = text.includes('-----BEGIN') && text.includes('KEY-----');
|
||||
const isCompressed = text.startsWith('STEGASOO-Z:');
|
||||
if (isRawPem || isCompressed) {
|
||||
// Valid RSA key data scanned
|
||||
document.getElementById('rsaKeyPem').value = text;
|
||||
// Show success in drop zone
|
||||
const dropZone = document.getElementById('qrDropZone');
|
||||
const label = dropZone?.querySelector('.drop-zone-label');
|
||||
if (label) {
|
||||
label.innerHTML = '<i class="bi bi-check-circle text-success fs-4 d-block mb-1"></i><span class="text-success small">RSA Key scanned successfully</span>';
|
||||
}
|
||||
} else {
|
||||
alert('QR code does not contain a valid RSA key');
|
||||
}
|
||||
}, 'Scan RSA Key QR');
|
||||
});
|
||||
|
||||
// Form submission with async progress tracking (v4.1.5)
|
||||
const form = document.getElementById('decodeForm');
|
||||
const btn = document.getElementById('decodeBtn');
|
||||
form?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!this.validateChannelKeyOnSubmit(form, 'channelSelectDec', 'channelKeyInputDec')) {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
const selectedMode = document.querySelector('input[name="embed_mode"]:checked')?.value || 'auto';
|
||||
|
||||
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>Decoding (${selectedMode.toUpperCase()})... ${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.submitDecodeAsync(form, btn);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
--overlay-dark: rgba(0, 0, 0, 0.3);
|
||||
--overlay-light: rgba(255, 255, 255, 0.05);
|
||||
--day-highlight: #E3FF54; /* Bright yellow/green for day of week */
|
||||
--header-gold: #fee862; /* Halfway between light straw and 24k gold */
|
||||
--header-gold: #e5d058; /* Muted gold - less harsh on varied monitors */
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
@@ -91,6 +91,56 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Compact inline mode buttons */
|
||||
.mode-btn.mode-btn-sm {
|
||||
padding: 0.35rem 0.6rem;
|
||||
padding-left: 1.75rem;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 0.375rem;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.mode-btn.mode-btn-sm .form-check-input {
|
||||
left: 8px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.mode-btn.mode-btn-sm i {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Disabled button labels for btn-check groups */
|
||||
.btn-check:disabled + .btn {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
Form Labels - Gold
|
||||
---------------------------------------------------------------------------- */
|
||||
.card .form-label {
|
||||
color: #d9c580;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Dropdown selects - ensure chevron is visible in dark mode */
|
||||
.form-select,
|
||||
select.form-select {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23d9c580' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e") !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-position: right 0.75rem center !important;
|
||||
background-size: 16px 12px !important;
|
||||
padding-right: 2.25rem !important;
|
||||
}
|
||||
|
||||
/* Payload type toggle - gold text when selected */
|
||||
.btn-check:checked + .btn-outline-primary {
|
||||
color: #d9c580 !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
Security Factor Boxes - Matches drop-zone dashed border style
|
||||
---------------------------------------------------------------------------- */
|
||||
@@ -125,6 +175,122 @@ body {
|
||||
.navbar {
|
||||
background: var(--overlay-dark) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 1030; /* Above page content for dropdowns */
|
||||
}
|
||||
|
||||
.navbar > .container {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
/* Ensure navbar dropdown appears above all page content */
|
||||
.navbar .dropdown-menu {
|
||||
z-index: 1031;
|
||||
}
|
||||
|
||||
/* Left-align collapsed navbar menu on mobile */
|
||||
@media (max-width: 991.98px) {
|
||||
.navbar-collapse .navbar-nav {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
Nav Icons - Floating Label on Hover (label floats below, no layout shift)
|
||||
---------------------------------------------------------------------------- */
|
||||
.nav-icons {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-icons .nav-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-expand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-expand i {
|
||||
font-size: 1.15rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Floating label - absolutely positioned below */
|
||||
.nav-expand span {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-4px);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
color: var(--header-gold);
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
|
||||
transition: opacity 0.2s ease,
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 1040;
|
||||
}
|
||||
|
||||
.nav-expand:hover {
|
||||
background: linear-gradient(135deg, rgba(74, 40, 96, 0.25) 0%, rgba(85, 112, 212, 0.2) 100%);
|
||||
box-shadow: 0 0 8px rgba(102, 126, 234, 0.15),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.nav-expand:hover i {
|
||||
color: var(--header-gold);
|
||||
filter: drop-shadow(0 0 4px rgba(254, 232, 98, 0.4));
|
||||
}
|
||||
|
||||
.nav-expand:hover span {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(2px);
|
||||
}
|
||||
|
||||
/* Active state for current page */
|
||||
.nav-expand.active,
|
||||
.nav-item.active .nav-expand {
|
||||
background: linear-gradient(135deg, rgba(74, 40, 96, 0.6) 0%, rgba(85, 112, 212, 0.5) 100%);
|
||||
}
|
||||
|
||||
.nav-expand.active i,
|
||||
.nav-item.active .nav-expand i {
|
||||
color: var(--header-gold);
|
||||
}
|
||||
|
||||
/* Mobile: Always show labels inline in collapsed menu */
|
||||
@media (max-width: 991.98px) {
|
||||
.nav-expand span {
|
||||
position: static;
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.nav-expand:hover span {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.nav-expand:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
@@ -893,36 +1059,36 @@ footer {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Color variants - 60% opacity */
|
||||
/* Color variants - 70% opacity with tighter glow for thin lines */
|
||||
.embed-trace.color-yellow {
|
||||
background: rgba(212, 225, 87, 0.6);
|
||||
box-shadow: 0 0 6px rgba(212, 225, 87, 0.5), 0 0 12px rgba(212, 225, 87, 0.3);
|
||||
background: rgba(212, 225, 87, 0.7);
|
||||
box-shadow: 0 0 3px rgba(212, 225, 87, 0.6), 0 0 6px rgba(212, 225, 87, 0.3);
|
||||
}
|
||||
|
||||
.embed-trace.color-cyan {
|
||||
background: rgba(0, 255, 170, 0.6);
|
||||
box-shadow: 0 0 6px rgba(0, 255, 170, 0.5), 0 0 12px rgba(0, 255, 170, 0.3);
|
||||
background: rgba(0, 255, 170, 0.7);
|
||||
box-shadow: 0 0 3px rgba(0, 255, 170, 0.6), 0 0 6px rgba(0, 255, 170, 0.3);
|
||||
}
|
||||
|
||||
.embed-trace.color-purple {
|
||||
background: rgba(167, 139, 250, 0.6);
|
||||
box-shadow: 0 0 6px rgba(167, 139, 250, 0.5), 0 0 12px rgba(167, 139, 250, 0.3);
|
||||
background: rgba(167, 139, 250, 0.7);
|
||||
box-shadow: 0 0 3px rgba(167, 139, 250, 0.6), 0 0 6px rgba(167, 139, 250, 0.3);
|
||||
}
|
||||
|
||||
.embed-trace.color-blue {
|
||||
background: rgba(102, 126, 234, 0.6);
|
||||
box-shadow: 0 0 6px rgba(102, 126, 234, 0.5), 0 0 12px rgba(102, 126, 234, 0.3);
|
||||
background: rgba(102, 126, 234, 0.7);
|
||||
box-shadow: 0 0 3px rgba(102, 126, 234, 0.6), 0 0 6px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
/* Vertical segments shrink from top */
|
||||
.embed-trace.v {
|
||||
width: 2px;
|
||||
width: 1px;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
/* Horizontal segments shrink from left */
|
||||
.embed-trace.h {
|
||||
height: 2px;
|
||||
height: 1px;
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
@@ -1094,7 +1260,8 @@ footer {
|
||||
---------------------------------------------------------------------------- */
|
||||
#rsaQrSection {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#rsaQrSection .drop-zone {
|
||||
@@ -1699,3 +1866,459 @@ footer {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
TOOLS PAGE - Office-style Ribbon + Two-Panel Layout
|
||||
============================================================================ */
|
||||
|
||||
/* Icon Toolbar Ribbon - Purple/Blue Gradient Theme */
|
||||
.tools-ribbon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
|
||||
border-bottom: 1px solid rgba(139, 92, 246, 0.2);
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tools-ribbon-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tools-ribbon-divider {
|
||||
width: 2px;
|
||||
height: 32px;
|
||||
background: linear-gradient(180deg, rgba(102, 126, 234, 0.4) 0%, rgba(139, 92, 246, 0.4) 100%);
|
||||
margin: 0 0.75rem;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* Tool Icon Buttons */
|
||||
.tool-icon-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 52px;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-icon-btn i {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.tool-icon-btn span {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.tool-icon-btn:hover {
|
||||
background: rgba(255, 230, 150, 0.1);
|
||||
border-color: rgba(255, 230, 150, 0.3);
|
||||
color: var(--header-gold);
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.33);
|
||||
}
|
||||
|
||||
.tool-icon-btn.active {
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.25) 0%, rgba(139, 92, 246, 0.25) 100%);
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
color: #c4b5fd;
|
||||
box-shadow: 0 0 12px rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
|
||||
/* Two-Panel Layout */
|
||||
.tools-panels {
|
||||
display: flex;
|
||||
min-height: 400px;
|
||||
border-radius: 0 0 0.5rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Left Panel - Input/Dropzone */
|
||||
.tools-panel-input {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Tool Mode Banner - bottom of input panel */
|
||||
.tool-mode-banner {
|
||||
margin-top: auto; /* Push to bottom */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(139, 92, 246, 0.2) 100%);
|
||||
border-top: 1px solid rgba(139, 92, 246, 0.2);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tool-mode-type {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 600;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
background: rgba(139, 92, 246, 0.3);
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.tool-mode-banner.mode-analyze .tool-mode-type {
|
||||
background: rgba(72, 187, 120, 0.3);
|
||||
color: #9ae6b4;
|
||||
}
|
||||
|
||||
.tool-mode-banner.mode-transform .tool-mode-type {
|
||||
background: rgba(237, 181, 71, 0.3);
|
||||
color: #fbd38d;
|
||||
}
|
||||
|
||||
.tool-mode-name {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Right Panel - Results */
|
||||
.tools-panel-results {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* Tool Options Row */
|
||||
.tool-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tool-options:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tool-options label {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tool-options .form-select,
|
||||
.tool-options .form-control {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
}
|
||||
|
||||
/* Tool Drop Zone */
|
||||
.tool-dropzone {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool-dropzone input[type="file"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tool-dropzone-label {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.tool-dropzone-label i {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
display: block;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tool-dropzone.drag-over {
|
||||
border-color: #63b3ed;
|
||||
background: rgba(99, 179, 237, 0.1);
|
||||
}
|
||||
|
||||
.tool-dropzone.drag-over .tool-dropzone-label i {
|
||||
color: #63b3ed;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Dropzone with preview */
|
||||
.tool-dropzone.has-file .tool-dropzone-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tool-dropzone-preview {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.tool-dropzone.has-file .tool-dropzone-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tool-dropzone-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 180px;
|
||||
object-fit: contain;
|
||||
border-radius: 0.375rem;
|
||||
border: 2px solid rgba(99, 179, 237, 0.3);
|
||||
}
|
||||
|
||||
/* Rotate preview - smooth transitions for size and transform */
|
||||
#rotateThumb {
|
||||
transition: transform 0.1s ease-out, width 0.1s ease-out, height 0.1s ease-out;
|
||||
}
|
||||
|
||||
/* Rotate image container - fixed height to contain rotated images */
|
||||
.rotate-img-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
/* Rotate file info - separate row below dropzone */
|
||||
.rotate-file-info {
|
||||
text-align: center;
|
||||
padding: 0.5rem 0;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.rotate-file-info .file-name {
|
||||
font-size: 0.85rem;
|
||||
color: #63b3ed;
|
||||
font-weight: 500;
|
||||
}
|
||||
.rotate-file-info .file-meta {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.tool-dropzone-preview .file-name {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #63b3ed;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tool-dropzone-preview .file-meta {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.tool-dropzone-clear {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 20;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tool-dropzone-clear:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Results Panel Content */
|
||||
.tool-results-header {
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tool-results-header h6 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tool-results-header small {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.tool-results-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-results-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tool-results-empty i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Result Items */
|
||||
.tool-result-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.tool-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tool-result-label {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.tool-result-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.tool-result-value.text-primary { color: #63b3ed !important; }
|
||||
.tool-result-value.text-success { color: #48bb78 !important; }
|
||||
.tool-result-value.text-warning { color: #edb547 !important; }
|
||||
|
||||
/* Results Actions */
|
||||
.tool-results-actions {
|
||||
margin-top: auto;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-results-actions .btn {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Tool Section Visibility */
|
||||
.tool-section {
|
||||
display: none;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.tool-section.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* EXIF Table in Results */
|
||||
.tool-exif-table {
|
||||
font-size: 0.8rem;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-exif-table table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-exif-table th,
|
||||
.tool-exif-table td {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.tool-exif-table th {
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: left;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.tool-exif-table td {
|
||||
font-family: 'SF Mono', 'Consolas', monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.tool-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 30;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.tools-panels {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tools-panel-results {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
border-right: none;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tools-ribbon {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tool-icon-btn {
|
||||
width: 48px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.tool-icon-btn span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
5
frontends/web/static/vendor/css/bootstrap-icons.min.css
vendored
Normal file
6
frontends/web/static/vendor/css/bootstrap.min.css
vendored
Normal file
BIN
frontends/web/static/vendor/css/fonts/bootstrap-icons.woff
vendored
Normal file
BIN
frontends/web/static/vendor/css/fonts/bootstrap-icons.woff2
vendored
Normal file
7
frontends/web/static/vendor/js/bootstrap.bundle.min.js
vendored
Normal file
1
frontends/web/static/vendor/js/html5-qrcode.min.js
vendored
Normal file
@@ -3,7 +3,7 @@
|
||||
Stegasoo Subprocess Worker (v4.0.0)
|
||||
|
||||
This script runs in a subprocess and handles encode/decode operations.
|
||||
If it crashes due to jpegio/scipy issues, the parent Flask process survives.
|
||||
If it crashes due to jpeglib/scipy issues, the parent Flask process survives.
|
||||
|
||||
CHANGES in v4.0.0:
|
||||
- Added channel_key support for encode/decode operations
|
||||
@@ -136,14 +136,33 @@ def encode_operation(params: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _write_decode_progress(progress_file: str | None, percent: int, phase: str) -> None:
|
||||
"""Write decode progress to file."""
|
||||
if not progress_file:
|
||||
return
|
||||
try:
|
||||
import json
|
||||
with open(progress_file, "w") as f:
|
||||
json.dump({"percent": percent, "phase": phase}, f)
|
||||
except Exception:
|
||||
pass # Best effort
|
||||
|
||||
|
||||
def decode_operation(params: dict) -> dict:
|
||||
"""Handle decode operation."""
|
||||
from stegasoo import decode
|
||||
|
||||
progress_file = params.get("progress_file")
|
||||
|
||||
# Progress: starting
|
||||
_write_decode_progress(progress_file, 5, "reading")
|
||||
|
||||
# Decode base64 inputs
|
||||
stego_data = base64.b64decode(params["stego_b64"])
|
||||
reference_data = base64.b64decode(params["reference_b64"])
|
||||
|
||||
_write_decode_progress(progress_file, 15, "reading")
|
||||
|
||||
# Optional RSA key
|
||||
rsa_key_data = None
|
||||
if params.get("rsa_key_b64"):
|
||||
@@ -152,6 +171,7 @@ def decode_operation(params: dict) -> dict:
|
||||
# Resolve channel key (v4.0.0)
|
||||
resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto"))
|
||||
|
||||
# Library handles progress internally via progress_file parameter
|
||||
# Call decode with correct parameter names
|
||||
result = decode(
|
||||
stego_image=stego_data,
|
||||
@@ -162,7 +182,9 @@ def decode_operation(params: dict) -> dict:
|
||||
rsa_password=params.get("rsa_password"),
|
||||
embed_mode=params.get("embed_mode", "auto"),
|
||||
channel_key=resolved_channel_key, # v4.0.0
|
||||
progress_file=progress_file, # v4.2.0: pass through for real-time progress
|
||||
)
|
||||
# Library writes 100% "complete" - no need for worker to write again
|
||||
|
||||
if result.is_file:
|
||||
return {
|
||||
|
||||
@@ -132,7 +132,7 @@ class SubprocessStego:
|
||||
"""
|
||||
Subprocess-isolated steganography operations.
|
||||
|
||||
All operations run in a separate Python process. If jpegio or scipy
|
||||
All operations run in a separate Python process. If jpeglib or scipy
|
||||
crashes, only the subprocess dies - Flask keeps running.
|
||||
"""
|
||||
|
||||
@@ -314,6 +314,8 @@ class SubprocessStego:
|
||||
# Channel key (v4.0.0)
|
||||
channel_key: str | None = "auto",
|
||||
timeout: int | None = None,
|
||||
# Progress tracking (v4.1.5)
|
||||
progress_file: str | None = None,
|
||||
) -> DecodeResult:
|
||||
"""
|
||||
Decode a message or file from a stego image.
|
||||
@@ -328,6 +330,7 @@ class SubprocessStego:
|
||||
embed_mode: 'auto', 'lsb', or 'dct'
|
||||
channel_key: 'auto' (server config), 'none' (public), or explicit key (v4.0.0)
|
||||
timeout: Operation timeout in seconds
|
||||
progress_file: Path to write progress updates (v4.1.5)
|
||||
|
||||
Returns:
|
||||
DecodeResult with message or file_data on success
|
||||
@@ -340,6 +343,7 @@ class SubprocessStego:
|
||||
"pin": pin,
|
||||
"embed_mode": embed_mode,
|
||||
"channel_key": channel_key, # v4.0.0
|
||||
"progress_file": progress_file, # v4.1.5
|
||||
}
|
||||
|
||||
if rsa_key_data:
|
||||
|
||||
212
frontends/web/temp_storage.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
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 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),
|
||||
}
|
||||
@@ -271,8 +271,7 @@
|
||||
<div class="card-body">
|
||||
<p class="small mb-2">Uses server-configured key if available, otherwise public mode.</p>
|
||||
<ul class="small mb-0">
|
||||
<li>Set via <code>STEGASOO_CHANNEL_KEY</code> env var</li>
|
||||
<li>Or <code>channel_key</code> in config file</li>
|
||||
<li>Server admin configures the shared key</li>
|
||||
<li>All users share the same channel</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -317,55 +316,18 @@
|
||||
</div>
|
||||
|
||||
{% if channel_configured %}
|
||||
<div class="alert alert-success mt-3 mb-3">
|
||||
<div class="alert alert-success mt-3 mb-0">
|
||||
<i class="bi bi-shield-lock me-2"></i>
|
||||
<strong>This server has a channel key configured:</strong>
|
||||
<code class="ms-2">{{ channel_fingerprint }}</code>
|
||||
<span class="text-muted ms-2">({{ channel_source }})</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info mt-3 mb-3">
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
This server is running in <strong>public mode</strong>.
|
||||
Set <code>STEGASOO_CHANNEL_KEY</code> to enable server-wide channel isolation.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Channel Key QR Generator -->
|
||||
<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
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted mb-3">Generate a QR code to share a channel key with others.</p>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label small">Channel Key</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace" id="channelKeyQrInput"
|
||||
placeholder="Enter or generate a key">
|
||||
<button class="btn btn-outline-secondary" type="button" id="channelKeyQrGenerate"
|
||||
title="Generate random key">
|
||||
<i class="bi bi-shuffle"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button class="btn btn-primary w-100" type="button" id="channelKeyQrShow">
|
||||
<i class="bi bi-qr-code me-1"></i>Show QR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-3 d-none" id="channelKeyQrContainer">
|
||||
<canvas id="channelKeyQrCanvas" class="bg-white p-2 rounded"></canvas>
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" id="channelKeyQrDownload">
|
||||
<i class="bi bi-download me-1"></i>Download PNG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -378,11 +340,13 @@
|
||||
<!-- 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>
|
||||
<span class="badge bg-success fs-6 me-3">v4.2.0</span>
|
||||
<div>
|
||||
<strong>Progress bars</strong> for encode operations,
|
||||
<strong>mobile-responsive polish</strong>,
|
||||
DCT decode bug fix, release validation script
|
||||
<strong>Performance optimizations:</strong>
|
||||
~70% faster decode (vectorized DCT),
|
||||
50% less RAM (float32),
|
||||
async API endpoints,
|
||||
decode progress callbacks
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -400,6 +364,10 @@
|
||||
<div class="accordion-body p-0">
|
||||
<table class="table table-dark table-sm small mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="80"><strong>4.1.7</strong></td>
|
||||
<td>Progress bars for encode, mobile polish, release validation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="80"><strong>4.1.1</strong></td>
|
||||
<td>DCT RS format stability, Docker cleanup, first-boot wizard</td>
|
||||
@@ -597,7 +565,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-clock me-2"></i>File expiry</td>
|
||||
<td><strong>5 min</strong></td>
|
||||
<td><strong>10 min</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-key me-2"></i>PIN</td>
|
||||
@@ -605,7 +573,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-shield me-2"></i>RSA keys</td>
|
||||
<td><strong>2048, 3072, 4096 bit</strong></td>
|
||||
<td><strong>2048, 3072 bit</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-chat-quote me-2"></i>Passphrase</td>
|
||||
@@ -632,62 +600,3 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- QR Code library for channel key sharing -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const input = document.getElementById('channelKeyQrInput');
|
||||
const generateBtn = document.getElementById('channelKeyQrGenerate');
|
||||
const showBtn = document.getElementById('channelKeyQrShow');
|
||||
const container = document.getElementById('channelKeyQrContainer');
|
||||
const canvas = document.getElementById('channelKeyQrCanvas');
|
||||
const downloadBtn = document.getElementById('channelKeyQrDownload');
|
||||
|
||||
// Generate random key
|
||||
generateBtn?.addEventListener('click', function() {
|
||||
if (input && typeof Stegasoo !== 'undefined') {
|
||||
input.value = Stegasoo.generateChannelKey();
|
||||
}
|
||||
});
|
||||
|
||||
// Show QR code
|
||||
showBtn?.addEventListener('click', function() {
|
||||
const key = input?.value?.trim().replace(/-/g, '');
|
||||
if (!key || key.length !== 32) {
|
||||
alert('Please enter a valid 32-character channel key');
|
||||
return;
|
||||
}
|
||||
|
||||
// Format key with dashes for QR
|
||||
const formatted = key.match(/.{4}/g)?.join('-') || key;
|
||||
|
||||
// Generate QR code
|
||||
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;
|
||||
}
|
||||
container?.classList.remove('d-none');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Download QR as PNG
|
||||
downloadBtn?.addEventListener('click', function() {
|
||||
if (canvas) {
|
||||
const link = document.createElement('a');
|
||||
link.download = 'stegasoo-channel-key.png';
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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">
|
||||
@@ -170,9 +177,16 @@
|
||||
placeholder="Key name" required maxlength="50">
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<input type="text" name="channel_key" class="form-control form-control-sm font-monospace"
|
||||
placeholder="Channel key (32 hex chars)" required
|
||||
pattern="[0-9a-fA-F\-]{32,39}" title="32 hex characters">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" name="channel_key" id="channelKeyInput"
|
||||
class="form-control font-monospace"
|
||||
placeholder="XXXX-XXXX-..." required
|
||||
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}">
|
||||
<button type="button" class="btn btn-outline-secondary" id="scanChannelKeyBtn"
|
||||
title="Scan QR code with camera">
|
||||
<i class="bi bi-camera"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">
|
||||
@@ -218,17 +232,209 @@
|
||||
</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
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="qrPrint">
|
||||
<i class="bi bi-printer me-1"></i>Print Sheet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||
{% if is_admin %}
|
||||
<script src="{{ url_for('static', filename='js/qrcode.min.js') }}"></script>
|
||||
{% endif %}
|
||||
<script>
|
||||
StegasooAuth.initPasswordConfirmation('accountForm', 'newPasswordInput', 'newPasswordConfirmInput');
|
||||
|
||||
// Webcam QR scanning for channel key input (v4.1.5)
|
||||
document.getElementById('scanChannelKeyBtn')?.addEventListener('click', function() {
|
||||
Stegasoo.showQrScanner((text) => {
|
||||
const input = document.getElementById('channelKeyInput');
|
||||
if (input) {
|
||||
// Clean and format the key
|
||||
const clean = text.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
|
||||
if (clean.length === 32) {
|
||||
input.value = clean.match(/.{4}/g).join('-');
|
||||
} else {
|
||||
input.value = text.toUpperCase();
|
||||
}
|
||||
}
|
||||
}, 'Scan Channel Key');
|
||||
});
|
||||
|
||||
// Format channel key input as user types
|
||||
document.getElementById('channelKeyInput')?.addEventListener('input', function() {
|
||||
Stegasoo.formatChannelKeyInput(this);
|
||||
});
|
||||
|
||||
function renameKey(keyId, currentName) {
|
||||
document.getElementById('renameInput').value = 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 using QRious
|
||||
const canvas = document.getElementById('qrCanvas');
|
||||
if (typeof QRious !== 'undefined' && canvas) {
|
||||
try {
|
||||
new QRious({
|
||||
element: canvas,
|
||||
value: formatted,
|
||||
size: 200,
|
||||
level: 'M'
|
||||
});
|
||||
new bootstrap.Modal(document.getElementById('qrModal')).show();
|
||||
} catch (error) {
|
||||
console.error('QR generation error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
// Print tiled QR sheet (US Letter)
|
||||
document.getElementById('qrPrint')?.addEventListener('click', function() {
|
||||
const canvas = document.getElementById('qrCanvas');
|
||||
const keyText = document.getElementById('qrKeyDisplay').textContent;
|
||||
const keyName = document.getElementById('qrKeyName').textContent;
|
||||
if (canvas && keyText) {
|
||||
printQrSheet(canvas, keyText, keyName);
|
||||
}
|
||||
});
|
||||
|
||||
// Print QR codes tiled on US Letter paper (8.5" x 11")
|
||||
function printQrSheet(canvas, keyText, title) {
|
||||
const qrDataUrl = canvas.toDataURL('image/png');
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) {
|
||||
alert('Please allow popups to print');
|
||||
return;
|
||||
}
|
||||
|
||||
// US Letter: 8.5" x 11" - create 4x5 grid of QR codes
|
||||
const cols = 4;
|
||||
const rows = 5;
|
||||
|
||||
// Split key into two lines (4 groups each)
|
||||
const keyParts = keyText.split('-');
|
||||
const keyLine1 = keyParts.slice(0, 4).join('-');
|
||||
const keyLine2 = keyParts.slice(4).join('-');
|
||||
|
||||
let qrGrid = '';
|
||||
for (let i = 0; i < rows * cols; i++) {
|
||||
qrGrid += `
|
||||
<div class="qr-tile">
|
||||
<div class="key-text">${keyLine1}</div>
|
||||
<img src="${qrDataUrl}" alt="QR">
|
||||
<div class="key-text">${keyLine2}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title></title>
|
||||
<style>
|
||||
@page {
|
||||
size: letter;
|
||||
margin: 0.2in;
|
||||
margin-top: 0.1in;
|
||||
margin-bottom: 0.1in;
|
||||
}
|
||||
@media print {
|
||||
@page { margin: 0.15in; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: white;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(${cols}, 1fr);
|
||||
gap: 0;
|
||||
margin-top: 0.09in;
|
||||
}
|
||||
.qr-tile {
|
||||
border: 1px dashed #ccc;
|
||||
padding: 0.04in;
|
||||
text-align: center;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.qr-tile img {
|
||||
width: 1.6in;
|
||||
height: 1.6in;
|
||||
}
|
||||
.key-text {
|
||||
font-size: 10pt;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.footer {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">${qrGrid}</div>
|
||||
<div class="footer">Cut along dashed lines</div>
|
||||
<script>
|
||||
window.onload = function() { window.print(); };
|
||||
<\/script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
}
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
506
frontends/web/templates/admin/settings.html
Normal file
@@ -0,0 +1,506 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}System Settings - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<!-- Channel Key Configuration -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-broadcast me-2"></i>Channel Key Configuration</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if channel_configured %}
|
||||
<div class="alert alert-success mb-4">
|
||||
<i class="bi bi-shield-lock me-2"></i>
|
||||
<strong>Server channel key active:</strong>
|
||||
<code class="ms-2">{{ channel_fingerprint }}</code>
|
||||
<span class="text-muted ms-2">({{ channel_source }})</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info mb-4">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Server running in <strong>public mode</strong>.
|
||||
Set <code>STEGASOO_CHANNEL_KEY</code> environment variable to enable server-wide channel isolation.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- QR Code Generator -->
|
||||
<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
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted mb-3">Generate a QR code to share a channel key with others.</p>
|
||||
|
||||
<!-- Locked state - requires password -->
|
||||
<div id="channelKeyLocked">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label small">Channel Key</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control font-monospace"
|
||||
value="********************************" disabled>
|
||||
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button class="btn btn-warning w-100" type="button" id="channelKeyUnlock">
|
||||
<i class="bi bi-unlock me-1"></i>Unlock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block">
|
||||
<i class="bi bi-shield-lock me-1"></i>Re-enter your password to view or export the channel key.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Unlocked state - shows key and QR options -->
|
||||
<div id="channelKeyUnlocked" style="display: none;">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label small">Channel Key</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace" id="channelKeyQrInput"
|
||||
placeholder="Enter or generate a key">
|
||||
<button class="btn btn-outline-secondary" type="button" id="channelKeyQrGenerate"
|
||||
title="Generate random key">
|
||||
<i class="bi bi-shuffle"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button class="btn btn-primary w-100" type="button" id="channelKeyQrShow">
|
||||
<i class="bi bi-qr-code me-1"></i>Show QR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-success mt-2 d-block">
|
||||
<i class="bi bi-unlock me-1"></i>Unlocked for this session.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Configuration -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-gear me-2"></i>Server Configuration</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<table class="table table-dark table-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><i class="bi bi-hdd-network me-2"></i>Hostname</td>
|
||||
<td><code>{{ hostname }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-ethernet me-2"></i>Port</td>
|
||||
<td><code>{{ port }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-shield-lock me-2"></i>HTTPS</td>
|
||||
<td>
|
||||
{% if https_enabled %}
|
||||
<span class="badge bg-success"><i class="bi bi-lock me-1"></i>Enabled</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-unlock me-1"></i>Disabled</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-person-lock me-2"></i>Authentication</td>
|
||||
<td>
|
||||
{% if auth_enabled %}
|
||||
<span class="badge bg-success"><i class="bi bi-check me-1"></i>Enabled</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger"><i class="bi bi-x me-1"></i>Disabled</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<table class="table table-dark table-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><i class="bi bi-file-earmark me-2"></i>Max Payload</td>
|
||||
<td><code>{{ max_payload_kb }} KB</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-upload me-2"></i>Max Upload</td>
|
||||
<td><code>{{ max_upload_mb }} MB</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-soundwave me-2"></i>DCT Mode</td>
|
||||
<td>
|
||||
{% if dct_available %}
|
||||
<span class="badge bg-success"><i class="bi bi-check me-1"></i>Available</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Not Available</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><i class="bi bi-qr-code me-2"></i>QR Support</td>
|
||||
<td>
|
||||
{% if qr_available %}
|
||||
<span class="badge bg-success"><i class="bi bi-check me-1"></i>Available</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Not Available</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-secondary small mt-3 mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
To change server settings, edit environment variables or config file and restart the service.
|
||||
<br>See <code>STEGASOO_HTTPS_ENABLED</code>, <code>STEGASOO_PORT</code>, <code>STEGASOO_CHANNEL_KEY</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment Info -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Environment</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-6 col-md-3 mb-3">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-box text-primary fs-3 d-block mb-2"></i>
|
||||
<div class="small text-muted">Version</div>
|
||||
<strong>{{ version }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 mb-3">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-terminal text-info fs-3 d-block mb-2"></i>
|
||||
<div class="small text-muted">Python</div>
|
||||
<strong>{{ python_version }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 mb-3">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-cpu text-warning fs-3 d-block mb-2"></i>
|
||||
<div class="small text-muted">Platform</div>
|
||||
<strong>{{ platform }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 mb-3">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-shield-check text-success fs-3 d-block mb-2"></i>
|
||||
<div class="small text-muted">KDF</div>
|
||||
<strong>{{ kdf_type }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Verification Modal -->
|
||||
<div class="modal fade" id="passwordModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title"><i class="bi bi-shield-lock me-2"></i>Verify Password</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="small text-muted mb-3">Re-enter your password to access sensitive data.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Password</label>
|
||||
<input type="password" class="form-control" id="verifyPassword" autocomplete="current-password">
|
||||
<div class="invalid-feedback" id="passwordError">Incorrect password</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-warning btn-sm" id="verifyPasswordBtn">
|
||||
<i class="bi bi-unlock me-1"></i>Unlock
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Modal -->
|
||||
<div class="modal fade" id="channelKeyQrModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title"><i class="bi bi-qr-code me-2"></i>Channel Key</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<canvas id="channelKeyQrCanvas" class="bg-white p-2 rounded"></canvas>
|
||||
<div class="mt-2">
|
||||
<code class="small" id="channelKeyQrDisplay"></code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-center">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="channelKeyQrDownload">
|
||||
<i class="bi bi-download me-1"></i>Download
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="channelKeyQrPrint">
|
||||
<i class="bi bi-printer me-1"></i>Print Sheet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/qrcode.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const input = document.getElementById('channelKeyQrInput');
|
||||
const generateBtn = document.getElementById('channelKeyQrGenerate');
|
||||
const showBtn = document.getElementById('channelKeyQrShow');
|
||||
const canvas = document.getElementById('channelKeyQrCanvas');
|
||||
const displayEl = document.getElementById('channelKeyQrDisplay');
|
||||
const downloadBtn = document.getElementById('channelKeyQrDownload');
|
||||
const modalEl = document.getElementById('channelKeyQrModal');
|
||||
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
||||
|
||||
// Password verification elements
|
||||
const lockedDiv = document.getElementById('channelKeyLocked');
|
||||
const unlockedDiv = document.getElementById('channelKeyUnlocked');
|
||||
const unlockBtn = document.getElementById('channelKeyUnlock');
|
||||
const passwordModalEl = document.getElementById('passwordModal');
|
||||
const passwordModal = passwordModalEl ? new bootstrap.Modal(passwordModalEl) : null;
|
||||
const verifyPasswordInput = document.getElementById('verifyPassword');
|
||||
const verifyPasswordBtn = document.getElementById('verifyPasswordBtn');
|
||||
const passwordError = document.getElementById('passwordError');
|
||||
|
||||
// Unlock button shows password modal
|
||||
unlockBtn?.addEventListener('click', function() {
|
||||
verifyPasswordInput.value = '';
|
||||
verifyPasswordInput.classList.remove('is-invalid');
|
||||
passwordModal?.show();
|
||||
setTimeout(() => verifyPasswordInput.focus(), 300);
|
||||
});
|
||||
|
||||
// Handle Enter key in password field
|
||||
verifyPasswordInput?.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
verifyPasswordBtn?.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Verify password and unlock
|
||||
verifyPasswordBtn?.addEventListener('click', async function() {
|
||||
const password = verifyPasswordInput.value;
|
||||
if (!password) {
|
||||
verifyPasswordInput.classList.add('is-invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
verifyPasswordBtn.disabled = true;
|
||||
verifyPasswordBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Verifying...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/settings/unlock', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Unlock successful
|
||||
passwordModal?.hide();
|
||||
lockedDiv.style.display = 'none';
|
||||
unlockedDiv.style.display = 'block';
|
||||
if (data.channel_key && input) {
|
||||
input.value = data.channel_key;
|
||||
}
|
||||
} else {
|
||||
// Password incorrect
|
||||
verifyPasswordInput.classList.add('is-invalid');
|
||||
passwordError.textContent = data.error || 'Incorrect password';
|
||||
}
|
||||
} catch (error) {
|
||||
verifyPasswordInput.classList.add('is-invalid');
|
||||
passwordError.textContent = 'Verification failed';
|
||||
} finally {
|
||||
verifyPasswordBtn.disabled = false;
|
||||
verifyPasswordBtn.innerHTML = '<i class="bi bi-unlock me-1"></i>Unlock';
|
||||
}
|
||||
});
|
||||
|
||||
// Generate random key
|
||||
generateBtn?.addEventListener('click', function() {
|
||||
if (!input) return;
|
||||
if (typeof Stegasoo !== 'undefined' && Stegasoo.generateChannelKey) {
|
||||
input.value = Stegasoo.generateChannelKey();
|
||||
} else {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let key = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (i > 0) key += '-';
|
||||
for (let j = 0; j < 4; j++) {
|
||||
key += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
}
|
||||
input.value = key;
|
||||
}
|
||||
});
|
||||
|
||||
// Show QR code in modal
|
||||
showBtn?.addEventListener('click', function() {
|
||||
const key = input?.value?.trim().replace(/-/g, '');
|
||||
if (!key || key.length !== 32) {
|
||||
alert('Please enter a valid 32-character channel key');
|
||||
return;
|
||||
}
|
||||
|
||||
const formatted = key.match(/.{4}/g)?.join('-') || key;
|
||||
|
||||
if (typeof QRious === 'undefined') {
|
||||
alert('QR Code library failed to load.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new QRious({
|
||||
element: canvas,
|
||||
value: formatted,
|
||||
size: 200,
|
||||
level: 'M'
|
||||
});
|
||||
if (displayEl) displayEl.textContent = formatted;
|
||||
modal?.show();
|
||||
} catch (error) {
|
||||
alert('Failed to generate QR code: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Download QR as PNG
|
||||
downloadBtn?.addEventListener('click', function() {
|
||||
if (canvas) {
|
||||
const link = document.createElement('a');
|
||||
link.download = 'stegasoo-channel-key.png';
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Print tiled QR sheet (US Letter)
|
||||
document.getElementById('channelKeyQrPrint')?.addEventListener('click', function() {
|
||||
if (canvas && displayEl) {
|
||||
printQrSheet(canvas, displayEl.textContent, 'Channel Key');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Print QR codes tiled on US Letter paper (8.5" x 11")
|
||||
function printQrSheet(canvas, keyText, title) {
|
||||
const qrDataUrl = canvas.toDataURL('image/png');
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) {
|
||||
alert('Please allow popups to print');
|
||||
return;
|
||||
}
|
||||
|
||||
// US Letter: 8.5" x 11" - create 4x5 grid of QR codes
|
||||
const cols = 4;
|
||||
const rows = 5;
|
||||
|
||||
// Split key into two lines (4 groups each)
|
||||
const keyParts = keyText.split('-');
|
||||
const keyLine1 = keyParts.slice(0, 4).join('-');
|
||||
const keyLine2 = keyParts.slice(4).join('-');
|
||||
|
||||
let qrGrid = '';
|
||||
for (let i = 0; i < rows * cols; i++) {
|
||||
qrGrid += `
|
||||
<div class="qr-tile">
|
||||
<div class="key-text">${keyLine1}</div>
|
||||
<img src="${qrDataUrl}" alt="QR">
|
||||
<div class="key-text">${keyLine2}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title></title>
|
||||
<style>
|
||||
@page {
|
||||
size: letter;
|
||||
margin: 0.2in;
|
||||
margin-top: 0.1in;
|
||||
margin-bottom: 0.1in;
|
||||
}
|
||||
@media print {
|
||||
@page { margin: 0.15in; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: white;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(${cols}, 1fr);
|
||||
gap: 0;
|
||||
margin-top: 0.09in;
|
||||
}
|
||||
.qr-tile {
|
||||
border: 1px dashed #ccc;
|
||||
padding: 0.04in;
|
||||
text-align: center;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.qr-tile img {
|
||||
width: 1.6in;
|
||||
height: 1.6in;
|
||||
}
|
||||
.key-text {
|
||||
font-size: 10pt;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.footer {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">${qrGrid}</div>
|
||||
<div class="footer">Cut along dashed lines</div>
|
||||
<script>
|
||||
window.onload = function() { window.print(); };
|
||||
<\/script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -5,44 +5,46 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Stegasoo{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='vendor/css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='vendor/css/bootstrap-icons.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='style.css') }}" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand d-flex align-items-center" href="/">
|
||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="36" class="me-2">
|
||||
<span style="position: relative; display: inline-block; margin-top: -14px;">
|
||||
<span class="fw-bold title-gold">Stegasoo</span>
|
||||
<span class="badge bg-success" style="position: absolute; font-size: 0.45rem; bottom: -8px; right: 6px;">v4.1</span>
|
||||
</span>
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/" style="padding-left: 6px; margin-right: 8px;">
|
||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="28">
|
||||
</a>
|
||||
{% if channel_configured %}
|
||||
<span class="badge bg-success bg-opacity-25 small" style="padding-left: 0.35rem;" title="Private Channel: {{ channel_fingerprint }}">
|
||||
<i class="bi bi-shield-lock me-2" style="color: #6ee7b7;"></i><code style="font-size: 0.7rem; font-weight: 300; color: #c9a860;">{{ channel_fingerprint[:4] }}-••••-{{ channel_fingerprint[-4:] }}</code>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary bg-opacity-25 small text-muted" style="padding-left: 0.35rem;" title="Public Channel: No shared channel key configured. Messages use only passphrase and PIN for encryption.">
|
||||
<i class="bi bi-globe me-1"></i>Public Channel
|
||||
</span>
|
||||
{% endif %}
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<ul class="navbar-nav ms-auto nav-icons">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/"><i class="bi bi-house me-1"></i> Home</a>
|
||||
<a class="nav-link nav-expand" href="/"><i class="bi bi-house"></i><span>Home</span></a>
|
||||
</li>
|
||||
{% if not auth_enabled or is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/encode"><i class="bi bi-lock me-1"></i> Encode</a>
|
||||
<a class="nav-link nav-expand" href="/encode"><i class="bi bi-lock"></i><span>Encode</span></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/decode"><i class="bi bi-unlock me-1"></i> Decode</a>
|
||||
<a class="nav-link nav-expand" href="/decode"><i class="bi bi-unlock"></i><span>Decode</span></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/generate"><i class="bi bi-key me-1"></i> Generate</a>
|
||||
<a class="nav-link nav-expand" href="/generate"><i class="bi bi-key"></i><span>Generate</span></a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/about"><i class="bi bi-info-circle me-1"></i> About</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/tools"><i class="bi bi-tools me-1"></i> Tools</a>
|
||||
<a class="nav-link nav-expand" href="/tools"><i class="bi bi-tools"></i><span>Tools</span></a>
|
||||
</li>
|
||||
{% if auth_enabled %}
|
||||
{% if is_authenticated %}
|
||||
@@ -54,6 +56,7 @@
|
||||
<li><a class="dropdown-item" href="/account"><i class="bi bi-gear me-2"></i>Account</a></li>
|
||||
{% if is_admin %}
|
||||
<li><a class="dropdown-item" href="/admin/users"><i class="bi bi-people me-2"></i>Users</a></li>
|
||||
<li><a class="dropdown-item" href="/admin/settings"><i class="bi bi-sliders me-2"></i>System Settings</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-left me-2"></i>Logout</a></li>
|
||||
@@ -96,11 +99,15 @@
|
||||
<small>
|
||||
<img src="{{ url_for('static', filename='favicon.svg') }}" alt="" height="16" class="me-1" style="vertical-align: text-bottom;">
|
||||
Stegasoo v{{ version }} — Steganography with Reference Photo + Passphrase + PIN/Key
|
||||
<span class="mx-2">|</span>
|
||||
<a href="/about" class="text-muted text-decoration-none"><i class="bi bi-info-circle me-1"></i>About</a>
|
||||
</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='vendor/js/bootstrap.bundle.min.js') }}"></script>
|
||||
<!-- QR Code scanning library (local) -->
|
||||
<script src="{{ url_for('static', filename='vendor/js/html5-qrcode.min.js') }}"></script>
|
||||
<script>
|
||||
// Initialize toasts (auto-hide after delay)
|
||||
document.querySelectorAll('.toast').forEach(el => new bootstrap.Toast(el));
|
||||
|
||||
@@ -4,6 +4,79 @@
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
/* Accordion styling */
|
||||
.step-accordion .accordion-button {
|
||||
background: rgba(35, 45, 55, 0.8);
|
||||
color: #fff;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 3px solid rgba(255, 230, 153, 0.3);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.step-accordion .accordion-button:hover {
|
||||
background: rgba(45, 55, 65, 0.9);
|
||||
border-left-color: rgba(255, 230, 153, 0.5);
|
||||
}
|
||||
.step-accordion .accordion-button:not(.collapsed) {
|
||||
background: linear-gradient(90deg, rgba(255, 230, 153, 0.12) 0%, rgba(40, 50, 60, 0.85) 40%, rgba(40, 50, 60, 0.85) 100%);
|
||||
color: #fff;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 230, 153, 0.1);
|
||||
border-left: 3px solid #ffe699;
|
||||
}
|
||||
.step-accordion .accordion-button::after {
|
||||
filter: invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(1.2);
|
||||
}
|
||||
.step-accordion .accordion-body {
|
||||
background: rgba(30, 40, 50, 0.4);
|
||||
padding: 1rem;
|
||||
}
|
||||
.step-accordion .accordion-item {
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
background: transparent;
|
||||
}
|
||||
.step-accordion .accordion-item:first-child .accordion-button {
|
||||
border-radius: 0;
|
||||
}
|
||||
.step-accordion .accordion-item:last-child .accordion-button.collapsed {
|
||||
border-radius: 0;
|
||||
}
|
||||
.step-summary {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255,255,255,0.5);
|
||||
margin-left: auto;
|
||||
padding-right: 1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 50%;
|
||||
}
|
||||
.step-summary.has-content {
|
||||
color: rgba(99, 179, 237, 0.8);
|
||||
}
|
||||
.step-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.step-number {
|
||||
background: rgba(246, 173, 85, 0.2);
|
||||
color: #f6ad55;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
border: 1px solid rgba(246, 173, 85, 0.3);
|
||||
}
|
||||
.step-number.complete {
|
||||
background: rgba(72, 187, 120, 0.2);
|
||||
color: #48bb78;
|
||||
border-color: rgba(72, 187, 120, 0.3);
|
||||
}
|
||||
|
||||
/* Glowing passphrase input */
|
||||
.passphrase-input {
|
||||
background: rgba(30, 40, 50, 0.8) !important;
|
||||
@@ -13,20 +86,17 @@
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 12px 16px;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease, background 0.3s ease;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.passphrase-input:focus {
|
||||
border-color: rgba(99, 179, 237, 0.8) !important;
|
||||
box-shadow: 0 0 20px rgba(99, 179, 237, 0.4), 0 0 40px rgba(99, 179, 237, 0.2) !important;
|
||||
background: rgba(30, 40, 50, 0.95) !important;
|
||||
box-shadow: 0 0 20px rgba(99, 179, 237, 0.4) !important;
|
||||
}
|
||||
|
||||
.passphrase-input::placeholder {
|
||||
color: rgba(99, 179, 237, 0.4);
|
||||
}
|
||||
|
||||
/* Glowing PIN input */
|
||||
/* PIN input */
|
||||
.pin-input-container .form-control {
|
||||
background: rgba(30, 40, 50, 0.8) !important;
|
||||
border: 2px solid rgba(246, 173, 85, 0.3) !important;
|
||||
@@ -35,76 +105,13 @@
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 3px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.pin-input-container .form-control:focus {
|
||||
border-color: rgba(246, 173, 85, 0.8) !important;
|
||||
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4), 0 0 40px rgba(246, 173, 85, 0.2) !important;
|
||||
background: rgba(30, 40, 50, 0.95) !important;
|
||||
box-shadow: 0 0 20px rgba(246, 173, 85, 0.4) !important;
|
||||
}
|
||||
|
||||
.pin-input-container .form-control::placeholder {
|
||||
color: rgba(246, 173, 85, 0.4);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* QR Crop Animation */
|
||||
.qr-crop-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.qr-crop-container img {
|
||||
display: block;
|
||||
max-height: 180px;
|
||||
max-width: 180px;
|
||||
width: auto;
|
||||
margin: 0 auto;
|
||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.qr-crop-container .qr-original {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.qr-crop-container .qr-cropped {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0.3);
|
||||
opacity: 0;
|
||||
max-height: 160px;
|
||||
min-width: 140px;
|
||||
min-height: 140px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.qr-crop-container.scan-complete .qr-original {
|
||||
opacity: 0;
|
||||
transform: scale(1.1);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
.qr-crop-container.scan-complete .qr-cropped {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
.qr-crop-container .crop-badge {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
font-size: 0.65rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease 0.4s;
|
||||
}
|
||||
|
||||
.qr-crop-container.scan-complete .crop-badge {
|
||||
opacity: 1;
|
||||
}
|
||||
/* QR Crop Animation - uses .qr-scan-container from style.css */
|
||||
</style>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
@@ -113,13 +120,13 @@
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-unlock-fill me-2"></i>Decode Secret Message or File</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-body {% if not decoded_message and not decoded_file %}p-0{% endif %}">
|
||||
{% if decoded_message %}
|
||||
<!-- Text Message Result -->
|
||||
<div class="alert alert-success">
|
||||
<h6><i class="bi bi-check-circle me-2"></i>Message Decrypted Successfully!</h6>
|
||||
</div>
|
||||
|
||||
|
||||
<label class="form-label text-muted">Decoded Message: <small class="text-secondary">(click to copy)</small></label>
|
||||
<div class="alert-message p-3 rounded bg-dark border border-secondary mb-3" id="decodedContent" style="white-space: pre-wrap; cursor: pointer; transition: border-color 0.2s;"
|
||||
onclick="navigator.clipboard.writeText(this.innerText).then(() => { this.style.borderColor = '#198754'; this.dataset.origText = this.innerHTML; this.innerHTML = '<i class=\'bi bi-check-circle text-success\'></i> Copied to clipboard!'; setTimeout(() => { this.innerHTML = this.dataset.origText; this.style.borderColor = ''; }, 1500); }).catch(() => alert('Failed to copy'))"
|
||||
@@ -129,13 +136,13 @@
|
||||
<a href="/decode" class="btn btn-outline-light w-100">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
|
||||
</a>
|
||||
|
||||
|
||||
{% elif decoded_file %}
|
||||
<!-- File Result -->
|
||||
<div class="alert alert-success">
|
||||
<h6><i class="bi bi-check-circle me-2"></i>File Decrypted Successfully!</h6>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-file-earmark-check text-success" style="font-size: 4rem;"></i>
|
||||
<h5 class="mt-3">{{ filename }}</h5>
|
||||
@@ -144,345 +151,262 @@
|
||||
<small class="text-muted">Type: {{ mime_type }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<a href="{{ url_for('decode_download', file_id=file_id) }}" class="btn btn-primary btn-lg w-100 mb-3">
|
||||
<i class="bi bi-download me-2"></i>Download File
|
||||
</a>
|
||||
|
||||
|
||||
<div class="alert alert-warning small">
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
<strong>File expires in 5 minutes.</strong> Download now.
|
||||
<strong>File expires in 10 minutes.</strong> Download now.
|
||||
</div>
|
||||
|
||||
|
||||
<a href="/decode" class="btn btn-outline-light w-100">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
|
||||
</a>
|
||||
|
||||
|
||||
{% else %}
|
||||
<!-- Decode Form -->
|
||||
<form method="POST" enctype="multipart/form-data" id="decodeForm">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-image me-1"></i> Reference Photo
|
||||
</label>
|
||||
<div class="drop-zone scan-container" id="refDropZone">
|
||||
<input type="file" name="reference_photo" accept="image/*" required>
|
||||
<div class="drop-zone-label">
|
||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
||||
<span class="text-muted">Drop image or click to browse</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="refPreview">
|
||||
<!-- Scan overlay elements -->
|
||||
<div class="scan-overlay">
|
||||
<div class="scan-grid"></div>
|
||||
<div class="scan-line"></div>
|
||||
</div>
|
||||
<!-- Corner brackets (shown after scan) -->
|
||||
<div class="scan-corners">
|
||||
<div class="scan-corner tl"></div>
|
||||
<div class="scan-corner tr"></div>
|
||||
<div class="scan-corner bl"></div>
|
||||
<div class="scan-corner br"></div>
|
||||
</div>
|
||||
<!-- Data panel (shown after scan) -->
|
||||
<div class="scan-data-panel">
|
||||
<div class="scan-data-filename">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span id="refFileName">image.jpg</span>
|
||||
</div>
|
||||
<div class="scan-data-row">
|
||||
<span class="scan-status-badge">Hash Acquired</span>
|
||||
<span class="scan-data-value" id="refFileSize">--</span>
|
||||
</div>
|
||||
<div class="scan-hash-preview" id="refHashPreview">SHA256: ················</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
The same reference photo used for encoding
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
|
||||
</label>
|
||||
<div class="drop-zone pixel-container" id="stegoDropZone">
|
||||
<input type="file" name="stego_image" accept="image/*" required>
|
||||
<div class="drop-zone-label">
|
||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
||||
<span class="text-muted">Drop image or click to browse</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="stegoPreview">
|
||||
<!-- Pixel blocks overlay - populated by JS -->
|
||||
<div class="pixel-blocks"></div>
|
||||
<!-- Pixel scan line -->
|
||||
<div class="pixel-scan-line"></div>
|
||||
<!-- Corner brackets -->
|
||||
<div class="pixel-corners">
|
||||
<div class="pixel-corner tl"></div>
|
||||
<div class="pixel-corner tr"></div>
|
||||
<div class="pixel-corner bl"></div>
|
||||
<div class="pixel-corner br"></div>
|
||||
</div>
|
||||
<!-- Data panel -->
|
||||
<div class="pixel-data-panel">
|
||||
<div class="pixel-data-filename">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span id="stegoFileName">image.png</span>
|
||||
</div>
|
||||
<div class="pixel-data-row">
|
||||
<span class="pixel-status-badge">Stego Loaded</span>
|
||||
<span class="pixel-data-value" id="stegoFileSize">--</span>
|
||||
</div>
|
||||
<div class="pixel-dimensions" id="stegoDims">-- × -- px</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
The image containing the hidden message/file
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-chat-quote me-1"></i> Passphrase
|
||||
</label>
|
||||
<input type="text" name="passphrase" id="passphraseInput" class="form-control passphrase-input"
|
||||
placeholder="e.g., correct horse battery staple" required>
|
||||
<div class="form-text">
|
||||
The passphrase used during encoding (typically 4 words)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<h6 class="text-muted mb-3">
|
||||
SECURITY FACTORS
|
||||
<span class="text-warning small">(provide same factors used during encoding)</span>
|
||||
</h6>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="security-box">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
|
||||
</label>
|
||||
|
||||
<!-- RSA Input Method Toggle -->
|
||||
<div class="btn-group w-100 mb-2" role="group">
|
||||
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked>
|
||||
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile">
|
||||
<i class="bi bi-file-earmark me-1"></i>.pem File
|
||||
</label>
|
||||
<div class="accordion step-accordion" id="decodeAccordion">
|
||||
|
||||
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr">
|
||||
<i class="bi bi-qr-code me-1"></i>QR Code
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- .pem File Input -->
|
||||
<div id="rsaFileSection">
|
||||
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
|
||||
</div>
|
||||
|
||||
<!-- QR Code Input -->
|
||||
<div id="rsaQrSection" class="d-none">
|
||||
<div class="drop-zone p-3" id="qrDropZone">
|
||||
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaKeyQrInput">
|
||||
<div class="drop-zone-label text-center">
|
||||
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
|
||||
<span class="text-muted small">Drop QR image or click to browse</span>
|
||||
</div>
|
||||
<!-- Crop animation container -->
|
||||
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
|
||||
<img class="qr-original" id="qrOriginal" alt="Original">
|
||||
<img class="qr-cropped" id="qrCropped" alt="Cropped QR">
|
||||
<!-- Data panel -->
|
||||
<div class="qr-data-panel">
|
||||
<div class="qr-data-filename">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<span>RSA Key loaded</span>
|
||||
</div>
|
||||
<div class="qr-data-row">
|
||||
<span class="qr-status-badge">RSA Key</span>
|
||||
<span class="qr-data-value">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Password (always visible) -->
|
||||
<div class="input-group input-group-sm mt-2">
|
||||
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PIN + Channel Row -->
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
|
||||
<div class="input-group pin-input-container">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
|
||||
<i class="bi bi-eye"></i>
|
||||
<!-- ================================================================
|
||||
STEP 1: IMAGES & MODE
|
||||
================================================================ -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#stepImages">
|
||||
<span class="step-title">
|
||||
<span class="step-number" id="stepImagesNumber">1</span>
|
||||
<i class="bi bi-images me-1"></i> Images & Mode
|
||||
</span>
|
||||
<span class="step-summary" id="stepImagesSummary">Select reference & stego</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">If PIN was used during encoding</div>
|
||||
</div>
|
||||
</div>
|
||||
</h2>
|
||||
<div id="stepImages" class="accordion-collapse collapse show" data-bs-parent="#decodeAccordion">
|
||||
<div class="accordion-body">
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-broadcast me-1"></i> Channel
|
||||
<span class="badge bg-info ms-1">v4.1</span>
|
||||
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
|
||||
</label>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-image me-1"></i> Reference Photo
|
||||
</label>
|
||||
<div class="drop-zone scan-container" id="refDropZone">
|
||||
<input type="file" name="reference_photo" accept="image/*" required id="refPhotoInput">
|
||||
<div class="drop-zone-label">
|
||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
||||
<span class="text-muted">Drop image or click</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="refPreview">
|
||||
<div class="scan-overlay"><div class="scan-grid"></div><div class="scan-line"></div></div>
|
||||
<div class="scan-corners">
|
||||
<div class="scan-corner tl"></div><div class="scan-corner tr"></div>
|
||||
<div class="scan-corner bl"></div><div class="scan-corner br"></div>
|
||||
</div>
|
||||
<div class="scan-data-panel">
|
||||
<div class="scan-data-filename"><i class="bi bi-check-circle-fill"></i><span id="refFileName">image.jpg</span></div>
|
||||
<div class="scan-data-row"><span class="scan-status-badge">Hash Acquired</span><span class="scan-data-value" id="refFileSize">--</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Same reference photo used for encoding</div>
|
||||
</div>
|
||||
|
||||
<select class="form-select" name="channel_key" id="channelSelectDec">
|
||||
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
|
||||
<option value="none">Public</option>
|
||||
{% if saved_channel_keys %}
|
||||
<optgroup label="Saved Keys">
|
||||
{% for key in saved_channel_keys %}
|
||||
<option value="{{ key.channel_key }}" data-key-id="{{ key.id }}">{{ key.name }} ({{ key.channel_key[:4] }}...)</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
|
||||
<!-- Server channel indicator (compact) -->
|
||||
<div class="small text-success mt-2 {% if not channel_configured %}d-none{% endif %}" id="channelServerInfoDec" data-fingerprint="{{ (channel_fingerprint[:4] if channel_fingerprint else '') }}-••••-···-••••-{{ channel_fingerprint[-4:] if channel_fingerprint else '' }}">
|
||||
{% if channel_configured and channel_fingerprint %}
|
||||
<i class="bi bi-shield-lock me-1"></i>
|
||||
Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Channel Key Input (shown when Custom selected) -->
|
||||
<div class="mb-4 d-none" id="channelCustomInputDec">
|
||||
<div class="security-box">
|
||||
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
|
||||
<div class="input-group">
|
||||
<input type="text" name="channel_key_custom" class="form-control font-monospace"
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
|
||||
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}"
|
||||
id="channelKeyInputDec">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
ADVANCED OPTIONS (v3.0) - Extraction Mode
|
||||
================================================================ -->
|
||||
<div class="mb-4">
|
||||
<a class="btn btn-sm btn-outline-secondary w-100" data-bs-toggle="collapse" href="#advancedOptionsDec" role="button" aria-expanded="false">
|
||||
<i class="bi bi-gear me-1"></i> Advanced Options
|
||||
<i class="bi bi-chevron-down ms-1" id="advancedChevronDec"></i>
|
||||
</a>
|
||||
|
||||
<div class="collapse" id="advancedOptionsDec">
|
||||
<div class="card card-body mt-2 bg-dark border-secondary">
|
||||
|
||||
<!-- Extraction Mode Selection -->
|
||||
<div class="mb-0">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-cpu me-1"></i> Extraction Mode
|
||||
<span class="badge bg-info ms-1">v3.0</span>
|
||||
</label>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<!-- Auto Mode -->
|
||||
<label class="mode-btn flex-fill active" id="autoModeCard" for="modeAuto">
|
||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeAuto" value="auto" checked>
|
||||
<i class="bi bi-magic text-success"></i>
|
||||
<span class="ms-2"><strong>Auto</strong> <span class="text-muted d-none d-sm-inline">· Try both</span></span>
|
||||
</label>
|
||||
|
||||
<!-- LSB Mode -->
|
||||
<label class="mode-btn flex-fill" id="lsbModeCardDec" for="modeLsbDec">
|
||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeLsbDec" value="lsb">
|
||||
<i class="bi bi-grid-3x3-gap text-primary"></i>
|
||||
<span class="ms-2"><strong>LSB</strong> <span class="text-muted d-none d-sm-inline">· Spatial</span></span>
|
||||
</label>
|
||||
|
||||
<!-- DCT Mode -->
|
||||
<label class="mode-btn flex-fill {% if not has_dct %}opacity-50{% endif %}" id="dctModeCardDec" for="modeDctDec">
|
||||
<input class="form-check-input" type="radio" name="embed_mode" id="modeDctDec" value="dct" {% if not has_dct %}disabled{% endif %}>
|
||||
<i class="bi bi-soundwave text-warning"></i>
|
||||
<span class="ms-2"><strong>DCT</strong> <span class="text-muted d-none d-sm-inline">· Frequency</span></span>
|
||||
</label>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-file-earmark-image me-1"></i> Stego Image
|
||||
</label>
|
||||
<div class="drop-zone pixel-container" id="stegoDropZone">
|
||||
<input type="file" name="stego_image" accept="image/*" required id="stegoInput">
|
||||
<div class="drop-zone-label">
|
||||
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
|
||||
<span class="text-muted">Drop image or click</span>
|
||||
</div>
|
||||
<img class="drop-zone-preview d-none" id="stegoPreview">
|
||||
<div class="pixel-blocks"></div>
|
||||
<div class="pixel-scan-line"></div>
|
||||
<div class="pixel-corners">
|
||||
<div class="pixel-corner tl"></div><div class="pixel-corner tr"></div>
|
||||
<div class="pixel-corner bl"></div><div class="pixel-corner br"></div>
|
||||
</div>
|
||||
<div class="pixel-data-panel">
|
||||
<div class="pixel-data-filename"><i class="bi bi-check-circle-fill"></i><span id="stegoFileName">image.png</span></div>
|
||||
<div class="pixel-data-row"><span class="pixel-status-badge">Stego Loaded</span><span class="pixel-data-value" id="stegoFileSize">--</span></div>
|
||||
<div class="pixel-dimensions" id="stegoDims">-- x -- px</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Image containing the hidden message</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-text mt-2">
|
||||
<i class="bi bi-lightbulb me-1"></i>
|
||||
<strong>Auto</strong> tries LSB first, then DCT.
|
||||
{% if not has_dct %}
|
||||
<span class="text-warning ms-2"><i class="bi bi-exclamation-triangle me-1"></i>DCT requires scipy</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- Extraction Mode -->
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap mb-2">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeAuto" value="auto" checked>
|
||||
<label class="btn btn-outline-secondary text-nowrap" for="modeAuto"><i class="bi bi-magic me-1"></i>Auto</label>
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeLsb" value="lsb">
|
||||
<label class="btn btn-outline-secondary text-nowrap" for="modeLsb"><i class="bi bi-grid-3x3-gap me-1"></i>LSB</label>
|
||||
<input type="radio" class="btn-check" name="embed_mode" id="modeDct" value="dct" {% if not has_dct %}disabled{% endif %}>
|
||||
<label class="btn btn-outline-secondary text-nowrap" for="modeDct" id="dctModeLabel"><i class="bi bi-soundwave me-1"></i>DCT</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text" id="modeHint">
|
||||
<i class="bi bi-lightning me-1"></i>Tries LSB first, then DCT
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
STEP 2: SECURITY
|
||||
================================================================ -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#stepSecurity">
|
||||
<span class="step-title">
|
||||
<span class="step-number" id="stepSecurityNumber">2</span>
|
||||
<i class="bi bi-shield-lock me-1"></i> Security
|
||||
</span>
|
||||
<span class="step-summary" id="stepSecuritySummary">Passphrase & keys</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="stepSecurity" class="accordion-collapse collapse" data-bs-parent="#decodeAccordion">
|
||||
<div class="accordion-body">
|
||||
|
||||
<!-- Passphrase -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label"><i class="bi bi-chat-quote me-1"></i> Passphrase</label>
|
||||
<input type="text" name="passphrase" class="form-control passphrase-input"
|
||||
placeholder="e.g., apple forest thunder mountain" required id="passphraseInput">
|
||||
<div class="form-text">The passphrase used during encoding</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-3 opacity-25">
|
||||
<div class="small text-muted mb-2">Provide same factors used during encoding</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- PIN -->
|
||||
<div class="col-md-6 mb-2">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
|
||||
<div class="input-group pin-input-container">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel -->
|
||||
<div class="col-md-6 mb-2">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label"><i class="bi bi-broadcast me-1"></i> Channel</label>
|
||||
<select class="form-select form-select-sm" name="channel_key" id="channelSelectDec">
|
||||
<option value="auto" selected>Auto{% if channel_configured %} (Server){% endif %}</option>
|
||||
<option value="none">Public</option>
|
||||
{% if saved_channel_keys %}
|
||||
<optgroup label="Saved Keys">
|
||||
{% for key in saved_channel_keys %}
|
||||
<option value="{{ key.channel_key }}">{{ key.name }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% endif %}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Channel Key -->
|
||||
<div class="mb-3 d-none" id="channelCustomInputDec">
|
||||
<div class="security-box">
|
||||
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
|
||||
<div class="input-group">
|
||||
<input type="text" name="channel_key_custom" class="form-control form-control-sm font-monospace"
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" id="channelKeyInputDec">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="button" id="channelKeyScanDec" title="Scan QR"><i class="bi bi-camera"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSA Key -->
|
||||
<div class="mb-3">
|
||||
<div class="security-box">
|
||||
<label class="form-label"><i class="bi bi-file-earmark-lock me-1"></i> RSA Key <span class="text-muted">(if used)</span></label>
|
||||
<div class="btn-group w-100 mb-2" role="group">
|
||||
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked>
|
||||
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile"><i class="bi bi-file-earmark me-1"></i>.pem</label>
|
||||
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr">
|
||||
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr"><i class="bi bi-qr-code me-1"></i>QR</label>
|
||||
</div>
|
||||
<div id="rsaFileSection">
|
||||
<input type="file" name="rsa_key" class="form-control form-control-sm" accept=".pem">
|
||||
</div>
|
||||
<div id="rsaQrSection" class="d-none d-flex flex-column">
|
||||
<input type="hidden" name="rsa_key_pem" id="rsaKeyPem">
|
||||
<div class="drop-zone p-2 w-100" id="qrDropZone">
|
||||
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaQrInput">
|
||||
<div class="drop-zone-label text-center">
|
||||
<i class="bi bi-qr-code-scan fs-5 d-block text-muted mb-1"></i>
|
||||
<span class="text-muted small">Drop QR image</span>
|
||||
</div>
|
||||
<div class="qr-scan-container d-none" id="qrCropContainer">
|
||||
<img class="qr-original" id="qrOriginal" alt="Original">
|
||||
<img class="qr-cropped" id="qrCropped" alt="Cropped">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-2" id="rsaQrWebcam">
|
||||
<i class="bi bi-camera me-1"></i>Scan with Camera
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group input-group-sm mt-2">
|
||||
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput"><i class="bi bi-eye"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn">
|
||||
<i class="bi bi-unlock me-2"></i>Decode
|
||||
</button>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="p-3">
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn">
|
||||
<i class="bi bi-unlock me-2"></i>Decode
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if not decoded_message and not decoded_file %}
|
||||
<!-- Troubleshooting Card -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-3"><i class="bi bi-question-circle me-2"></i>Troubleshooting</h6>
|
||||
<ul class="list-unstyled text-muted small mb-0">
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle-fill text-success me-1"></i>
|
||||
Use the <strong>exact same reference photo</strong> file (byte-for-byte identical)
|
||||
Use the <strong>exact same reference photo</strong> (byte-for-byte identical)
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle-fill text-success me-1"></i>
|
||||
Enter the <strong>exact passphrase</strong> used during encoding (case-sensitive, spacing matters)
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle-fill text-success me-1"></i>
|
||||
Provide the <strong>same security factors</strong> (PIN and/or RSA key) used during encoding
|
||||
Enter the <strong>exact passphrase</strong> used during encoding
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>
|
||||
Ensure the stego image hasn't been <strong>resized, cropped, or recompressed</strong>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>
|
||||
<strong>Format compatibility:</strong> v4.0 cannot decode messages from v3.1 or earlier (different format)
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-broadcast text-info me-1"></i>
|
||||
<strong>Channel key:</strong> Use the same channel (Auto/Public/Custom) that was used during encoding
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-info-circle-fill text-info me-1"></i>
|
||||
If using an RSA key, verify the <strong>password is correct</strong> (if key is encrypted)
|
||||
Ensure the stego image hasn't been <strong>resized or recompressed</strong>
|
||||
</li>
|
||||
<li class="mb-0">
|
||||
<i class="bi bi-info-circle-fill text-info me-1"></i>
|
||||
If auto-detection fails, try specifying <strong>LSB or DCT mode</strong> in Advanced Options
|
||||
If auto-detection fails, try specifying <strong>LSB or DCT mode</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -495,28 +419,106 @@
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/stegasoo.js') }}"></script>
|
||||
<script>
|
||||
// Extraction mode button active state toggle
|
||||
const extractModeRadios = document.querySelectorAll('input[name="embed_mode"]');
|
||||
const extractModeBtns = {
|
||||
'auto': document.getElementById('autoModeCard'),
|
||||
'lsb': document.getElementById('lsbModeCardDec'),
|
||||
'dct': document.getElementById('dctModeCardDec')
|
||||
// ============================================================================
|
||||
// MODE HINT - Dynamic text based on selected extraction mode
|
||||
// ============================================================================
|
||||
const modeHints = {
|
||||
auto: { icon: 'lightning', text: 'Tries LSB first, then DCT' },
|
||||
lsb: { icon: 'hdd', text: 'For email and direct transfers' },
|
||||
dct: { icon: 'phone', text: 'For social media images' }
|
||||
};
|
||||
|
||||
extractModeRadios.forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
Object.values(extractModeBtns).forEach(btn => btn?.classList.remove('active'));
|
||||
extractModeBtns[radio.value]?.classList.add('active');
|
||||
document.querySelectorAll('input[name="embed_mode"]').forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
const hint = document.getElementById('modeHint');
|
||||
const data = modeHints[this.value];
|
||||
if (hint && data) {
|
||||
hint.innerHTML = `<i class="bi bi-${data.icon} me-1"></i>${data.text}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Advanced options chevron
|
||||
const advancedOptionsDec = document.getElementById('advancedOptionsDec');
|
||||
advancedOptionsDec?.addEventListener('show.bs.collapse', () => {
|
||||
document.getElementById('advancedChevronDec')?.classList.replace('bi-chevron-down', 'bi-chevron-up');
|
||||
});
|
||||
advancedOptionsDec?.addEventListener('hide.bs.collapse', () => {
|
||||
document.getElementById('advancedChevronDec')?.classList.replace('bi-chevron-up', 'bi-chevron-down');
|
||||
});
|
||||
// ============================================================================
|
||||
// ACCORDION SUMMARY UPDATES
|
||||
// ============================================================================
|
||||
|
||||
function updateImagesSummary() {
|
||||
const ref = document.getElementById('refPhotoInput')?.files[0];
|
||||
const stego = document.getElementById('stegoInput')?.files[0];
|
||||
const mode = document.querySelector('input[name="embed_mode"]:checked')?.value?.toUpperCase() || 'AUTO';
|
||||
const summary = document.getElementById('stepImagesSummary');
|
||||
const stepNum = document.getElementById('stepImagesNumber');
|
||||
|
||||
if (ref && stego) {
|
||||
const refName = ref.name.length > 12 ? ref.name.slice(0, 10) + '..' : ref.name;
|
||||
const stegoName = stego.name.length > 12 ? stego.name.slice(0, 10) + '..' : stego.name;
|
||||
summary.textContent = `${refName} + ${stegoName}, ${mode}`;
|
||||
summary.classList.add('has-content');
|
||||
stepNum.classList.add('complete');
|
||||
stepNum.innerHTML = '<i class="bi bi-check"></i>';
|
||||
} else if (ref || stego) {
|
||||
summary.textContent = ref ? ref.name.slice(0, 15) : stego.name.slice(0, 15);
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '1';
|
||||
} else {
|
||||
summary.textContent = 'Select reference & stego';
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '1';
|
||||
}
|
||||
}
|
||||
|
||||
function updateSecuritySummary() {
|
||||
const passphrase = document.getElementById('passphraseInput')?.value || '';
|
||||
const pin = document.getElementById('pinInput')?.value || '';
|
||||
const rsaFile = document.querySelector('input[name="rsa_key"]')?.files[0];
|
||||
const rsaPem = document.getElementById('rsaKeyPem')?.value || '';
|
||||
const summary = document.getElementById('stepSecuritySummary');
|
||||
const stepNum = document.getElementById('stepSecurityNumber');
|
||||
|
||||
const parts = [];
|
||||
if (passphrase.trim()) parts.push('passphrase');
|
||||
if (pin) parts.push('PIN');
|
||||
if (rsaFile || rsaPem) parts.push('RSA');
|
||||
|
||||
if (parts.length > 0) {
|
||||
summary.textContent = parts.join(' + ');
|
||||
summary.classList.add('has-content');
|
||||
if (passphrase.trim()) {
|
||||
stepNum.classList.add('complete');
|
||||
stepNum.innerHTML = '<i class="bi bi-check"></i>';
|
||||
}
|
||||
} else {
|
||||
summary.textContent = 'Passphrase & keys';
|
||||
summary.classList.remove('has-content');
|
||||
stepNum.classList.remove('complete');
|
||||
stepNum.textContent = '2';
|
||||
}
|
||||
}
|
||||
|
||||
// Attach listeners
|
||||
document.getElementById('refPhotoInput')?.addEventListener('change', updateImagesSummary);
|
||||
document.getElementById('stegoInput')?.addEventListener('change', updateImagesSummary);
|
||||
document.querySelectorAll('input[name="embed_mode"]').forEach(r => r.addEventListener('change', updateImagesSummary));
|
||||
|
||||
document.getElementById('passphraseInput')?.addEventListener('input', updateSecuritySummary);
|
||||
document.getElementById('pinInput')?.addEventListener('input', updateSecuritySummary);
|
||||
document.querySelector('input[name="rsa_key"]')?.addEventListener('change', updateSecuritySummary);
|
||||
|
||||
// ============================================================================
|
||||
// MODE SWITCHING
|
||||
// ============================================================================
|
||||
|
||||
// Apply disabled styling to DCT if not available
|
||||
if (document.getElementById('modeDct')?.disabled) {
|
||||
document.getElementById('dctModeLabel')?.classList.add('disabled', 'text-muted');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LOADING STATE
|
||||
// ============================================================================
|
||||
|
||||
Stegasoo.initFormLoading('decodeForm', 'decodeBtn', 'Decoding...');
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Important:</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
<li>This file expires in <strong>5 minutes</strong></li>
|
||||
<li>This file expires in <strong>10 minutes</strong></li>
|
||||
<li>Do <strong>not</strong> resize or recompress the image</li>
|
||||
{% if embed_mode == 'dct' and output_format == 'jpeg' %}
|
||||
<li>JPEG format is lossy - avoid re-saving or editing</li>
|
||||
|
||||
@@ -65,11 +65,7 @@
|
||||
<select name="rsa_bits" class="form-select form-select-sm" id="rsaBitsSelect">
|
||||
<option value="2048" selected>2048 bits (~128 bits entropy)</option>
|
||||
<option value="3072">3072 bits (~128 bits entropy)</option>
|
||||
<option value="4096">4096 bits (~128 bits entropy)</option>
|
||||
</select>
|
||||
<div class="form-text text-warning d-none" id="rsaQrWarning">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>QR code unavailable for keys >3072 bits
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,8 +96,8 @@
|
||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||
<input type="text" class="form-control font-monospace" id="channelKeyGenerated"
|
||||
placeholder="Click Generate to create a key" readonly>
|
||||
<button class="btn btn-outline-primary" type="button" id="generateChannelKeyBtn">
|
||||
<i class="bi bi-shuffle me-1"></i>Generate
|
||||
<button class="btn btn-outline-primary" type="button" id="generateChannelKeyBtn" title="Generate Channel Key">
|
||||
<i class="bi bi-shuffle"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" type="button" id="copyChannelKeyBtn" disabled title="Copy to clipboard">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
@@ -286,12 +282,6 @@
|
||||
<i class="bi bi-shield-exclamation me-1"></i>
|
||||
<strong>Security note:</strong> The QR code contains your unencrypted private key.
|
||||
Only scan in a secure environment. Consider using the password-protected download instead.
|
||||
{% if rsa_bits >= 4096 %}
|
||||
<br><br>
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>4096-bit keys</strong> produce very dense QR codes. If scanning fails,
|
||||
use the PEM text or download options instead.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -483,17 +473,17 @@
|
||||
/* Responsive */
|
||||
@media (max-width: 576px) {
|
||||
.pin-container, .passphrase-container {
|
||||
padding: 1rem 1.25rem;
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
.pin-digit-box {
|
||||
width: 2.25rem;
|
||||
height: 2.75rem;
|
||||
font-size: 1.25rem;
|
||||
width: 1.9rem;
|
||||
height: 2.4rem;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
|
||||
.pin-digits-row {
|
||||
gap: 0.35rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.passphrase-text {
|
||||
|
||||
@@ -3,170 +3,64 @@
|
||||
{% block title %}Stegasoo - Secure Steganography{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.home-icon {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.home-icon i {
|
||||
font-size: 2.5rem;
|
||||
color: #fff;
|
||||
margin-bottom: 0.5rem;
|
||||
filter: drop-shadow(0 3px 2px rgba(0, 0, 0, 0.9));
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.home-icon span {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.home-icon:hover i {
|
||||
color: #e5d058;
|
||||
transform: translateY(-3px);
|
||||
filter: drop-shadow(0 5px 4px rgba(0, 0, 0, 0.8));
|
||||
}
|
||||
.home-icon:hover span {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
color: #e5d058;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex align-items-end justify-content-center gap-4">
|
||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="155">
|
||||
<div style="margin-bottom: 40px;">
|
||||
<h1 class="display-4 fw-bold mb-2 title-gold">
|
||||
Stegasoo
|
||||
<span class="badge bg-success fs-6 ms-2">v4.1</span>
|
||||
</h1>
|
||||
<p class="lead text-muted mb-0">Hide encrypted data in plain sight.</p>
|
||||
</div>
|
||||
<div class="d-flex flex-column align-items-center justify-content-center" style="min-height: 70vh;">
|
||||
|
||||
<!-- Hero -->
|
||||
<div class="d-flex align-items-center mb-4" style="gap: 8px;">
|
||||
<div class="position-relative">
|
||||
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="80">
|
||||
<span class="badge bg-success position-absolute" style="bottom: 1px; left: -6px; font-size: 0.6rem;">v4.1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel Status Banner (v4.0.0) -->
|
||||
{% if channel_configured %}
|
||||
<div class="alert alert-success mb-4">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<i class="bi bi-shield-lock me-2"></i>
|
||||
<strong>Private Channel Mode</strong>
|
||||
</div>
|
||||
<div class="key-capsule">
|
||||
<span class="badge led-badge-yellow"><span class="led-indicator led-yellow me-1"></span>Key Loaded</span>
|
||||
<code class="small ms-2">{{ channel_fingerprint }}</code>
|
||||
<h1 class="display-5 fw-bold title-gold mb-0">Stegasoo</h1>
|
||||
<p class="text-muted mb-0 small" style="margin-top: 3px; padding-left: 3px; font-size: 0.85rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);">Hide encrypted data in plain sight.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-4 mb-5">
|
||||
<!-- Encode Card -->
|
||||
<div class="col-md-4">
|
||||
<a href="/encode" class="text-decoration-none card-link">
|
||||
<div class="card h-100 feature-card">
|
||||
<div class="card-header text-center py-3">
|
||||
<i class="bi bi-lock-fill fs-1 embossed-icon"></i>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title">Encode</h5>
|
||||
<p class="card-text text-muted">
|
||||
Hide encrypted messages or files inside images
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<!-- Action Icons -->
|
||||
<div class="d-flex gap-4">
|
||||
<a href="/encode" class="home-icon"><i class="bi bi-lock-fill"></i><span>Encode</span></a>
|
||||
<a href="/decode" class="home-icon"><i class="bi bi-unlock-fill"></i><span>Decode</span></a>
|
||||
<a href="/generate" class="home-icon"><i class="bi bi-key-fill"></i><span>Generate</span></a>
|
||||
</div>
|
||||
|
||||
<!-- Decode Card -->
|
||||
<div class="col-md-4">
|
||||
<a href="/decode" class="text-decoration-none card-link">
|
||||
<div class="card h-100 feature-card">
|
||||
<div class="card-header text-center py-3">
|
||||
<i class="bi bi-unlock-fill fs-1 embossed-icon"></i>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title">Decode</h5>
|
||||
<p class="card-text text-muted">
|
||||
Extract and decrypt hidden data from stego images
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Generate Card -->
|
||||
<div class="col-md-4">
|
||||
<a href="/generate" class="text-decoration-none card-link">
|
||||
<div class="card h-100 feature-card">
|
||||
<div class="card-header text-center py-3">
|
||||
<i class="bi bi-key-fill fs-1 embossed-icon"></i>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title">Generate</h5>
|
||||
<p class="card-text text-muted">
|
||||
Create passphrases, PINs, and RSA keys
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embedding Modes -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-cpu me-2"></i>Embedding Modes</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-md-6 mb-3 mb-md-0">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-soundwave text-warning fs-2 d-block mb-2"></i>
|
||||
<strong>DCT Mode</strong>
|
||||
<span class="badge bg-success ms-1">Default</span>
|
||||
<div class="small text-muted mt-2">
|
||||
Survives JPEG recompression<br>
|
||||
Best for social media
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="p-3 bg-dark rounded h-100">
|
||||
<i class="bi bi-grid-3x3-gap text-primary fs-2 d-block mb-2"></i>
|
||||
<strong>LSB Mode</strong>
|
||||
<div class="small text-muted mt-2">
|
||||
Higher capacity (~375 KB/MP)<br>
|
||||
Best for email & file transfer
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-diagram-3 me-2"></i>How It Works</h5>
|
||||
<a href="/about" class="btn btn-sm btn-outline-light">Learn More</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-primary"><i class="bi bi-key me-2"></i>You Provide</h6>
|
||||
<ul class="list-unstyled small">
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-image text-info me-2"></i>
|
||||
<strong>Reference Photo</strong>: shared secret
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-chat-quote text-info me-2"></i>
|
||||
<strong>Passphrase</strong>: 4+ words
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-123 text-info me-2"></i>
|
||||
<strong>PIN</strong>: 6-9 digits (or RSA key)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-primary"><i class="bi bi-shield-check me-2"></i>Security</h6>
|
||||
<ul class="list-unstyled small">
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-lock text-success me-2"></i>
|
||||
AES-256-GCM encryption
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-memory text-success me-2"></i>
|
||||
Argon2id key derivation (256MB)
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-shuffle text-success me-2"></i>
|
||||
Pseudo-random embedding
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<i class="bi bi-broadcast text-success me-2"></i>
|
||||
<strong>Channel keys</strong> for group isolation
|
||||
<span class="badge bg-info ms-1">v4.1</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
6a7378172fc0ec37143720f09a4ca34e83ec2409893aa8cd79ace5b78a64276c
|
||||
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "stegasoo"
|
||||
version = "4.1.2"
|
||||
version = "4.2.0"
|
||||
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.11"
|
||||
authors = [
|
||||
{ name = "Aaron D. Lee" }
|
||||
]
|
||||
@@ -29,9 +29,10 @@ classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Topic :: Security :: Cryptography",
|
||||
"Topic :: Multimedia :: Graphics",
|
||||
]
|
||||
@@ -40,6 +41,7 @@ dependencies = [
|
||||
"pillow>=10.0.0",
|
||||
"cryptography>=41.0.0",
|
||||
"argon2-cffi>=23.0.0",
|
||||
"zstandard>=0.22.0", # v4.2.0: Default compression algorithm
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -47,7 +49,7 @@ dependencies = [
|
||||
dct = [
|
||||
"numpy>=2.0.0",
|
||||
"scipy>=1.10.0",
|
||||
"jpegio>=0.2.0",
|
||||
"jpeglib>=1.0.0",
|
||||
"reedsolo>=1.7.0",
|
||||
]
|
||||
cli = [
|
||||
@@ -57,7 +59,7 @@ cli = [
|
||||
"rich>=13.0.0",
|
||||
]
|
||||
compression = [
|
||||
"lz4>=4.0.0",
|
||||
"lz4>=4.0.0", # Optional: faster but slightly worse ratio than zstd
|
||||
]
|
||||
web = [
|
||||
"flask>=3.0.0",
|
||||
@@ -68,7 +70,7 @@ web = [
|
||||
# Include DCT support for web UI
|
||||
"numpy>=2.0.0",
|
||||
"scipy>=1.10.0",
|
||||
"jpegio>=0.2.0",
|
||||
"jpeglib>=1.0.0",
|
||||
"reedsolo>=1.7.0",
|
||||
]
|
||||
api = [
|
||||
@@ -80,7 +82,7 @@ api = [
|
||||
# Include DCT support for API
|
||||
"numpy>=2.0.0",
|
||||
"scipy>=1.10.0",
|
||||
"jpegio>=0.2.0",
|
||||
"jpeglib>=1.0.0",
|
||||
"reedsolo>=1.7.0",
|
||||
]
|
||||
all = [
|
||||
@@ -110,7 +112,7 @@ include = [
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/stegasoo"]
|
||||
packages = ["src/stegasoo", "frontends"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
@@ -119,7 +121,7 @@ addopts = "-v --cov=stegasoo --cov-report=term-missing"
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ["py310", "py311", "py312"]
|
||||
target-version = ["py311", "py312", "py313"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
@@ -136,7 +138,7 @@ ignore = ["E501"]
|
||||
"src/stegasoo/__init__.py" = ["E402"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
python_version = "3.11"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
ignore_missing_imports = true
|
||||
|
||||
@@ -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
|
||||
@@ -23,29 +26,50 @@ ssh admin@stegasoo.local
|
||||
## Step 3: Pre-Setup
|
||||
|
||||
```bash
|
||||
# Take ownership of /opt (for pyenv, jpegio builds)
|
||||
# Take ownership of /opt
|
||||
sudo chown admin:admin /opt
|
||||
|
||||
# Install git (not included in Lite image)
|
||||
sudo apt-get update && sudo apt-get install -y git
|
||||
```
|
||||
|
||||
## Step 4: Clone & Run Setup
|
||||
## Step 4: Clone Repo
|
||||
|
||||
```bash
|
||||
cd /opt
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
cd stegasoo
|
||||
git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
```
|
||||
|
||||
## Step 5: Run Setup
|
||||
|
||||
```bash
|
||||
cd /opt/stegasoo
|
||||
./rpi/setup.sh
|
||||
```
|
||||
|
||||
This takes ~15-20 minutes and installs:
|
||||
- Python 3.12 via pyenv
|
||||
- jpegio (patched for ARM)
|
||||
- Stegasoo with web UI
|
||||
- Systemd service
|
||||
The setup script:
|
||||
- Verifies Python 3.11+ (system Python, no pyenv needed)
|
||||
- Installs dependencies via apt and pip
|
||||
- jpeglib installs cleanly (no ARM patching like jpegio)
|
||||
- Creates and enables systemd service
|
||||
|
||||
## Step 5: Test It Works
|
||||
Install time: **5-10 minutes** (from source)
|
||||
|
||||
### Pre-built Venv (optional)
|
||||
|
||||
For faster installs, you can provide a pre-built venv tarball:
|
||||
|
||||
```bash
|
||||
# On your host machine:
|
||||
scp rpi/stegasoo-rpi-venv-arm64.tar.zst admin@stegasoo.local:/opt/stegasoo/rpi/
|
||||
|
||||
# Then on Pi:
|
||||
cd /opt/stegasoo && ./rpi/setup.sh # Detects local tarball, skips pip build
|
||||
```
|
||||
|
||||
Install time with pre-built: **~2 minutes**
|
||||
|
||||
## Step 6: Test It Works
|
||||
|
||||
```bash
|
||||
sudo systemctl start stegasoo
|
||||
@@ -53,7 +77,7 @@ curl -k https://localhost:5000
|
||||
# Should return HTML
|
||||
```
|
||||
|
||||
## Step 6: Sanitize for Distribution
|
||||
## Step 7: Sanitize for Distribution
|
||||
|
||||
```bash
|
||||
# Full sanitize (for final image - removes WiFi, shuts down)
|
||||
@@ -73,7 +97,7 @@ This removes:
|
||||
|
||||
The script validates all cleanup steps before finishing.
|
||||
|
||||
## Step 7: Copy the Image
|
||||
## Step 8: Pull the Image
|
||||
|
||||
Remove SD card, insert into your Linux machine:
|
||||
|
||||
@@ -81,21 +105,11 @@ Remove SD card, insert into your Linux machine:
|
||||
# Find the SD card device (CAREFUL!)
|
||||
lsblk
|
||||
|
||||
# Copy (replace sdX with actual device, e.g., sda)
|
||||
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
|
||||
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.0.img.zst
|
||||
```
|
||||
|
||||
## Step 8: Shrink & Compress
|
||||
|
||||
```bash
|
||||
# Optional: Shrink image (saves space)
|
||||
wget https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh
|
||||
chmod +x pishrink.sh
|
||||
sudo ./pishrink.sh stegasoo-rpi-*.img
|
||||
|
||||
# Compress (zstd is faster than xz with similar ratio)
|
||||
zstd -19 -T0 stegasoo-rpi-*.img
|
||||
```
|
||||
The script automatically resizes rootfs to 16GB (for smaller download), preserves auto-expand, and compresses.
|
||||
|
||||
## Step 9: Distribute
|
||||
|
||||
@@ -115,19 +129,49 @@ zstdcat stegasoo-rpi-*.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
|
||||
---
|
||||
|
||||
## Creating the Pre-built Venv Tarball
|
||||
|
||||
After a successful from-source build, create the pre-built tarball for future installs:
|
||||
|
||||
```bash
|
||||
# On the Pi after successful setup:
|
||||
cd /opt/stegasoo
|
||||
|
||||
# Strip caches and tests from venv (saves ~100MB)
|
||||
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
|
||||
|
||||
# Create venv tarball
|
||||
tar -cf - venv/ | zstd -19 -T0 > /tmp/stegasoo-rpi-venv-arm64.tar.zst
|
||||
|
||||
# Check size (should be ~40-50MB)
|
||||
ls -lh /tmp/stegasoo-rpi-venv-arm64.tar.zst
|
||||
```
|
||||
|
||||
Pull to host and upload to GitHub releases:
|
||||
```bash
|
||||
# On host:
|
||||
scp admin@stegasoo.local:/tmp/stegasoo-rpi-venv-arm64.tar.zst ./rpi/
|
||||
# Upload to GitHub releases as stegasoo-rpi-venv-arm64.tar.zst
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Command Summary
|
||||
|
||||
```bash
|
||||
# On Pi (after SSH):
|
||||
sudo chown admin:admin /opt
|
||||
sudo apt-get update && sudo apt-get install -y git
|
||||
cd /opt && git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
cd stegasoo && ./rpi/setup.sh
|
||||
cd /opt && git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
|
||||
# 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:
|
||||
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
|
||||
zstd -19 -T0 stegasoo-rpi-*.img
|
||||
# On host (pull image - auto-resizes to 16GB):
|
||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.0.img.zst
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@ Scripts and resources for deploying Stegasoo on Raspberry Pi.
|
||||
|
||||
## Quick Install
|
||||
|
||||
On a fresh Raspberry Pi OS Lite (64-bit) installation:
|
||||
On a fresh Raspberry Pi OS (64-bit) installation:
|
||||
|
||||
```bash
|
||||
# Pre-setup (git not included in Lite image)
|
||||
@@ -13,16 +13,16 @@ sudo apt-get update && sudo apt-get install -y git
|
||||
|
||||
# Clone and run setup
|
||||
cd /opt
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
cd stegasoo
|
||||
./rpi/setup.sh
|
||||
```
|
||||
|
||||
## What the Setup Script Does
|
||||
|
||||
1. **Installs system dependencies** - build tools, libraries
|
||||
2. **Installs Python 3.12** - via pyenv (Pi OS ships with 3.13 which is incompatible)
|
||||
3. **Builds jpegio for ARM** - patches x86-specific flags
|
||||
1. **Verifies Python 3.11+** - uses system Python (no pyenv needed)
|
||||
2. **Installs system dependencies** - build tools, libraries
|
||||
3. **Installs jpeglib** - DCT steganography (Python 3.11-3.14 compatible)
|
||||
4. **Installs Stegasoo** - with web UI and all dependencies
|
||||
5. **Creates systemd service** - auto-starts on boot
|
||||
6. **Enables the service** - ready to start
|
||||
@@ -30,11 +30,18 @@ cd stegasoo
|
||||
## Requirements
|
||||
|
||||
- Raspberry Pi 4 or 5
|
||||
- Raspberry Pi OS Lite (64-bit) - Bookworm or later
|
||||
- Raspberry Pi OS (64-bit) - Bookworm (Python 3.11) or Trixie (Python 3.13)
|
||||
- 4GB+ RAM recommended (2GB minimum)
|
||||
- ~2GB free disk space
|
||||
- 16GB+ SD card (pre-built images are 16GB)
|
||||
- Internet connection
|
||||
|
||||
### Python Compatibility
|
||||
|
||||
| Raspberry Pi OS | Python | Supported |
|
||||
|-----------------|--------|-----------|
|
||||
| Bookworm | 3.11 | Yes |
|
||||
| Trixie | 3.13 | Yes |
|
||||
|
||||
### Performance
|
||||
|
||||
On a Pi 4 at 2GHz with USB 3.0 NVMe, expect ~60 seconds to encode/decode a 10MB JPEG with full encryption (passphrase + PIN + reference photo).
|
||||
@@ -49,6 +56,25 @@ If using a pre-built image from GitHub Releases:
|
||||
|
||||
> **Security note**: Change the default password after setup with `passwd`
|
||||
|
||||
## Updating an Existing Installation
|
||||
|
||||
To update to the latest version:
|
||||
|
||||
```bash
|
||||
cd /opt/stegasoo
|
||||
git pull origin main
|
||||
sudo systemctl restart stegasoo
|
||||
```
|
||||
|
||||
That's it - the editable install means Python uses the source directly.
|
||||
|
||||
**If dependencies changed** (check release notes), also run:
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
pip install -e ".[web]"
|
||||
sudo systemctl restart stegasoo
|
||||
```
|
||||
|
||||
## After Installation
|
||||
|
||||
### Start the Service
|
||||
@@ -140,7 +166,7 @@ sudo apt-get update && sudo apt-get install -y git
|
||||
|
||||
# Clone and run setup
|
||||
cd /opt
|
||||
git clone -b 4.1 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
git clone -b 4.2 https://github.com/adlee-was-taken/stegasoo.git stegasoo
|
||||
cd stegasoo
|
||||
./rpi/setup.sh
|
||||
```
|
||||
@@ -180,18 +206,15 @@ After Pi shuts down, remove SD card and on another Linux machine:
|
||||
# Find SD card device (BE CAREFUL - wrong device = data loss!)
|
||||
lsblk
|
||||
|
||||
# Copy (replace sdX with your SD card)
|
||||
sudo dd if=/dev/sdX of=stegasoo-rpi-$(date +%Y%m%d).img bs=4M status=progress
|
||||
|
||||
# Shrink the image (optional but recommended)
|
||||
wget https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh
|
||||
chmod +x pishrink.sh
|
||||
sudo ./pishrink.sh stegasoo-rpi-*.img
|
||||
|
||||
# Compress (zstd is faster than xz with similar compression)
|
||||
zstd -19 -T0 stegasoo-rpi-*.img
|
||||
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.0.img.zst
|
||||
```
|
||||
|
||||
The `pull-image.sh` script automatically:
|
||||
- Resizes rootfs to exactly 16GB (for smaller download)
|
||||
- Preserves auto-expand (image fills SD card on first boot)
|
||||
- Compresses with zstd for fast decompression
|
||||
|
||||
### 6. Distribute
|
||||
|
||||
Upload the `.img.zst` file to GitHub Releases.
|
||||
|
||||
63
rpi/banner.sh
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
# Stegasoo Banner/Header Template
|
||||
# Source this file to use the banner functions
|
||||
#
|
||||
# Usage:
|
||||
# source "$(dirname "${BASH_SOURCE[0]}")/banner.sh"
|
||||
# print_banner "Raspberry Pi Setup"
|
||||
# print_gradient_line
|
||||
|
||||
# Colors
|
||||
STEGASOO_GOLD='\033[38;5;220m'
|
||||
STEGASOO_GRAY='\033[0;90m'
|
||||
STEGASOO_WHITE='\033[1;37m'
|
||||
STEGASOO_GREEN='\033[0;32m'
|
||||
STEGASOO_NC='\033[0m'
|
||||
|
||||
# Gradient line (purple -> blue)
|
||||
print_gradient_line() {
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
}
|
||||
|
||||
# Starfield decoration line
|
||||
print_starfield() {
|
||||
echo -e "${STEGASOO_GRAY} · . · . * · . * · . * · . * · . * · . ·${STEGASOO_NC}"
|
||||
}
|
||||
|
||||
# ASCII logo (gold)
|
||||
print_logo() {
|
||||
echo -e "${STEGASOO_GOLD} ___ _____ ___ ___ _ ___ ___ ___${STEGASOO_NC}"
|
||||
echo -e "${STEGASOO_GOLD} / __||_ _|| __| / __| /_\\ / __| / _ \\ / _ \\\\${STEGASOO_NC}"
|
||||
echo -e "${STEGASOO_GOLD} \\__ \\ | | | _| | (_ | / _ \\ \\__ \\ | (_) || (_) |${STEGASOO_NC}"
|
||||
echo -e "${STEGASOO_GOLD} |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/${STEGASOO_NC}"
|
||||
}
|
||||
|
||||
# Full banner with optional subtitle
|
||||
# Usage: print_banner "Subtitle Text"
|
||||
print_banner() {
|
||||
local subtitle="$1"
|
||||
echo ""
|
||||
print_gradient_line
|
||||
print_starfield
|
||||
print_logo
|
||||
print_starfield
|
||||
print_gradient_line
|
||||
if [ -n "$subtitle" ]; then
|
||||
echo -e "${STEGASOO_WHITE} ${subtitle}${STEGASOO_NC}"
|
||||
print_gradient_line
|
||||
fi
|
||||
}
|
||||
|
||||
# Completion banner (green title)
|
||||
# Usage: print_complete_banner "Setup Complete!"
|
||||
print_complete_banner() {
|
||||
local title="$1"
|
||||
echo ""
|
||||
print_gradient_line
|
||||
print_starfield
|
||||
print_logo
|
||||
print_starfield
|
||||
print_gradient_line
|
||||
echo -e "\033[1;32m ${title}\033[0m"
|
||||
print_gradient_line
|
||||
}
|
||||
70
rpi/build-runtime-tarball.sh
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Build Stegasoo Pi venv Tarball
|
||||
# Run this ON THE PI after a successful from-source build
|
||||
#
|
||||
# Creates: stegasoo-rpi-venv-arm64.tar.zst (~40-50MB)
|
||||
# Contains: venv with all dependencies (uses system Python 3.11+)
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/stegasoo}"
|
||||
OUTPUT_FILE="${1:-$HOME/stegasoo-rpi-venv-arm64.tar.zst}"
|
||||
|
||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Stegasoo Pi venv Tarball Builder ║${NC}"
|
||||
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Verify we're on ARM64
|
||||
ARCH=$(uname -m)
|
||||
if [[ "$ARCH" != "aarch64" ]]; then
|
||||
echo -e "${RED}Error: This script must be run on ARM64 (aarch64)${NC}"
|
||||
echo "Current architecture: $ARCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify venv exists
|
||||
if [[ ! -d "$INSTALL_DIR/venv" ]]; then
|
||||
echo -e "${RED}Error: venv not found at $INSTALL_DIR/venv${NC}"
|
||||
echo "Run a from-source build first: ./rpi/setup.sh --no-prebuilt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 1: Clean caches from venv
|
||||
echo -e "${GREEN}[1/2]${NC} Cleaning caches from venv..."
|
||||
VENV_SIZE_BEFORE=$(du -sh "$INSTALL_DIR/venv" | cut -f1)
|
||||
find "$INSTALL_DIR/venv/" -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$INSTALL_DIR/venv/" -type d -name 'tests' -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$INSTALL_DIR/venv/" -type d -name 'test' -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$INSTALL_DIR/venv/" -type f -name '*.pyc' -delete 2>/dev/null || true
|
||||
VENV_SIZE_AFTER=$(du -sh "$INSTALL_DIR/venv" | cut -f1)
|
||||
echo " venv: $VENV_SIZE_BEFORE -> $VENV_SIZE_AFTER"
|
||||
|
||||
# Step 2: Create tarball
|
||||
echo -e "${GREEN}[2/2]${NC} Creating tarball..."
|
||||
cd "$INSTALL_DIR"
|
||||
tar -cf - venv/ | zstd -19 -T0 > "$OUTPUT_FILE"
|
||||
|
||||
# Summary
|
||||
FINAL_SIZE=$(ls -lh "$OUTPUT_FILE" | awk '{print $5}')
|
||||
echo ""
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e " Output: ${YELLOW}$OUTPUT_FILE${NC}"
|
||||
echo -e " Size: ${YELLOW}$FINAL_SIZE${NC}"
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo "To pull to your host machine:"
|
||||
echo " scp $(whoami)@$(hostname).local:$OUTPUT_FILE ./"
|
||||
echo ""
|
||||
echo "To use in setup.sh, place at:"
|
||||
echo " rpi/stegasoo-rpi-venv-arm64.tar.zst"
|
||||
echo ""
|
||||
echo "Or upload to GitHub releases for automatic download."
|
||||
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
|
||||
}
|
||||
@@ -14,6 +14,10 @@
|
||||
INSTALL_DIR="/opt/stegasoo"
|
||||
FLAG_FILE="/etc/stegasoo-first-boot"
|
||||
PROFILE_HOOK="/etc/profile.d/stegasoo-wizard.sh"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Source banner functions
|
||||
source "$SCRIPT_DIR/banner.sh"
|
||||
|
||||
# Check if this is first boot
|
||||
if [ ! -f "$FLAG_FILE" ]; then
|
||||
@@ -39,25 +43,58 @@ clear
|
||||
# Welcome
|
||||
# =============================================================================
|
||||
|
||||
print_banner "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"
|
||||
|
||||
gum style --foreground 245 "This wizard will help you configure your Stegasoo server"
|
||||
echo ""
|
||||
gum style --foreground 245 "This wizard will help you configure your Stegasoo server."
|
||||
gum style --foreground 245 "You can reconfigure later by editing /etc/systemd/system/stegasoo.service"
|
||||
gum style --foreground 245 "You can reconfigure later by editing:"
|
||||
gum style --foreground 214 " /etc/systemd/system/stegasoo.service"
|
||||
echo ""
|
||||
|
||||
gum confirm "Ready to begin setup?" || exit 0
|
||||
|
||||
# =============================================================================
|
||||
# Step 0: Expand Filesystem
|
||||
# =============================================================================
|
||||
|
||||
clear
|
||||
gum style \
|
||||
--foreground 212 --bold \
|
||||
"Step 0: Expand Filesystem"
|
||||
echo ""
|
||||
|
||||
# Get current and total size
|
||||
ROOT_DEV=$(findmnt -n -o SOURCE /)
|
||||
CURRENT_SIZE=$(df -h / | awk 'NR==2 {print $2}')
|
||||
TOTAL_SIZE=$(lsblk -b -d -o SIZE $(echo "$ROOT_DEV" | sed 's/[0-9]*$//') 2>/dev/null | tail -1 | awk '{printf "%.0fG", $1/1024/1024/1024}')
|
||||
|
||||
gum style --foreground 245 "\
|
||||
The filesystem is currently $CURRENT_SIZE but your SD card may be larger.
|
||||
Expanding will use all available space on the SD card."
|
||||
echo ""
|
||||
gum style --foreground 245 "Current: $CURRENT_SIZE"
|
||||
echo ""
|
||||
|
||||
if gum confirm "Expand filesystem to fill SD card?" --default=true; then
|
||||
# Get the disk device (strip partition number) and partition number
|
||||
DISK_DEV=$(echo "$ROOT_DEV" | sed 's/p\?[0-9]*$//')
|
||||
PART_NUM=$(echo "$ROOT_DEV" | grep -o '[0-9]*$')
|
||||
|
||||
echo ""
|
||||
gum style --foreground 245 "Expanding partition..."
|
||||
sudo growpart "$DISK_DEV" "$PART_NUM" 2>&1 || true
|
||||
|
||||
gum style --foreground 245 "Expanding filesystem..."
|
||||
sudo resize2fs "$ROOT_DEV" 2>&1
|
||||
|
||||
NEW_SIZE=$(df -h / | awk 'NR==2 {print $2}')
|
||||
echo ""
|
||||
gum style --foreground 82 "✓ Expanded to: $NEW_SIZE"
|
||||
else
|
||||
gum style --foreground 214 "→ Skipped (run 'sudo growpart /dev/sdX 2 && sudo resize2fs /dev/sdX2' later)"
|
||||
fi
|
||||
sleep 1
|
||||
|
||||
# =============================================================================
|
||||
# Configuration Variables
|
||||
# =============================================================================
|
||||
@@ -142,52 +179,100 @@ This is useful if you want to share encoded images only with
|
||||
specific people (family, team, etc)."
|
||||
echo ""
|
||||
|
||||
if gum confirm "Generate a private channel key?" --default=false; then
|
||||
echo ""
|
||||
# Generate key to temp file (gum spin doesn't capture stdout well)
|
||||
KEY_FILE=$(mktemp)
|
||||
ERR_FILE=$(mktemp)
|
||||
VENV_PYTHON="$INSTALL_DIR/venv/bin/python"
|
||||
gum spin --spinner dot --title "Generating channel key..." -- \
|
||||
bash -c "'$VENV_PYTHON' -c 'from stegasoo.channel import generate_channel_key; print(generate_channel_key())' > '$KEY_FILE' 2>'$ERR_FILE'"
|
||||
CHANNEL_CHOICE=$(gum choose \
|
||||
"Skip (public mode)" \
|
||||
"Generate new key" \
|
||||
"Enter existing key")
|
||||
|
||||
CHANNEL_KEY=$(cat "$KEY_FILE" 2>/dev/null | head -1)
|
||||
KEY_ERROR=$(cat "$ERR_FILE" 2>/dev/null)
|
||||
rm -f "$KEY_FILE" "$ERR_FILE"
|
||||
case "$CHANNEL_CHOICE" in
|
||||
"Generate new key")
|
||||
echo ""
|
||||
# Generate key to temp file (gum spin doesn't capture stdout well)
|
||||
KEY_FILE=$(mktemp)
|
||||
ERR_FILE=$(mktemp)
|
||||
VENV_PYTHON="$INSTALL_DIR/venv/bin/python"
|
||||
gum spin --spinner dot --title "Generating channel key..." -- \
|
||||
bash -c "'$VENV_PYTHON' -c 'from stegasoo.channel import generate_channel_key; print(generate_channel_key())' > '$KEY_FILE' 2>'$ERR_FILE'"
|
||||
|
||||
if [ -n "$CHANNEL_KEY" ] && [[ "$CHANNEL_KEY" =~ ^[A-Za-z0-9] ]]; then
|
||||
echo ""
|
||||
gum style --foreground 82 "✓ Channel key generated!"
|
||||
echo ""
|
||||
gum style \
|
||||
--border rounded \
|
||||
--border-foreground 226 \
|
||||
--padding "1 2" \
|
||||
--foreground 226 --bold \
|
||||
"$CHANNEL_KEY"
|
||||
echo ""
|
||||
gum style --foreground 196 --bold \
|
||||
"*** IMPORTANT: Write down or copy this key NOW! ***"
|
||||
gum style --foreground 196 \
|
||||
"You'll need to share it with anyone who should decode" \
|
||||
"your images. This key won't be shown again."
|
||||
echo ""
|
||||
gum confirm "I've saved the key" --default=true --affirmative="Continue" --negative=""
|
||||
else
|
||||
gum style --foreground 196 "Failed to generate key. Using public mode."
|
||||
if [ -n "$KEY_ERROR" ]; then
|
||||
CHANNEL_KEY=$(cat "$KEY_FILE" 2>/dev/null | head -1)
|
||||
KEY_ERROR=$(cat "$ERR_FILE" 2>/dev/null)
|
||||
rm -f "$KEY_FILE" "$ERR_FILE"
|
||||
|
||||
if [ -n "$CHANNEL_KEY" ] && [[ "$CHANNEL_KEY" =~ ^[A-Za-z0-9] ]]; then
|
||||
echo ""
|
||||
gum style --foreground 245 "Error details:"
|
||||
echo "$KEY_ERROR"
|
||||
gum style --foreground 82 "✓ Channel key generated!"
|
||||
echo ""
|
||||
gum style \
|
||||
--border rounded \
|
||||
--border-foreground 226 \
|
||||
--padding "1 2" \
|
||||
--foreground 226 --bold \
|
||||
"$CHANNEL_KEY"
|
||||
echo ""
|
||||
gum style --foreground 196 --bold \
|
||||
"*** IMPORTANT: Write down or copy this key NOW! ***"
|
||||
gum style --foreground 196 \
|
||||
"You'll need to share it with anyone who should decode" \
|
||||
"your images. This key won't be shown again."
|
||||
echo ""
|
||||
gum confirm "I've saved the key" --default=true --affirmative="Continue" --negative=""
|
||||
else
|
||||
gum style --foreground 196 "Failed to generate key. Using public mode."
|
||||
if [ -n "$KEY_ERROR" ]; then
|
||||
echo ""
|
||||
gum style --foreground 245 "Error details:"
|
||||
echo "$KEY_ERROR"
|
||||
fi
|
||||
CHANNEL_KEY=""
|
||||
echo ""
|
||||
gum confirm "Continue" --default=true --affirmative="OK" --negative=""
|
||||
fi
|
||||
CHANNEL_KEY=""
|
||||
;;
|
||||
|
||||
"Enter existing key")
|
||||
echo ""
|
||||
gum confirm "Continue" --default=true --affirmative="OK" --negative=""
|
||||
fi
|
||||
else
|
||||
gum style --foreground 214 "→ Using public mode"
|
||||
sleep 0.5
|
||||
fi
|
||||
gum style --foreground 245 "Enter the channel key from your team/deployment."
|
||||
gum style --foreground 245 "Format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
|
||||
echo ""
|
||||
|
||||
while true; do
|
||||
ENTERED_KEY=$(gum input --placeholder "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456" --width 50)
|
||||
|
||||
if [ -z "$ENTERED_KEY" ]; then
|
||||
gum style --foreground 214 "→ Cancelled, using public mode"
|
||||
CHANNEL_KEY=""
|
||||
break
|
||||
fi
|
||||
|
||||
# Validate the key using Python
|
||||
VENV_PYTHON="$INSTALL_DIR/venv/bin/python"
|
||||
if "$VENV_PYTHON" -c "from stegasoo.channel import validate_channel_key, format_channel_key; k='$ENTERED_KEY'; exit(0 if validate_channel_key(k) else 1)" 2>/dev/null; then
|
||||
# Get formatted key
|
||||
CHANNEL_KEY=$("$VENV_PYTHON" -c "from stegasoo.channel import format_channel_key; print(format_channel_key('$ENTERED_KEY'))" 2>/dev/null)
|
||||
echo ""
|
||||
gum style --foreground 82 "✓ Channel key accepted!"
|
||||
gum style --foreground 245 "Key: $CHANNEL_KEY"
|
||||
break
|
||||
else
|
||||
echo ""
|
||||
gum style --foreground 196 "Invalid key format. Please check and try again."
|
||||
gum style --foreground 245 "Expected: 32 alphanumeric characters (with or without dashes)"
|
||||
echo ""
|
||||
if ! gum confirm "Try again?" --default=true; then
|
||||
gum style --foreground 214 "→ Using public mode"
|
||||
CHANNEL_KEY=""
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
;;
|
||||
|
||||
*)
|
||||
gum style --foreground 214 "→ Using public mode"
|
||||
CHANNEL_KEY=""
|
||||
sleep 0.5
|
||||
;;
|
||||
esac
|
||||
|
||||
# =============================================================================
|
||||
# Step 4: Overclock Configuration
|
||||
@@ -279,6 +364,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 "
|
||||
@@ -381,22 +492,11 @@ else
|
||||
fi
|
||||
|
||||
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"
|
||||
|
||||
print_complete_banner "Setup Complete!"
|
||||
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)"
|
||||
gum style --foreground 226 " $ACCESS_URL_LOCAL"
|
||||
gum style --foreground 245 " $ACCESS_URL (fallback IP)"
|
||||
|
||||
if [ -n "$CHANNEL_KEY" ]; then
|
||||
echo ""
|
||||
|
||||
@@ -8,9 +8,14 @@
|
||||
# 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,6 +23,28 @@ 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 lsblk; do
|
||||
if ! command -v $cmd &> /dev/null; then
|
||||
@@ -53,9 +80,9 @@ if [ -z "$1" ]; then
|
||||
echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
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"
|
||||
echo " $0 stegasoo-rpi-4.2.0.img.zst # auto-detect SD card"
|
||||
echo " $0 stegasoo-rpi-4.2.0.img.zst.zip # from GitHub release"
|
||||
echo " $0 stegasoo-rpi-4.2.0.img.zst /dev/sdb # specify device"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -222,6 +249,10 @@ if [ -n "$MOUNTED" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# Ask about wiping (defer actual wipe until after final confirmation)
|
||||
echo
|
||||
read -p "Wipe partition table first? (recommended if having issues) [y/N] " wipe_confirm
|
||||
|
||||
# Final confirmation
|
||||
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${RED}║ WARNING: ALL DATA ON THIS DEVICE WILL BE DESTROYED! ║${NC}"
|
||||
@@ -234,56 +265,151 @@ if [[ ! $REPLY == "yes" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Now wipe if requested
|
||||
if [[ "$wipe_confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Wiping partition table..."
|
||||
sudo wipefs -af "$SELECTED" 2>/dev/null || true
|
||||
sync
|
||||
echo " Wiped"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Flashing image to $SELECTED...${NC}"
|
||||
echo ""
|
||||
|
||||
# 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
|
||||
# Flash with dd (status=progress shows actual write progress)
|
||||
echo -e "${YELLOW}Flashing (this may take several minutes for SD cards)...${NC}"
|
||||
if [ "$COMPRESSED" = true ]; then
|
||||
case "$COMP_TYPE" in
|
||||
xz) xzcat "$IMAGE" | sudo dd of="$SELECTED" bs=1M status=progress ;;
|
||||
zst) zstdcat "$IMAGE" | sudo dd of="$SELECTED" bs=1M status=progress ;;
|
||||
gz) zcat "$IMAGE" | sudo dd of="$SELECTED" bs=1M status=progress ;;
|
||||
esac
|
||||
else
|
||||
USE_DD=true
|
||||
fi
|
||||
|
||||
# Fallback to dd
|
||||
if [ "$USE_DD" = true ]; then
|
||||
if [ "$HAS_PV" = true ]; then
|
||||
echo -e "${YELLOW}Using dd with progress...${NC}"
|
||||
if [ "$COMPRESSED" = true ]; then
|
||||
case "$COMP_TYPE" in
|
||||
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
|
||||
sudo dd if="$IMAGE" of="$SELECTED" bs=1M status=progress
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Syncing...${NC}"
|
||||
sync
|
||||
|
||||
# Wait for partitions to appear
|
||||
sleep 2
|
||||
partprobe "$SELECTED" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# Determine partition names
|
||||
if [[ "$SELECTED" == *"nvme"* ]] || [[ "$SELECTED" == *"mmcblk"* ]]; then
|
||||
BOOT_PART="${SELECTED}p1"
|
||||
ROOT_PART="${SELECTED}p2"
|
||||
else
|
||||
BOOT_PART="${SELECTED}1"
|
||||
ROOT_PART="${SELECTED}2"
|
||||
fi
|
||||
|
||||
# Validate and repair filesystems
|
||||
echo ""
|
||||
echo -e "${YELLOW}Validating filesystems...${NC}"
|
||||
|
||||
echo " Checking boot partition ($BOOT_PART)..."
|
||||
sudo fsck.vfat -a "$BOOT_PART" 2>&1 | grep -v "^$" || true
|
||||
|
||||
echo " Checking root partition ($ROOT_PART)..."
|
||||
sudo e2fsck -f -y "$ROOT_PART" 2>&1 | tail -5 || true
|
||||
|
||||
echo -e "${GREEN} ✓ Filesystems validated${NC}"
|
||||
|
||||
# Inject WiFi config if config.json was loaded
|
||||
if [ "$HAS_CONFIG" = true ]; then
|
||||
echo ""
|
||||
echo -e "${GREEN}Configuring WiFi from config.json...${NC}"
|
||||
|
||||
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}"
|
||||
@@ -291,5 +417,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 ""
|
||||
|
||||
332
rpi/flash-stock-img.sh
Executable file
@@ -0,0 +1,332 @@
|
||||
#!/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 current partition size in bytes
|
||||
CURRENT_SIZE=$(sudo blockdev --getsize64 "$ROOT_PART")
|
||||
TARGET_BYTES=$((16 * 1024 * 1024 * 1024)) # 16GB in bytes
|
||||
|
||||
# Get boot partition end in sectors
|
||||
BOOT_END=$(sudo parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
||||
|
||||
# Calculate 16GB in sectors (512 byte sectors)
|
||||
ROOT_SIZE_SECTORS=33554432
|
||||
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
||||
|
||||
if [ "$CURRENT_SIZE" -lt "$TARGET_BYTES" ]; then
|
||||
# EXPANDING: partition first, then filesystem
|
||||
echo "Current partition is smaller than 16GB - expanding..."
|
||||
|
||||
# Delete and recreate partition 2 with 16GB size
|
||||
echo "Expanding partition to 16GB..."
|
||||
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 2
|
||||
|
||||
# Expand filesystem to fill the new partition
|
||||
echo "Expanding filesystem to fill partition..."
|
||||
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
sudo resize2fs "$ROOT_PART"
|
||||
else
|
||||
# SHRINKING: filesystem first, then partition
|
||||
echo "Current partition is larger than 16GB - shrinking..."
|
||||
|
||||
# Check and shrink filesystem first
|
||||
echo "Checking filesystem..."
|
||||
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
|
||||
# Shrink filesystem to 15.5GB (leave room for partition overhead)
|
||||
echo "Shrinking filesystem to 15500M..."
|
||||
sudo resize2fs "$ROOT_PART" 15500M
|
||||
|
||||
# Delete and recreate partition 2 with 16GB size
|
||||
echo "Shrinking partition to 16GB..."
|
||||
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 2
|
||||
|
||||
# Expand filesystem to fill the partition exactly
|
||||
echo "Expanding filesystem to fill partition..."
|
||||
sudo e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
sudo resize2fs "$ROOT_PART"
|
||||
fi
|
||||
|
||||
# Verify and show result
|
||||
echo "Verifying partition size..."
|
||||
sudo parted -s "$DEVICE" unit GB print | grep "^ 2"
|
||||
|
||||
# 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"
|
||||
|
||||
sudo umount "$TEMP_ROOT"
|
||||
rmdir "$TEMP_ROOT"
|
||||
|
||||
echo " Rootfs set 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. Access via:"
|
||||
echo " mDNS: http://$PI_HOSTNAME.local"
|
||||
echo " Find IP: ping $PI_HOSTNAME.local"
|
||||
echo
|
||||
echo "Once booted, SSH with: ssh $PI_USER@$PI_HOSTNAME.local"
|
||||
|
||||
# If we resized, remind about pull-image.sh
|
||||
if [[ ! "$resize_confirm" =~ ^[Nn]$ ]]; then
|
||||
echo
|
||||
echo "=== After setup, use pull-image.sh to create distributable image ==="
|
||||
echo " ./pull-image.sh $DEVICE stegasoo-rpi-VERSION.img.zst"
|
||||
echo
|
||||
echo "This will only pull the 16GB partition, not the entire SD card."
|
||||
fi
|
||||
29
rpi/host-requirements.txt
Normal file
@@ -0,0 +1,29 @@
|
||||
# Host Machine Dependencies for Stegasoo Pi Scripts
|
||||
# =================================================
|
||||
#
|
||||
# Quick install (Debian/Ubuntu):
|
||||
# sudo apt install parted e2fsprogs zstd zip bc pv jq unzip sshpass
|
||||
#
|
||||
# Or install with this file:
|
||||
# sudo apt install $(grep -v '^#' rpi/host-requirements.txt | grep -v '^$' | xargs)
|
||||
|
||||
# pull-image.sh - Create distributable images
|
||||
parted # Partition table reading/writing
|
||||
e2fsprogs # e2fsck, resize2fs for ext4
|
||||
zstd # Compression (zstd -T0 -3)
|
||||
zip # Optional .zst.zip wrapper for GitHub
|
||||
bc # Floating point math for size display
|
||||
pv # Progress bar (optional, falls back to dd status)
|
||||
|
||||
# flash-image.sh - Flash images to SD cards
|
||||
unzip # Extract .zst.zip wrappers
|
||||
zstd # Decompress .zst images
|
||||
pv # Progress bar (optional)
|
||||
jq # Parse config.json for headless WiFi (optional)
|
||||
|
||||
# kickoff-pi-test.sh - Automated flash+test
|
||||
sshpass # Non-interactive SSH with password
|
||||
avahi-utils # avahi-resolve for .local hostname lookup
|
||||
|
||||
# Optional tools
|
||||
rpi-imager # Faster flashing (flash-image.sh falls back to dd)
|
||||
@@ -2,56 +2,39 @@
|
||||
|
||||
This directory contains patches for dependencies that need modifications to build on ARM64.
|
||||
|
||||
## Current Status (v4.2+)
|
||||
|
||||
As of Stegasoo 4.2, we use **jpeglib** instead of jpegio. The jpeglib build process is handled inline in `setup.sh` and includes:
|
||||
|
||||
- Cloning from GitHub (PyPI tarball missing headers)
|
||||
- Downloading libjpeg headers for each version (6b through 9f)
|
||||
- Patching setup.py to skip turbo/mozjpeg (need cmake-generated headers)
|
||||
|
||||
See `setup.sh` for the full implementation.
|
||||
|
||||
## Legacy: jpegio Patches (v4.1 and earlier)
|
||||
|
||||
The `jpegio/` directory contains patches for the old jpegio dependency, which required removing x86-specific `-m64` compiler flags. These are no longer used but kept for reference.
|
||||
|
||||
## jpeglib Helper Script
|
||||
|
||||
The `jpeglib/install-jpeglib-arm64.sh` script is a standalone version of the jpeglib build process. It's not used by setup.sh (which has the logic inline) but can be useful for manual testing or debugging.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
patches/
|
||||
<package>/
|
||||
arm64.patch # Standard unified diff patch file
|
||||
apply-patch.sh # Script with fallback strategies
|
||||
jpegio/ # Legacy (v4.1) - not used in v4.2+
|
||||
arm64.patch
|
||||
apply-patch.sh
|
||||
jpeglib/ # Reference script for manual builds
|
||||
install-jpeglib-arm64.sh
|
||||
```
|
||||
|
||||
## How It Works
|
||||
## Adding New Patches
|
||||
|
||||
The `apply-patch.sh` script tries multiple strategies in order:
|
||||
|
||||
1. **Patch file** - Apply the `.patch` file using `patch -p1`
|
||||
2. **Sed fallback** - Use sed for simple string replacements
|
||||
3. **Python fallback** - Use regex for flexible pattern matching
|
||||
|
||||
This layered approach handles:
|
||||
- Exact matches (patch file works)
|
||||
- Minor upstream changes (sed catches variations)
|
||||
- Significant changes (Python regex is most flexible)
|
||||
- Already patched files (detected and skipped)
|
||||
|
||||
## Adding a New Patch
|
||||
If a new dependency needs ARM64 patches:
|
||||
|
||||
1. Create a directory: `patches/<package>/`
|
||||
2. Create the patch file: `git diff > arm64.patch`
|
||||
3. Create `apply-patch.sh` with appropriate fallback logic
|
||||
4. Update `setup.sh` to call the patch script
|
||||
|
||||
## jpegio Patch
|
||||
|
||||
The jpegio library includes x86-specific `-m64` compiler flags that fail on ARM64.
|
||||
The patch removes these flags by replacing:
|
||||
|
||||
```python
|
||||
cargs.append('-m64')
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
pass # ARM64: removed x86-specific -m64 flag
|
||||
```
|
||||
|
||||
## Updating Patches
|
||||
|
||||
When upstream changes break a patch:
|
||||
|
||||
1. Clone the new version
|
||||
2. Make the necessary modifications
|
||||
3. Generate a new patch: `diff -u original modified > arm64.patch`
|
||||
4. Test on a fresh Pi install
|
||||
2. Add patch files or helper scripts
|
||||
3. Update `setup.sh` to apply the patch during installation
|
||||
|
||||
57
rpi/patches/jpeglib/install-jpeglib-arm64.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Install jpeglib on ARM64 Linux (Raspberry Pi)
|
||||
# Works around missing headers in the source tarball
|
||||
#
|
||||
# Usage: ./install-jpeglib-arm64.sh
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
echo "Installing jpeglib for ARM64..."
|
||||
|
||||
# Create temp directory
|
||||
WORKDIR=$(mktemp -d)
|
||||
cd "$WORKDIR"
|
||||
|
||||
# Download jpeglib source
|
||||
echo " Downloading jpeglib source..."
|
||||
pip download jpeglib==1.0.2 --no-binary :all: --no-deps -d . -q
|
||||
tar -xzf jpeglib-1.0.2.tar.gz
|
||||
cd jpeglib-1.0.2
|
||||
|
||||
# Download official libjpeg sources and copy headers
|
||||
echo " Downloading libjpeg headers..."
|
||||
CJPEGLIB="src/jpeglib/cjpeglib"
|
||||
|
||||
# libjpeg 6b
|
||||
curl -sL "https://www.ijg.org/files/jpegsrc.v6b.tar.gz" | tar -xzf -
|
||||
cp jpeg-6b/*.h "$CJPEGLIB/6b/"
|
||||
|
||||
# libjpeg 7-9f (all use similar headers from 9e)
|
||||
curl -sL "https://www.ijg.org/files/jpegsrc.v9f.tar.gz" | tar -xzf -
|
||||
for v in 7 8 8a 8b 8c 8d 9 9a 9b 9c 9d 9e 9f; do
|
||||
cp jpeg-9f/*.h "$CJPEGLIB/$v/"
|
||||
done
|
||||
|
||||
# libjpeg-turbo versions
|
||||
curl -sL "https://github.com/libjpeg-turbo/libjpeg-turbo/archive/refs/tags/2.1.0.tar.gz" | tar -xzf -
|
||||
for v in turbo120 turbo130 turbo140 turbo150 turbo200 turbo210; do
|
||||
cp libjpeg-turbo-2.1.0/*.h "$CJPEGLIB/$v/" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# mozjpeg versions
|
||||
curl -sL "https://github.com/mozilla/mozjpeg/archive/refs/tags/v4.0.3.tar.gz" | tar -xzf -
|
||||
for v in mozjpeg101 mozjpeg201 mozjpeg300 mozjpeg403; do
|
||||
cp mozjpeg-4.0.3/*.h "$CJPEGLIB/$v/" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Build and install
|
||||
echo " Building jpeglib..."
|
||||
pip install . -q
|
||||
|
||||
# Cleanup
|
||||
cd /
|
||||
rm -rf "$WORKDIR"
|
||||
|
||||
echo " Done! jpeglib installed successfully."
|
||||
@@ -1,31 +1,26 @@
|
||||
#!/bin/bash
|
||||
# Pull Raspberry Pi image from SD card (after setup)
|
||||
# Resizes rootfs to 16GB for consistent image size, then pulls
|
||||
#
|
||||
# Pull Stegasoo image from SD card
|
||||
# Auto-detects SD card, copies with progress, shrinks, and compresses
|
||||
#
|
||||
# Usage: ./pull-image.sh [output-name] [device]
|
||||
# Output will be: stegasoo-rpi-YYYYMMDD.img.zst (or custom name)
|
||||
# Use .img extension to skip compression: ./pull-image.sh foo.img
|
||||
#
|
||||
# If device is specified, skips auto-detection (useful for large drives)
|
||||
#
|
||||
# Usage: ./pull-image.sh <device> <output.img.zst>
|
||||
# Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.2.0.img.zst
|
||||
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Check for required tools
|
||||
for cmd in dd pv zstd lsblk; do
|
||||
if ! command -v $cmd &> /dev/null; then
|
||||
echo -e "${RED}Error: $cmd is required but not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ $# -ne 2 ]; then
|
||||
echo "Usage: $0 <device> <output.img.zst>"
|
||||
echo "Example: $0 /dev/sdb stegasoo-rpi-4.2.0.img.zst"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEVICE="$1"
|
||||
OUTPUT="$2"
|
||||
|
||||
# Check for root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
@@ -33,175 +28,166 @@ if [ "$EUID" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Output filename and optional device
|
||||
if [ -n "$1" ]; then
|
||||
OUTPUT="$1"
|
||||
else
|
||||
OUTPUT="stegasoo-rpi-$(date +%Y%m%d).img.zst"
|
||||
fi
|
||||
MANUAL_DEVICE="$2"
|
||||
|
||||
# Check if output ends in .img (skip compression) or .zst (compress)
|
||||
SKIP_COMPRESS=false
|
||||
if [[ "$OUTPUT" == *.img ]]; then
|
||||
IMG_FILE="$OUTPUT"
|
||||
SKIP_COMPRESS=true
|
||||
elif [[ "$OUTPUT" == *.zst ]]; then
|
||||
IMG_FILE="${OUTPUT%.zst}"
|
||||
else
|
||||
# No recognized extension, add .img.zst
|
||||
IMG_FILE="${OUTPUT}.img"
|
||||
OUTPUT="${OUTPUT}.img.zst"
|
||||
if [ ! -b "$DEVICE" ]; then
|
||||
echo -e "${RED}Error: Device not found: $DEVICE${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}"
|
||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Stegasoo SD Card Image Puller ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
# Use manual device or auto-detect
|
||||
if [ -n "$MANUAL_DEVICE" ]; then
|
||||
# Manual device specified
|
||||
if [ ! -b "$MANUAL_DEVICE" ]; then
|
||||
echo -e "${RED}Error: $MANUAL_DEVICE is not a block device${NC}"
|
||||
exit 1
|
||||
fi
|
||||
SELECTED="$MANUAL_DEVICE"
|
||||
echo -e "Using specified device: ${YELLOW}$SELECTED${NC}"
|
||||
echo ""
|
||||
lsblk "$SELECTED" -o NAME,SIZE,TYPE,MODEL
|
||||
echo ""
|
||||
else
|
||||
# Auto-detect SD card candidates
|
||||
# Looking for: USB/removable, 8-128GB, not mounted as root filesystem
|
||||
echo -e "${BOLD}Scanning for SD cards...${NC}"
|
||||
echo ""
|
||||
|
||||
declare -a CANDIDATES
|
||||
declare -a CANDIDATE_INFO
|
||||
|
||||
while IFS= read -r line; do
|
||||
DEV=$(echo "$line" | awk '{print $1}')
|
||||
SIZE=$(echo "$line" | awk '{print $2}')
|
||||
TYPE=$(echo "$line" | awk '{print $3}')
|
||||
TRAN=$(echo "$line" | awk '{print $4}')
|
||||
MODEL=$(echo "$line" | awk '{print $5" "$6" "$7}' | xargs)
|
||||
|
||||
# Skip if it's the root filesystem
|
||||
if mount | grep -q "^/dev/${DEV}[0-9]* on / "; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip if any partition is mounted as root
|
||||
ROOT_DEV=$(mount | grep " on / " | awk '{print $1}' | sed 's/[0-9]*$//')
|
||||
if [[ "/dev/$DEV" == "$ROOT_DEV" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get size in bytes for reliable comparison
|
||||
SIZE_BYTES=$(lsblk -b -d -o SIZE -n "/dev/$DEV" 2>/dev/null | tr -d ' ')
|
||||
SIZE_GB_INT=$((SIZE_BYTES / 1073741824)) # 1024^3
|
||||
|
||||
# Check if size is in SD card range (8GB - 128GB)
|
||||
if [ "$SIZE_GB_INT" -ge 8 ] && [ "$SIZE_GB_INT" -le 128 ]; then
|
||||
CANDIDATES+=("/dev/$DEV")
|
||||
CANDIDATE_INFO+=("$SIZE $TYPE ${TRAN:-???} $MODEL")
|
||||
fi
|
||||
done < <(lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL -n | grep "disk")
|
||||
|
||||
if [ ${#CANDIDATES[@]} -eq 0 ]; then
|
||||
echo -e "${RED}No SD card candidates found.${NC}"
|
||||
echo "Looking for USB/removable disks between 8GB and 128GB."
|
||||
echo ""
|
||||
echo "Available disks:"
|
||||
lsblk -d -o NAME,SIZE,TYPE,TRAN,MODEL
|
||||
echo ""
|
||||
echo -e "${YELLOW}Tip: Specify device manually: $0 output.img.zst /dev/sdX${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Found ${#CANDIDATES[@]} candidate(s):${NC}"
|
||||
echo ""
|
||||
|
||||
for i in "${!CANDIDATES[@]}"; do
|
||||
echo -e " ${BOLD}[$((i+1))]${NC} ${CANDIDATES[$i]} - ${CANDIDATE_INFO[$i]}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
if [ ${#CANDIDATES[@]} -eq 1 ]; then
|
||||
SELECTED="${CANDIDATES[0]}"
|
||||
echo -e "Auto-selected: ${YELLOW}$SELECTED${NC}"
|
||||
else
|
||||
read -p "Select device [1-${#CANDIDATES[@]}]: " -r
|
||||
if [[ ! $REPLY =~ ^[0-9]+$ ]] || [ "$REPLY" -lt 1 ] || [ "$REPLY" -gt ${#CANDIDATES[@]} ]; then
|
||||
echo -e "${RED}Invalid selection.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
SELECTED="${CANDIDATES[$((REPLY-1))]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Show partitions
|
||||
echo ""
|
||||
echo -e "${BOLD}Partitions on $SELECTED:${NC}"
|
||||
lsblk "$SELECTED" -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT
|
||||
echo ""
|
||||
|
||||
# Final confirmation
|
||||
echo -e "${RED}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${RED}║ WARNING: This will read the ENTIRE device: ║${NC}"
|
||||
echo -e "${RED}║ $SELECTED ║${NC}"
|
||||
echo -e "${RED}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "Output: ${YELLOW}$OUTPUT${NC}"
|
||||
echo ""
|
||||
read -p "Continue? [y/N] " -n 1 -r
|
||||
echo -e "${BOLD}Device info:${NC}"
|
||||
lsblk "$DEVICE"
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
|
||||
# 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 -e "${RED}Error: Could not find partitions${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Unmount any mounted partitions
|
||||
echo -e "${YELLOW}Unmounting partitions...${NC}"
|
||||
umount "$BOOT_PART" 2>/dev/null || true
|
||||
umount "$ROOT_PART" 2>/dev/null || true
|
||||
|
||||
# ============================================================================
|
||||
# Resize rootfs to 16GB
|
||||
# ============================================================================
|
||||
echo
|
||||
echo -e "${BOLD}Checking partition size...${NC}"
|
||||
|
||||
# Get current partition size in bytes
|
||||
CURRENT_SIZE=$(blockdev --getsize64 "$ROOT_PART")
|
||||
TARGET_BYTES=$((16 * 1024 * 1024 * 1024)) # 16GB in bytes
|
||||
CURRENT_GB=$(echo "scale=2; $CURRENT_SIZE / 1073741824" | bc)
|
||||
|
||||
echo " Current rootfs size: ${CURRENT_GB}GB"
|
||||
|
||||
if [ "$CURRENT_SIZE" -gt "$TARGET_BYTES" ]; then
|
||||
echo -e "${YELLOW}Resizing rootfs to 16GB...${NC}"
|
||||
|
||||
# Get boot partition end in sectors
|
||||
BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
||||
|
||||
# Calculate 16GB in sectors (512 byte sectors)
|
||||
ROOT_SIZE_SECTORS=33554432
|
||||
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
||||
|
||||
# SHRINKING: filesystem first, then partition
|
||||
echo " Checking filesystem..."
|
||||
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
|
||||
# Shrink filesystem to 15.5GB (leave room for partition overhead)
|
||||
echo " Shrinking filesystem to 15500M..."
|
||||
resize2fs "$ROOT_PART" 15500M
|
||||
|
||||
# Delete and recreate partition 2 with 16GB size
|
||||
echo " Shrinking partition to 16GB..."
|
||||
parted -s "$DEVICE" rm 2
|
||||
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||
|
||||
# Refresh partition table
|
||||
partprobe "$DEVICE"
|
||||
sleep 2
|
||||
|
||||
# Expand filesystem to fill the partition exactly
|
||||
echo " Expanding filesystem to fill partition..."
|
||||
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
resize2fs "$ROOT_PART"
|
||||
|
||||
echo -e "${GREEN} Rootfs resized to 16GB${NC}"
|
||||
elif [ "$CURRENT_SIZE" -lt "$TARGET_BYTES" ]; then
|
||||
echo -e "${YELLOW} Rootfs is smaller than 16GB - expanding...${NC}"
|
||||
|
||||
# Get boot partition end in sectors
|
||||
BOOT_END=$(parted -s "$DEVICE" unit s print | grep "^ 1" | awk '{print $3}' | tr -d 's')
|
||||
ROOT_SIZE_SECTORS=33554432
|
||||
ROOT_END=$((BOOT_END + ROOT_SIZE_SECTORS))
|
||||
|
||||
# EXPANDING: partition first, then filesystem
|
||||
parted -s "$DEVICE" rm 2
|
||||
parted -s "$DEVICE" mkpart primary ext4 $((BOOT_END + 1))s ${ROOT_END}s
|
||||
|
||||
partprobe "$DEVICE"
|
||||
sleep 2
|
||||
|
||||
e2fsck -f -y "$ROOT_PART" 2>/dev/null || true
|
||||
resize2fs "$ROOT_PART"
|
||||
|
||||
echo -e "${GREEN} Rootfs expanded to 16GB${NC}"
|
||||
else
|
||||
echo -e "${GREEN} Rootfs already ~16GB${NC}"
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# Pull image
|
||||
# ============================================================================
|
||||
echo
|
||||
echo -e "${BOLD}Partition table:${NC}"
|
||||
parted -s "$DEVICE" unit s print
|
||||
echo
|
||||
|
||||
# Get the end of the last partition (partition 2 = rootfs)
|
||||
END_SECTOR=$(parted -s "$DEVICE" unit s print | grep "^ 2" | awk '{print $3}' | tr -d 's')
|
||||
|
||||
if [ -z "$END_SECTOR" ]; then
|
||||
echo -e "${RED}Error: Could not determine partition 2 end sector${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add a small buffer (1MB = 2048 sectors) for safety
|
||||
TOTAL_SECTORS=$((END_SECTOR + 2048))
|
||||
TOTAL_BYTES=$((TOTAL_SECTORS * 512))
|
||||
TOTAL_GB=$(echo "scale=2; $TOTAL_BYTES / 1073741824" | bc)
|
||||
|
||||
echo -e "Image size: ${YELLOW}~${TOTAL_GB}GB${NC} (${TOTAL_SECTORS} sectors)"
|
||||
echo -e "Output: ${YELLOW}$OUTPUT${NC}"
|
||||
echo
|
||||
|
||||
read -p "Proceed with image pull? [Y/n] " confirm
|
||||
if [[ "$confirm" =~ ^[Nn]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get device size for pv
|
||||
DEV_SIZE=$(blockdev --getsize64 "$SELECTED")
|
||||
echo
|
||||
echo -e "${GREEN}Pulling image...${NC}"
|
||||
echo
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}[1/3]${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..."
|
||||
if command -v pishrink.sh &> /dev/null; then
|
||||
pishrink.sh "$IMG_FILE"
|
||||
elif [ -f "./pishrink.sh" ]; then
|
||||
bash ./pishrink.sh "$IMG_FILE"
|
||||
elif [ -f "../pishrink.sh" ]; then
|
||||
bash ../pishrink.sh "$IMG_FILE"
|
||||
# Use pv if available for progress, otherwise fallback to dd status
|
||||
if command -v pv &> /dev/null; then
|
||||
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS 2>/dev/null | \
|
||||
pv -s $TOTAL_BYTES | \
|
||||
zstd -T0 -3 > "$OUTPUT"
|
||||
else
|
||||
echo -e "${YELLOW}pishrink.sh not found, skipping shrink step.${NC}"
|
||||
echo "Download from: https://github.com/Drewsif/PiShrink"
|
||||
dd if="$DEVICE" bs=512 count=$TOTAL_SECTORS status=progress | \
|
||||
zstd -T0 -3 > "$OUTPUT"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [ "$SKIP_COMPRESS" = true ]; then
|
||||
echo -e "${GREEN}[3/3]${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..."
|
||||
pv "$IMG_FILE" | zstd -19 -T0 -q > "$OUTPUT"
|
||||
rm -f "$IMG_FILE"
|
||||
FINAL_SIZE=$(du -h "$OUTPUT" | awk '{print $1}')
|
||||
fi
|
||||
echo
|
||||
echo -e "${GREEN}Done!${NC} Image saved to: $OUTPUT"
|
||||
ls -lh "$OUTPUT"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Image Complete! ║${NC}"
|
||||
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "Output: ${YELLOW}$OUTPUT${NC}"
|
||||
echo -e "Size: ${YELLOW}$FINAL_SIZE${NC}"
|
||||
echo ""
|
||||
# ============================================================================
|
||||
# Optional: Zip-wrap for GitHub releases
|
||||
# ============================================================================
|
||||
echo
|
||||
read -p "Create .zst.zip wrapper for GitHub? [y/N] " zip_confirm
|
||||
if [[ "$zip_confirm" =~ ^[Yy]$ ]]; then
|
||||
ZIP_OUTPUT="${OUTPUT}.zip"
|
||||
echo -e "${YELLOW}Creating zip wrapper (store mode, no compression)...${NC}"
|
||||
zip -0 "$ZIP_OUTPUT" "$OUTPUT"
|
||||
echo -e "${GREEN}Done!${NC} Upload this to GitHub Releases:"
|
||||
ls -lh "$ZIP_OUTPUT"
|
||||
echo
|
||||
echo "Users can flash with:"
|
||||
echo " sudo ./rpi/flash-image.sh $ZIP_OUTPUT"
|
||||
else
|
||||
echo
|
||||
echo "To verify:"
|
||||
echo " zstdcat $OUTPUT | fdisk -l /dev/stdin"
|
||||
fi
|
||||
|
||||
144
rpi/remote-build-pi.sh
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Stegasoo Remote Pi Build Script
|
||||
# Waits for Pi to be reachable, then sets up Stegasoo
|
||||
#
|
||||
# Usage: ./remote-build-pi.sh [host] [user] [pass]
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Pi connection settings (defaults)
|
||||
PI_HOST="${1:-stegasoo.local}"
|
||||
PI_USER="${2:-admin}"
|
||||
PI_PASS="${3:-stegasoo}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
wait_for_pi() {
|
||||
local attempt=1
|
||||
ssh-keygen -R "$PI_HOST" 2>/dev/null || true
|
||||
|
||||
echo "Waiting for $PI_USER@$PI_HOST..."
|
||||
while ! sshpass -p "$PI_PASS" ssh -o ConnectTimeout=2 -o StrictHostKeyChecking=no -o BatchMode=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "exit" 2>/dev/null; do
|
||||
printf "\rAttempt %d..." "$attempt"
|
||||
((attempt++))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
printf "\r${GREEN}✓ Ready after %d attempts${NC}\n" "$attempt"
|
||||
printf '\a'
|
||||
}
|
||||
|
||||
run_on_pi() {
|
||||
sshpass -p "$PI_PASS" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
|
||||
}
|
||||
|
||||
run_on_pi_interactive() {
|
||||
sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
|
||||
}
|
||||
|
||||
scp_to_pi() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
sshpass -p "$PI_PASS" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$src" "$PI_USER@$PI_HOST:$dst"
|
||||
}
|
||||
|
||||
ssh_pi() {
|
||||
ssh-keygen -R "$PI_HOST" 2>/dev/null || true
|
||||
sshpass -p "$PI_PASS" ssh -t -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$PI_USER@$PI_HOST" "$@"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${CYAN}║ Stegasoo Remote Pi Build ║${NC}"
|
||||
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "Host: ${YELLOW}$PI_HOST${NC}"
|
||||
echo -e "User: ${YELLOW}$PI_USER${NC}"
|
||||
echo ""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 1: Wait for Pi to be ready
|
||||
# -----------------------------------------------------------------------------
|
||||
echo -e "${GREEN}[1/6]${NC} Waiting for Pi..."
|
||||
echo ""
|
||||
|
||||
wait_for_pi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 2: Install dependencies
|
||||
# -----------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo -e "${GREEN}[2/6]${NC} Installing dependencies on Pi..."
|
||||
echo ""
|
||||
|
||||
run_on_pi "sudo chown admin:admin /opt && sudo apt-get update && sudo apt-get install -y git zstd jq ca-certificates && sudo update-ca-certificates"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 3: Clone repo
|
||||
# -----------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo -e "${GREEN}[3/6]${NC} Cloning Stegasoo repo..."
|
||||
echo ""
|
||||
|
||||
run_on_pi "cd /opt && rm -rf stegasoo && git clone https://github.com/adlee-was-taken/stegasoo.git stegasoo"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 4: Copy pre-built tarball
|
||||
# -----------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo -e "${GREEN}[4/6]${NC} Copying pre-built tarball to Pi..."
|
||||
echo ""
|
||||
|
||||
TARBALL="$SCRIPT_DIR/stegasoo-rpi-venv-arm64.tar.zst"
|
||||
if [[ -f "$TARBALL" ]]; then
|
||||
scp_to_pi "$TARBALL" "/opt/stegasoo/rpi/"
|
||||
echo -e " ${GREEN}✓${NC} Tarball copied"
|
||||
else
|
||||
echo -e " ${YELLOW}⚠${NC} Tarball not found at $TARBALL"
|
||||
echo -e " ${YELLOW}⚠${NC} Setup will build from source (takes longer)"
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 5: Run setup
|
||||
# -----------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo -e "${GREEN}[5/6]${NC} Running setup.sh on Pi..."
|
||||
echo ""
|
||||
|
||||
run_on_pi_interactive "cd /opt/stegasoo && ./rpi/setup.sh"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 6: Test it works
|
||||
# -----------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo -e "${GREEN}[6/6]${NC} Testing Stegasoo..."
|
||||
echo ""
|
||||
|
||||
run_on_pi "sudo systemctl start stegasoo && sleep 2 && curl -sk https://localhost:5000 | head -5"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} Build complete! Pi is ready for testing.${NC}"
|
||||
echo -e "${GREEN}════════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e "Access: ${YELLOW}https://$PI_HOST:5000${NC}"
|
||||
echo ""
|
||||
read -p "Press ENTER to SSH into Pi for manual testing..."
|
||||
|
||||
ssh_pi
|
||||
@@ -29,6 +29,10 @@ GRAY='\033[0;90m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Source banner functions
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/banner.sh"
|
||||
|
||||
# Show help
|
||||
show_help() {
|
||||
echo "Stegasoo Sanitize Script - Prepare Pi for SD Card Imaging"
|
||||
@@ -70,21 +74,11 @@ if [ "$EUID" -ne 0 ]; then
|
||||
fi
|
||||
|
||||
clear
|
||||
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 "\033[1;37m Soft Reset (Factory)\033[0m"
|
||||
print_banner "Soft Reset (Factory)"
|
||||
else
|
||||
echo -e "\033[1;37m Sanitize for Imaging\033[0m"
|
||||
print_banner "Sanitize for Imaging"
|
||||
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
|
||||
@@ -270,49 +264,25 @@ if [ -n "$STEGASOO_DIR" ] && [ -d "$STEGASOO_DIR/venv" ]; then
|
||||
echo " Venv broken or stegasoo not installed, rebuilding..."
|
||||
rm -rf "$STEGASOO_DIR/venv"
|
||||
|
||||
# Find Python 3.12 (prefer pyenv, fall back to system)
|
||||
USER_HOME=$(eval echo "~$STEGASOO_USER")
|
||||
PYENV_PYTHON="$USER_HOME/.pyenv/versions/3.12*/bin/python"
|
||||
if compgen -G "$PYENV_PYTHON" > /dev/null 2>&1; then
|
||||
PYTHON_BIN=$(ls $PYENV_PYTHON 2>/dev/null | head -1)
|
||||
echo " Using pyenv Python: $PYTHON_BIN"
|
||||
elif command -v python3.12 &>/dev/null; then
|
||||
PYTHON_BIN="python3.12"
|
||||
echo " Using system Python 3.12"
|
||||
else
|
||||
PYTHON_BIN="python3"
|
||||
echo " Warning: Python 3.12 not found, using $($PYTHON_BIN --version)"
|
||||
fi
|
||||
|
||||
sudo -u "$STEGASOO_USER" "$PYTHON_BIN" -m venv "$STEGASOO_DIR/venv"
|
||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet --upgrade pip setuptools wheel
|
||||
|
||||
# On ARM64, jpegio needs patching before install
|
||||
ARCH=$(uname -m)
|
||||
if [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then
|
||||
echo " Building jpegio for ARM64 (this may take a minute)..."
|
||||
# Install build deps
|
||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet cython numpy
|
||||
JPEGIO_DIR="/tmp/jpegio-build-$$"
|
||||
rm -rf "$JPEGIO_DIR"
|
||||
if git clone https://github.com/dwgoon/jpegio.git "$JPEGIO_DIR" 2>/dev/null; then
|
||||
# Apply patch to remove -m64 flag
|
||||
if [ -f "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" ]; then
|
||||
bash "$STEGASOO_DIR/rpi/patches/jpegio/apply-patch.sh" "$JPEGIO_DIR"
|
||||
else
|
||||
sed -i "s/cargs.append('-m64')/pass # ARM64 fix/g" "$JPEGIO_DIR/setup.py"
|
||||
fi
|
||||
# Change ownership so user can build
|
||||
chown -R "$STEGASOO_USER:$STEGASOO_USER" "$JPEGIO_DIR"
|
||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install "$JPEGIO_DIR"
|
||||
rm -rf "$JPEGIO_DIR"
|
||||
else
|
||||
echo " Warning: Failed to clone jpegio, DCT mode may not work"
|
||||
# Find system Python 3.11+ (no pyenv needed)
|
||||
PYTHON_BIN=""
|
||||
for py in python3.14 python3.13 python3.12 python3.11 python3; do
|
||||
if command -v "$py" &>/dev/null; then
|
||||
PYTHON_BIN=$(command -v "$py")
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet -e "$STEGASOO_DIR[web]"
|
||||
echo " Venv rebuilt and stegasoo installed"
|
||||
if [ -z "$PYTHON_BIN" ]; then
|
||||
echo " Error: Python 3.11+ not found"
|
||||
VALIDATION_ERRORS=$((VALIDATION_ERRORS + 1))
|
||||
else
|
||||
echo " Using: $PYTHON_BIN ($($PYTHON_BIN --version 2>&1))"
|
||||
sudo -u "$STEGASOO_USER" "$PYTHON_BIN" -m venv "$STEGASOO_DIR/venv"
|
||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet --upgrade pip setuptools wheel
|
||||
sudo -u "$STEGASOO_USER" "$STEGASOO_DIR/venv/bin/pip" install --quiet -e "$STEGASOO_DIR[web]"
|
||||
echo " Venv rebuilt and stegasoo installed"
|
||||
fi
|
||||
else
|
||||
echo " Venv OK"
|
||||
fi
|
||||
|
||||
523
rpi/setup.sh
@@ -4,14 +4,14 @@
|
||||
# Tested on: Raspberry Pi 4/5 with Raspberry Pi OS (64-bit)
|
||||
#
|
||||
# Usage:
|
||||
# curl -sSL https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.1/rpi/setup.sh | bash
|
||||
# curl -sSL https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.2/rpi/setup.sh | bash
|
||||
# # or
|
||||
# wget -qO- https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.1/rpi/setup.sh | bash
|
||||
# wget -qO- https://raw.githubusercontent.com/adlee-was-taken/stegasoo/4.2/rpi/setup.sh | bash
|
||||
#
|
||||
# What this script does:
|
||||
# 1. Installs system dependencies
|
||||
# 2. Installs Python 3.12 via pyenv (Pi OS ships with 3.13 which is incompatible)
|
||||
# 3. Patches and builds jpegio for ARM
|
||||
# 2. Verifies Python 3.11+ (uses system Python)
|
||||
# 3. Installs jpeglib for DCT steganography (Python 3.11-3.14 compatible)
|
||||
# 4. Installs Stegasoo with web UI
|
||||
# 5. Creates systemd service for auto-start
|
||||
# 6. Enables the service
|
||||
@@ -29,6 +29,33 @@ GRAY='\033[0;90m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Source banner.sh if available (for local runs), otherwise define inline
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
|
||||
if [ -f "$SCRIPT_DIR/banner.sh" ]; then
|
||||
source "$SCRIPT_DIR/banner.sh"
|
||||
else
|
||||
# Inline banner functions for curl-pipe execution
|
||||
print_gradient_line() {
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
}
|
||||
print_banner() {
|
||||
local subtitle="$1"
|
||||
echo ""
|
||||
print_gradient_line
|
||||
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"
|
||||
print_gradient_line
|
||||
if [ -n "$subtitle" ]; then
|
||||
echo -e "\033[1;37m ${subtitle}\033[0m"
|
||||
print_gradient_line
|
||||
fi
|
||||
}
|
||||
fi
|
||||
|
||||
# Show help
|
||||
show_help() {
|
||||
echo "Stegasoo Raspberry Pi Setup Script"
|
||||
@@ -36,7 +63,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):"
|
||||
@@ -46,9 +75,8 @@ show_help() {
|
||||
echo ""
|
||||
echo " Available variables:"
|
||||
echo " INSTALL_DIR Install location (default: /opt/stegasoo)"
|
||||
echo " PYTHON_VERSION Python version (default: 3.12)"
|
||||
echo " STEGASOO_REPO Git repo URL"
|
||||
echo " STEGASOO_BRANCH Git branch (default: 4.1)"
|
||||
echo " STEGASOO_BRANCH Git branch (default: 4.2)"
|
||||
echo ""
|
||||
echo " Example:"
|
||||
echo " export INSTALL_DIR=\"/home/pi/stegasoo\""
|
||||
@@ -66,10 +94,8 @@ done
|
||||
|
||||
# Default configuration
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/stegasoo}"
|
||||
PYTHON_VERSION="${PYTHON_VERSION:-3.12}"
|
||||
STEGASOO_REPO="${STEGASOO_REPO:-https://github.com/adlee-was-taken/stegasoo.git}"
|
||||
STEGASOO_BRANCH="${STEGASOO_BRANCH:-4.1}"
|
||||
JPEGIO_REPO="https://github.com/dwgoon/jpegio.git"
|
||||
STEGASOO_BRANCH="${STEGASOO_BRANCH:-4.2}"
|
||||
|
||||
# Load config files (system, then user - user overrides system)
|
||||
for config_file in "/etc/stegasoo.conf" "$HOME/.config/stegasoo/stegasoo.conf"; do
|
||||
@@ -80,20 +106,10 @@ for config_file in "/etc/stegasoo.conf" "$HOME/.config/stegasoo/stegasoo.conf";
|
||||
done
|
||||
|
||||
clear
|
||||
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"
|
||||
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"
|
||||
print_banner "Raspberry Pi Setup"
|
||||
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 5-10 min (from source)"
|
||||
echo ""
|
||||
|
||||
# Check if running on ARM
|
||||
@@ -104,6 +120,63 @@ if [[ "$ARCH" != "aarch64" && "$ARCH" != "arm64" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Python Version Check
|
||||
# =============================================================================
|
||||
echo -e "${GREEN}Checking Python version...${NC}"
|
||||
|
||||
# Find system Python
|
||||
SYSTEM_PYTHON=""
|
||||
for py in python3.14 python3.13 python3.12 python3.11 python3; do
|
||||
if command -v "$py" &>/dev/null; then
|
||||
SYSTEM_PYTHON=$(command -v "$py")
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$SYSTEM_PYTHON" ]; then
|
||||
echo -e "${RED}Error: Python 3 not found.${NC}"
|
||||
echo "Please install Python 3.11 or later:"
|
||||
echo " sudo apt-get install python3"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get version numbers
|
||||
PY_VERSION=$("$SYSTEM_PYTHON" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
PY_MAJOR=$("$SYSTEM_PYTHON" -c 'import sys; print(sys.version_info.major)')
|
||||
PY_MINOR=$("$SYSTEM_PYTHON" -c 'import sys; print(sys.version_info.minor)')
|
||||
|
||||
echo " Found: $SYSTEM_PYTHON (Python $PY_VERSION)"
|
||||
|
||||
# Check version range (3.11 <= version <= 3.14)
|
||||
if [ "$PY_MAJOR" -ne 3 ]; then
|
||||
echo -e "${RED}Error: Python 3 required, found Python $PY_MAJOR${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$PY_MINOR" -lt 11 ]; then
|
||||
echo -e "${RED}Error: Python 3.11+ required, found Python $PY_VERSION${NC}"
|
||||
echo ""
|
||||
echo "Raspberry Pi OS Bookworm ships with Python 3.11."
|
||||
echo "Raspberry Pi OS Trixie ships with Python 3.13."
|
||||
echo ""
|
||||
echo "Please upgrade your Raspberry Pi OS or install Python 3.11+."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$PY_MINOR" -gt 14 ]; then
|
||||
echo -e "${YELLOW}Warning: Python $PY_VERSION detected.${NC}"
|
||||
echo "Stegasoo is tested with Python 3.11-3.14."
|
||||
echo "Newer versions may work but are not officially supported."
|
||||
read -p "Continue anyway? [y/N] " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e " ${GREEN}✓${NC} Python $PY_VERSION supported"
|
||||
|
||||
# Check available memory
|
||||
TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}')
|
||||
if [ "$TOTAL_MEM" -lt 2000 ]; then
|
||||
@@ -117,8 +190,11 @@ if [ "$TOTAL_MEM" -lt 2000 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create /opt/stegasoo with proper permissions
|
||||
echo -e "${GREEN}[1/12]${NC} Setting up install directory..."
|
||||
# =============================================================================
|
||||
# Installation
|
||||
# =============================================================================
|
||||
|
||||
echo -e "${GREEN}[1/9]${NC} Setting up install directory..."
|
||||
if [ ! -d "$INSTALL_DIR" ]; then
|
||||
sudo mkdir -p "$INSTALL_DIR"
|
||||
sudo chown "$USER:$USER" "$INSTALL_DIR"
|
||||
@@ -129,12 +205,13 @@ else
|
||||
echo " $INSTALL_DIR exists, updated ownership"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[2/12]${NC} Installing system dependencies..."
|
||||
echo -e "${GREEN}[2/9]${NC} Installing system dependencies..."
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
git \
|
||||
curl \
|
||||
zstd \
|
||||
libssl-dev \
|
||||
zlib1g-dev \
|
||||
libbz2-dev \
|
||||
@@ -150,9 +227,11 @@ sudo apt-get install -y \
|
||||
libzbar0 \
|
||||
libjpeg-dev \
|
||||
python3-dev \
|
||||
python3-venv \
|
||||
python3-pip \
|
||||
btop
|
||||
|
||||
echo -e "${GREEN}[3/12]${NC} Installing gum (TUI toolkit)..."
|
||||
echo -e "${GREEN}[3/9]${NC} Installing gum (TUI toolkit)..."
|
||||
# Add Charm repo for gum
|
||||
if ! command -v gum &>/dev/null; then
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
@@ -164,49 +243,23 @@ else
|
||||
echo " gum already installed"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[4/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
|
||||
# Install mkcert for browser-trusted certificates (no warning screen!)
|
||||
echo " Installing mkcert for trusted HTTPS certificates..."
|
||||
if ! command -v mkcert &>/dev/null; then
|
||||
sudo apt-get install -y libnss3-tools
|
||||
# Download mkcert for ARM64
|
||||
sudo curl -sL "https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-arm64" -o /usr/local/bin/mkcert
|
||||
sudo chmod +x /usr/local/bin/mkcert
|
||||
# Install local CA (makes certs trusted on this Pi)
|
||||
mkcert -install 2>/dev/null || true
|
||||
echo " mkcert installed"
|
||||
else
|
||||
echo "pyenv already installed, skipping..."
|
||||
export PYENV_ROOT="$HOME/.pyenv"
|
||||
export PATH="$PYENV_ROOT/bin:$PATH"
|
||||
eval "$(pyenv init -)"
|
||||
echo " mkcert already installed"
|
||||
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
|
||||
echo -e "${GREEN}[4/9]${NC} Cloning Stegasoo..."
|
||||
|
||||
# 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"
|
||||
@@ -218,53 +271,137 @@ else
|
||||
cd "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[6/12]${NC} Creating Python virtual environment..."
|
||||
# Pre-built venv tarball (skips pip compile time)
|
||||
PREBUILT_TARBALL="$INSTALL_DIR/rpi/stegasoo-rpi-venv-arm64.tar.zst"
|
||||
PREBUILT_URL="${PREBUILT_URL:-https://github.com/adlee-was-taken/stegasoo/releases/download/v4.2.0/stegasoo-rpi-venv-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 venv - 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 venv - 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 .
|
||||
echo -e "${GREEN}[5/9]${NC} Setting up Python environment..."
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
rm -rf "$JPEGIO_DIR"
|
||||
if [ "$USE_PREBUILT" = true ]; then
|
||||
# Fast path: use pre-built venv
|
||||
|
||||
echo -e "${GREEN}[8/12]${NC} Installing Stegasoo..."
|
||||
# Download if local file doesn't exist
|
||||
if [ ! -f "$PREBUILT_TARBALL" ]; then
|
||||
echo " Downloading pre-built venv (~50MB)..."
|
||||
curl -L -o "$PREBUILT_TARBALL" "$PREBUILT_URL"
|
||||
fi
|
||||
|
||||
# Install dependencies (jpegio already in venv, won't re-download)
|
||||
pip install -e ".[web]"
|
||||
# Extract pre-built venv
|
||||
echo " Extracting pre-built venv..."
|
||||
zstd -d "$PREBUILT_TARBALL" --stdout | tar -xf - -C "$INSTALL_DIR"
|
||||
|
||||
echo -e "${GREEN}[9/12]${NC} Creating systemd service..."
|
||||
# Fix venv Python symlinks to point to system Python
|
||||
echo " Updating venv to use system Python..."
|
||||
rm -f "$INSTALL_DIR/venv/bin/python" "$INSTALL_DIR/venv/bin/python3"
|
||||
ln -s "$SYSTEM_PYTHON" "$INSTALL_DIR/venv/bin/python"
|
||||
ln -s "$SYSTEM_PYTHON" "$INSTALL_DIR/venv/bin/python3"
|
||||
|
||||
# Create systemd service file
|
||||
# Update pip shebang if needed
|
||||
if [ -f "$INSTALL_DIR/venv/bin/pip" ]; then
|
||||
sed -i "1s|^#!.*|#!$INSTALL_DIR/venv/bin/python|" "$INSTALL_DIR/venv/bin/pip" 2>/dev/null || true
|
||||
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 " Installing Stegasoo package..."
|
||||
pip install -e "." --quiet
|
||||
else
|
||||
# Build from source
|
||||
echo -e " ${YELLOW}Building from source (this takes 5-10 minutes)${NC}"
|
||||
|
||||
# Create venv with system Python
|
||||
if [ ! -d "$INSTALL_DIR/venv" ]; then
|
||||
"$SYSTEM_PYTHON" -m venv "$INSTALL_DIR/venv"
|
||||
fi
|
||||
source "$INSTALL_DIR/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"
|
||||
|
||||
# Upgrade pip and install build tools
|
||||
pip install --upgrade pip setuptools wheel
|
||||
|
||||
# Install jpeglib (no ARM64 wheel, PyPI tarball missing headers - use GitHub)
|
||||
echo " Installing jpeglib for ARM64..."
|
||||
JPEGLIB_WORKDIR=$(mktemp -d)
|
||||
cd "$JPEGLIB_WORKDIR"
|
||||
|
||||
# Clone from GitHub (PyPI source tarball is missing .h files)
|
||||
echo " Cloning jpeglib from GitHub..."
|
||||
git clone --depth 1 --branch 1.0.2 https://github.com/martinbenes1996/jpeglib.git
|
||||
cd jpeglib
|
||||
CJPEGLIB="src/jpeglib/cjpeglib"
|
||||
|
||||
# Fix broken include paths in setup.py (uses jpeglib/ but files are in src/jpeglib/)
|
||||
ln -s src/jpeglib jpeglib
|
||||
|
||||
# Download libjpeg headers (not included in repo either)
|
||||
# Each version needs EXACT matching headers (APIs differ between versions)
|
||||
echo " Downloading libjpeg headers (all versions)..."
|
||||
|
||||
# Download each version separately (APIs are incompatible between versions)
|
||||
for v in 6b 7 8 8a 8b 8c 8d 9 9a 9b 9c 9d 9e 9f; do
|
||||
echo " libjpeg $v..."
|
||||
curl -sL "https://www.ijg.org/files/jpegsrc.v${v}.tar.gz" | tar -xzf -
|
||||
cp jpeg-${v}/*.h "$CJPEGLIB/$v/" 2>/dev/null || cp jpeg-${v//.}/*.h "$CJPEGLIB/$v/" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Skip turbo/mozjpeg - they need cmake-generated headers
|
||||
# Only remove dict entries (lines 49-59), keep if blocks (they're safe when is_turbo=False)
|
||||
echo " Patching setup.py to skip turbo/mozjpeg (need cmake)..."
|
||||
python3 << 'PYPATCH'
|
||||
with open('setup.py', 'r') as f:
|
||||
lines = f.readlines()
|
||||
filtered = []
|
||||
for line in lines:
|
||||
# Only skip dict entries like "'turbo120': ..." or "'mozjpeg101': ..."
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("'turbo") and ':' in stripped:
|
||||
continue
|
||||
if stripped.startswith("'mozjpeg") and ':' in stripped:
|
||||
continue
|
||||
if stripped.startswith("# 'turbo"): # commented turbo line
|
||||
continue
|
||||
filtered.append(line)
|
||||
with open('setup.py', 'w') as f:
|
||||
f.writelines(filtered)
|
||||
PYPATCH
|
||||
|
||||
# Build and install
|
||||
echo " Building jpeglib (this takes a few minutes)..."
|
||||
pip install .
|
||||
cd "$INSTALL_DIR"
|
||||
rm -rf "$JPEGLIB_WORKDIR"
|
||||
|
||||
# Install remaining dependencies
|
||||
echo " Installing remaining dependencies..."
|
||||
pip install -e ".[web]"
|
||||
fi
|
||||
|
||||
echo -e " ${GREEN}✓${NC} Stegasoo installed"
|
||||
|
||||
echo -e "${GREEN}[6/9]${NC} Creating systemd services..."
|
||||
|
||||
# Create systemd service file for Web UI
|
||||
sudo tee /etc/systemd/system/stegasoo.service > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Stegasoo Web UI
|
||||
@@ -286,12 +423,53 @@ RestartSec=5
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}[10/12]${NC} Enabling service..."
|
||||
# Create systemd service file for REST API (optional)
|
||||
sudo tee /etc/systemd/system/stegasoo-api.service > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Stegasoo REST API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$USER
|
||||
WorkingDirectory=$INSTALL_DIR/frontends/api
|
||||
Environment="PATH=$INSTALL_DIR/venv/bin:/usr/bin"
|
||||
Environment="PYTHONPATH=$INSTALL_DIR/src"
|
||||
ExecStart=$INSTALL_DIR/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}[7/9]${NC} Enabling services..."
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable stegasoo.service
|
||||
|
||||
echo -e "${GREEN}[11/12]${NC} Setting up user environment..."
|
||||
# Prompt for REST API service (optional, with security warning)
|
||||
echo ""
|
||||
echo -e "${CYAN}Would you like to enable the REST API service? (port 8000)${NC}"
|
||||
echo ""
|
||||
echo -e " ${RED}⚠ WARNING: The REST API has NO AUTHENTICATION${NC}"
|
||||
echo " Anyone on your network can use it to encode/decode messages."
|
||||
echo " Only enable if you understand the security implications."
|
||||
echo ""
|
||||
echo " The Web UI (port 5000) has authentication and works independently."
|
||||
echo ""
|
||||
read -p "Enable REST API (no auth)? [y/N]: " ENABLE_API
|
||||
if [[ "$ENABLE_API" =~ ^[Yy]$ ]]; then
|
||||
sudo systemctl enable stegasoo-api.service
|
||||
STEGASOO_API_ENABLED=true
|
||||
echo -e " ${YELLOW}⚠${NC} REST API enabled on port 8000 ${RED}(no authentication)${NC}"
|
||||
else
|
||||
STEGASOO_API_ENABLED=false
|
||||
echo -e " ${GREEN}✓${NC} REST API not enabled (recommended)"
|
||||
echo " Can enable later with: sudo systemctl enable --now stegasoo-api"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[8/9]${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'
|
||||
@@ -317,7 +495,15 @@ if [ -f "$INSTALL_DIR/rpi/skel/.bashrc" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[12/12]${NC} Setting up login banner..."
|
||||
# Install man page
|
||||
if [ -f "$INSTALL_DIR/docs/stegasoo.1" ]; then
|
||||
sudo mkdir -p /usr/local/share/man/man1
|
||||
sudo cp "$INSTALL_DIR/docs/stegasoo.1" /usr/local/share/man/man1/
|
||||
sudo mandb -q 2>/dev/null || true
|
||||
echo " Installed man page (man stegasoo)"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}[9/9]${NC} Setting up login banner..."
|
||||
|
||||
# Create dynamic MOTD script
|
||||
sudo tee /etc/profile.d/stegasoo-motd.sh > /dev/null <<'MOTDEOF'
|
||||
@@ -344,18 +530,35 @@ if systemctl is-active --quiet stegasoo 2>/dev/null; then
|
||||
echo -e "\033[38;5;220m |___/ |_| |___| \\___//_/ \\_\\|___/ \\___/ \\___/\033[0m"
|
||||
echo -e "\033[0;90m · . · . * · . * · . * · . * · . * · . ·\033[0m"
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
echo -e " \033[0;32m●\033[0m Stegasoo is running"
|
||||
echo -e " \033[0;33m$STEGASOO_URL\033[0m"
|
||||
# Show CPU stats if overclocked
|
||||
if grep -qE "^(arm_freq|over_voltage)" /boot/firmware/config.txt 2>/dev/null || \
|
||||
grep -qE "^(arm_freq|over_voltage)" /boot/config.txt 2>/dev/null; then
|
||||
CPU_FREQ=$(vcgencmd measure_clock arm 2>/dev/null | cut -d= -f2)
|
||||
# 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
|
||||
CPU_MHZ=""
|
||||
CPU_TEMP=""
|
||||
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_FREQ" ] && [ -n "$CPU_TEMP" ]; then
|
||||
CPU_MHZ=$((CPU_FREQ / 1000000))
|
||||
echo -e " \033[0;35m⚡\033[0m ${CPU_MHZ} MHz \033[0;35m🌡\033[0m ${CPU_TEMP}"
|
||||
fi
|
||||
fi
|
||||
# Compact two-column layout
|
||||
echo -e " 🚀 Stegasoo running 🌐 \033[0;33m$STEGASOO_URL\033[0m"
|
||||
if [ -n "$CPU_MHZ" ] && [ -n "$CPU_TEMP" ]; then
|
||||
# Temp emoji: ice<50, cool 50-70, fire>70
|
||||
TEMP_NUM=$(echo "$CPU_TEMP" | grep -oE "[0-9]+" | head -1)
|
||||
if [ -n "$TEMP_NUM" ]; then
|
||||
if [ "$TEMP_NUM" -ge 70 ]; then
|
||||
TEMP_EMOJI="🔥"
|
||||
elif [ "$TEMP_NUM" -ge 50 ]; then
|
||||
TEMP_EMOJI="😎"
|
||||
else
|
||||
TEMP_EMOJI="🧊"
|
||||
fi
|
||||
else
|
||||
TEMP_EMOJI="🌡"
|
||||
fi
|
||||
echo -e " \033[0;35m⚡\033[0m ${CPU_MHZ} MHz ${TEMP_EMOJI} ${CPU_TEMP}"
|
||||
fi
|
||||
echo -e "\033[38;5;93m══════════════\033[38;5;99m══════════════\033[38;5;105m══════════════\033[38;5;117m══════════════\033[0m"
|
||||
echo ""
|
||||
else
|
||||
echo ""
|
||||
@@ -367,6 +570,10 @@ MOTDEOF
|
||||
sudo chmod 644 /etc/profile.d/stegasoo-motd.sh
|
||||
echo " Created login banner"
|
||||
|
||||
# Shorten the default Debian MOTD boilerplate
|
||||
echo "Debian GNU/Linux · License: /usr/share/doc/*/copyright" | sudo tee /etc/motd > /dev/null
|
||||
echo " Shortened system MOTD"
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}Installation Complete!${NC}"
|
||||
echo -e "${BLUE}-------------------------------------------------------${NC}"
|
||||
@@ -425,9 +632,15 @@ echo ""
|
||||
read -p "Generate a private channel key? [y/N] " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
# Generate channel key using the CLI
|
||||
CHANNEL_KEY=$($INSTALL_DIR/venv/bin/python -c "from stegasoo.channel import generate_channel_key; print(generate_channel_key())")
|
||||
# Generate channel key and save encrypted to config
|
||||
CHANNEL_KEY=$($INSTALL_DIR/venv/bin/python -c "
|
||||
from stegasoo.channel import generate_channel_key, set_channel_key
|
||||
key = generate_channel_key()
|
||||
set_channel_key(key, 'user') # Saves encrypted to ~/.stegasoo/channel.key
|
||||
print(key)
|
||||
")
|
||||
echo -e " ${GREEN}✓${NC} Channel key generated: ${YELLOW}$CHANNEL_KEY${NC}"
|
||||
echo -e " ${GREEN}✓${NC} Key saved (encrypted) to ~/.stegasoo/channel.key"
|
||||
echo ""
|
||||
echo -e " ${RED}IMPORTANT: Save this key!${NC} You'll need to share it with anyone"
|
||||
echo " who should be able to decode your images."
|
||||
@@ -465,6 +678,52 @@ 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)
|
||||
|
||||
# Try mkcert first (creates browser-trusted certs - no warning screen!)
|
||||
if command -v mkcert &> /dev/null; then
|
||||
echo " Using mkcert for browser-trusted certificates..."
|
||||
cd "$CERT_DIR"
|
||||
mkcert -key-file server.key -cert-file server.crt \
|
||||
"$PI_HOSTNAME" "$PI_HOSTNAME.local" localhost "$LOCAL_IP" 127.0.0.1 ::1
|
||||
|
||||
# Copy CA to web-accessible location for easy device setup
|
||||
CA_ROOT=$(mkcert -CAROOT)
|
||||
CA_DIR="$INSTALL_DIR/frontends/web/static/ca"
|
||||
mkdir -p "$CA_DIR"
|
||||
cp "$CA_ROOT/rootCA.pem" "$CA_DIR/"
|
||||
|
||||
echo -e " ${GREEN}✓${NC} Trusted certificates generated with mkcert"
|
||||
echo -e " ${CYAN}Tip:${NC} New devices can get the CA from: http://$PI_HOSTNAME.local/static/ca/rootCA.pem"
|
||||
else
|
||||
# Fallback to self-signed (shows browser warning)
|
||||
echo " Using self-signed certificate (browser will show warning)"
|
||||
echo " Tip: Install mkcert for trusted certs without warnings"
|
||||
|
||||
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
|
||||
|
||||
echo -e " ${GREEN}✓${NC} Self-signed certificates generated"
|
||||
fi
|
||||
|
||||
# Fix permissions
|
||||
chmod 600 "$CERT_DIR/server.key"
|
||||
chown -R "$USER:$USER" "$CERT_DIR"
|
||||
fi
|
||||
|
||||
# Setup port 443 redirect if requested
|
||||
if [ "$USE_PORT_443" = "true" ]; then
|
||||
echo " Setting up port 443 redirect..."
|
||||
@@ -507,15 +766,19 @@ echo -e "${BLUE}-------------------------------------------------------${NC}"
|
||||
echo ""
|
||||
|
||||
PI_IP=$(hostname -I | awk '{print $1}')
|
||||
PI_HOST=$(hostname)
|
||||
|
||||
echo -e "${GREEN}Create your admin account:${NC}"
|
||||
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||
if [ "$USE_PORT_443" = "true" ]; then
|
||||
echo -e " ${YELLOW}https://$PI_HOST.local/setup${NC}"
|
||||
echo -e " ${YELLOW}https://$PI_IP/setup${NC}"
|
||||
else
|
||||
echo -e " ${YELLOW}https://$PI_HOST.local:5000/setup${NC}"
|
||||
echo -e " ${YELLOW}https://$PI_IP:5000/setup${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}http://$PI_HOST.local:5000/setup${NC}"
|
||||
echo -e " ${YELLOW}http://$PI_IP:5000/setup${NC}"
|
||||
fi
|
||||
|
||||
@@ -531,6 +794,14 @@ echo " Start: sudo systemctl start stegasoo"
|
||||
echo " Stop: sudo systemctl stop stegasoo"
|
||||
echo " Status: sudo systemctl status stegasoo"
|
||||
echo " Logs: journalctl -u stegasoo -f"
|
||||
if [ "$STEGASOO_API_ENABLED" = "true" ]; then
|
||||
echo ""
|
||||
echo -e "${GREEN}REST API Commands:${NC}"
|
||||
echo " Start: sudo systemctl start stegasoo-api"
|
||||
echo " Stop: sudo systemctl stop stegasoo-api"
|
||||
echo " Status: sudo systemctl status stegasoo-api"
|
||||
echo " Logs: journalctl -u stegasoo-api -f"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Offer to start now
|
||||
@@ -538,17 +809,27 @@ read -p "Start Stegasoo now? [Y/n] " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
sudo systemctl start stegasoo
|
||||
if [ "$STEGASOO_API_ENABLED" = "true" ]; then
|
||||
sudo systemctl start stegasoo-api
|
||||
fi
|
||||
sleep 2
|
||||
if systemctl is-active --quiet stegasoo; then
|
||||
echo -e "${GREEN}✓ Stegasoo is running!${NC}"
|
||||
echo -e "${GREEN}✓ Stegasoo Web UI is running!${NC}"
|
||||
if [ "$ENABLE_HTTPS" = "true" ]; then
|
||||
if [ "$USE_PORT_443" = "true" ]; then
|
||||
echo -e " Create admin: ${YELLOW}https://$PI_IP/setup${NC}"
|
||||
echo -e " Create admin: ${YELLOW}https://$PI_HOST.local/setup${NC} or ${YELLOW}https://$PI_IP/setup${NC}"
|
||||
else
|
||||
echo -e " Create admin: ${YELLOW}https://$PI_IP:5000/setup${NC}"
|
||||
echo -e " Create admin: ${YELLOW}https://$PI_HOST.local:5000/setup${NC} or ${YELLOW}https://$PI_IP:5000/setup${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e " Create admin: ${YELLOW}http://$PI_IP:5000/setup${NC}"
|
||||
echo -e " Create admin: ${YELLOW}http://$PI_HOST.local:5000/setup${NC} or ${YELLOW}http://$PI_IP:5000/setup${NC}"
|
||||
fi
|
||||
if [ "$STEGASOO_API_ENABLED" = "true" ]; then
|
||||
if systemctl is-active --quiet stegasoo-api; then
|
||||
echo -e "${GREEN}✓ Stegasoo REST API is running on port 8000${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ REST API failed to start. Check logs:${NC} journalctl -u stegasoo-api -f"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}✗ Failed to start. Check logs:${NC} journalctl -u stegasoo -f"
|
||||
|
||||
98
scripts/build.sh
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/bin/bash
|
||||
# Stegasoo Build Script
|
||||
# Usage: ./build.sh [base|fast|full|clean]
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
DOCKER_DIR="$PROJECT_DIR/docker"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Detect docker compose command
|
||||
if docker compose version &>/dev/null; then
|
||||
COMPOSE_CMD="docker compose"
|
||||
elif command -v docker-compose &>/dev/null; then
|
||||
COMPOSE_CMD="docker-compose"
|
||||
else
|
||||
echo -e "${RED}Error: docker compose not found${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if we need sudo
|
||||
SUDO=""
|
||||
if ! docker ps &>/dev/null; then
|
||||
SUDO="sudo"
|
||||
fi
|
||||
|
||||
COMPOSE_FILE="$DOCKER_DIR/docker-compose.yml"
|
||||
|
||||
case "${1:-fast}" in
|
||||
base)
|
||||
echo -e "${YELLOW}Building base image (this takes 5-10 minutes)...${NC}"
|
||||
$SUDO docker build -f "$DOCKER_DIR/Dockerfile.base" -t stegasoo-base:latest .
|
||||
echo -e "${GREEN}Base image built! Future builds will be fast.${NC}"
|
||||
echo ""
|
||||
echo "Optional: Push to registry for team use:"
|
||||
echo " docker tag stegasoo-base:latest yourregistry/stegasoo-base:latest"
|
||||
echo " docker push yourregistry/stegasoo-base:latest"
|
||||
;;
|
||||
|
||||
fast)
|
||||
if ! $SUDO docker image inspect stegasoo-base:latest >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW}Base image not found. Building it first (one-time)...${NC}"
|
||||
$0 base
|
||||
fi
|
||||
echo -e "${CYAN}Fast build using base image...${NC}"
|
||||
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" build
|
||||
echo -e "${GREEN}Done! Start with: $COMPOSE_CMD -f docker/docker-compose.yml up -d${NC}"
|
||||
;;
|
||||
|
||||
full)
|
||||
echo -e "${YELLOW}Full build from scratch (slow)...${NC}"
|
||||
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" build --no-cache
|
||||
echo -e "${GREEN}Done! Start with: $COMPOSE_CMD -f docker/docker-compose.yml up -d${NC}"
|
||||
;;
|
||||
|
||||
clean)
|
||||
echo -e "${YELLOW}Cleaning up...${NC}"
|
||||
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" down --rmi local -v 2>/dev/null || true
|
||||
$SUDO docker rmi stegasoo-base:latest 2>/dev/null || true
|
||||
echo -e "${GREEN}Cleaned!${NC}"
|
||||
;;
|
||||
|
||||
rebuild)
|
||||
echo -e "${YELLOW}Full rebuild from scratch (no cache)...${NC}"
|
||||
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" down --rmi local -v 2>/dev/null || true
|
||||
$SUDO docker rmi stegasoo-base:latest 2>/dev/null || true
|
||||
$SUDO docker build --no-cache -f "$DOCKER_DIR/Dockerfile.base" -t stegasoo-base:latest .
|
||||
$SUDO $COMPOSE_CMD -f "$COMPOSE_FILE" build --no-cache
|
||||
echo -e "${GREEN}Done! Start with: $COMPOSE_CMD -f docker/docker-compose.yml up -d${NC}"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo -e "${CYAN}Stegasoo Build Script${NC}"
|
||||
echo ""
|
||||
echo "Usage: $0 [command]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " base Build the base image (one-time, 5-10 min)"
|
||||
echo " fast Fast build using base image (default, ~10 sec)"
|
||||
echo " full Rebuild services without cache (uses existing base)"
|
||||
echo " rebuild Complete rebuild with no cache (base + services)"
|
||||
echo " clean Remove all images and volumes"
|
||||
echo ""
|
||||
echo "Typical workflow:"
|
||||
echo " 1. First time: $0 base"
|
||||
echo " 2. Daily dev: $0 fast"
|
||||
echo " 3. Deps change: $0 base"
|
||||
echo " 4. Nuclear: $0 rebuild"
|
||||
;;
|
||||
esac
|
||||
93
scripts/screenshots.sh
Executable file
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
# Capture Web UI screenshots for documentation
|
||||
# Requires: chromium, imagemagick
|
||||
# Usage: ./scripts/screenshots.sh [base_url]
|
||||
#
|
||||
# Modes:
|
||||
# Default (auth disabled): Captures main UI pages
|
||||
# With auth: Also captures login/setup/account pages
|
||||
#
|
||||
# Start server with: STEGASOO_AUTH_ENABLED=false python frontends/web/app.py
|
||||
|
||||
set -e
|
||||
|
||||
BASE_URL="${1:-http://localhost:5000}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
OUTPUT_DIR="$PROJECT_DIR/data"
|
||||
WINDOW_SIZE="1280,900"
|
||||
|
||||
echo "╔══════════════════════════════════════════╗"
|
||||
echo "║ Stegasoo Screenshot Capture ║"
|
||||
echo "╚══════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "Base URL: $BASE_URL"
|
||||
echo "Output: $OUTPUT_DIR"
|
||||
echo ""
|
||||
|
||||
# Check dependencies
|
||||
for cmd in chromium magick curl; do
|
||||
if ! command -v "$cmd" &> /dev/null; then
|
||||
echo "Error: $cmd not found"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check if server is running (-k for self-signed certs)
|
||||
if ! curl -sk "$BASE_URL" > /dev/null 2>&1; then
|
||||
echo "Error: Server not responding at $BASE_URL"
|
||||
echo "Start with: STEGASOO_AUTH_ENABLED=false python frontends/web/app.py"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Capture a single screenshot
|
||||
capture() {
|
||||
local name="$1"
|
||||
local route="$2"
|
||||
local url="$BASE_URL$route"
|
||||
|
||||
printf " %-20s <- %s\n" "$name" "$route"
|
||||
chromium --headless --screenshot="$OUTPUT_DIR/$name.png" \
|
||||
--window-size="$WINDOW_SIZE" --hide-scrollbars \
|
||||
--disable-gpu --no-sandbox --ignore-certificate-errors \
|
||||
"$url" 2>/dev/null
|
||||
}
|
||||
|
||||
echo "Capturing main pages..."
|
||||
echo ""
|
||||
|
||||
# Core pages (always capture)
|
||||
capture "WebUI" "/"
|
||||
capture "WebUI_Encode" "/encode"
|
||||
capture "WebUI_Decode" "/decode"
|
||||
capture "WebUI_Generate" "/generate"
|
||||
capture "WebUI_Tools" "/tools"
|
||||
capture "WebUI_About" "/about"
|
||||
|
||||
echo ""
|
||||
echo "Capturing auth pages..."
|
||||
echo ""
|
||||
|
||||
# Auth pages (may redirect if auth disabled, that's OK)
|
||||
capture "WebUI_Login" "/login"
|
||||
capture "WebUI_Setup" "/setup"
|
||||
capture "WebUI_Account" "/account"
|
||||
capture "WebUI_Recover" "/recover"
|
||||
|
||||
echo ""
|
||||
echo "Converting to webp..."
|
||||
echo ""
|
||||
|
||||
for png in "$OUTPUT_DIR"/WebUI*.png; do
|
||||
[ -f "$png" ] || continue
|
||||
name=$(basename "$png" .png)
|
||||
printf " %-20s -> %s.webp\n" "$name.png" "$name"
|
||||
magick "$png" -quality 85 "$OUTPUT_DIR/$name.webp"
|
||||
rm -f "$png"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done! Screenshots:"
|
||||
echo ""
|
||||
ls -lh "$OUTPUT_DIR"/WebUI*.webp 2>/dev/null | awk '{print " " $NF " (" $5 ")"}'
|
||||
echo ""
|
||||
149
scripts/setup-trusted-certs.sh
Executable file
@@ -0,0 +1,149 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Setup trusted HTTPS certificates for Stegasoo
|
||||
# Uses mkcert to create browser-trusted certs (no warning screens!)
|
||||
#
|
||||
# Usage: ./setup-trusted-certs.sh [hostname]
|
||||
#
|
||||
# This script:
|
||||
# 1. Installs mkcert if needed
|
||||
# 2. Creates a local CA (one-time)
|
||||
# 3. Generates certs for your hostname
|
||||
# 4. Shows how to trust the CA on other devices
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
HOSTNAME="${1:-stegasoo.local}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$SCRIPT_DIR/.."
|
||||
CERT_DIR="$PROJECT_ROOT/frontends/web/certs"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${CYAN}║ Stegasoo Trusted Certificate Setup ║${NC}"
|
||||
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Check/install mkcert
|
||||
install_mkcert() {
|
||||
if command -v mkcert &> /dev/null; then
|
||||
echo -e "${GREEN}✓${NC} mkcert already installed"
|
||||
return
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Installing mkcert...${NC}"
|
||||
|
||||
# Detect OS and install
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS
|
||||
if command -v brew &> /dev/null; then
|
||||
brew install mkcert
|
||||
else
|
||||
echo -e "${RED}Please install Homebrew first: https://brew.sh${NC}"
|
||||
exit 1
|
||||
fi
|
||||
elif [[ -f /etc/debian_version ]]; then
|
||||
# Debian/Ubuntu/Raspberry Pi OS
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libnss3-tools
|
||||
|
||||
# Download mkcert binary
|
||||
ARCH=$(dpkg --print-architecture)
|
||||
if [[ "$ARCH" == "arm64" ]] || [[ "$ARCH" == "aarch64" ]]; then
|
||||
MKCERT_URL="https://github.com/FiloSottile/mkcert/releases/latest/download/mkcert-linux-arm64"
|
||||
else
|
||||
MKCERT_URL="https://github.com/FiloSottile/mkcert/releases/latest/download/mkcert-linux-amd64"
|
||||
fi
|
||||
|
||||
sudo curl -L "$MKCERT_URL" -o /usr/local/bin/mkcert
|
||||
sudo chmod +x /usr/local/bin/mkcert
|
||||
elif [[ -f /etc/arch-release ]]; then
|
||||
# Arch Linux
|
||||
sudo pacman -S mkcert
|
||||
else
|
||||
echo -e "${RED}Unsupported OS. Please install mkcert manually:${NC}"
|
||||
echo " https://github.com/FiloSottile/mkcert#installation"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓${NC} mkcert installed"
|
||||
}
|
||||
|
||||
# Install local CA
|
||||
setup_ca() {
|
||||
echo ""
|
||||
echo -e "${CYAN}Setting up local Certificate Authority...${NC}"
|
||||
|
||||
if mkcert -install 2>/dev/null; then
|
||||
echo -e "${GREEN}✓${NC} Local CA installed in system trust store"
|
||||
else
|
||||
echo -e "${YELLOW}!${NC} Could not auto-install CA (may need manual browser import)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Generate certificates
|
||||
generate_certs() {
|
||||
echo ""
|
||||
echo -e "${CYAN}Generating trusted certificate for: ${YELLOW}$HOSTNAME${NC}"
|
||||
|
||||
mkdir -p "$CERT_DIR"
|
||||
cd "$CERT_DIR"
|
||||
|
||||
# Generate cert for hostname + common local names
|
||||
mkcert -key-file key.pem -cert-file cert.pem \
|
||||
"$HOSTNAME" \
|
||||
localhost \
|
||||
127.0.0.1 \
|
||||
::1
|
||||
|
||||
echo -e "${GREEN}✓${NC} Certificates generated in: $CERT_DIR"
|
||||
}
|
||||
|
||||
# Show CA location for other devices
|
||||
show_ca_info() {
|
||||
CA_ROOT=$(mkcert -CAROOT)
|
||||
CA_FILE="$CA_ROOT/rootCA.pem"
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} Setup Complete!${NC}"
|
||||
echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo "Your certificates are ready. Browsers on THIS machine will trust them."
|
||||
echo ""
|
||||
echo -e "${YELLOW}To trust on OTHER devices (phones, tablets, other computers):${NC}"
|
||||
echo ""
|
||||
echo " 1. Copy the CA certificate to that device:"
|
||||
echo -e " ${CYAN}$CA_FILE${NC}"
|
||||
echo ""
|
||||
echo " 2. Import it as a trusted CA:"
|
||||
echo " - iOS: AirDrop/email the file, Settings > Profile Downloaded > Install"
|
||||
echo " - Android: Settings > Security > Install from storage"
|
||||
echo " - Windows: Double-click > Install > Trusted Root CAs"
|
||||
echo " - macOS: Double-click > Keychain Access > Trust Always"
|
||||
echo " - Linux: Copy to /usr/local/share/ca-certificates/ && update-ca-certificates"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Quick copy command:${NC}"
|
||||
echo " scp $CA_FILE user@device:/path/"
|
||||
echo ""
|
||||
|
||||
# Offer to serve CA file via HTTP for easy phone download
|
||||
echo -e "${YELLOW}Or serve the CA for easy phone download:${NC}"
|
||||
echo " python3 -m http.server 8080 -d $CA_ROOT"
|
||||
echo " Then visit: http://$(hostname -I | awk '{print $1}'):8080/rootCA.pem"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Main
|
||||
install_mkcert
|
||||
setup_ca
|
||||
generate_certs
|
||||
show_ca_info
|
||||
333
scripts/smoke-test.sh
Executable file
@@ -0,0 +1,333 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Stegasoo Smoke Test
|
||||
# Tests all core functionality against a running instance (Pi, Docker, or dev)
|
||||
#
|
||||
# Usage: ./smoke-test.sh [host] [port] [user] [pass]
|
||||
#
|
||||
# Examples:
|
||||
# ./smoke-test.sh # Pi default (stegasoo.local:443)
|
||||
# ./smoke-test.sh localhost 5000 # Docker default
|
||||
# ./smoke-test.sh 192.168.1.100 5000 # Custom host
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
HOST="${1:-stegasoo.local}"
|
||||
PORT="${2:-443}"
|
||||
USER="${3:-admin}"
|
||||
PASS="${4:-stegasoo}"
|
||||
|
||||
# Build URL (don't include :443 since it's default for https)
|
||||
if [ "$PORT" = "443" ]; then
|
||||
BASE_URL="https://$HOST"
|
||||
else
|
||||
BASE_URL="https://$HOST:$PORT"
|
||||
fi
|
||||
COOKIE_JAR="/tmp/stegasoo_smoke_cookies.txt"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TEST_DATA="$SCRIPT_DIR/../test_data"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helper functions
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
log_test() {
|
||||
echo -e "${CYAN}[TEST]${NC} $1"
|
||||
}
|
||||
|
||||
log_pass() {
|
||||
echo -e "${GREEN}[PASS]${NC} $1"
|
||||
PASSED=$((PASSED + 1))
|
||||
}
|
||||
|
||||
log_fail() {
|
||||
echo -e "${RED}[FAIL]${NC} $1"
|
||||
FAILED=$((FAILED + 1))
|
||||
}
|
||||
|
||||
curl_get() {
|
||||
curl -sk "$BASE_URL$1" -b "$COOKIE_JAR" -c "$COOKIE_JAR" "${@:2}"
|
||||
}
|
||||
|
||||
curl_post() {
|
||||
curl -sk -X POST "$BASE_URL$1" -b "$COOKIE_JAR" -c "$COOKIE_JAR" "${@:2}"
|
||||
}
|
||||
|
||||
wait_for_job() {
|
||||
local endpoint="$1"
|
||||
local job_id="$2"
|
||||
local max_polls="${3:-30}"
|
||||
|
||||
for i in $(seq 1 $max_polls); do
|
||||
sleep 1
|
||||
result=$(curl_get "$endpoint/$job_id")
|
||||
if echo "$result" | grep -q '"status":\s*"complete"'; then
|
||||
echo "$result"
|
||||
return 0
|
||||
fi
|
||||
if echo "$result" | grep -q '"status":\s*"error"'; then
|
||||
echo "$result"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
echo '{"status":"timeout"}'
|
||||
return 1
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
test_connectivity() {
|
||||
log_test "Connectivity to $BASE_URL"
|
||||
if curl -sk --connect-timeout 5 "$BASE_URL" -o /dev/null; then
|
||||
log_pass "Server reachable"
|
||||
else
|
||||
log_fail "Cannot reach server"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
test_setup_or_login() {
|
||||
log_test "Setup/Login"
|
||||
|
||||
# Check if setup needed
|
||||
response=$(curl_get "/" -L -o /dev/null -w "%{url_effective}")
|
||||
|
||||
if echo "$response" | grep -q "/setup"; then
|
||||
log_test "Completing first-time setup..."
|
||||
curl_post "/setup" \
|
||||
-d "username=$USER" \
|
||||
-d "password=$PASS" \
|
||||
-d "password_confirm=$PASS" \
|
||||
-L -o /dev/null
|
||||
fi
|
||||
|
||||
# Login
|
||||
curl_get "/login" -o /dev/null # Get session
|
||||
curl_post "/login" \
|
||||
-d "username=$USER" \
|
||||
-d "password=$PASS" \
|
||||
-L -o /dev/null
|
||||
|
||||
# Verify logged in
|
||||
code=$(curl_get "/encode" -o /dev/null -w "%{http_code}")
|
||||
if [ "$code" = "200" ]; then
|
||||
log_pass "Authenticated successfully"
|
||||
else
|
||||
log_fail "Authentication failed (got $code)"
|
||||
fi
|
||||
}
|
||||
|
||||
test_pages() {
|
||||
log_test "Page accessibility"
|
||||
|
||||
local pages="encode decode generate tools about"
|
||||
local all_pass=true
|
||||
|
||||
for page in $pages; do
|
||||
code=$(curl_get "/$page" -o /dev/null -w "%{http_code}")
|
||||
if [ "$code" = "200" ]; then
|
||||
echo -e " ${GREEN}✓${NC} /$page"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} /$page ($code)"
|
||||
all_pass=false
|
||||
fi
|
||||
done
|
||||
|
||||
if $all_pass; then
|
||||
log_pass "All pages accessible"
|
||||
else
|
||||
log_fail "Some pages inaccessible"
|
||||
fi
|
||||
}
|
||||
|
||||
test_encode_decode_dct() {
|
||||
log_test "DCT Encode/Decode round trip"
|
||||
|
||||
local message="DCT smoke test $(date +%s)"
|
||||
|
||||
# Encode
|
||||
response=$(curl_post "/encode" \
|
||||
-F "reference_photo=@$TEST_DATA/ref.jpg" \
|
||||
-F "carrier=@$TEST_DATA/carrier.jpg" \
|
||||
-F "message=$message" \
|
||||
-F "passphrase=tower booty sunny windy" \
|
||||
-F "pin=727643678" \
|
||||
-F "embed_mode=dct" \
|
||||
-F "channel_key=auto" \
|
||||
-F "async=true")
|
||||
|
||||
job_id=$(echo "$response" | grep -oP '"job_id":\s*"[^"]+"' | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$job_id" ]; then
|
||||
log_fail "DCT encode - no job ID returned"
|
||||
return
|
||||
fi
|
||||
|
||||
# Wait for encode
|
||||
result=$(wait_for_job "/encode/status" "$job_id" 15)
|
||||
if ! echo "$result" | grep -q '"status":\s*"complete"'; then
|
||||
log_fail "DCT encode timeout or error"
|
||||
return
|
||||
fi
|
||||
|
||||
file_id=$(echo "$result" | grep -oP '"file_id":\s*"[^"]+"' | cut -d'"' -f4)
|
||||
curl_get "/encode/download/$file_id" -o /tmp/stego_dct_test.jpg
|
||||
|
||||
echo -e " ${GREEN}✓${NC} Encoded $(ls -lh /tmp/stego_dct_test.jpg | awk '{print $5}')"
|
||||
|
||||
# Decode
|
||||
response=$(curl_post "/decode" \
|
||||
-F "reference_photo=@$TEST_DATA/ref.jpg" \
|
||||
-F "stego_image=@/tmp/stego_dct_test.jpg" \
|
||||
-F "passphrase=tower booty sunny windy" \
|
||||
-F "pin=727643678" \
|
||||
-F "embed_mode=auto" \
|
||||
-F "channel_key=auto" \
|
||||
-F "async=true")
|
||||
|
||||
job_id=$(echo "$response" | grep -oP '"job_id":\s*"[^"]+"' | cut -d'"' -f4)
|
||||
|
||||
# Wait for decode (DCT is slower on Pi)
|
||||
result=$(wait_for_job "/decode/status" "$job_id" 60)
|
||||
|
||||
if echo "$result" | grep -q "$message"; then
|
||||
log_pass "DCT round trip - message verified"
|
||||
else
|
||||
log_fail "DCT decode - message mismatch"
|
||||
echo " Expected: $message"
|
||||
echo " Got: $result"
|
||||
fi
|
||||
}
|
||||
|
||||
test_encode_decode_lsb() {
|
||||
log_test "LSB Encode/Decode round trip"
|
||||
|
||||
local message="LSB smoke test $(date +%s)"
|
||||
|
||||
# Encode
|
||||
response=$(curl_post "/encode" \
|
||||
-F "reference_photo=@$TEST_DATA/ref.jpg" \
|
||||
-F "carrier=@$TEST_DATA/carrier.jpg" \
|
||||
-F "message=$message" \
|
||||
-F "passphrase=tower booty sunny windy" \
|
||||
-F "pin=727643678" \
|
||||
-F "embed_mode=lsb" \
|
||||
-F "channel_key=auto" \
|
||||
-F "async=true")
|
||||
|
||||
job_id=$(echo "$response" | grep -oP '"job_id":\s*"[^"]+"' | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$job_id" ]; then
|
||||
log_fail "LSB encode - no job ID returned"
|
||||
return
|
||||
fi
|
||||
|
||||
result=$(wait_for_job "/encode/status" "$job_id" 10)
|
||||
if ! echo "$result" | grep -q '"status":\s*"complete"'; then
|
||||
log_fail "LSB encode timeout or error"
|
||||
return
|
||||
fi
|
||||
|
||||
file_id=$(echo "$result" | grep -oP '"file_id":\s*"[^"]+"' | cut -d'"' -f4)
|
||||
curl_get "/encode/download/$file_id" -o /tmp/stego_lsb_test.png
|
||||
|
||||
echo -e " ${GREEN}✓${NC} Encoded $(ls -lh /tmp/stego_lsb_test.png | awk '{print $5}')"
|
||||
|
||||
# Decode
|
||||
response=$(curl_post "/decode" \
|
||||
-F "reference_photo=@$TEST_DATA/ref.jpg" \
|
||||
-F "stego_image=@/tmp/stego_lsb_test.png" \
|
||||
-F "passphrase=tower booty sunny windy" \
|
||||
-F "pin=727643678" \
|
||||
-F "embed_mode=lsb" \
|
||||
-F "channel_key=auto" \
|
||||
-F "async=true")
|
||||
|
||||
job_id=$(echo "$response" | grep -oP '"job_id":\s*"[^"]+"' | cut -d'"' -f4)
|
||||
result=$(wait_for_job "/decode/status" "$job_id" 15)
|
||||
|
||||
if echo "$result" | grep -q "$message"; then
|
||||
log_pass "LSB round trip - message verified"
|
||||
else
|
||||
log_fail "LSB decode - message mismatch"
|
||||
fi
|
||||
}
|
||||
|
||||
test_tools() {
|
||||
log_test "Tools endpoints"
|
||||
|
||||
# Capacity check
|
||||
response=$(curl_post "/api/tools/capacity" \
|
||||
-F "image=@$TEST_DATA/carrier.jpg" \
|
||||
-w "%{http_code}" -o /tmp/capacity_result.json)
|
||||
|
||||
if [ "$response" = "200" ]; then
|
||||
echo -e " ${GREEN}✓${NC} Capacity check"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Capacity check ($response)"
|
||||
fi
|
||||
|
||||
# EXIF read
|
||||
response=$(curl_post "/api/tools/exif" \
|
||||
-F "image=@$TEST_DATA/carrier.jpg" \
|
||||
-w "%{http_code}" -o /tmp/exif_result.json)
|
||||
|
||||
if [ "$response" = "200" ]; then
|
||||
echo -e " ${GREEN}✓${NC} EXIF read"
|
||||
log_pass "Tools API works"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} EXIF read ($response)"
|
||||
log_fail "Tools API failed"
|
||||
fi
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${CYAN}║ Stegasoo Smoke Test ║${NC}"
|
||||
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "Target: ${YELLOW}$BASE_URL${NC}"
|
||||
echo -e "User: ${YELLOW}$USER${NC}"
|
||||
echo ""
|
||||
|
||||
# Clean up
|
||||
rm -f "$COOKIE_JAR" /tmp/stego_*_test.* /tmp/exif_stripped.jpg
|
||||
|
||||
# Run tests
|
||||
test_connectivity
|
||||
test_setup_or_login
|
||||
test_pages
|
||||
test_encode_decode_lsb
|
||||
test_encode_decode_dct
|
||||
test_tools
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
|
||||
echo -e "Results: ${GREEN}$PASSED passed${NC}, ${RED}$FAILED failed${NC}"
|
||||
echo -e "${CYAN}════════════════════════════════════════════════════════════════${NC}"
|
||||
|
||||
# Clean up
|
||||
rm -f "$COOKIE_JAR"
|
||||
|
||||
if [ $FAILED -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -120,7 +120,34 @@ else
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 2. Unit Tests (if they exist)
|
||||
# 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"
|
||||
|
||||
@@ -136,7 +163,7 @@ else
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 3. Import Tests
|
||||
# 4. Import Tests
|
||||
# =============================================================================
|
||||
section "Import Tests"
|
||||
|
||||
@@ -165,7 +192,7 @@ else
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 4. Encode/Decode Sanity Test
|
||||
# 5. Encode/Decode Sanity Test
|
||||
# =============================================================================
|
||||
section "Encode/Decode Test"
|
||||
|
||||
@@ -205,7 +232,7 @@ else
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 5. Docker Build & Test (optional)
|
||||
# 6. Docker Build & Test (optional)
|
||||
# =============================================================================
|
||||
if $INCLUDE_DOCKER; then
|
||||
section "Docker"
|
||||
@@ -248,7 +275,7 @@ else
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 6. Pi Smoke Test (optional)
|
||||
# 7. Pi Smoke Test (optional)
|
||||
# =============================================================================
|
||||
if $INCLUDE_PI; then
|
||||
section "Pi Smoke Test"
|
||||
|
||||
@@ -7,7 +7,7 @@ Changes in v4.0.0:
|
||||
- encode() and decode() now accept channel_key parameter
|
||||
"""
|
||||
|
||||
__version__ = "4.1.2"
|
||||
__version__ = "4.2.0"
|
||||
|
||||
# Core functionality
|
||||
# Channel key management (v4.0.0)
|
||||
|
||||
@@ -47,6 +47,80 @@ CONFIG_LOCATIONS = [
|
||||
Path.home() / ".stegasoo" / "channel.key", # User config
|
||||
]
|
||||
|
||||
# Encrypted config marker
|
||||
ENCRYPTED_PREFIX = "ENC:"
|
||||
|
||||
|
||||
def _get_machine_key() -> bytes:
|
||||
"""
|
||||
Get a machine-specific key for encrypting stored channel keys.
|
||||
|
||||
Uses /etc/machine-id on Linux, falls back to hostname hash.
|
||||
This ties the encrypted key to this specific machine.
|
||||
"""
|
||||
machine_id = None
|
||||
|
||||
# Try Linux machine-id
|
||||
try:
|
||||
machine_id = Path("/etc/machine-id").read_text().strip()
|
||||
except (OSError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# Fallback to hostname
|
||||
if not machine_id:
|
||||
import socket
|
||||
machine_id = socket.gethostname()
|
||||
|
||||
# Hash to get consistent 32 bytes
|
||||
return hashlib.sha256(machine_id.encode()).digest()
|
||||
|
||||
|
||||
def _encrypt_for_storage(plaintext: str) -> str:
|
||||
"""
|
||||
Encrypt a channel key for storage using machine-specific key.
|
||||
|
||||
Returns ENC: prefixed base64 string.
|
||||
"""
|
||||
import base64
|
||||
|
||||
key = _get_machine_key()
|
||||
plaintext_bytes = plaintext.encode()
|
||||
|
||||
# XOR with key (cycling if needed)
|
||||
encrypted = bytes(
|
||||
pb ^ key[i % len(key)]
|
||||
for i, pb in enumerate(plaintext_bytes)
|
||||
)
|
||||
|
||||
return ENCRYPTED_PREFIX + base64.b64encode(encrypted).decode()
|
||||
|
||||
|
||||
def _decrypt_from_storage(stored: str) -> str | None:
|
||||
"""
|
||||
Decrypt a stored channel key.
|
||||
|
||||
Returns None if decryption fails or format is invalid.
|
||||
"""
|
||||
import base64
|
||||
|
||||
if not stored.startswith(ENCRYPTED_PREFIX):
|
||||
# Not encrypted, return as-is (legacy plaintext)
|
||||
return stored
|
||||
|
||||
try:
|
||||
encrypted = base64.b64decode(stored[len(ENCRYPTED_PREFIX):])
|
||||
key = _get_machine_key()
|
||||
|
||||
# XOR to decrypt
|
||||
decrypted = bytes(
|
||||
eb ^ key[i % len(key)]
|
||||
for i, eb in enumerate(encrypted)
|
||||
)
|
||||
|
||||
return decrypted.decode()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def generate_channel_key() -> str:
|
||||
"""
|
||||
@@ -154,11 +228,13 @@ def get_channel_key() -> str | None:
|
||||
else:
|
||||
debug.print(f"Warning: Invalid {CHANNEL_KEY_ENV_VAR} format, ignoring")
|
||||
|
||||
# 2. Check config files
|
||||
# 2. Check config files (may be encrypted)
|
||||
for config_path in CONFIG_LOCATIONS:
|
||||
if config_path.exists():
|
||||
try:
|
||||
key = config_path.read_text().strip()
|
||||
stored = config_path.read_text().strip()
|
||||
# Decrypt if encrypted, otherwise use as-is (legacy)
|
||||
key = _decrypt_from_storage(stored)
|
||||
if key and validate_channel_key(key):
|
||||
debug.print(f"Channel key from {config_path}: {get_channel_fingerprint(key)}")
|
||||
return format_channel_key(key)
|
||||
@@ -200,8 +276,9 @@ def set_channel_key(key: str, location: str = "project") -> Path:
|
||||
# Create directory if needed
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write key with newline
|
||||
config_path.write_text(formatted + "\n")
|
||||
# Encrypt and write (tied to this machine's identity)
|
||||
encrypted = _encrypt_for_storage(formatted)
|
||||
config_path.write_text(encrypted + "\n")
|
||||
|
||||
# Set restrictive permissions (owner read/write only)
|
||||
try:
|
||||
@@ -334,11 +411,12 @@ def get_channel_status() -> dict:
|
||||
for config_path in CONFIG_LOCATIONS:
|
||||
if config_path.exists():
|
||||
try:
|
||||
file_key = config_path.read_text().strip()
|
||||
if file_key and format_channel_key(file_key) == key:
|
||||
stored = config_path.read_text().strip()
|
||||
file_key = _decrypt_from_storage(stored)
|
||||
if file_key and validate_channel_key(file_key) and format_channel_key(file_key) == key:
|
||||
source = str(config_path)
|
||||
break
|
||||
except (OSError, PermissionError):
|
||||
except (OSError, PermissionError, ValueError):
|
||||
continue
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,69 @@
|
||||
"""
|
||||
Stegasoo CLI Module (v3.2.0)
|
||||
|
||||
Command-line interface with batch processing and compression support.
|
||||
A proper CLI architecture using Click. This module demonstrates several
|
||||
important patterns for building production-quality command-line tools:
|
||||
|
||||
PATTERN: COMMAND GROUPS
|
||||
=======================
|
||||
Click's @group decorator creates a hierarchy of commands:
|
||||
|
||||
stegasoo <- Main entry point
|
||||
├── encode <- Simple commands at root level
|
||||
├── decode
|
||||
├── generate
|
||||
├── info
|
||||
├── batch/ <- Group for related commands
|
||||
│ ├── encode
|
||||
│ ├── decode
|
||||
│ └── check
|
||||
├── channel/ <- Another group
|
||||
│ ├── generate
|
||||
│ ├── show
|
||||
│ ├── status
|
||||
│ ├── qr
|
||||
│ └── clear
|
||||
├── tools/ <- Utility group
|
||||
│ ├── capacity
|
||||
│ ├── strip
|
||||
│ ├── peek
|
||||
│ └── exif
|
||||
└── admin/ <- Administration group
|
||||
├── recover
|
||||
└── generate-key
|
||||
|
||||
PATTERN: JSON OUTPUT MODE
|
||||
=========================
|
||||
Every command supports --json for machine-readable output. The pattern:
|
||||
|
||||
@click.pass_context
|
||||
def my_command(ctx, ...):
|
||||
if ctx.obj.get("json"):
|
||||
click.echo(json.dumps(result, indent=2))
|
||||
else:
|
||||
# Human-readable output with colors/formatting
|
||||
click.echo(f"✓ Success: {result}")
|
||||
|
||||
This makes the CLI scriptable - you can pipe to jq, use in shell scripts, etc.
|
||||
|
||||
PATTERN: SENSITIVE INPUT
|
||||
========================
|
||||
Passwords/secrets use Click's secure prompts:
|
||||
|
||||
@click.option("--passphrase", prompt=True, hide_input=True,
|
||||
confirmation_prompt=True, help="Passphrase")
|
||||
|
||||
- prompt=True: Asks if not provided
|
||||
- hide_input=True: No echo (like sudo)
|
||||
- confirmation_prompt=True: "Repeat for confirmation"
|
||||
|
||||
PATTERN: DRY-RUN MODE
|
||||
=====================
|
||||
For destructive or slow operations, --dry-run shows what WOULD happen:
|
||||
|
||||
if dry_run:
|
||||
click.echo(f"Would encode to {output}")
|
||||
return
|
||||
|
||||
Changes in v3.2.0:
|
||||
- Updated to use DEFAULT_PASSPHRASE_WORDS (consistency with v3.2.0 naming)
|
||||
@@ -18,12 +80,6 @@ from .batch import (
|
||||
batch_capacity_check,
|
||||
print_batch_result,
|
||||
)
|
||||
from .compression import (
|
||||
HAS_LZ4,
|
||||
CompressionAlgorithm,
|
||||
algorithm_name,
|
||||
get_available_algorithms,
|
||||
)
|
||||
from .constants import (
|
||||
DEFAULT_PASSPHRASE_WORDS, # v3.2.0: renamed from DEFAULT_PHRASE_WORDS
|
||||
DEFAULT_PIN_LENGTH,
|
||||
@@ -32,10 +88,23 @@ from .constants import (
|
||||
__version__,
|
||||
)
|
||||
|
||||
# Click context settings
|
||||
# Click context settings - these apply to all commands
|
||||
# help_option_names lets users use either -h or --help
|
||||
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ROOT GROUP - The main entry point
|
||||
# =============================================================================
|
||||
#
|
||||
# @click.group() creates a command group. The function becomes both:
|
||||
# 1. A callable that sets up shared state (ctx.obj)
|
||||
# 2. A container for subcommands via @cli.command() decorators
|
||||
#
|
||||
# The context object (ctx.obj) is passed down to all subcommands.
|
||||
# We use it to share the --json flag across the entire CLI.
|
||||
|
||||
|
||||
@click.group(context_settings=CONTEXT_SETTINGS)
|
||||
@click.version_option(__version__, "-v", "--version")
|
||||
@click.option("--json", "json_output", is_flag=True, help="Output results as JSON")
|
||||
@@ -46,6 +115,8 @@ def cli(ctx, json_output):
|
||||
|
||||
Hide messages in images using PIN + passphrase security.
|
||||
"""
|
||||
# ensure_object(dict) creates ctx.obj if it doesn't exist
|
||||
# This prevents "NoneType has no attribute" errors
|
||||
ctx.ensure_object(dict)
|
||||
ctx.obj["json"] = json_output
|
||||
|
||||
@@ -53,6 +124,31 @@ def cli(ctx, json_output):
|
||||
# =============================================================================
|
||||
# ENCODE COMMANDS
|
||||
# =============================================================================
|
||||
#
|
||||
# The encode command demonstrates several Click patterns:
|
||||
#
|
||||
# 1. ARGUMENT vs OPTION
|
||||
# - Arguments are positional: `stegasoo encode photo.png`
|
||||
# - Options have flags: `stegasoo encode -m "message" --pin 1234`
|
||||
# Rule of thumb: required inputs → arguments, optional/secret → options
|
||||
#
|
||||
# 2. MUTUAL EXCLUSIVITY
|
||||
# We need either --message OR --file, not both. Click doesn't have built-in
|
||||
# mutual exclusivity, so we check manually:
|
||||
#
|
||||
# if not message and not file_payload:
|
||||
# raise click.UsageError("Either --message or --file is required")
|
||||
#
|
||||
# 3. TYPE VALIDATION
|
||||
# Click validates types automatically:
|
||||
# - type=click.Path(exists=True) → file must exist
|
||||
# - type=click.Choice(["a", "b"]) → must be one of these values
|
||||
# - type=int → must be an integer
|
||||
#
|
||||
# 4. DEFAULT VALUES
|
||||
# Options can have smart defaults:
|
||||
# - default="zlib" → use this if not specified
|
||||
# - default=True with is_flag=True → boolean flag defaults to on
|
||||
|
||||
|
||||
@cli.command()
|
||||
@@ -81,19 +177,10 @@ def cli(ctx, json_output):
|
||||
help="Passphrase (recommend 4+ words)",
|
||||
)
|
||||
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
|
||||
@click.option(
|
||||
"--compress/--no-compress", default=True, help="Enable/disable compression (default: enabled)"
|
||||
)
|
||||
@click.option(
|
||||
"--algorithm",
|
||||
type=click.Choice(["zlib", "lz4", "none"]),
|
||||
default="zlib",
|
||||
help="Compression algorithm",
|
||||
)
|
||||
@click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding")
|
||||
@click.pass_context
|
||||
def encode(
|
||||
ctx, carrier, reference, message, file_payload, output, passphrase, pin, compress, algorithm, dry_run
|
||||
ctx, carrier, reference, message, file_payload, output, passphrase, pin, dry_run
|
||||
):
|
||||
"""
|
||||
Encode a message or file into an image.
|
||||
@@ -112,18 +199,6 @@ def encode(
|
||||
if not message and not file_payload:
|
||||
raise click.UsageError("Either --message or --file is required")
|
||||
|
||||
# Parse compression algorithm
|
||||
algo_map = {
|
||||
"zlib": CompressionAlgorithm.ZLIB,
|
||||
"lz4": CompressionAlgorithm.LZ4,
|
||||
"none": CompressionAlgorithm.NONE,
|
||||
}
|
||||
compression_algo = algo_map[algorithm] if compress else CompressionAlgorithm.NONE
|
||||
|
||||
if algorithm == "lz4" and not HAS_LZ4:
|
||||
click.echo("Warning: LZ4 not available, falling back to zlib", err=True)
|
||||
compression_algo = CompressionAlgorithm.ZLIB
|
||||
|
||||
# Calculate payload size
|
||||
if file_payload:
|
||||
payload_size = Path(file_payload).stat().st_size
|
||||
@@ -145,7 +220,6 @@ def encode(
|
||||
"capacity_bytes": capacity_bytes,
|
||||
"payload_type": payload_type,
|
||||
"payload_size": payload_size,
|
||||
"compression": algorithm_name(compression_algo),
|
||||
"usage_percent": round(payload_size / capacity_bytes * 100, 1),
|
||||
"fits": payload_size < capacity_bytes,
|
||||
}
|
||||
@@ -157,7 +231,6 @@ def encode(
|
||||
click.echo(f"Reference: {reference}")
|
||||
click.echo(f"Capacity: {capacity_bytes:,} bytes ({capacity_bytes//1024} KB)")
|
||||
click.echo(f"Payload: {payload_size:,} bytes ({payload_type})")
|
||||
click.echo(f"Compression: {algorithm_name(compression_algo)}")
|
||||
click.echo(f"Usage: {result['usage_percent']}%")
|
||||
click.echo(f"Status: {'✓ Fits' if result['fits'] else '✗ Too large'}")
|
||||
return
|
||||
@@ -204,7 +277,6 @@ def encode(
|
||||
"reference": reference,
|
||||
"output": output,
|
||||
"payload_type": payload_type,
|
||||
"compression": algorithm_name(compression_algo),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
@@ -212,7 +284,6 @@ def encode(
|
||||
else:
|
||||
click.echo(f"✓ Encoded {payload_type} to {output}")
|
||||
click.echo(f" Reference: {reference}")
|
||||
click.echo(f" Compression: {algorithm_name(compression_algo)}")
|
||||
|
||||
except Exception as e:
|
||||
if ctx.obj.get("json"):
|
||||
@@ -320,6 +391,32 @@ def decode(ctx, image, reference, passphrase, pin, output):
|
||||
# =============================================================================
|
||||
# BATCH COMMANDS
|
||||
# =============================================================================
|
||||
#
|
||||
# Batch processing demonstrates:
|
||||
#
|
||||
# 1. SUBGROUPS
|
||||
# @cli.group() creates a nested command group:
|
||||
# stegasoo batch encode *.png
|
||||
# stegasoo batch decode *.png
|
||||
# stegasoo batch check *.png
|
||||
#
|
||||
# 2. VARIADIC ARGUMENTS
|
||||
# nargs=-1 accepts multiple arguments:
|
||||
# @click.argument("images", nargs=-1, required=True)
|
||||
# This lets users do: `stegasoo batch encode img1.png img2.png img3.png`
|
||||
# Or with shell expansion: `stegasoo batch encode *.png`
|
||||
#
|
||||
# 3. PROGRESS CALLBACKS
|
||||
# We pass a callback to the BatchProcessor for real-time updates:
|
||||
#
|
||||
# def progress(current, total, item):
|
||||
# click.echo(f"[{current}/{total}] {item.input_path.name}")
|
||||
#
|
||||
# processor.batch_encode(..., progress_callback=progress)
|
||||
#
|
||||
# 4. PARALLEL PROCESSING
|
||||
# --jobs/-j controls worker count. Default is 4 for good balance between
|
||||
# speed and memory usage. Each worker loads images into memory.
|
||||
|
||||
|
||||
@cli.group()
|
||||
@@ -346,13 +443,6 @@ def batch():
|
||||
help="Passphrase (recommend 4+ words)",
|
||||
)
|
||||
@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code")
|
||||
@click.option("--compress/--no-compress", default=True, help="Enable/disable compression")
|
||||
@click.option(
|
||||
"--algorithm",
|
||||
type=click.Choice(["zlib", "lz4", "none"]),
|
||||
default="zlib",
|
||||
help="Compression algorithm",
|
||||
)
|
||||
@click.option("-r", "--recursive", is_flag=True, help="Search directories recursively")
|
||||
@click.option("-j", "--jobs", default=4, help="Parallel workers (default: 4)")
|
||||
@click.option("-v", "--verbose", is_flag=True, help="Show detailed output")
|
||||
@@ -366,8 +456,6 @@ def batch_encode(
|
||||
suffix,
|
||||
passphrase,
|
||||
pin,
|
||||
compress,
|
||||
algorithm,
|
||||
recursive,
|
||||
jobs,
|
||||
verbose,
|
||||
@@ -402,7 +490,6 @@ def batch_encode(
|
||||
output_dir=Path(output_dir) if output_dir else None,
|
||||
output_suffix=suffix,
|
||||
credentials=credentials,
|
||||
compress=compress,
|
||||
recursive=recursive,
|
||||
progress_callback=progress if not ctx.obj.get("json") else None,
|
||||
)
|
||||
@@ -586,33 +673,165 @@ 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_JPEGIO, HAS_SCIPY
|
||||
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_fingerprint, get_channel_key, 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__,
|
||||
"compression": {
|
||||
"available": [algorithm_name(a) for a in get_available_algorithms()],
|
||||
"lz4_installed": HAS_LZ4,
|
||||
},
|
||||
"service": service_status,
|
||||
"url": service_url,
|
||||
"dct_support": has_dct,
|
||||
"channel": {
|
||||
"fingerprint": channel_fingerprint,
|
||||
"source": channel_source,
|
||||
} if channel_fingerprint else None,
|
||||
"limits": {
|
||||
"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(" Service: \033[32m● running\033[0m")
|
||||
if service_url:
|
||||
click.echo(f" URL: {service_url}")
|
||||
elif service_status == "inactive":
|
||||
click.echo(" 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(" Channel: public")
|
||||
|
||||
# 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")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||