Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f54f80214 | ||
|
|
1cd2656e60 | ||
|
|
ce728cec6e | ||
|
|
555735a4fd | ||
|
|
08b70043e4 | ||
|
|
d395e5731e | ||
|
|
110b160e68 | ||
|
|
b09f607d34 | ||
|
|
34ede3815f | ||
|
|
3b5ab41ce9 | ||
|
|
525bcec3c9 | ||
|
|
afc8c93923 | ||
|
|
38bef32750 | ||
|
|
4e3acfca20 |
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false # Don't cancel other jobs if one fails
|
fail-fast: false # Don't cancel other jobs if one fails
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12"]
|
python-version: ["3.11", "3.12", "3.13"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# 1. Get the code
|
# 1. Get the code
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -102,3 +102,6 @@ rpi/*.img.zst.zip
|
|||||||
aur-upload/
|
aur-upload/
|
||||||
aur/.SRCINFO
|
aur/.SRCINFO
|
||||||
aur/*.pkg.tar.zst
|
aur/*.pkg.tar.zst
|
||||||
|
|
||||||
|
# Docker pre-built images and deps (release assets, too large for git)
|
||||||
|
docker/*.tar.zst
|
||||||
|
|||||||
@@ -1,3 +1,72 @@
|
|||||||
|
## Stegasoo v4.2.1
|
||||||
|
|
||||||
|
### API Security
|
||||||
|
|
||||||
|
**API Key Authentication**
|
||||||
|
- All protected endpoints require `X-API-Key` header
|
||||||
|
- Keys stored hashed (SHA-256) in `~/.stegasoo/api_keys.json`
|
||||||
|
- Auth disabled when no keys configured (easy onboarding)
|
||||||
|
|
||||||
|
**TLS Support**
|
||||||
|
- Self-signed certificates auto-generated on first run
|
||||||
|
- Certs valid for localhost, all local IPs, hostname.local
|
||||||
|
- CLI: `stegasoo api tls generate` to pre-generate
|
||||||
|
|
||||||
|
### CLI Improvements
|
||||||
|
|
||||||
|
**New API Management Commands**
|
||||||
|
```bash
|
||||||
|
stegasoo api keys create NAME # Create new key
|
||||||
|
stegasoo api keys list # List API keys
|
||||||
|
stegasoo api tls generate # Generate TLS cert
|
||||||
|
stegasoo api serve # Start server with TLS
|
||||||
|
```
|
||||||
|
|
||||||
|
**New Image Tools**
|
||||||
|
```bash
|
||||||
|
stegasoo tools compress IMG -q 75 # JPEG compression
|
||||||
|
stegasoo tools rotate IMG -r 90 # Lossless rotation
|
||||||
|
stegasoo tools convert IMG -f png # Format conversion
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **DCT rotation**: Portrait photos no longer export rotated 90°
|
||||||
|
- **jpegtran**: Removed `-trim` flag that destroyed DCT stego data
|
||||||
|
- **CLI encode**: Now outputs JPEG when carrier is JPEG (was always PNG)
|
||||||
|
- **Import paths**: Fixed for installed packages (AUR/pip)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
**AUR (Arch Linux)**
|
||||||
|
```bash
|
||||||
|
yay -S stegasoo-git # Full (Web + API + CLI)
|
||||||
|
yay -S stegasoo-cli-git # CLI only
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker**
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker/docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Raspberry Pi**
|
||||||
|
Flash `stegasoo-rpi-4.2.1.img.zst.zip` to SD card.
|
||||||
|
Default login: `admin` / `stegasoo`
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Python 3.11 - 3.14 (dropped 3.10 support)
|
||||||
|
|
||||||
|
### Release Assets
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `stegasoo-rpi-4.2.1.img.zst.zip` | Raspberry Pi SD card image |
|
||||||
|
| `stegasoo-docker-base-4.2.1.tar.zst` | Docker base image |
|
||||||
|
| Source code (zip/tar.gz) | Auto-generated |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Stegasoo v4.2.0
|
## Stegasoo v4.2.0
|
||||||
|
|
||||||
### Performance Optimizations
|
### Performance Optimizations
|
||||||
@@ -55,32 +124,8 @@ Major performance improvements for Raspberry Pi and resource-constrained deploym
|
|||||||
|--------|--------|--------|-------------|
|
|--------|--------|--------|-------------|
|
||||||
| Decode (1MB) | ~2.6s | ~0.8s | **70% faster** |
|
| Decode (1MB) | ~2.6s | ~0.8s | **70% faster** |
|
||||||
| Peak RAM | 211 MB | 107 MB | **50% less** |
|
| Peak RAM | 211 MB | 107 MB | **50% less** |
|
||||||
| Concurrent API | No | Yes | ✓ |
|
| Concurrent API | No | Yes | check |
|
||||||
| QR Compression | zlib | zstd | **~15% smaller** |
|
| 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
|
### Full Changelog
|
||||||
See [CHANGELOG.md](CHANGELOG.md) for complete version history.
|
See [CHANGELOG.md](CHANGELOG.md) for complete version history.
|
||||||
|
|||||||
54
TODO-4.2.1.md
Normal file
54
TODO-4.2.1.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Stegasoo 4.2.1 Plan
|
||||||
|
|
||||||
|
## Bugs
|
||||||
|
- [x] Fix EXIF viewer panel not loading metadata in Web UI
|
||||||
|
- Redesigned with card-based grid layout and categories
|
||||||
|
- Compact styling for better space usage
|
||||||
|
- [x] DCT mode: portrait photos export rotated 90° (EXIF orientation not handled)
|
||||||
|
- Added `_apply_exif_orientation()` to apply EXIF rotation before embedding
|
||||||
|
- [x] DCT mode: add rotation fallback (try as-is, rotate 90°, retry on failure)
|
||||||
|
- Added rotation fallback in `extract_from_dct()` with quick header validation
|
||||||
|
- [x] Rotate tool: use jpegtran for lossless JPEG rotation (preserves DCT stego!)
|
||||||
|
- Web UI rotate tool now uses jpegtran for JPEGs
|
||||||
|
- DCT decode rotation fallback now uses jpegtran for JPEGs
|
||||||
|
- Dynamic UI shows "DCT Safe" for JPEGs, warning for other formats
|
||||||
|
|
||||||
|
## Tools Audit
|
||||||
|
- [x] Web UI tools - full shakedown and fixes
|
||||||
|
- Compress, Rotate, Strip, EXIF viewer all working
|
||||||
|
- Rotate uses jpegtran for lossless JPEG rotation
|
||||||
|
- Compact UI styling
|
||||||
|
- [x] CLI tools - full shakedown and fixes
|
||||||
|
- Fixed encode to output JPEG when carrier is JPEG (was always PNG)
|
||||||
|
- Fixed jpegtran -trim flag destroying DCT stego data
|
||||||
|
- Added compress, rotate, convert tools (matching Web UI)
|
||||||
|
- Rotate uses jpegtran for JPEGs, supports flip-only operations
|
||||||
|
|
||||||
|
## AUR Packages
|
||||||
|
- [x] `stegasoo-cli` - standalone CLI package (no web dependencies)
|
||||||
|
- Created aur-cli/PKGBUILD with [cli,dct,compression] extras only
|
||||||
|
- No flask/gunicorn/fastapi/uvicorn/pyzbar deps
|
||||||
|
- 68MB vs 79MB for full package
|
||||||
|
- [x] `stegasoo-api` - REST API package
|
||||||
|
- Created aur-api/PKGBUILD with [api,cli,compression] extras
|
||||||
|
- Has fastapi/uvicorn, no flask/gunicorn
|
||||||
|
- 74MB package size
|
||||||
|
- Includes systemd service with TLS
|
||||||
|
|
||||||
|
## API Auth Work
|
||||||
|
- [x] API key authentication (simpler than OAuth2 for personal use)
|
||||||
|
- `frontends/api/auth.py` - key generation, hashing, validation
|
||||||
|
- Keys stored in `~/.stegasoo/api_keys.json` (hashed)
|
||||||
|
- `X-API-Key` header for authentication
|
||||||
|
- Auth disabled when no keys configured
|
||||||
|
- [x] TLS with self-signed certificates
|
||||||
|
- Auto-generates certs on first run
|
||||||
|
- CLI: `stegasoo api tls generate`
|
||||||
|
- Certs stored in `~/.stegasoo/certs/`
|
||||||
|
- [x] CLI commands for API management
|
||||||
|
- `stegasoo api keys list/create/delete`
|
||||||
|
- `stegasoo api tls generate/info`
|
||||||
|
- `stegasoo api serve` (starts with TLS by default)
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
- [ ] Postman collection (with environment templates)
|
||||||
23
aur-api/.SRCINFO
Normal file
23
aur-api/.SRCINFO
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
pkgbase = stegasoo-api-git
|
||||||
|
pkgdesc = Stegasoo REST API with TLS and API key authentication
|
||||||
|
pkgver = 4.2.1
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/adlee-was-taken/stegasoo
|
||||||
|
install = stegasoo-api-git.install
|
||||||
|
arch = x86_64
|
||||||
|
license = MIT
|
||||||
|
makedepends = git
|
||||||
|
makedepends = python
|
||||||
|
makedepends = python-build
|
||||||
|
makedepends = python-hatchling
|
||||||
|
depends = python>=3.11
|
||||||
|
depends = zbar
|
||||||
|
optdepends = libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)
|
||||||
|
provides = stegasoo-api
|
||||||
|
conflicts = stegasoo-api
|
||||||
|
conflicts = stegasoo
|
||||||
|
conflicts = stegasoo-git
|
||||||
|
source = stegasoo-api-git::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main
|
||||||
|
sha256sums = SKIP
|
||||||
|
|
||||||
|
pkgname = stegasoo-api-git
|
||||||
109
aur-api/PKGBUILD
Normal file
109
aur-api/PKGBUILD
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||||
|
pkgname=stegasoo-api-git
|
||||||
|
pkgver=4.2.1
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Stegasoo REST API with TLS and API key authentication"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/adlee-was-taken/stegasoo"
|
||||||
|
license=('MIT')
|
||||||
|
|
||||||
|
# Python 3.11-3.14 supported
|
||||||
|
depends=(
|
||||||
|
'python>=3.11'
|
||||||
|
'zbar' # QR code reading for RSA key extraction
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
'git'
|
||||||
|
'python'
|
||||||
|
'python-build'
|
||||||
|
'python-hatchling'
|
||||||
|
)
|
||||||
|
optdepends=(
|
||||||
|
'libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)'
|
||||||
|
)
|
||||||
|
provides=('stegasoo-api')
|
||||||
|
conflicts=('stegasoo-api' 'stegasoo' 'stegasoo-git')
|
||||||
|
install=stegasoo-api-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.1" "$(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-api with dedicated venv
|
||||||
|
install -dm755 "$pkgdir/opt/stegasoo-api"
|
||||||
|
|
||||||
|
# Create fresh venv in package
|
||||||
|
python -m venv "$pkgdir/opt/stegasoo-api/venv"
|
||||||
|
|
||||||
|
# Install the wheel with API + CLI + compression extras
|
||||||
|
local wheel=$(ls dist/*.whl | head -1)
|
||||||
|
"$pkgdir/opt/stegasoo-api/venv/bin/pip" install --no-cache-dir "${wheel}[api,cli,compression]"
|
||||||
|
|
||||||
|
# Install API frontend (not included in wheel by default)
|
||||||
|
local site_packages="$pkgdir/opt/stegasoo-api/venv/lib/python${pyver}/site-packages"
|
||||||
|
install -dm755 "$site_packages/frontends/api"
|
||||||
|
cp -r frontends/api/*.py "$site_packages/frontends/api/"
|
||||||
|
cp -r frontends/__init__.py "$site_packages/frontends/" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create temp directory for API
|
||||||
|
install -dm755 "$site_packages/frontends/api/temp_files"
|
||||||
|
|
||||||
|
# Create config directories
|
||||||
|
install -dm755 "$pkgdir/opt/stegasoo-api/config"
|
||||||
|
install -dm700 "$pkgdir/opt/stegasoo-api/certs"
|
||||||
|
|
||||||
|
# Fix shebangs - replace build-time paths with installed paths
|
||||||
|
find "$pkgdir/opt/stegasoo-api/venv/bin" -type f -exec \
|
||||||
|
sed -i "s|$pkgdir/opt/stegasoo-api/venv|/opt/stegasoo-api/venv|g" {} \;
|
||||||
|
|
||||||
|
# Fix pyvenv.cfg
|
||||||
|
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo-api/venv/pyvenv.cfg"
|
||||||
|
|
||||||
|
# Create symlink to /usr/bin
|
||||||
|
install -dm755 "$pkgdir/usr/bin"
|
||||||
|
ln -s /opt/stegasoo-api/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
|
||||||
|
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Stegasoo REST API (HTTPS)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=stegasoo
|
||||||
|
WorkingDirectory=/opt/stegasoo-api/venv/lib/python${pyver}/site-packages/frontends/api
|
||||||
|
Environment="PATH=/opt/stegasoo-api/venv/bin"
|
||||||
|
Environment="HOME=/opt/stegasoo-api"
|
||||||
|
# TLS enabled by default - certs auto-generated on first run
|
||||||
|
# Use: stegasoo api tls generate (to pre-generate certs)
|
||||||
|
# Use: stegasoo api keys create <name> (to create API keys)
|
||||||
|
ExecStart=/opt/stegasoo-api/venv/bin/stegasoo api serve --host 127.0.0.1 --port 8000
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
}
|
||||||
48
aur-api/stegasoo-api-git.install
Normal file
48
aur-api/stegasoo-api-git.install
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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-api stegasoo
|
||||||
|
echo "Created system user 'stegasoo'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set ownership of directories
|
||||||
|
chown -R stegasoo:stegasoo /opt/stegasoo-api/config 2>/dev/null || true
|
||||||
|
chown -R stegasoo:stegasoo /opt/stegasoo-api/certs 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Stegasoo API installed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "Quick Start:"
|
||||||
|
echo " 1. Create an API key:"
|
||||||
|
echo " stegasoo api keys create mykey"
|
||||||
|
echo ""
|
||||||
|
echo " 2. Start the API server:"
|
||||||
|
echo " sudo systemctl start stegasoo-api"
|
||||||
|
echo ""
|
||||||
|
echo " 3. Access the API:"
|
||||||
|
echo " curl -k -H 'X-API-Key: YOUR_KEY' https://localhost:8000/"
|
||||||
|
echo ""
|
||||||
|
echo "Management commands:"
|
||||||
|
echo " stegasoo api keys list # List API keys"
|
||||||
|
echo " stegasoo api keys create X # Create new key"
|
||||||
|
echo " stegasoo api tls info # Show certificate info"
|
||||||
|
echo " stegasoo api serve --help # Server options"
|
||||||
|
echo ""
|
||||||
|
echo "API docs available at: https://localhost:8000/docs"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
post_upgrade() {
|
||||||
|
post_install
|
||||||
|
}
|
||||||
|
|
||||||
|
pre_remove() {
|
||||||
|
# Stop service if running
|
||||||
|
systemctl stop stegasoo-api 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
post_remove() {
|
||||||
|
echo "Stegasoo API removed."
|
||||||
|
echo "User 'stegasoo' and config in /opt/stegasoo-api were not removed."
|
||||||
|
echo "To remove: userdel stegasoo && rm -rf /opt/stegasoo-api"
|
||||||
|
}
|
||||||
22
aur-api/test-build.sh
Executable file
22
aur-api/test-build.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test build the AUR API package locally
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "=== Cleaning previous builds ==="
|
||||||
|
rm -rf stegasoo-api-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-api-git-*.pkg.tar.zst"
|
||||||
|
echo "To test: makepkg -si"
|
||||||
22
aur-cli/.SRCINFO
Normal file
22
aur-cli/.SRCINFO
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
pkgbase = stegasoo-cli-git
|
||||||
|
pkgdesc = Secure steganography CLI with hybrid photo + passphrase + PIN authentication
|
||||||
|
pkgver = 4.2.1
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/adlee-was-taken/stegasoo
|
||||||
|
install = stegasoo-cli-git.install
|
||||||
|
arch = x86_64
|
||||||
|
license = MIT
|
||||||
|
makedepends = git
|
||||||
|
makedepends = python
|
||||||
|
makedepends = python-build
|
||||||
|
makedepends = python-hatchling
|
||||||
|
depends = python>=3.11
|
||||||
|
optdepends = libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)
|
||||||
|
provides = stegasoo-cli
|
||||||
|
conflicts = stegasoo-cli
|
||||||
|
conflicts = stegasoo
|
||||||
|
conflicts = stegasoo-git
|
||||||
|
source = stegasoo-cli-git::git+https://github.com/adlee-was-taken/stegasoo.git#branch=main
|
||||||
|
sha256sums = SKIP
|
||||||
|
|
||||||
|
pkgname = stegasoo-cli-git
|
||||||
69
aur-cli/PKGBUILD
Normal file
69
aur-cli/PKGBUILD
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||||
|
pkgname=stegasoo-cli-git
|
||||||
|
pkgver=4.2.1
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Secure steganography CLI 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'
|
||||||
|
)
|
||||||
|
makedepends=(
|
||||||
|
'git'
|
||||||
|
'python'
|
||||||
|
'python-build'
|
||||||
|
'python-hatchling'
|
||||||
|
)
|
||||||
|
optdepends=(
|
||||||
|
'libjpeg-turbo: jpegtran for lossless JPEG rotation (DCT-safe)'
|
||||||
|
)
|
||||||
|
provides=('stegasoo-cli')
|
||||||
|
conflicts=('stegasoo-cli' 'stegasoo' 'stegasoo-git')
|
||||||
|
install=stegasoo-cli-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.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "$pkgname"
|
||||||
|
python -m build --wheel --no-isolation
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$pkgname"
|
||||||
|
|
||||||
|
# Install to /opt/stegasoo-cli with dedicated venv
|
||||||
|
install -dm755 "$pkgdir/opt/stegasoo-cli"
|
||||||
|
|
||||||
|
# Create fresh venv in package
|
||||||
|
python -m venv "$pkgdir/opt/stegasoo-cli/venv"
|
||||||
|
|
||||||
|
# Install the wheel with CLI + DCT + compression extras (no web/api)
|
||||||
|
local wheel=$(ls dist/*.whl | head -1)
|
||||||
|
"$pkgdir/opt/stegasoo-cli/venv/bin/pip" install --no-cache-dir "${wheel}[cli,dct,compression]"
|
||||||
|
|
||||||
|
# Fix shebangs - replace build-time paths with installed paths
|
||||||
|
find "$pkgdir/opt/stegasoo-cli/venv/bin" -type f -exec \
|
||||||
|
sed -i "s|$pkgdir/opt/stegasoo-cli/venv|/opt/stegasoo-cli/venv|g" {} \;
|
||||||
|
|
||||||
|
# Fix pyvenv.cfg
|
||||||
|
sed -i "s|$pkgdir||g" "$pkgdir/opt/stegasoo-cli/venv/pyvenv.cfg"
|
||||||
|
|
||||||
|
# Create symlink to /usr/bin
|
||||||
|
install -dm755 "$pkgdir/usr/bin"
|
||||||
|
ln -s /opt/stegasoo-cli/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"
|
||||||
|
}
|
||||||
17
aur-cli/stegasoo-cli-git.install
Normal file
17
aur-cli/stegasoo-cli-git.install
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
post_install() {
|
||||||
|
echo ""
|
||||||
|
echo "Stegasoo CLI installed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "Usage:"
|
||||||
|
echo " stegasoo --help # Show all commands"
|
||||||
|
echo " stegasoo encode ... # Hide data in an image"
|
||||||
|
echo " stegasoo decode ... # Extract hidden data"
|
||||||
|
echo " stegasoo tools --help # Image tools (compress, rotate, etc.)"
|
||||||
|
echo ""
|
||||||
|
echo "For web UI or REST API, install stegasoo-git instead."
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
post_upgrade() {
|
||||||
|
post_install
|
||||||
|
}
|
||||||
22
aur-cli/test-build.sh
Executable file
22
aur-cli/test-build.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test build the AUR CLI package locally
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "=== Cleaning previous builds ==="
|
||||||
|
rm -rf stegasoo-cli-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-cli-git-*.pkg.tar.zst"
|
||||||
|
echo "To test: makepkg -si"
|
||||||
12
aur/PKGBUILD
12
aur/PKGBUILD
@@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Aaron D. Lee <your-email@example.com>
|
# Maintainer: Aaron D. Lee <your-email@example.com>
|
||||||
pkgname=stegasoo-git
|
pkgname=stegasoo-git
|
||||||
pkgver=4.2.0.r0.g530e5de
|
pkgver=4.2.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication"
|
pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
@@ -27,7 +27,7 @@ sha256sums=('SKIP')
|
|||||||
pkgver() {
|
pkgver() {
|
||||||
cd "$pkgname"
|
cd "$pkgname"
|
||||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
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)"
|
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||||
}
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
@@ -98,7 +98,7 @@ EOF
|
|||||||
|
|
||||||
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
|
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Stegasoo REST API
|
Description=Stegasoo REST API (HTTPS)
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
@@ -106,7 +106,11 @@ Type=simple
|
|||||||
User=stegasoo
|
User=stegasoo
|
||||||
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/api
|
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/api
|
||||||
Environment="PATH=/opt/stegasoo/venv/bin"
|
Environment="PATH=/opt/stegasoo/venv/bin"
|
||||||
ExecStart=/opt/stegasoo/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000
|
Environment="HOME=/opt/stegasoo"
|
||||||
|
# TLS enabled by default - certs auto-generated on first run
|
||||||
|
# Use stegasoo api tls generate to pre-generate certs
|
||||||
|
# Use stegasoo api keys create <name> to create API keys
|
||||||
|
ExecStart=/opt/stegasoo/venv/bin/stegasoo api serve --host 127.0.0.1 --port 8000
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
|
|||||||
@@ -54,4 +54,4 @@ RUN python -c "import jpegio; import scipy; import numpy; import zstandard; prin
|
|||||||
# Label for tracking
|
# Label for tracking
|
||||||
LABEL org.opencontainers.image.title="Stegasoo Base"
|
LABEL org.opencontainers.image.title="Stegasoo Base"
|
||||||
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
|
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
|
||||||
LABEL org.opencontainers.image.version="4.2.0"
|
LABEL org.opencontainers.image.version="4.2.1"
|
||||||
|
|||||||
256
frontends/api/auth.py
Normal file
256
frontends/api/auth.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
"""
|
||||||
|
API Key Authentication for Stegasoo REST API.
|
||||||
|
|
||||||
|
Provides simple API key authentication with hashed key storage.
|
||||||
|
Keys can be stored in user config (~/.stegasoo/) or project config (./config/).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from .auth import require_api_key, get_api_key_status
|
||||||
|
|
||||||
|
@app.get("/protected")
|
||||||
|
async def protected_endpoint(api_key: str = Depends(require_api_key)):
|
||||||
|
return {"status": "authenticated"}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Security
|
||||||
|
from fastapi.security import APIKeyHeader
|
||||||
|
|
||||||
|
# API key header name
|
||||||
|
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||||
|
|
||||||
|
# Config locations
|
||||||
|
USER_CONFIG_DIR = Path.home() / ".stegasoo"
|
||||||
|
PROJECT_CONFIG_DIR = Path("./config")
|
||||||
|
|
||||||
|
# Key file name
|
||||||
|
API_KEYS_FILE = "api_keys.json"
|
||||||
|
|
||||||
|
# Environment variable for API key (alternative to file)
|
||||||
|
API_KEY_ENV_VAR = "STEGASOO_API_KEY"
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_key(key: str) -> str:
|
||||||
|
"""Hash an API key for storage."""
|
||||||
|
return hashlib.sha256(key.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_keys_file(location: str = "user") -> Path:
|
||||||
|
"""Get path to API keys file."""
|
||||||
|
if location == "project":
|
||||||
|
return PROJECT_CONFIG_DIR / API_KEYS_FILE
|
||||||
|
return USER_CONFIG_DIR / API_KEYS_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def _load_keys(location: str = "user") -> dict:
|
||||||
|
"""Load API keys from config file."""
|
||||||
|
keys_file = _get_keys_file(location)
|
||||||
|
if keys_file.exists():
|
||||||
|
try:
|
||||||
|
with open(keys_file) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return {"keys": [], "enabled": True}
|
||||||
|
return {"keys": [], "enabled": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_keys(data: dict, location: str = "user") -> None:
|
||||||
|
"""Save API keys to config file."""
|
||||||
|
keys_file = _get_keys_file(location)
|
||||||
|
keys_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(keys_file, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
# Secure permissions (owner read/write only)
|
||||||
|
os.chmod(keys_file, 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_api_key() -> str:
|
||||||
|
"""Generate a new API key."""
|
||||||
|
# Format: stegasoo_XXXX_XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
|
# 32 bytes = 256 bits of entropy
|
||||||
|
random_part = secrets.token_hex(16)
|
||||||
|
return f"stegasoo_{random_part[:4]}_{random_part[4:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def add_api_key(name: str, location: str = "user") -> str:
|
||||||
|
"""
|
||||||
|
Generate and store a new API key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Descriptive name for the key (e.g., "laptop", "automation")
|
||||||
|
location: "user" or "project"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The generated API key (only shown once!)
|
||||||
|
"""
|
||||||
|
key = generate_api_key()
|
||||||
|
key_hash = _hash_key(key)
|
||||||
|
|
||||||
|
data = _load_keys(location)
|
||||||
|
|
||||||
|
# Check for duplicate name
|
||||||
|
for existing in data["keys"]:
|
||||||
|
if existing["name"] == name:
|
||||||
|
raise ValueError(f"Key with name '{name}' already exists")
|
||||||
|
|
||||||
|
data["keys"].append({
|
||||||
|
"name": name,
|
||||||
|
"hash": key_hash,
|
||||||
|
"created": __import__("datetime").datetime.now().isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
_save_keys(data, location)
|
||||||
|
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def remove_api_key(name: str, location: str = "user") -> bool:
|
||||||
|
"""
|
||||||
|
Remove an API key by name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if key was found and removed, False otherwise
|
||||||
|
"""
|
||||||
|
data = _load_keys(location)
|
||||||
|
original_count = len(data["keys"])
|
||||||
|
|
||||||
|
data["keys"] = [k for k in data["keys"] if k["name"] != name]
|
||||||
|
|
||||||
|
if len(data["keys"]) < original_count:
|
||||||
|
_save_keys(data, location)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def list_api_keys(location: str = "user") -> list[dict]:
|
||||||
|
"""
|
||||||
|
List all API keys (names and creation dates, not actual keys).
|
||||||
|
"""
|
||||||
|
data = _load_keys(location)
|
||||||
|
return [{"name": k["name"], "created": k.get("created", "unknown")} for k in data["keys"]]
|
||||||
|
|
||||||
|
|
||||||
|
def set_auth_enabled(enabled: bool, location: str = "user") -> None:
|
||||||
|
"""Enable or disable API key authentication."""
|
||||||
|
data = _load_keys(location)
|
||||||
|
data["enabled"] = enabled
|
||||||
|
_save_keys(data, location)
|
||||||
|
|
||||||
|
|
||||||
|
def is_auth_enabled() -> bool:
|
||||||
|
"""Check if API key authentication is enabled."""
|
||||||
|
# Check project config first, then user config
|
||||||
|
for location in ["project", "user"]:
|
||||||
|
data = _load_keys(location)
|
||||||
|
if "enabled" in data:
|
||||||
|
return data["enabled"]
|
||||||
|
|
||||||
|
# Default: enabled if any keys exist
|
||||||
|
return bool(get_all_key_hashes())
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_key_hashes() -> set[str]:
|
||||||
|
"""Get all valid API key hashes from all sources."""
|
||||||
|
hashes = set()
|
||||||
|
|
||||||
|
# Check environment variable first
|
||||||
|
env_key = os.environ.get(API_KEY_ENV_VAR)
|
||||||
|
if env_key:
|
||||||
|
hashes.add(_hash_key(env_key))
|
||||||
|
|
||||||
|
# Check project and user configs
|
||||||
|
for location in ["project", "user"]:
|
||||||
|
data = _load_keys(location)
|
||||||
|
for key_entry in data.get("keys", []):
|
||||||
|
if "hash" in key_entry:
|
||||||
|
hashes.add(key_entry["hash"])
|
||||||
|
|
||||||
|
return hashes
|
||||||
|
|
||||||
|
|
||||||
|
def validate_api_key(key: str) -> bool:
|
||||||
|
"""Validate an API key against stored hashes."""
|
||||||
|
if not key:
|
||||||
|
return False
|
||||||
|
|
||||||
|
key_hash = _hash_key(key)
|
||||||
|
valid_hashes = get_all_key_hashes()
|
||||||
|
|
||||||
|
return key_hash in valid_hashes
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_key_status() -> dict:
|
||||||
|
"""Get current API key authentication status."""
|
||||||
|
user_keys = list_api_keys("user")
|
||||||
|
project_keys = list_api_keys("project")
|
||||||
|
env_configured = bool(os.environ.get(API_KEY_ENV_VAR))
|
||||||
|
|
||||||
|
total_keys = len(user_keys) + len(project_keys) + (1 if env_configured else 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": is_auth_enabled(),
|
||||||
|
"total_keys": total_keys,
|
||||||
|
"user_keys": len(user_keys),
|
||||||
|
"project_keys": len(project_keys),
|
||||||
|
"env_configured": env_configured,
|
||||||
|
"keys": {
|
||||||
|
"user": user_keys,
|
||||||
|
"project": project_keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# FastAPI dependency for API key authentication
|
||||||
|
async def require_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) -> str:
|
||||||
|
"""
|
||||||
|
FastAPI dependency that requires a valid API key.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
@app.get("/protected")
|
||||||
|
async def endpoint(key: str = Depends(require_api_key)):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
# Check if auth is enabled
|
||||||
|
if not is_auth_enabled():
|
||||||
|
return "auth_disabled"
|
||||||
|
|
||||||
|
# No keys configured = auth disabled
|
||||||
|
if not get_all_key_hashes():
|
||||||
|
return "no_keys_configured"
|
||||||
|
|
||||||
|
# Validate the provided key
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="API key required. Provide X-API-Key header.",
|
||||||
|
headers={"WWW-Authenticate": "ApiKey"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not validate_api_key(api_key):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Invalid API key.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
|
async def optional_api_key(api_key: Optional[str] = Security(API_KEY_HEADER)) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
FastAPI dependency that optionally validates API key.
|
||||||
|
|
||||||
|
Returns the key if valid, None if not provided or invalid.
|
||||||
|
Doesn't raise exceptions - useful for endpoints that work
|
||||||
|
with or without auth.
|
||||||
|
"""
|
||||||
|
if api_key and validate_api_key(api_key):
|
||||||
|
return api_key
|
||||||
|
return None
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Stegasoo REST API (v4.2.0)
|
Stegasoo REST API (v4.2.1)
|
||||||
|
|
||||||
FastAPI-based REST API for steganography operations.
|
FastAPI-based REST API for steganography operations.
|
||||||
Supports both text messages and file embedding.
|
Supports both text messages and file embedding.
|
||||||
|
|
||||||
|
CHANGES in v4.2.1:
|
||||||
|
- API key authentication (X-API-Key header)
|
||||||
|
- TLS support with self-signed certificates
|
||||||
|
- /auth/* endpoints for key management
|
||||||
|
|
||||||
CHANGES in v4.2.0:
|
CHANGES in v4.2.0:
|
||||||
- Async encode/decode operations (run in thread pool)
|
- Async encode/decode operations (run in thread pool)
|
||||||
- Server can handle concurrent requests without blocking
|
- Server can handle concurrent requests without blocking
|
||||||
@@ -32,10 +37,31 @@ from functools import partial
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
|
from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# API Key Authentication
|
||||||
|
try:
|
||||||
|
from .auth import (
|
||||||
|
require_api_key,
|
||||||
|
get_api_key_status,
|
||||||
|
add_api_key,
|
||||||
|
remove_api_key,
|
||||||
|
list_api_keys,
|
||||||
|
is_auth_enabled,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
# When running directly (not as package)
|
||||||
|
from auth import (
|
||||||
|
require_api_key,
|
||||||
|
get_api_key_status,
|
||||||
|
add_api_key,
|
||||||
|
remove_api_key,
|
||||||
|
list_api_keys,
|
||||||
|
is_auth_enabled,
|
||||||
|
)
|
||||||
|
|
||||||
# Add parent to path for development
|
# Add parent to path for development
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||||
|
|
||||||
@@ -357,6 +383,23 @@ class ChannelSetRequest(BaseModel):
|
|||||||
location: str = Field(default="user", description="'user' or 'project'")
|
location: str = Field(default="user", description="'user' or 'project'")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthStatusResponse(BaseModel):
|
||||||
|
"""Response for API key authentication status."""
|
||||||
|
|
||||||
|
enabled: bool = Field(description="Whether API key auth is enabled")
|
||||||
|
total_keys: int = Field(description="Total number of configured API keys")
|
||||||
|
user_keys: int = Field(description="Keys in user config")
|
||||||
|
project_keys: int = Field(description="Keys in project config")
|
||||||
|
env_configured: bool = Field(description="Whether env var key is set")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthKeyInfo(BaseModel):
|
||||||
|
"""Info about a single API key (not the actual key)."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
created: str
|
||||||
|
|
||||||
|
|
||||||
class ModesResponse(BaseModel):
|
class ModesResponse(BaseModel):
|
||||||
"""Response showing available embedding modes."""
|
"""Response showing available embedding modes."""
|
||||||
|
|
||||||
@@ -614,6 +657,7 @@ async def api_channel_status(
|
|||||||
|
|
||||||
@app.post("/channel/generate", response_model=ChannelGenerateResponse)
|
@app.post("/channel/generate", response_model=ChannelGenerateResponse)
|
||||||
async def api_channel_generate(
|
async def api_channel_generate(
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
save: bool = Query(False, description="Save to user config"),
|
save: bool = Query(False, description="Save to user config"),
|
||||||
save_project: bool = Query(False, description="Save to project config"),
|
save_project: bool = Query(False, description="Save to project config"),
|
||||||
):
|
):
|
||||||
@@ -652,7 +696,7 @@ async def api_channel_generate(
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/channel/set")
|
@app.post("/channel/set")
|
||||||
async def api_channel_set(request: ChannelSetRequest):
|
async def api_channel_set(request: ChannelSetRequest, _: str = Depends(require_api_key)):
|
||||||
"""
|
"""
|
||||||
Set/save a channel key to config.
|
Set/save a channel key to config.
|
||||||
|
|
||||||
@@ -678,6 +722,7 @@ async def api_channel_set(request: ChannelSetRequest):
|
|||||||
|
|
||||||
@app.delete("/channel")
|
@app.delete("/channel")
|
||||||
async def api_channel_clear(
|
async def api_channel_clear(
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
location: str = Query("user", description="'user', 'project', or 'all'")
|
location: str = Query("user", description="'user', 'project', or 'all'")
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -704,8 +749,98 @@ async def api_channel_clear(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ROUTES - AUTHENTICATION (v4.2.1)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/status", response_model=AuthStatusResponse)
|
||||||
|
async def api_auth_status():
|
||||||
|
"""
|
||||||
|
Get API key authentication status.
|
||||||
|
|
||||||
|
v4.2.1: New endpoint for auth status.
|
||||||
|
Returns whether auth is enabled and key counts.
|
||||||
|
"""
|
||||||
|
status = get_api_key_status()
|
||||||
|
return AuthStatusResponse(
|
||||||
|
enabled=status["enabled"],
|
||||||
|
total_keys=status["total_keys"],
|
||||||
|
user_keys=status["user_keys"],
|
||||||
|
project_keys=status["project_keys"],
|
||||||
|
env_configured=status["env_configured"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/keys", response_model=list[AuthKeyInfo])
|
||||||
|
async def api_auth_list_keys(
|
||||||
|
location: str = Query("user", description="'user' or 'project'"),
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List configured API keys (names only, not actual keys).
|
||||||
|
|
||||||
|
v4.2.1: New endpoint for auth management.
|
||||||
|
Requires authentication.
|
||||||
|
"""
|
||||||
|
if location not in ("user", "project"):
|
||||||
|
raise HTTPException(400, "location must be 'user' or 'project'")
|
||||||
|
|
||||||
|
keys = list_api_keys(location)
|
||||||
|
return [AuthKeyInfo(name=k["name"], created=k["created"]) for k in keys]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth/keys")
|
||||||
|
async def api_auth_create_key(
|
||||||
|
name: str = Query(..., description="Name for the new API key"),
|
||||||
|
location: str = Query("user", description="'user' or 'project'"),
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new API key.
|
||||||
|
|
||||||
|
v4.2.1: New endpoint for auth management.
|
||||||
|
Returns the key ONCE - it cannot be retrieved again!
|
||||||
|
Requires authentication (or no keys configured yet).
|
||||||
|
"""
|
||||||
|
if location not in ("user", "project"):
|
||||||
|
raise HTTPException(400, "location must be 'user' or 'project'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = add_api_key(name, location)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"name": name,
|
||||||
|
"key": key,
|
||||||
|
"warning": "Save this key now! It cannot be retrieved again.",
|
||||||
|
}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/auth/keys")
|
||||||
|
async def api_auth_delete_key(
|
||||||
|
name: str = Query(..., description="Name of key to delete"),
|
||||||
|
location: str = Query("user", description="'user' or 'project'"),
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete an API key by name.
|
||||||
|
|
||||||
|
v4.2.1: New endpoint for auth management.
|
||||||
|
Requires authentication.
|
||||||
|
"""
|
||||||
|
if location not in ("user", "project"):
|
||||||
|
raise HTTPException(400, "location must be 'user' or 'project'")
|
||||||
|
|
||||||
|
if remove_api_key(name, location):
|
||||||
|
return {"success": True, "deleted": name}
|
||||||
|
else:
|
||||||
|
raise HTTPException(404, f"Key '{name}' not found in {location} config")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/compare", response_model=CompareModesResponse)
|
@app.post("/compare", response_model=CompareModesResponse)
|
||||||
async def api_compare_modes(request: CompareModesRequest):
|
async def api_compare_modes(request: CompareModesRequest, _: str = Depends(require_api_key)):
|
||||||
"""
|
"""
|
||||||
Compare LSB and DCT embedding modes for a carrier image.
|
Compare LSB and DCT embedding modes for a carrier image.
|
||||||
|
|
||||||
@@ -763,7 +898,7 @@ async def api_compare_modes(request: CompareModesRequest):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/will-fit", response_model=WillFitResponse)
|
@app.post("/will-fit", response_model=WillFitResponse)
|
||||||
async def api_will_fit(request: WillFitRequest):
|
async def api_will_fit(request: WillFitRequest, _: str = Depends(require_api_key)):
|
||||||
"""
|
"""
|
||||||
Check if a payload of given size will fit in the carrier image.
|
Check if a payload of given size will fit in the carrier image.
|
||||||
|
|
||||||
@@ -799,6 +934,7 @@ async def api_will_fit(request: WillFitRequest):
|
|||||||
|
|
||||||
@app.post("/extract-key-from-qr", response_model=QrExtractResponse)
|
@app.post("/extract-key-from-qr", response_model=QrExtractResponse)
|
||||||
async def api_extract_key_from_qr(
|
async def api_extract_key_from_qr(
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
qr_image: UploadFile = File(..., description="QR code image containing RSA key")
|
qr_image: UploadFile = File(..., description="QR code image containing RSA key")
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -823,7 +959,7 @@ async def api_extract_key_from_qr(
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/generate-key-qr", response_model=QrGenerateResponse)
|
@app.post("/generate-key-qr", response_model=QrGenerateResponse)
|
||||||
async def api_generate_key_qr(request: QrGenerateRequest):
|
async def api_generate_key_qr(request: QrGenerateRequest, _: str = Depends(require_api_key)):
|
||||||
"""
|
"""
|
||||||
Generate QR code from an RSA private key.
|
Generate QR code from an RSA private key.
|
||||||
|
|
||||||
@@ -873,7 +1009,7 @@ async def api_generate_key_qr(request: QrGenerateRequest):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/generate", response_model=GenerateResponse)
|
@app.post("/generate", response_model=GenerateResponse)
|
||||||
async def api_generate(request: GenerateRequest):
|
async def api_generate(request: GenerateRequest, _: str = Depends(require_api_key)):
|
||||||
"""
|
"""
|
||||||
Generate credentials for encoding/decoding.
|
Generate credentials for encoding/decoding.
|
||||||
|
|
||||||
@@ -955,7 +1091,7 @@ def _get_output_info(embed_mode: str, dct_output_format: str, dct_color_mode: st
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/encode", response_model=EncodeResponse)
|
@app.post("/encode", response_model=EncodeResponse)
|
||||||
async def api_encode(request: EncodeRequest):
|
async def api_encode(request: EncodeRequest, _: str = Depends(require_api_key)):
|
||||||
"""
|
"""
|
||||||
Encode a text message into an image.
|
Encode a text message into an image.
|
||||||
|
|
||||||
@@ -1027,7 +1163,7 @@ async def api_encode(request: EncodeRequest):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/encode/file", response_model=EncodeResponse)
|
@app.post("/encode/file", response_model=EncodeResponse)
|
||||||
async def api_encode_file(request: EncodeFileRequest):
|
async def api_encode_file(request: EncodeFileRequest, _: str = Depends(require_api_key)):
|
||||||
"""
|
"""
|
||||||
Encode a file into an image (JSON with base64).
|
Encode a file into an image (JSON with base64).
|
||||||
|
|
||||||
@@ -1109,7 +1245,7 @@ async def api_encode_file(request: EncodeFileRequest):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/decode", response_model=DecodeResponse)
|
@app.post("/decode", response_model=DecodeResponse)
|
||||||
async def api_decode(request: DecodeRequest):
|
async def api_decode(request: DecodeRequest, _: str = Depends(require_api_key)):
|
||||||
"""
|
"""
|
||||||
Decode a message or file from a stego image.
|
Decode a message or file from a stego image.
|
||||||
|
|
||||||
@@ -1172,6 +1308,7 @@ async def api_decode(request: DecodeRequest):
|
|||||||
|
|
||||||
@app.post("/encode/multipart")
|
@app.post("/encode/multipart")
|
||||||
async def api_encode_multipart(
|
async def api_encode_multipart(
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
|
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
|
||||||
reference_photo: UploadFile = File(...),
|
reference_photo: UploadFile = File(...),
|
||||||
carrier: UploadFile = File(...),
|
carrier: UploadFile = File(...),
|
||||||
@@ -1313,6 +1450,7 @@ async def api_encode_multipart(
|
|||||||
|
|
||||||
@app.post("/decode/multipart", response_model=DecodeResponse)
|
@app.post("/decode/multipart", response_model=DecodeResponse)
|
||||||
async def api_decode_multipart(
|
async def api_decode_multipart(
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
|
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
|
||||||
reference_photo: UploadFile = File(...),
|
reference_photo: UploadFile = File(...),
|
||||||
stego_image: UploadFile = File(...),
|
stego_image: UploadFile = File(...),
|
||||||
@@ -1418,6 +1556,7 @@ async def api_decode_multipart(
|
|||||||
|
|
||||||
@app.post("/image/info", response_model=ImageInfoResponse)
|
@app.post("/image/info", response_model=ImageInfoResponse)
|
||||||
async def api_image_info(
|
async def api_image_info(
|
||||||
|
_: str = Depends(require_api_key),
|
||||||
image: UploadFile = File(...),
|
image: UploadFile = File(...),
|
||||||
include_modes: bool = Query(True, description="Include capacity by mode (v3.0+)"),
|
include_modes: bool = Query(True, description="Include capacity by mode (v3.0+)"),
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -2100,8 +2100,11 @@ def api_tools_exif_clear():
|
|||||||
@app.route("/api/tools/rotate", methods=["POST"])
|
@app.route("/api/tools/rotate", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def api_tools_rotate():
|
def api_tools_rotate():
|
||||||
"""Rotate and/or flip an image."""
|
"""Rotate and/or flip an image, using lossless jpegtran for JPEGs."""
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
image_file = request.files.get("image")
|
image_file = request.files.get("image")
|
||||||
if not image_file:
|
if not image_file:
|
||||||
@@ -2112,22 +2115,115 @@ def api_tools_rotate():
|
|||||||
flip_v = request.form.get("flip_v", "false").lower() == "true"
|
flip_v = request.form.get("flip_v", "false").lower() == "true"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
img = Image.open(io.BytesIO(image_file.read()))
|
image_data = image_file.read()
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
original_format = img.format # JPEG, PNG, etc.
|
||||||
|
img.close()
|
||||||
|
|
||||||
# Apply rotation (PIL rotates counter-clockwise, so negate)
|
# For JPEGs, use jpegtran for lossless rotation/flip (preserves DCT stego)
|
||||||
if rotation:
|
has_jpegtran = shutil.which("jpegtran") is not None
|
||||||
img = img.rotate(-rotation, expand=True)
|
use_jpegtran = original_format == "JPEG" and has_jpegtran and (rotation or flip_h or flip_v)
|
||||||
|
|
||||||
# Apply flips
|
if use_jpegtran:
|
||||||
if flip_h:
|
# Chain jpegtran operations for lossless transformation
|
||||||
img = img.transpose(Image.FLIP_LEFT_RIGHT)
|
current_data = image_data
|
||||||
if flip_v:
|
|
||||||
img = img.transpose(Image.FLIP_TOP_BOTTOM)
|
|
||||||
|
|
||||||
# Output as PNG (lossless)
|
# Apply rotation first
|
||||||
buffer = io.BytesIO()
|
if rotation in (90, 180, 270):
|
||||||
img.save(buffer, format="PNG")
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||||
buffer.seek(0)
|
f.write(current_data)
|
||||||
|
input_path = f.name
|
||||||
|
output_path = tempfile.mktemp(suffix=".jpg")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["jpegtran", "-rotate", str(rotation), "-copy", "all",
|
||||||
|
"-outfile", output_path, input_path],
|
||||||
|
capture_output=True, timeout=30
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
with open(output_path, "rb") as f:
|
||||||
|
current_data = f.read()
|
||||||
|
finally:
|
||||||
|
for p in [input_path, output_path]:
|
||||||
|
try:
|
||||||
|
os.unlink(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply horizontal flip
|
||||||
|
if flip_h:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||||
|
f.write(current_data)
|
||||||
|
input_path = f.name
|
||||||
|
output_path = tempfile.mktemp(suffix=".jpg")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["jpegtran", "-flip", "horizontal", "-copy", "all",
|
||||||
|
"-outfile", output_path, input_path],
|
||||||
|
capture_output=True, timeout=30
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
with open(output_path, "rb") as f:
|
||||||
|
current_data = f.read()
|
||||||
|
finally:
|
||||||
|
for p in [input_path, output_path]:
|
||||||
|
try:
|
||||||
|
os.unlink(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Apply vertical flip
|
||||||
|
if flip_v:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||||
|
f.write(current_data)
|
||||||
|
input_path = f.name
|
||||||
|
output_path = tempfile.mktemp(suffix=".jpg")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["jpegtran", "-flip", "vertical", "-copy", "all",
|
||||||
|
"-outfile", output_path, input_path],
|
||||||
|
capture_output=True, timeout=30
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
with open(output_path, "rb") as f:
|
||||||
|
current_data = f.read()
|
||||||
|
finally:
|
||||||
|
for p in [input_path, output_path]:
|
||||||
|
try:
|
||||||
|
os.unlink(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
buffer = io.BytesIO(current_data)
|
||||||
|
mimetype = "image/jpeg"
|
||||||
|
ext = "jpg"
|
||||||
|
else:
|
||||||
|
# Fallback to PIL for non-JPEGs or when jpegtran unavailable
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
|
||||||
|
# Apply rotation (PIL rotates counter-clockwise, so negate)
|
||||||
|
if rotation:
|
||||||
|
img = img.rotate(-rotation, expand=True)
|
||||||
|
|
||||||
|
# Apply flips
|
||||||
|
if flip_h:
|
||||||
|
img = img.transpose(Image.FLIP_LEFT_RIGHT)
|
||||||
|
if flip_v:
|
||||||
|
img = img.transpose(Image.FLIP_TOP_BOTTOM)
|
||||||
|
|
||||||
|
# Preserve original format
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
if original_format == "JPEG":
|
||||||
|
if img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.save(buffer, format="JPEG", quality=95)
|
||||||
|
mimetype = "image/jpeg"
|
||||||
|
ext = "jpg"
|
||||||
|
else:
|
||||||
|
img.save(buffer, format="PNG")
|
||||||
|
mimetype = "image/png"
|
||||||
|
ext = "png"
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
stem = (
|
stem = (
|
||||||
image_file.filename.rsplit(".", 1)[0]
|
image_file.filename.rsplit(".", 1)[0]
|
||||||
@@ -2136,9 +2232,9 @@ def api_tools_rotate():
|
|||||||
)
|
)
|
||||||
return send_file(
|
return send_file(
|
||||||
buffer,
|
buffer,
|
||||||
mimetype="image/png",
|
mimetype=mimetype,
|
||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
download_name=f"{stem}_transformed.png",
|
download_name=f"{stem}_transformed.{ext}",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|||||||
@@ -2247,7 +2247,7 @@ footer {
|
|||||||
display: none;
|
display: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 1.25rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-section.active {
|
.tool-section.active {
|
||||||
@@ -2255,33 +2255,92 @@ footer {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* EXIF Table in Results */
|
/* EXIF Grid Layout */
|
||||||
.tool-exif-table {
|
.exif-grid {
|
||||||
font-size: 0.8rem;
|
display: grid;
|
||||||
max-height: 250px;
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 0.3rem;
|
||||||
|
max-height: 280px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-exif-table table {
|
.exif-card {
|
||||||
width: 100%;
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-exif-table th,
|
.exif-card:hover {
|
||||||
.tool-exif-table td {
|
background: rgba(255, 255, 255, 0.06);
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-exif-table th {
|
.exif-card-label {
|
||||||
|
font-size: 0.55rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.4);
|
||||||
text-align: left;
|
text-transform: uppercase;
|
||||||
width: 40%;
|
letter-spacing: 0.02em;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-exif-table td {
|
.exif-card-value {
|
||||||
|
font-size: 0.7rem;
|
||||||
font-family: 'SF Mono', 'Consolas', monospace;
|
font-family: 'SF Mono', 'Consolas', monospace;
|
||||||
word-break: break-all;
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-card-value.truncated {
|
||||||
|
max-height: 2.4em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category headers */
|
||||||
|
.exif-category {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bs-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0.35rem 0 0.15rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-category:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact tool headers and actions */
|
||||||
|
.tool-results-header {
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-results-header h6 {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-results-header small {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-results-actions {
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading State */
|
/* Loading State */
|
||||||
|
|||||||
@@ -340,13 +340,13 @@
|
|||||||
<!-- Current Version - Prominent -->
|
<!-- Current Version - Prominent -->
|
||||||
<div class="alert alert-success mb-4">
|
<div class="alert alert-success mb-4">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<span class="badge bg-success fs-6 me-3">v4.2.0</span>
|
<span class="badge bg-success fs-6 me-3">v4.2.1</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>Performance optimizations:</strong>
|
<strong>Security & API improvements:</strong>
|
||||||
~70% faster decode (vectorized DCT),
|
API key authentication,
|
||||||
50% less RAM (float32),
|
TLS with self-signed certs,
|
||||||
async API endpoints,
|
CLI tools (compress, rotate, convert),
|
||||||
decode progress callbacks
|
jpegtran lossless JPEG rotation
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,17 +22,17 @@
|
|||||||
<div class="tools-ribbon-divider"></div>
|
<div class="tools-ribbon-divider"></div>
|
||||||
|
|
||||||
<div class="tools-ribbon-group">
|
<div class="tools-ribbon-group">
|
||||||
<button class="tool-icon-btn" data-tool="strip" title="Strip Metadata">
|
<button class="tool-icon-btn" data-tool="compress" title="JPEG Compression">
|
||||||
<i class="bi bi-eraser"></i>
|
<i class="bi bi-file-zip"></i>
|
||||||
<span>Strip</span>
|
<span>Compress</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tool-icon-btn" data-tool="rotate" title="Rotate / Flip">
|
<button class="tool-icon-btn" data-tool="rotate" title="Rotate / Flip">
|
||||||
<i class="bi bi-arrow-repeat"></i>
|
<i class="bi bi-arrow-repeat"></i>
|
||||||
<span>Rotate</span>
|
<span>Rotate</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tool-icon-btn" data-tool="compress" title="JPEG Compression">
|
<button class="tool-icon-btn" data-tool="strip" title="Strip Metadata">
|
||||||
<i class="bi bi-file-zip"></i>
|
<i class="bi bi-eraser"></i>
|
||||||
<span>Compress</span>
|
<span>Strip</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tool-icon-btn" data-tool="convert" title="Format Convert">
|
<button class="tool-icon-btn" data-tool="convert" title="Format Convert">
|
||||||
<i class="bi bi-arrow-left-right"></i>
|
<i class="bi bi-arrow-left-right"></i>
|
||||||
@@ -283,10 +283,8 @@
|
|||||||
<span>Drop an image to view metadata</span>
|
<span>Drop an image to view metadata</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="exifData" class="d-none">
|
<div id="exifData" class="d-none">
|
||||||
<div class="tool-exif-table">
|
<div class="exif-grid" id="exifGrid">
|
||||||
<table>
|
<!-- Cards populated by JS -->
|
||||||
<tbody id="exifTable"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="exifNoData" class="text-muted text-center py-3 d-none">
|
<div id="exifNoData" class="text-muted text-center py-3 d-none">
|
||||||
<i class="bi bi-inbox d-block mb-2"></i>
|
<i class="bi bi-inbox d-block mb-2"></i>
|
||||||
@@ -368,6 +366,14 @@
|
|||||||
<span class="tool-result-label">Flipped</span>
|
<span class="tool-result-label">Flipped</span>
|
||||||
<span class="tool-result-value" id="rotateFlip">None</span>
|
<span class="tool-result-value" id="rotateFlip">None</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="alert alert-success small mt-3 mb-0" id="rotateJpegSafe" style="display: none;">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>
|
||||||
|
<strong>DCT Safe:</strong> Uses jpegtran for lossless JPEG rotation. Your stego data will be preserved.
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning small mt-3 mb-0" id="rotateNonJpegWarn" style="display: none;">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
<strong>Note:</strong> Non-JPEG images are re-encoded during rotation.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-results-actions d-none" id="rotateActions">
|
<div class="tool-results-actions d-none" id="rotateActions">
|
||||||
@@ -634,30 +640,104 @@ setupDropZone('exifZone', 'exifFile', async (file) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/tools/exif', { method: 'POST', body: formData });
|
const res = await fetch('/api/tools/exif', { method: 'POST', body: formData });
|
||||||
|
|
||||||
|
// Check for auth redirect or non-JSON response
|
||||||
|
const contentType = res.headers.get('content-type') || '';
|
||||||
|
if (!contentType.includes('application/json')) {
|
||||||
|
console.error('EXIF API returned non-JSON:', res.status, contentType);
|
||||||
|
document.getElementById('exifNoData').classList.remove('d-none');
|
||||||
|
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-exclamation-triangle d-block mb-2"></i>Session expired - please refresh';
|
||||||
|
document.getElementById('exifEmpty').classList.add('d-none');
|
||||||
|
document.getElementById('exifData').classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
const tbody = document.getElementById('exifTable');
|
const grid = document.getElementById('exifGrid');
|
||||||
const entries = Object.entries(data.exif).sort((a, b) => a[0].localeCompare(b[0]));
|
const entries = Object.entries(data.exif);
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
tbody.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
document.getElementById('exifNoData').classList.remove('d-none');
|
document.getElementById('exifNoData').classList.remove('d-none');
|
||||||
|
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-inbox d-block mb-2"></i>No metadata found';
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('exifNoData').classList.add('d-none');
|
document.getElementById('exifNoData').classList.add('d-none');
|
||||||
tbody.innerHTML = entries.map(([key, value]) => {
|
|
||||||
|
// Categorize EXIF fields
|
||||||
|
const categories = {
|
||||||
|
'Camera': ['Make', 'Model', 'Software', 'LensMake', 'LensModel', 'BodySerialNumber'],
|
||||||
|
'Image': ['ImageWidth', 'ImageLength', 'Orientation', 'ResolutionUnit', 'XResolution', 'YResolution', 'ColorSpace', 'ExifImageWidth', 'ExifImageHeight'],
|
||||||
|
'Date/Time': ['DateTime', 'DateTimeOriginal', 'DateTimeDigitized', 'SubsecTime', 'SubsecTimeOriginal', 'SubsecTimeDigitized', 'OffsetTime', 'OffsetTimeOriginal'],
|
||||||
|
'Exposure': ['ExposureTime', 'FNumber', 'ExposureProgram', 'ISOSpeedRatings', 'ExposureBiasValue', 'MaxApertureValue', 'MeteringMode', 'Flash', 'FocalLength', 'FocalLengthIn35mmFilm', 'WhiteBalance', 'ExposureMode', 'DigitalZoomRatio', 'SceneCaptureType', 'Contrast', 'Saturation', 'Sharpness'],
|
||||||
|
'GPS': ['GPSInfo', 'GPSLatitude', 'GPSLatitudeRef', 'GPSLongitude', 'GPSLongitudeRef', 'GPSAltitude', 'GPSAltitudeRef', 'GPSTimeStamp', 'GPSDateStamp'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const categorized = {};
|
||||||
|
const other = [];
|
||||||
|
const allCategoryFields = new Set(Object.values(categories).flat());
|
||||||
|
|
||||||
|
entries.forEach(([key, value]) => {
|
||||||
|
let found = false;
|
||||||
|
for (const [cat, fields] of Object.entries(categories)) {
|
||||||
|
if (fields.includes(key)) {
|
||||||
|
if (!categorized[cat]) categorized[cat] = [];
|
||||||
|
categorized[cat].push([key, value]);
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) other.push([key, value]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render cards
|
||||||
|
let html = '';
|
||||||
|
const renderCard = ([key, value]) => {
|
||||||
let displayVal = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
let displayVal = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||||
if (displayVal.length > 40) displayVal = displayVal.substring(0, 37) + '...';
|
const needsTruncate = displayVal.length > 60;
|
||||||
return `<tr><th>${key}</th><td title="${String(value)}">${displayVal}</td></tr>`;
|
if (needsTruncate) displayVal = displayVal.substring(0, 57) + '...';
|
||||||
}).join('');
|
const fullVal = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||||
|
return `<div class="exif-card" title="${fullVal.replace(/"/g, '"')}">
|
||||||
|
<div class="exif-card-label">${key}</div>
|
||||||
|
<div class="exif-card-value${needsTruncate ? ' truncated' : ''}">${displayVal}</div>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render each category
|
||||||
|
for (const [cat, fields] of Object.entries(categories)) {
|
||||||
|
if (categorized[cat] && categorized[cat].length > 0) {
|
||||||
|
html += `<div class="exif-category"><i class="bi bi-${cat === 'Camera' ? 'camera' : cat === 'Image' ? 'image' : cat === 'Date/Time' ? 'clock' : cat === 'Exposure' ? 'aperture' : cat === 'GPS' ? 'geo-alt' : 'tag'} me-1"></i>${cat}</div>`;
|
||||||
|
html += categorized[cat].map(renderCard).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render other fields
|
||||||
|
if (other.length > 0) {
|
||||||
|
html += `<div class="exif-category"><i class="bi bi-three-dots me-1"></i>Other</div>`;
|
||||||
|
html += other.map(renderCard).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('exifEmpty').classList.add('d-none');
|
document.getElementById('exifEmpty').classList.add('d-none');
|
||||||
document.getElementById('exifData').classList.remove('d-none');
|
document.getElementById('exifData').classList.remove('d-none');
|
||||||
document.getElementById('exifActions').classList.remove('d-none');
|
document.getElementById('exifActions').classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
// API returned success: false
|
||||||
|
console.error('EXIF API error:', data.error);
|
||||||
|
document.getElementById('exifNoData').classList.remove('d-none');
|
||||||
|
document.getElementById('exifNoData').innerHTML = `<i class="bi bi-exclamation-triangle d-block mb-2"></i>${data.error || 'Error reading metadata'}`;
|
||||||
|
document.getElementById('exifEmpty').classList.add('d-none');
|
||||||
|
document.getElementById('exifData').classList.remove('d-none');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error('EXIF fetch error:', err);
|
||||||
|
document.getElementById('exifNoData').classList.remove('d-none');
|
||||||
|
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-exclamation-triangle d-block mb-2"></i>Error loading metadata';
|
||||||
|
document.getElementById('exifEmpty').classList.add('d-none');
|
||||||
|
document.getElementById('exifData').classList.remove('d-none');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -796,6 +876,11 @@ setupDropZone('rotateZone', 'rotateFile', async (file) => {
|
|||||||
document.getElementById('rotateData').classList.remove('d-none');
|
document.getElementById('rotateData').classList.remove('d-none');
|
||||||
document.getElementById('rotateActions').classList.remove('d-none');
|
document.getElementById('rotateActions').classList.remove('d-none');
|
||||||
|
|
||||||
|
// Show appropriate DCT warning based on file type
|
||||||
|
const isJpeg = file.type === 'image/jpeg' || file.name.toLowerCase().match(/\.jpe?g$/);
|
||||||
|
document.getElementById('rotateJpegSafe').style.display = isJpeg ? 'block' : 'none';
|
||||||
|
document.getElementById('rotateNonJpegWarn').style.display = isJpeg ? 'none' : 'block';
|
||||||
|
|
||||||
// Load image to get dimensions, then show preview
|
// Load image to get dimensions, then show preview
|
||||||
const thumb = document.getElementById('rotateThumb');
|
const thumb = document.getElementById('rotateThumb');
|
||||||
const objectUrl = URL.createObjectURL(file);
|
const objectUrl = URL.createObjectURL(file);
|
||||||
@@ -889,6 +974,8 @@ function clearRotate() {
|
|||||||
document.getElementById('rotateData').classList.add('d-none');
|
document.getElementById('rotateData').classList.add('d-none');
|
||||||
document.getElementById('rotateActions').classList.add('d-none');
|
document.getElementById('rotateActions').classList.add('d-none');
|
||||||
document.getElementById('rotateFileInfo').classList.add('d-none');
|
document.getElementById('rotateFileInfo').classList.add('d-none');
|
||||||
|
document.getElementById('rotateJpegSafe').style.display = 'none';
|
||||||
|
document.getElementById('rotateNonJpegWarn').style.display = 'none';
|
||||||
const thumb = document.getElementById('rotateThumb');
|
const thumb = document.getElementById('rotateThumb');
|
||||||
thumb.style.transform = '';
|
thumb.style.transform = '';
|
||||||
thumb.style.width = '';
|
thumb.style.width = '';
|
||||||
@@ -920,8 +1007,7 @@ document.getElementById('rotateDownload')?.addEventListener('click', async funct
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
const baseName = rotateCurrentFile?.name?.replace(/\.[^.]+$/, '') || 'rotated';
|
a.download = res.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || 'rotated.jpg';
|
||||||
a.download = `${baseName}_transformed.png`;
|
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "stegasoo"
|
name = "stegasoo"
|
||||||
version = "4.2.0"
|
version = "4.2.1"
|
||||||
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ Remove SD card, insert into your Linux machine:
|
|||||||
lsblk
|
lsblk
|
||||||
|
|
||||||
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
||||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.0.img.zst
|
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
The script automatically resizes rootfs to 16GB (for smaller download), preserves auto-expand, and compresses.
|
The script automatically resizes rootfs to 16GB (for smaller download), preserves auto-expand, and compresses.
|
||||||
@@ -173,5 +173,5 @@ curl -k https://localhost:5000
|
|||||||
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
||||||
|
|
||||||
# On host (pull image - auto-resizes to 16GB):
|
# On host (pull image - auto-resizes to 16GB):
|
||||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.0.img.zst
|
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ After Pi shuts down, remove SD card and on another Linux machine:
|
|||||||
lsblk
|
lsblk
|
||||||
|
|
||||||
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
# Pull image (auto-resizes to 16GB, compresses with zstd)
|
||||||
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.0.img.zst
|
sudo ./rpi/pull-image.sh /dev/sdX stegasoo-rpi-4.2.1.img.zst
|
||||||
```
|
```
|
||||||
|
|
||||||
The `pull-image.sh` script automatically:
|
The `pull-image.sh` script automatically:
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ if [ -z "$1" ]; then
|
|||||||
echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
|
echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Examples:"
|
echo "Examples:"
|
||||||
echo " $0 stegasoo-rpi-4.2.0.img.zst # auto-detect SD card"
|
echo " $0 stegasoo-rpi-4.2.1.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.1.img.zst.zip # from GitHub release"
|
||||||
echo " $0 stegasoo-rpi-4.2.0.img.zst /dev/sdb # specify device"
|
echo " $0 stegasoo-rpi-4.2.1.img.zst /dev/sdb # specify device"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Resizes rootfs to 16GB for consistent image size, then pulls
|
# Resizes rootfs to 16GB for consistent image size, then pulls
|
||||||
#
|
#
|
||||||
# Usage: ./pull-image.sh <device> <output.img.zst>
|
# Usage: ./pull-image.sh <device> <output.img.zst>
|
||||||
# Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.2.0.img.zst
|
# Example: ./pull-image.sh /dev/sdb stegasoo-rpi-4.2.1.img.zst
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ NC='\033[0m'
|
|||||||
|
|
||||||
if [ $# -ne 2 ]; then
|
if [ $# -ne 2 ]; then
|
||||||
echo "Usage: $0 <device> <output.img.zst>"
|
echo "Usage: $0 <device> <output.img.zst>"
|
||||||
echo "Example: $0 /dev/sdb stegasoo-rpi-4.2.0.img.zst"
|
echo "Example: $0 /dev/sdb stegasoo-rpi-4.2.1.img.zst"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ fi
|
|||||||
|
|
||||||
# Pre-built venv tarball (skips pip compile time)
|
# Pre-built venv tarball (skips pip compile time)
|
||||||
PREBUILT_TARBALL="$INSTALL_DIR/rpi/stegasoo-rpi-venv-arm64.tar.zst"
|
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}"
|
PREBUILT_URL="${PREBUILT_URL:-https://github.com/adlee-was-taken/stegasoo/releases/download/v4.2.1/stegasoo-rpi-venv-arm64.tar.zst}"
|
||||||
USE_PREBUILT=true
|
USE_PREBUILT=true
|
||||||
|
|
||||||
# Use local tarball if present, otherwise will download
|
# Use local tarball if present, otherwise will download
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Changes in v4.0.0:
|
|||||||
- encode() and decode() now accept channel_key parameter
|
- encode() and decode() now accept channel_key parameter
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "4.2.0"
|
__version__ = "4.2.1"
|
||||||
|
|
||||||
# Core functionality
|
# Core functionality
|
||||||
# Channel key management (v4.0.0)
|
# Channel key management (v4.0.0)
|
||||||
|
|||||||
@@ -241,8 +241,20 @@ def encode(
|
|||||||
with open(carrier, "rb") as f:
|
with open(carrier, "rb") as f:
|
||||||
carrier_data = f.read()
|
carrier_data = f.read()
|
||||||
|
|
||||||
# Determine output path
|
# Determine output path and format
|
||||||
output = output or f"{Path(carrier).stem}_encoded.png"
|
# Default to JPEG for JPEG carriers (preserves DCT mode benefits)
|
||||||
|
carrier_ext = Path(carrier).suffix.lower()
|
||||||
|
if not output:
|
||||||
|
if carrier_ext in ('.jpg', '.jpeg'):
|
||||||
|
output = f"{Path(carrier).stem}_encoded.jpg"
|
||||||
|
else:
|
||||||
|
output = f"{Path(carrier).stem}_encoded.png"
|
||||||
|
|
||||||
|
# Detect output format from extension
|
||||||
|
output_ext = Path(output).suffix.lower()
|
||||||
|
use_dct = output_ext in ('.jpg', '.jpeg')
|
||||||
|
|
||||||
|
from .steganography import EMBED_MODE_DCT, EMBED_MODE_LSB
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if file_payload:
|
if file_payload:
|
||||||
@@ -253,6 +265,8 @@ def encode(
|
|||||||
carrier_image=carrier_data,
|
carrier_image=carrier_data,
|
||||||
passphrase=passphrase,
|
passphrase=passphrase,
|
||||||
pin=pin,
|
pin=pin,
|
||||||
|
embed_mode=EMBED_MODE_DCT if use_dct else EMBED_MODE_LSB,
|
||||||
|
dct_output_format="jpeg" if use_dct else "png",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Encode message
|
# Encode message
|
||||||
@@ -262,6 +276,8 @@ def encode(
|
|||||||
carrier_image=carrier_data,
|
carrier_image=carrier_data,
|
||||||
passphrase=passphrase,
|
passphrase=passphrase,
|
||||||
pin=pin,
|
pin=pin,
|
||||||
|
embed_mode=EMBED_MODE_DCT if use_dct else EMBED_MODE_LSB,
|
||||||
|
dct_output_format="jpeg" if use_dct else "png",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Write output
|
# Write output
|
||||||
@@ -1297,6 +1313,203 @@ def tools_exif(image, clear, set_fields, output, as_json):
|
|||||||
raise click.UsageError(str(e))
|
raise click.UsageError(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@tools.command("compress")
|
||||||
|
@click.argument("image", type=click.Path(exists=True))
|
||||||
|
@click.option("-q", "--quality", type=int, default=75, help="JPEG quality (1-100, default: 75)")
|
||||||
|
@click.option("-o", "--output", type=click.Path(), help="Output file (default: <name>_q<quality>.jpg)")
|
||||||
|
def tools_compress(image, quality, output):
|
||||||
|
"""Compress a JPEG image.
|
||||||
|
|
||||||
|
DCT steganography survives JPEG compression! Use this to reduce file size
|
||||||
|
while preserving hidden data.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo tools compress photo.jpg -q 60
|
||||||
|
stegasoo tools compress photo.jpg -q 80 -o smaller.jpg
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
if not 1 <= quality <= 100:
|
||||||
|
raise click.UsageError("Quality must be between 1 and 100")
|
||||||
|
|
||||||
|
with open(image, "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
|
||||||
|
# Convert to RGB if needed (JPEG doesn't support alpha)
|
||||||
|
if img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format="JPEG", quality=quality)
|
||||||
|
compressed_data = buffer.getvalue()
|
||||||
|
|
||||||
|
if not output:
|
||||||
|
stem = Path(image).stem
|
||||||
|
output = f"{stem}_q{quality}.jpg"
|
||||||
|
|
||||||
|
with open(output, "wb") as f:
|
||||||
|
f.write(compressed_data)
|
||||||
|
|
||||||
|
orig_size = len(image_data)
|
||||||
|
new_size = len(compressed_data)
|
||||||
|
reduction = (1 - new_size / orig_size) * 100
|
||||||
|
|
||||||
|
click.echo(f"Compressed to: {output}")
|
||||||
|
click.echo(f" Original: {orig_size:,} bytes")
|
||||||
|
click.echo(f" Compressed: {new_size:,} bytes ({reduction:.1f}% smaller)")
|
||||||
|
|
||||||
|
|
||||||
|
@tools.command("rotate")
|
||||||
|
@click.argument("image", type=click.Path(exists=True))
|
||||||
|
@click.option("-r", "--rotation", type=click.Choice(["90", "180", "270"]), help="Rotation degrees clockwise")
|
||||||
|
@click.option("--flip-h", is_flag=True, help="Flip horizontally")
|
||||||
|
@click.option("--flip-v", is_flag=True, help="Flip vertically")
|
||||||
|
@click.option("-o", "--output", type=click.Path(), help="Output file")
|
||||||
|
def tools_rotate(image, rotation, flip_h, flip_v, output):
|
||||||
|
"""Rotate and/or flip an image.
|
||||||
|
|
||||||
|
For JPEGs, uses lossless jpegtran rotation which preserves DCT steganography.
|
||||||
|
For other formats, uses PIL (re-encodes the image).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo tools rotate photo.jpg -r 90
|
||||||
|
stegasoo tools rotate photo.jpg -r 180 --flip-h -o rotated.jpg
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
with open(image, "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
|
||||||
|
# Must have rotation or flip
|
||||||
|
if not rotation and not flip_h and not flip_v:
|
||||||
|
raise click.UsageError("Must specify at least one of -r/--rotation, --flip-h, or --flip-v")
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
is_jpeg = img.format == "JPEG"
|
||||||
|
img.close()
|
||||||
|
|
||||||
|
rotation_deg = int(rotation) if rotation else 0
|
||||||
|
|
||||||
|
# For JPEGs, use lossless jpegtran
|
||||||
|
if is_jpeg and shutil.which("jpegtran"):
|
||||||
|
from .dct_steganography import _jpegtran_rotate
|
||||||
|
|
||||||
|
result_data = image_data
|
||||||
|
|
||||||
|
# Apply rotation
|
||||||
|
if rotation_deg in (90, 180, 270):
|
||||||
|
result_data = _jpegtran_rotate(result_data, rotation_deg)
|
||||||
|
|
||||||
|
# Apply flips using jpegtran
|
||||||
|
if flip_h or flip_v:
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
|
||||||
|
for flip_type in (["horizontal"] if flip_h else []) + (["vertical"] if flip_v else []):
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||||
|
f.write(result_data)
|
||||||
|
input_path = f.name
|
||||||
|
output_path = tempfile.mktemp(suffix=".jpg")
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["jpegtran", "-flip", flip_type, "-copy", "all",
|
||||||
|
"-outfile", output_path, input_path],
|
||||||
|
capture_output=True, timeout=30, check=True
|
||||||
|
)
|
||||||
|
with open(output_path, "rb") as f:
|
||||||
|
result_data = f.read()
|
||||||
|
finally:
|
||||||
|
for p in [input_path, output_path]:
|
||||||
|
try:
|
||||||
|
os.unlink(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ext = "jpg"
|
||||||
|
click.echo(" (Used lossless jpegtran - DCT stego preserved)")
|
||||||
|
else:
|
||||||
|
# Use PIL for non-JPEGs
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
|
||||||
|
# PIL rotation is counter-clockwise, we want clockwise
|
||||||
|
if rotation_deg:
|
||||||
|
pil_rotation = {90: 270, 180: 180, 270: 90}[rotation_deg]
|
||||||
|
img = img.rotate(pil_rotation, expand=True)
|
||||||
|
|
||||||
|
if flip_h:
|
||||||
|
img = img.transpose(Image.FLIP_LEFT_RIGHT)
|
||||||
|
if flip_v:
|
||||||
|
img = img.transpose(Image.FLIP_TOP_BOTTOM)
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format="PNG")
|
||||||
|
result_data = buffer.getvalue()
|
||||||
|
ext = "png"
|
||||||
|
|
||||||
|
if not output:
|
||||||
|
stem = Path(image).stem
|
||||||
|
suffix = "rotated" if rotation_deg else "flipped"
|
||||||
|
output = f"{stem}_{suffix}.{ext}"
|
||||||
|
|
||||||
|
with open(output, "wb") as f:
|
||||||
|
f.write(result_data)
|
||||||
|
|
||||||
|
click.echo(f"Saved to: {output}")
|
||||||
|
|
||||||
|
|
||||||
|
@tools.command("convert")
|
||||||
|
@click.argument("image", type=click.Path(exists=True))
|
||||||
|
@click.option("-f", "--format", "fmt", type=click.Choice(["png", "jpg", "bmp", "webp"]), required=True, help="Output format")
|
||||||
|
@click.option("-q", "--quality", type=int, default=95, help="Quality for lossy formats (default: 95)")
|
||||||
|
@click.option("-o", "--output", type=click.Path(), help="Output file")
|
||||||
|
def tools_convert(image, fmt, quality, output):
|
||||||
|
"""Convert image to a different format.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo tools convert photo.png -f jpg
|
||||||
|
stegasoo tools convert photo.jpg -f png -o lossless.png
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
with open(image, "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
|
||||||
|
# Handle format-specific conversions
|
||||||
|
save_format = {"jpg": "JPEG", "png": "PNG", "bmp": "BMP", "webp": "WEBP"}[fmt]
|
||||||
|
|
||||||
|
if save_format == "JPEG" and img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
if save_format in ("JPEG", "WEBP"):
|
||||||
|
img.save(buffer, format=save_format, quality=quality)
|
||||||
|
else:
|
||||||
|
img.save(buffer, format=save_format)
|
||||||
|
|
||||||
|
result_data = buffer.getvalue()
|
||||||
|
|
||||||
|
if not output:
|
||||||
|
stem = Path(image).stem
|
||||||
|
output = f"{stem}.{fmt}"
|
||||||
|
|
||||||
|
with open(output, "wb") as f:
|
||||||
|
f.write(result_data)
|
||||||
|
|
||||||
|
click.echo(f"Converted to: {output}")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ADMIN COMMANDS (Web UI administration)
|
# ADMIN COMMANDS (Web UI administration)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -1455,6 +1668,301 @@ def admin_generate_key(show_qr):
|
|||||||
click.echo("go to Account > Recovery Key > Regenerate")
|
click.echo("go to Account > Recovery Key > Regenerate")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API COMMANDS (REST API management)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_frontends_path():
|
||||||
|
"""Add frontends directory to sys.path for importing API/web modules."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Try multiple possible locations
|
||||||
|
possible_paths = [
|
||||||
|
# Development: stegasoo/frontends
|
||||||
|
Path(__file__).parent.parent.parent / "frontends",
|
||||||
|
# Installed package: site-packages/frontends
|
||||||
|
Path(__file__).parent.parent / "frontends",
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in possible_paths:
|
||||||
|
if path.exists() and str(path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(path))
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
@click.pass_context
|
||||||
|
def api(ctx):
|
||||||
|
"""REST API management commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@api.group("keys")
|
||||||
|
def api_keys():
|
||||||
|
"""Manage API keys for authentication."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@api_keys.command("list")
|
||||||
|
@click.option("--location", type=click.Choice(["user", "project", "all"]), default="all",
|
||||||
|
help="Config location to list keys from")
|
||||||
|
def api_keys_list(location):
|
||||||
|
"""List configured API keys.
|
||||||
|
|
||||||
|
Shows key names and creation dates (not actual keys).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo api keys list
|
||||||
|
stegasoo api keys list --location user
|
||||||
|
"""
|
||||||
|
_setup_frontends_path()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from api.auth import list_api_keys, get_api_key_status
|
||||||
|
except ImportError:
|
||||||
|
raise click.ClickException("API frontend not available")
|
||||||
|
|
||||||
|
status = get_api_key_status()
|
||||||
|
|
||||||
|
click.echo(f"\nAPI Key Authentication: {'Enabled' if status['enabled'] else 'Disabled'}")
|
||||||
|
click.echo(f"Total keys: {status['total_keys']}")
|
||||||
|
click.echo(f"Environment variable: {'Set' if status['env_configured'] else 'Not set'}")
|
||||||
|
|
||||||
|
locations = ["user", "project"] if location == "all" else [location]
|
||||||
|
|
||||||
|
for loc in locations:
|
||||||
|
keys = list_api_keys(loc)
|
||||||
|
click.echo(f"\n{loc.title()} keys ({len(keys)}):")
|
||||||
|
if keys:
|
||||||
|
for k in keys:
|
||||||
|
click.echo(f" - {k['name']} (created: {k['created'][:10]})")
|
||||||
|
else:
|
||||||
|
click.echo(" (none)")
|
||||||
|
|
||||||
|
|
||||||
|
@api_keys.command("create")
|
||||||
|
@click.argument("name")
|
||||||
|
@click.option("--location", type=click.Choice(["user", "project"]), default="user",
|
||||||
|
help="Where to store the key")
|
||||||
|
def api_keys_create(name, location):
|
||||||
|
"""Create a new API key.
|
||||||
|
|
||||||
|
The key is shown ONCE and cannot be retrieved again.
|
||||||
|
Save it immediately!
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo api keys create laptop
|
||||||
|
stegasoo api keys create automation --location project
|
||||||
|
"""
|
||||||
|
_setup_frontends_path()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from api.auth import add_api_key
|
||||||
|
except ImportError:
|
||||||
|
raise click.ClickException("API frontend not available")
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = add_api_key(name, location)
|
||||||
|
click.echo(f"\nAPI Key created: {name}")
|
||||||
|
click.echo("─" * 60)
|
||||||
|
click.echo(f" {key}")
|
||||||
|
click.echo("─" * 60)
|
||||||
|
click.echo("\nSave this key NOW! It cannot be retrieved again.")
|
||||||
|
click.echo(f"Stored in: {location} config")
|
||||||
|
except ValueError as e:
|
||||||
|
raise click.ClickException(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@api_keys.command("delete")
|
||||||
|
@click.argument("name")
|
||||||
|
@click.option("--location", type=click.Choice(["user", "project"]), default="user",
|
||||||
|
help="Config location")
|
||||||
|
def api_keys_delete(name, location):
|
||||||
|
"""Delete an API key by name.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo api keys delete laptop
|
||||||
|
stegasoo api keys delete automation --location project
|
||||||
|
"""
|
||||||
|
_setup_frontends_path()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from api.auth import remove_api_key
|
||||||
|
except ImportError:
|
||||||
|
raise click.ClickException("API frontend not available")
|
||||||
|
|
||||||
|
if remove_api_key(name, location):
|
||||||
|
click.echo(f"Deleted API key: {name}")
|
||||||
|
else:
|
||||||
|
raise click.ClickException(f"Key '{name}' not found in {location} config")
|
||||||
|
|
||||||
|
|
||||||
|
@api.group("tls")
|
||||||
|
def api_tls():
|
||||||
|
"""Manage TLS certificates for HTTPS."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@api_tls.command("generate")
|
||||||
|
@click.option("--hostname", default="localhost", help="Server hostname for certificate")
|
||||||
|
@click.option("--days", default=365, help="Certificate validity in days")
|
||||||
|
@click.option("--output", "-o", type=click.Path(), help="Output directory (default: ~/.stegasoo/certs)")
|
||||||
|
def api_tls_generate(hostname, days, output):
|
||||||
|
"""Generate self-signed TLS certificate.
|
||||||
|
|
||||||
|
Creates a certificate valid for:
|
||||||
|
- The specified hostname
|
||||||
|
- localhost / 127.0.0.1
|
||||||
|
- hostname.local (for mDNS)
|
||||||
|
- All detected local network IPs
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo api tls generate
|
||||||
|
stegasoo api tls generate --hostname myserver --days 730
|
||||||
|
stegasoo api tls generate -o /etc/stegasoo/certs
|
||||||
|
"""
|
||||||
|
_setup_frontends_path()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from web.ssl_utils import generate_self_signed_cert, get_cert_paths
|
||||||
|
except ImportError:
|
||||||
|
raise click.ClickException("Web frontend not available (ssl_utils required)")
|
||||||
|
|
||||||
|
if output:
|
||||||
|
base_dir = Path(output)
|
||||||
|
else:
|
||||||
|
base_dir = Path.home() / ".stegasoo"
|
||||||
|
|
||||||
|
click.echo(f"Generating TLS certificate for: {hostname}")
|
||||||
|
click.echo(f"Validity: {days} days")
|
||||||
|
|
||||||
|
cert_path, key_path = generate_self_signed_cert(base_dir, hostname, days)
|
||||||
|
|
||||||
|
click.echo(f"\nCertificate: {cert_path}")
|
||||||
|
click.echo(f"Private Key: {key_path}")
|
||||||
|
click.echo("\nTo use with the API:")
|
||||||
|
click.echo(f" uvicorn main:app --ssl-certfile {cert_path} --ssl-keyfile {key_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@api_tls.command("info")
|
||||||
|
@click.option("--cert", "-c", type=click.Path(exists=True), help="Certificate file (default: ~/.stegasoo/certs/server.crt)")
|
||||||
|
def api_tls_info(cert):
|
||||||
|
"""Show information about a TLS certificate.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo api tls info
|
||||||
|
stegasoo api tls info --cert /path/to/server.crt
|
||||||
|
"""
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
|
||||||
|
if not cert:
|
||||||
|
cert = Path.home() / ".stegasoo" / "certs" / "server.crt"
|
||||||
|
if not cert.exists():
|
||||||
|
raise click.ClickException(f"No certificate found at {cert}. Generate one with: stegasoo api tls generate")
|
||||||
|
|
||||||
|
cert_data = Path(cert).read_bytes()
|
||||||
|
certificate = x509.load_pem_x509_certificate(cert_data)
|
||||||
|
|
||||||
|
click.echo(f"\nCertificate: {cert}")
|
||||||
|
click.echo("─" * 50)
|
||||||
|
click.echo(f"Subject: {certificate.subject.rfc4514_string()}")
|
||||||
|
click.echo(f"Issuer: {certificate.issuer.rfc4514_string()}")
|
||||||
|
click.echo(f"Serial: {certificate.serial_number}")
|
||||||
|
click.echo(f"Valid from: {certificate.not_valid_before_utc}")
|
||||||
|
click.echo(f"Valid until: {certificate.not_valid_after_utc}")
|
||||||
|
|
||||||
|
# Check expiry
|
||||||
|
import datetime
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
if certificate.not_valid_after_utc < now:
|
||||||
|
click.echo("\nStatus: EXPIRED")
|
||||||
|
elif certificate.not_valid_after_utc < now + datetime.timedelta(days=30):
|
||||||
|
days_left = (certificate.not_valid_after_utc - now).days
|
||||||
|
click.echo(f"\nStatus: Expires in {days_left} days (consider renewal)")
|
||||||
|
else:
|
||||||
|
days_left = (certificate.not_valid_after_utc - now).days
|
||||||
|
click.echo(f"\nStatus: Valid ({days_left} days remaining)")
|
||||||
|
|
||||||
|
# Show SANs
|
||||||
|
try:
|
||||||
|
san_ext = certificate.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||||||
|
click.echo("\nSubject Alternative Names:")
|
||||||
|
for name in san_ext.value:
|
||||||
|
click.echo(f" - {name.value}")
|
||||||
|
except x509.ExtensionNotFound:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@api.command("serve")
|
||||||
|
@click.option("--host", default="127.0.0.1", help="Host to bind to")
|
||||||
|
@click.option("--port", default=8000, help="Port to bind to")
|
||||||
|
@click.option("--ssl/--no-ssl", default=True, help="Enable/disable TLS")
|
||||||
|
@click.option("--cert", type=click.Path(exists=True), help="TLS certificate file")
|
||||||
|
@click.option("--key", type=click.Path(exists=True), help="TLS private key file")
|
||||||
|
@click.option("--reload", "do_reload", is_flag=True, help="Enable auto-reload for development")
|
||||||
|
def api_serve(host, port, ssl, cert, key, do_reload):
|
||||||
|
"""Start the REST API server.
|
||||||
|
|
||||||
|
By default starts with TLS using certificates from ~/.stegasoo/certs/.
|
||||||
|
If no certificates exist, they are generated automatically.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
stegasoo api serve
|
||||||
|
stegasoo api serve --host 0.0.0.0 --port 8443
|
||||||
|
stegasoo api serve --no-ssl
|
||||||
|
stegasoo api serve --cert /path/to/cert.pem --key /path/to/key.pem
|
||||||
|
"""
|
||||||
|
_setup_frontends_path()
|
||||||
|
|
||||||
|
# Determine cert paths
|
||||||
|
if ssl:
|
||||||
|
if cert and key:
|
||||||
|
cert_path, key_path = cert, key
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from web.ssl_utils import ensure_certs
|
||||||
|
base_dir = Path.home() / ".stegasoo"
|
||||||
|
cert_path, key_path = ensure_certs(base_dir, host if host != "0.0.0.0" else "localhost")
|
||||||
|
except ImportError:
|
||||||
|
raise click.ClickException("ssl_utils not available")
|
||||||
|
|
||||||
|
click.echo(f"Starting API server with TLS on https://{host}:{port}")
|
||||||
|
click.echo(f"Certificate: {cert_path}")
|
||||||
|
else:
|
||||||
|
cert_path = key_path = None
|
||||||
|
click.echo(f"Starting API server on http://{host}:{port}")
|
||||||
|
click.echo("WARNING: TLS disabled - connections are not encrypted!")
|
||||||
|
|
||||||
|
# Import and run uvicorn
|
||||||
|
try:
|
||||||
|
import uvicorn
|
||||||
|
except ImportError:
|
||||||
|
raise click.ClickException("uvicorn not installed. Install with: pip install uvicorn")
|
||||||
|
|
||||||
|
uvicorn_kwargs = {
|
||||||
|
"app": "api.main:app",
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"reload": do_reload,
|
||||||
|
}
|
||||||
|
|
||||||
|
if ssl and cert_path and key_path:
|
||||||
|
uvicorn_kwargs["ssl_certfile"] = str(cert_path)
|
||||||
|
uvicorn_kwargs["ssl_keyfile"] = str(key_path)
|
||||||
|
|
||||||
|
uvicorn.run(**uvicorn_kwargs)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Entry point for CLI."""
|
"""Entry point for CLI."""
|
||||||
cli(obj={})
|
cli(obj={})
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from pathlib import Path
|
|||||||
# VERSION
|
# VERSION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
__version__ = "4.2.0"
|
__version__ = "4.2.1"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# FILE FORMAT
|
# FILE FORMAT
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from dataclasses import dataclass
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
# Check for scipy availability (for PNG/DCT mode)
|
# Check for scipy availability (for PNG/DCT mode)
|
||||||
# Prefer scipy.fft (newer, more stable) over scipy.fftpack
|
# Prefer scipy.fft (newer, more stable) over scipy.fftpack
|
||||||
@@ -406,6 +406,45 @@ def _safe_idct2(block: np.ndarray) -> np.ndarray:
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_exif_orientation(image_data: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Apply EXIF orientation to image and return corrected bytes.
|
||||||
|
|
||||||
|
Portrait photos from cameras often have EXIF orientation metadata that
|
||||||
|
tells viewers to rotate the image for display. However, the raw pixel
|
||||||
|
data is stored in landscape orientation. This function applies that
|
||||||
|
rotation to the pixel data so the output matches what users expect.
|
||||||
|
|
||||||
|
Without this, a portrait photo encoded with DCT would come out rotated
|
||||||
|
90 degrees because we'd embed in the raw (landscape) orientation.
|
||||||
|
"""
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
original_format = img.format or "JPEG"
|
||||||
|
|
||||||
|
# Apply EXIF orientation (rotates/flips pixels to match EXIF tag)
|
||||||
|
# This also removes the EXIF orientation tag since it's now baked in
|
||||||
|
corrected = ImageOps.exif_transpose(img)
|
||||||
|
|
||||||
|
# If no change was needed, return original data unchanged
|
||||||
|
if corrected is img:
|
||||||
|
img.close()
|
||||||
|
return image_data
|
||||||
|
|
||||||
|
# Save corrected image back to bytes
|
||||||
|
output = io.BytesIO()
|
||||||
|
if original_format == "JPEG":
|
||||||
|
if corrected.mode in ("RGBA", "P"):
|
||||||
|
corrected = corrected.convert("RGB")
|
||||||
|
corrected.save(output, format="JPEG", quality=95)
|
||||||
|
else:
|
||||||
|
corrected.save(output, format="PNG")
|
||||||
|
|
||||||
|
img.close()
|
||||||
|
corrected.close()
|
||||||
|
output.seek(0)
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def _to_grayscale(image_data: bytes) -> np.ndarray:
|
def _to_grayscale(image_data: bytes) -> np.ndarray:
|
||||||
img = Image.open(io.BytesIO(image_data))
|
img = Image.open(io.BytesIO(image_data))
|
||||||
gray = img.convert("L")
|
gray = img.convert("L")
|
||||||
@@ -763,6 +802,10 @@ def embed_in_dct(
|
|||||||
if color_mode not in ("color", "grayscale"):
|
if color_mode not in ("color", "grayscale"):
|
||||||
color_mode = "color"
|
color_mode = "color"
|
||||||
|
|
||||||
|
# Apply EXIF orientation to carrier image before embedding
|
||||||
|
# This ensures portrait photos are embedded in their correct visual orientation
|
||||||
|
carrier_image = _apply_exif_orientation(carrier_image)
|
||||||
|
|
||||||
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
|
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
|
||||||
return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file)
|
return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file)
|
||||||
|
|
||||||
@@ -1173,24 +1216,251 @@ def _embed_jpegio(
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _jpegtran_available() -> bool:
|
||||||
|
"""Check if jpegtran is available on the system."""
|
||||||
|
import shutil
|
||||||
|
return shutil.which("jpegtran") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _jpegtran_rotate(image_data: bytes, rotation: int) -> bytes:
|
||||||
|
"""
|
||||||
|
Losslessly rotate a JPEG using jpegtran.
|
||||||
|
|
||||||
|
This preserves DCT coefficients by rearranging blocks rather than
|
||||||
|
re-encoding. Essential for rotating stego images without destroying
|
||||||
|
the hidden data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_data: JPEG image bytes
|
||||||
|
rotation: Degrees clockwise (90, 180, or 270)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Rotated JPEG bytes with DCT coefficients preserved
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
|
||||||
|
if rotation not in (90, 180, 270):
|
||||||
|
raise ValueError(f"Invalid rotation: {rotation}")
|
||||||
|
|
||||||
|
# Write input to temp file
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||||
|
f.write(image_data)
|
||||||
|
input_path = f.name
|
||||||
|
|
||||||
|
output_path = tempfile.mktemp(suffix=".jpg")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# jpegtran -rotate 90|180|270 -copy all
|
||||||
|
# -copy all: preserve all metadata
|
||||||
|
# NOTE: Don't use -trim as it drops edge blocks and destroys stego data
|
||||||
|
# NOTE: Don't use -perfect as it fails on images with non-MCU-aligned edges
|
||||||
|
result = subprocess.run(
|
||||||
|
["jpegtran", "-rotate", str(rotation), "-copy", "all",
|
||||||
|
"-outfile", output_path, input_path],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"jpegtran failed: {result.stderr.decode()}")
|
||||||
|
|
||||||
|
with open(output_path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
finally:
|
||||||
|
for path in [input_path, output_path]:
|
||||||
|
try:
|
||||||
|
os.unlink(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _rotate_image_bytes(image_data: bytes, rotation: int, lossless: bool = True) -> bytes:
|
||||||
|
"""
|
||||||
|
Rotate image by 90, 180, or 270 degrees and return as bytes.
|
||||||
|
|
||||||
|
For JPEGs with lossless=True (default), uses jpegtran to preserve DCT
|
||||||
|
coefficients. This is essential for rotating stego images.
|
||||||
|
|
||||||
|
For PNGs or when jpegtran is unavailable, uses PIL (which re-encodes
|
||||||
|
but PNGs are lossless anyway).
|
||||||
|
"""
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
original_format = img.format or "PNG"
|
||||||
|
img.close()
|
||||||
|
|
||||||
|
# Use jpegtran for lossless JPEG rotation
|
||||||
|
if lossless and original_format == "JPEG" and _jpegtran_available():
|
||||||
|
return _jpegtran_rotate(image_data, rotation)
|
||||||
|
|
||||||
|
# Fallback to PIL for PNGs or when jpegtran unavailable
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
|
||||||
|
# PIL rotation is counter-clockwise, we want clockwise
|
||||||
|
# 90 CW = 270 CCW, 180 = 180, 270 CW = 90 CCW
|
||||||
|
pil_rotation = {90: 270, 180: 180, 270: 90}[rotation]
|
||||||
|
rotated = img.rotate(pil_rotation, expand=True)
|
||||||
|
|
||||||
|
output = io.BytesIO()
|
||||||
|
# Save in original format if possible, fallback to PNG
|
||||||
|
save_format = original_format if original_format in ("JPEG", "PNG") else "PNG"
|
||||||
|
if save_format == "JPEG":
|
||||||
|
rotated.save(output, format="JPEG", quality=95)
|
||||||
|
else:
|
||||||
|
rotated.save(output, format="PNG")
|
||||||
|
output.seek(0)
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _quick_validate_dct_header(image_data: bytes, seed: bytes) -> bool:
|
||||||
|
"""
|
||||||
|
Quick validation that only extracts enough DCT data to check magic bytes.
|
||||||
|
Returns True if header looks valid, False otherwise.
|
||||||
|
|
||||||
|
This is much faster than full extraction - only processes first ~8 blocks.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Convert to grayscale for quick check
|
||||||
|
gray = _to_grayscale(image_data)
|
||||||
|
height, width = gray.shape
|
||||||
|
padded, _ = _pad_to_blocks(gray)
|
||||||
|
padded_h, padded_w = padded.shape
|
||||||
|
blocks_x = padded_w // BLOCK_SIZE
|
||||||
|
num_blocks = (padded_h // BLOCK_SIZE) * blocks_x
|
||||||
|
|
||||||
|
# Generate block order
|
||||||
|
block_order = _generate_block_order(num_blocks, seed)
|
||||||
|
|
||||||
|
# Only extract first 8 blocks (enough for RS length prefix + header)
|
||||||
|
# 8 blocks * 16 bits/block = 128 bits = 16 bytes (covers RS prefix)
|
||||||
|
blocks_needed = min(8, len(block_order))
|
||||||
|
|
||||||
|
all_bits = []
|
||||||
|
for block_num in block_order[:blocks_needed]:
|
||||||
|
by = (block_num // blocks_x) * BLOCK_SIZE
|
||||||
|
bx = (block_num % blocks_x) * BLOCK_SIZE
|
||||||
|
block = padded[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE].astype(np.float32)
|
||||||
|
|
||||||
|
dct_block = dctn(block, norm="ortho")
|
||||||
|
|
||||||
|
for row, col in EMBED_POSITIONS:
|
||||||
|
coef = dct_block[row, col]
|
||||||
|
bit = _extract_bit_from_coeff(coef)
|
||||||
|
all_bits.append(bit)
|
||||||
|
|
||||||
|
# Check RS format first (3 copies of 8-byte length header)
|
||||||
|
if len(all_bits) >= RS_LENGTH_PREFIX_SIZE * 8:
|
||||||
|
length_prefix_bits = all_bits[: RS_LENGTH_PREFIX_SIZE * 8]
|
||||||
|
length_prefix_bytes = bytes(
|
||||||
|
[
|
||||||
|
sum(length_prefix_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))
|
||||||
|
for i in range(RS_LENGTH_PREFIX_SIZE)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if 2+ copies match (indicates valid RS format)
|
||||||
|
copies = []
|
||||||
|
for i in range(RS_LENGTH_COPIES):
|
||||||
|
start = i * RS_LENGTH_HEADER_SIZE
|
||||||
|
end = start + RS_LENGTH_HEADER_SIZE
|
||||||
|
copies.append(length_prefix_bytes[start:end])
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
counter = Counter(copies)
|
||||||
|
_, count = counter.most_common(1)[0]
|
||||||
|
|
||||||
|
if count >= 2:
|
||||||
|
return True # Looks like valid RS format
|
||||||
|
|
||||||
|
# Check legacy format (magic bytes in first 10 bytes)
|
||||||
|
if len(all_bits) >= HEADER_SIZE * 8:
|
||||||
|
try:
|
||||||
|
_parse_header(all_bits[: HEADER_SIZE * 8])
|
||||||
|
return True # Magic bytes matched
|
||||||
|
except (ValueError, InvalidMagicBytesError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def extract_from_dct(
|
def extract_from_dct(
|
||||||
stego_image: bytes,
|
stego_image: bytes,
|
||||||
seed: bytes,
|
seed: bytes,
|
||||||
progress_file: str | None = None,
|
progress_file: str | None = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Extract data from DCT stego image."""
|
"""
|
||||||
img = Image.open(io.BytesIO(stego_image))
|
Extract data from DCT stego image.
|
||||||
fmt = img.format
|
|
||||||
img.close()
|
|
||||||
|
|
||||||
if fmt == "JPEG" and HAS_JPEGIO:
|
If extraction fails with InvalidMagicBytesError, automatically tries
|
||||||
|
90°, 180°, and 270° rotations to handle images that were rotated after
|
||||||
|
encoding (e.g., by external tools or EXIF orientation changes).
|
||||||
|
|
||||||
|
Uses quick header validation to skip obviously invalid rotations.
|
||||||
|
"""
|
||||||
|
rotations_to_try = [0, 90, 180, 270]
|
||||||
|
last_error = None
|
||||||
|
valid_rotations = []
|
||||||
|
|
||||||
|
# Phase 1: Quick validation to find candidate rotations
|
||||||
|
for rotation in rotations_to_try:
|
||||||
|
if rotation == 0:
|
||||||
|
image_to_check = stego_image
|
||||||
|
else:
|
||||||
|
image_to_check = _rotate_image_bytes(stego_image, rotation)
|
||||||
|
|
||||||
|
if _quick_validate_dct_header(image_to_check, seed):
|
||||||
|
valid_rotations.append((rotation, image_to_check))
|
||||||
|
|
||||||
|
# If no rotations pass quick check, try all anyway (fallback)
|
||||||
|
if not valid_rotations:
|
||||||
|
# Must try all rotations - quick validation might have failed due to
|
||||||
|
# scipy vs jpegio differences or other edge cases
|
||||||
|
for rotation in rotations_to_try:
|
||||||
|
if rotation == 0:
|
||||||
|
valid_rotations.append((0, stego_image))
|
||||||
|
else:
|
||||||
|
valid_rotations.append((rotation, _rotate_image_bytes(stego_image, rotation)))
|
||||||
|
|
||||||
|
# Phase 2: Full extraction on valid candidates
|
||||||
|
for rotation, image_to_decode in valid_rotations:
|
||||||
try:
|
try:
|
||||||
return _extract_jpegio(stego_image, seed, progress_file)
|
img = Image.open(io.BytesIO(image_to_decode))
|
||||||
except ValueError:
|
fmt = img.format
|
||||||
pass
|
img.close()
|
||||||
|
|
||||||
_check_scipy()
|
if fmt == "JPEG" and HAS_JPEGIO:
|
||||||
return _extract_scipy_dct_safe(stego_image, seed, progress_file)
|
try:
|
||||||
|
result = _extract_jpegio(image_to_decode, seed, progress_file)
|
||||||
|
if rotation != 0:
|
||||||
|
try:
|
||||||
|
from . import debug
|
||||||
|
debug.print(f"DCT decode succeeded after {rotation}° rotation")
|
||||||
|
except Exception:
|
||||||
|
pass # Don't let debug logging break extraction
|
||||||
|
return result
|
||||||
|
except (ValueError, InvalidMagicBytesError) as e:
|
||||||
|
last_error = e if isinstance(e, InvalidMagicBytesError) else last_error
|
||||||
|
continue
|
||||||
|
|
||||||
|
_check_scipy()
|
||||||
|
result = _extract_scipy_dct_safe(image_to_decode, seed, progress_file)
|
||||||
|
if rotation != 0:
|
||||||
|
try:
|
||||||
|
from . import debug
|
||||||
|
debug.print(f"DCT decode succeeded after {rotation}° rotation")
|
||||||
|
except Exception:
|
||||||
|
pass # Don't let debug logging break extraction
|
||||||
|
return result
|
||||||
|
|
||||||
|
except InvalidMagicBytesError as e:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
|
||||||
|
# All rotations failed
|
||||||
|
raise last_error or InvalidMagicBytesError("Not a Stegasoo image (tried all rotations)")
|
||||||
|
|
||||||
|
|
||||||
def _extract_scipy_dct_safe(
|
def _extract_scipy_dct_safe(
|
||||||
|
|||||||
107
test-aur-build.sh
Normal file
107
test-aur-build.sh
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test AUR package builds in a clean Arch container
|
||||||
|
#
|
||||||
|
# Usage: sudo ./test-aur-build.sh [package]
|
||||||
|
# package: all (default), full, cli, api
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PACKAGE="${1:-all}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
echo "=== Stegasoo AUR Build Test ==="
|
||||||
|
echo "Package: $PACKAGE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create a test script to run inside container
|
||||||
|
cat > /tmp/aur-build-test.sh << 'INNERSCRIPT'
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Update system
|
||||||
|
pacman -Syu --noconfirm
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
pacman -S --noconfirm --needed \
|
||||||
|
base-devel git python python-build python-hatchling \
|
||||||
|
zbar
|
||||||
|
|
||||||
|
# Create build user (makepkg won't run as root)
|
||||||
|
useradd -m builder
|
||||||
|
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
|
||||||
|
|
||||||
|
# Copy source to build location
|
||||||
|
cp -r /src /home/builder/stegasoo
|
||||||
|
chown -R builder:builder /home/builder/stegasoo
|
||||||
|
|
||||||
|
build_package() {
|
||||||
|
local pkg_dir="$1"
|
||||||
|
local pkg_name="$2"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Building: $pkg_name"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
cd "/home/builder/stegasoo/$pkg_dir"
|
||||||
|
|
||||||
|
# Build as non-root user
|
||||||
|
sudo -u builder makepkg -sf --noconfirm
|
||||||
|
|
||||||
|
# Show result
|
||||||
|
ls -lh *.pkg.tar.zst
|
||||||
|
|
||||||
|
# Test install
|
||||||
|
echo "Installing $pkg_name..."
|
||||||
|
pacman -U --noconfirm *.pkg.tar.zst
|
||||||
|
|
||||||
|
# Quick test
|
||||||
|
echo "Testing $pkg_name..."
|
||||||
|
stegasoo --version
|
||||||
|
stegasoo --help | head -20
|
||||||
|
|
||||||
|
# Uninstall for next test
|
||||||
|
pacman -R --noconfirm "${pkg_name%-git}" 2>/dev/null || pacman -R --noconfirm "$pkg_name" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "$pkg_name: SUCCESS"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
full)
|
||||||
|
build_package "aur" "stegasoo-git"
|
||||||
|
;;
|
||||||
|
cli)
|
||||||
|
build_package "aur-cli" "stegasoo-cli-git"
|
||||||
|
;;
|
||||||
|
api)
|
||||||
|
build_package "aur-api" "stegasoo-api-git"
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
build_package "aur" "stegasoo-git"
|
||||||
|
build_package "aur-cli" "stegasoo-cli-git"
|
||||||
|
build_package "aur-api" "stegasoo-api-git"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown package: $1"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "All builds completed successfully!"
|
||||||
|
echo "=========================================="
|
||||||
|
INNERSCRIPT
|
||||||
|
|
||||||
|
chmod +x /tmp/aur-build-test.sh
|
||||||
|
|
||||||
|
# Run in Arch container
|
||||||
|
echo "Starting Arch container..."
|
||||||
|
docker run --rm -it \
|
||||||
|
-v "$SCRIPT_DIR:/src:ro" \
|
||||||
|
-v "/tmp/aur-build-test.sh:/build.sh:ro" \
|
||||||
|
archlinux:latest \
|
||||||
|
/bin/bash -c "chmod +x /build.sh && /build.sh $PACKAGE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Build test complete ==="
|
||||||
130
test-aur-nspawn.sh
Normal file
130
test-aur-nspawn.sh
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test AUR package builds using systemd-nspawn
|
||||||
|
#
|
||||||
|
# Usage: sudo ./test-aur-nspawn.sh [package]
|
||||||
|
# package: all (default), full, cli, api
|
||||||
|
#
|
||||||
|
# First run creates Arch root at /tmp/arch-build-root
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PACKAGE="${1:-all}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
ARCH_ROOT="/tmp/arch-build-root"
|
||||||
|
|
||||||
|
echo "=== Stegasoo AUR Build Test (nspawn) ==="
|
||||||
|
echo "Package: $PACKAGE"
|
||||||
|
echo "Arch root: $ARCH_ROOT"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check for root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "Please run as root (sudo)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create Arch root if it doesn't exist
|
||||||
|
if [ ! -d "$ARCH_ROOT/usr" ]; then
|
||||||
|
echo "Creating Arch root (first time setup)..."
|
||||||
|
mkdir -p "$ARCH_ROOT"
|
||||||
|
pacstrap -c "$ARCH_ROOT" base base-devel git python python-build python-hatchling zbar
|
||||||
|
echo "Arch root created."
|
||||||
|
else
|
||||||
|
echo "Using existing Arch root."
|
||||||
|
# Update packages
|
||||||
|
arch-chroot "$ARCH_ROOT" pacman -Syu --noconfirm
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create build user if needed
|
||||||
|
if ! arch-chroot "$ARCH_ROOT" id builder &>/dev/null; then
|
||||||
|
arch-chroot "$ARCH_ROOT" useradd -m builder
|
||||||
|
echo "builder ALL=(ALL) NOPASSWD: ALL" >> "$ARCH_ROOT/etc/sudoers"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy source
|
||||||
|
rm -rf "$ARCH_ROOT/home/builder/stegasoo"
|
||||||
|
cp -r "$SCRIPT_DIR" "$ARCH_ROOT/home/builder/stegasoo"
|
||||||
|
arch-chroot "$ARCH_ROOT" chown -R builder:builder /home/builder/stegasoo
|
||||||
|
|
||||||
|
# Create build script
|
||||||
|
cat > "$ARCH_ROOT/tmp/build.sh" << 'BUILDSCRIPT'
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
build_package() {
|
||||||
|
local pkg_dir="$1"
|
||||||
|
local pkg_name="$2"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Building: $pkg_name"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
cd "/home/builder/stegasoo/$pkg_dir"
|
||||||
|
|
||||||
|
# Clean previous builds
|
||||||
|
rm -rf src pkg *.pkg.tar.zst "${pkg_name}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Build as non-root user
|
||||||
|
sudo -u builder makepkg -sf --noconfirm
|
||||||
|
|
||||||
|
# Show result
|
||||||
|
ls -lh *.pkg.tar.zst
|
||||||
|
|
||||||
|
# Test install
|
||||||
|
echo "Installing $pkg_name..."
|
||||||
|
pacman -U --noconfirm *.pkg.tar.zst
|
||||||
|
|
||||||
|
# Quick test
|
||||||
|
echo "Testing $pkg_name..."
|
||||||
|
/usr/bin/stegasoo --version
|
||||||
|
|
||||||
|
# More tests for API package
|
||||||
|
if [[ "$pkg_name" == *"api"* ]]; then
|
||||||
|
/usr/bin/stegasoo api --help
|
||||||
|
/usr/bin/stegasoo api keys list
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Uninstall for next test
|
||||||
|
pacman -Rns --noconfirm $(pacman -Qq | grep stegasoo) 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "$pkg_name: SUCCESS"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
full)
|
||||||
|
build_package "aur" "stegasoo-git"
|
||||||
|
;;
|
||||||
|
cli)
|
||||||
|
build_package "aur-cli" "stegasoo-cli-git"
|
||||||
|
;;
|
||||||
|
api)
|
||||||
|
build_package "aur-api" "stegasoo-api-git"
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
build_package "aur-cli" "stegasoo-cli-git"
|
||||||
|
build_package "aur-api" "stegasoo-api-git"
|
||||||
|
build_package "aur" "stegasoo-git"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown package: $1"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "All builds completed successfully!"
|
||||||
|
echo "=========================================="
|
||||||
|
BUILDSCRIPT
|
||||||
|
|
||||||
|
chmod +x "$ARCH_ROOT/tmp/build.sh"
|
||||||
|
|
||||||
|
# Run build in nspawn container
|
||||||
|
echo "Starting nspawn container..."
|
||||||
|
systemd-nspawn -D "$ARCH_ROOT" --bind-ro="$SCRIPT_DIR:/home/builder/stegasoo" /tmp/build.sh "$PACKAGE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Build test complete ==="
|
||||||
|
echo "Arch root preserved at: $ARCH_ROOT"
|
||||||
|
echo "To clean up: sudo rm -rf $ARCH_ROOT"
|
||||||
Reference in New Issue
Block a user