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:
|
||||
fail-fast: false # Don't cancel other jobs if one fails
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
# 1. Get the code
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -102,3 +102,6 @@ rpi/*.img.zst.zip
|
||||
aur-upload/
|
||||
aur/.SRCINFO
|
||||
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
|
||||
|
||||
### Performance Optimizations
|
||||
@@ -55,32 +124,8 @@ Major performance improvements for Raspberry Pi and resource-constrained deploym
|
||||
|--------|--------|--------|-------------|
|
||||
| Decode (1MB) | ~2.6s | ~0.8s | **70% faster** |
|
||||
| Peak RAM | 211 MB | 107 MB | **50% less** |
|
||||
| Concurrent API | No | Yes | ✓ |
|
||||
| Concurrent API | No | Yes | check |
|
||||
| QR Compression | zlib | zstd | **~15% smaller** |
|
||||
|
||||
### Raspberry Pi Image
|
||||
Download `stegasoo-rpi-4.2.0_final.img.zst` from Releases.
|
||||
|
||||
```bash
|
||||
# Flash (auto-detects SD card)
|
||||
sudo ./rpi/flash-image.sh stegasoo-rpi-4.2.0_final.img.zst
|
||||
|
||||
# Or manual
|
||||
zstdcat stegasoo-rpi-4.2.0_final.img.zst | sudo dd of=/dev/sdX bs=4M status=progress
|
||||
```
|
||||
|
||||
Default login: `admin` / `stegasoo`
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
# Build and run
|
||||
docker build -f docker/Dockerfile.base -t stegasoo-base:latest .
|
||||
docker-compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# Or individual services
|
||||
docker-compose -f docker/docker-compose.yml up -d web # Web UI on :5000
|
||||
docker-compose -f docker/docker-compose.yml up -d api # REST API on :8000
|
||||
```
|
||||
|
||||
### Full Changelog
|
||||
See [CHANGELOG.md](CHANGELOG.md) for complete version history.
|
||||
|
||||
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>
|
||||
pkgname=stegasoo-git
|
||||
pkgver=4.2.0.r0.g530e5de
|
||||
pkgver=4.2.1
|
||||
pkgrel=1
|
||||
pkgdesc="Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||
arch=('x86_64')
|
||||
@@ -27,7 +27,7 @@ sha256sums=('SKIP')
|
||||
pkgver() {
|
||||
cd "$pkgname"
|
||||
git describe --long --tags 2>/dev/null | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g' || \
|
||||
printf "%s.r%s.g%s" "4.2.0" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
printf "%s.r%s.g%s" "4.2.1" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
build() {
|
||||
@@ -98,7 +98,7 @@ EOF
|
||||
|
||||
install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/system/stegasoo-api.service" <<EOF
|
||||
[Unit]
|
||||
Description=Stegasoo REST API
|
||||
Description=Stegasoo REST API (HTTPS)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
@@ -106,7 +106,11 @@ Type=simple
|
||||
User=stegasoo
|
||||
WorkingDirectory=/opt/stegasoo/venv/lib/python${pyver}/site-packages/frontends/api
|
||||
Environment="PATH=/opt/stegasoo/venv/bin"
|
||||
ExecStart=/opt/stegasoo/venv/bin/uvicorn main:app --host 127.0.0.1 --port 8000
|
||||
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
|
||||
RestartSec=5
|
||||
|
||||
|
||||
@@ -54,4 +54,4 @@ RUN python -c "import jpegio; import scipy; import numpy; import zstandard; prin
|
||||
# Label for tracking
|
||||
LABEL org.opencontainers.image.title="Stegasoo Base"
|
||||
LABEL org.opencontainers.image.description="Pre-compiled dependencies for Stegasoo"
|
||||
LABEL org.opencontainers.image.version="4.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
|
||||
"""
|
||||
Stegasoo REST API (v4.2.0)
|
||||
Stegasoo REST API (v4.2.1)
|
||||
|
||||
FastAPI-based REST API for steganography operations.
|
||||
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:
|
||||
- Async encode/decode operations (run in thread pool)
|
||||
- Server can handle concurrent requests without blocking
|
||||
@@ -32,10 +37,31 @@ from functools import partial
|
||||
from pathlib import Path
|
||||
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 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
|
||||
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'")
|
||||
|
||||
|
||||
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):
|
||||
"""Response showing available embedding modes."""
|
||||
|
||||
@@ -614,6 +657,7 @@ async def api_channel_status(
|
||||
|
||||
@app.post("/channel/generate", response_model=ChannelGenerateResponse)
|
||||
async def api_channel_generate(
|
||||
_: str = Depends(require_api_key),
|
||||
save: bool = Query(False, description="Save to user config"),
|
||||
save_project: bool = Query(False, description="Save to project config"),
|
||||
):
|
||||
@@ -652,7 +696,7 @@ async def api_channel_generate(
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
@@ -678,6 +722,7 @@ async def api_channel_set(request: ChannelSetRequest):
|
||||
|
||||
@app.delete("/channel")
|
||||
async def api_channel_clear(
|
||||
_: str = Depends(require_api_key),
|
||||
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)
|
||||
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.
|
||||
|
||||
@@ -763,7 +898,7 @@ async def api_compare_modes(request: CompareModesRequest):
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
@@ -799,6 +934,7 @@ async def api_will_fit(request: WillFitRequest):
|
||||
|
||||
@app.post("/extract-key-from-qr", response_model=QrExtractResponse)
|
||||
async def api_extract_key_from_qr(
|
||||
_: str = Depends(require_api_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)
|
||||
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.
|
||||
|
||||
@@ -873,7 +1009,7 @@ async def api_generate_key_qr(request: QrGenerateRequest):
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
@@ -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)
|
||||
async def api_encode(request: EncodeRequest):
|
||||
async def api_encode(request: EncodeRequest, _: str = Depends(require_api_key)):
|
||||
"""
|
||||
Encode a text message into an image.
|
||||
|
||||
@@ -1027,7 +1163,7 @@ async def api_encode(request: EncodeRequest):
|
||||
|
||||
|
||||
@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).
|
||||
|
||||
@@ -1109,7 +1245,7 @@ async def api_encode_file(request: EncodeFileRequest):
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
@@ -1172,6 +1308,7 @@ async def api_decode(request: DecodeRequest):
|
||||
|
||||
@app.post("/encode/multipart")
|
||||
async def api_encode_multipart(
|
||||
_: str = Depends(require_api_key),
|
||||
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
|
||||
reference_photo: UploadFile = File(...),
|
||||
carrier: UploadFile = File(...),
|
||||
@@ -1313,6 +1450,7 @@ async def api_encode_multipart(
|
||||
|
||||
@app.post("/decode/multipart", response_model=DecodeResponse)
|
||||
async def api_decode_multipart(
|
||||
_: str = Depends(require_api_key),
|
||||
passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
|
||||
reference_photo: UploadFile = File(...),
|
||||
stego_image: UploadFile = File(...),
|
||||
@@ -1418,6 +1556,7 @@ async def api_decode_multipart(
|
||||
|
||||
@app.post("/image/info", response_model=ImageInfoResponse)
|
||||
async def api_image_info(
|
||||
_: str = Depends(require_api_key),
|
||||
image: UploadFile = File(...),
|
||||
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"])
|
||||
@login_required
|
||||
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
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
image_file = request.files.get("image")
|
||||
if not image_file:
|
||||
@@ -2112,22 +2115,115 @@ def api_tools_rotate():
|
||||
flip_v = request.form.get("flip_v", "false").lower() == "true"
|
||||
|
||||
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)
|
||||
if rotation:
|
||||
img = img.rotate(-rotation, expand=True)
|
||||
# For JPEGs, use jpegtran for lossless rotation/flip (preserves DCT stego)
|
||||
has_jpegtran = shutil.which("jpegtran") is not None
|
||||
use_jpegtran = original_format == "JPEG" and has_jpegtran and (rotation or flip_h or flip_v)
|
||||
|
||||
# Apply flips
|
||||
if flip_h:
|
||||
img = img.transpose(Image.FLIP_LEFT_RIGHT)
|
||||
if flip_v:
|
||||
img = img.transpose(Image.FLIP_TOP_BOTTOM)
|
||||
if use_jpegtran:
|
||||
# Chain jpegtran operations for lossless transformation
|
||||
current_data = image_data
|
||||
|
||||
# Output as PNG (lossless)
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
# Apply rotation first
|
||||
if rotation in (90, 180, 270):
|
||||
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", "-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 = (
|
||||
image_file.filename.rsplit(".", 1)[0]
|
||||
@@ -2136,9 +2232,9 @@ def api_tools_rotate():
|
||||
)
|
||||
return send_file(
|
||||
buffer,
|
||||
mimetype="image/png",
|
||||
mimetype=mimetype,
|
||||
as_attachment=True,
|
||||
download_name=f"{stem}_transformed.png",
|
||||
download_name=f"{stem}_transformed.{ext}",
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@@ -2247,7 +2247,7 @@ footer {
|
||||
display: none;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
padding: 1.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-section.active {
|
||||
@@ -2255,33 +2255,92 @@ footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* EXIF Table in Results */
|
||||
.tool-exif-table {
|
||||
font-size: 0.8rem;
|
||||
max-height: 250px;
|
||||
/* EXIF Grid Layout */
|
||||
.exif-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 0.3rem;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 0.15rem;
|
||||
}
|
||||
|
||||
.tool-exif-table table {
|
||||
width: 100%;
|
||||
.exif-card {
|
||||
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,
|
||||
.tool-exif-table td {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
.exif-card:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.tool-exif-table th {
|
||||
.exif-card-label {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: left;
|
||||
width: 40%;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
text-transform: uppercase;
|
||||
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;
|
||||
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 */
|
||||
|
||||
@@ -340,13 +340,13 @@
|
||||
<!-- Current Version - Prominent -->
|
||||
<div class="alert alert-success mb-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge bg-success fs-6 me-3">v4.2.0</span>
|
||||
<span class="badge bg-success fs-6 me-3">v4.2.1</span>
|
||||
<div>
|
||||
<strong>Performance optimizations:</strong>
|
||||
~70% faster decode (vectorized DCT),
|
||||
50% less RAM (float32),
|
||||
async API endpoints,
|
||||
decode progress callbacks
|
||||
<strong>Security & API improvements:</strong>
|
||||
API key authentication,
|
||||
TLS with self-signed certs,
|
||||
CLI tools (compress, rotate, convert),
|
||||
jpegtran lossless JPEG rotation
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,17 +22,17 @@
|
||||
<div class="tools-ribbon-divider"></div>
|
||||
|
||||
<div class="tools-ribbon-group">
|
||||
<button class="tool-icon-btn" data-tool="strip" title="Strip Metadata">
|
||||
<i class="bi bi-eraser"></i>
|
||||
<span>Strip</span>
|
||||
<button class="tool-icon-btn" data-tool="compress" title="JPEG Compression">
|
||||
<i class="bi bi-file-zip"></i>
|
||||
<span>Compress</span>
|
||||
</button>
|
||||
<button class="tool-icon-btn" data-tool="rotate" title="Rotate / Flip">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
<span>Rotate</span>
|
||||
</button>
|
||||
<button class="tool-icon-btn" data-tool="compress" title="JPEG Compression">
|
||||
<i class="bi bi-file-zip"></i>
|
||||
<span>Compress</span>
|
||||
<button class="tool-icon-btn" data-tool="strip" title="Strip Metadata">
|
||||
<i class="bi bi-eraser"></i>
|
||||
<span>Strip</span>
|
||||
</button>
|
||||
<button class="tool-icon-btn" data-tool="convert" title="Format Convert">
|
||||
<i class="bi bi-arrow-left-right"></i>
|
||||
@@ -283,10 +283,8 @@
|
||||
<span>Drop an image to view metadata</span>
|
||||
</div>
|
||||
<div id="exifData" class="d-none">
|
||||
<div class="tool-exif-table">
|
||||
<table>
|
||||
<tbody id="exifTable"></tbody>
|
||||
</table>
|
||||
<div class="exif-grid" id="exifGrid">
|
||||
<!-- Cards populated by JS -->
|
||||
</div>
|
||||
<div id="exifNoData" class="text-muted text-center py-3 d-none">
|
||||
<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-value" id="rotateFlip">None</span>
|
||||
</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 class="tool-results-actions d-none" id="rotateActions">
|
||||
@@ -634,30 +640,104 @@ setupDropZone('exifZone', 'exifFile', async (file) => {
|
||||
|
||||
try {
|
||||
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();
|
||||
|
||||
if (data.success) {
|
||||
const tbody = document.getElementById('exifTable');
|
||||
const entries = Object.entries(data.exif).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
const grid = document.getElementById('exifGrid');
|
||||
const entries = Object.entries(data.exif);
|
||||
|
||||
if (entries.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
grid.innerHTML = '';
|
||||
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 {
|
||||
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);
|
||||
if (displayVal.length > 40) displayVal = displayVal.substring(0, 37) + '...';
|
||||
return `<tr><th>${key}</th><td title="${String(value)}">${displayVal}</td></tr>`;
|
||||
}).join('');
|
||||
const needsTruncate = displayVal.length > 60;
|
||||
if (needsTruncate) displayVal = displayVal.substring(0, 57) + '...';
|
||||
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('exifData').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) {
|
||||
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('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
|
||||
const thumb = document.getElementById('rotateThumb');
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
@@ -889,6 +974,8 @@ function clearRotate() {
|
||||
document.getElementById('rotateData').classList.add('d-none');
|
||||
document.getElementById('rotateActions').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');
|
||||
thumb.style.transform = '';
|
||||
thumb.style.width = '';
|
||||
@@ -920,8 +1007,7 @@ document.getElementById('rotateDownload')?.addEventListener('click', async funct
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const baseName = rotateCurrentFile?.name?.replace(/\.[^.]+$/, '') || 'rotated';
|
||||
a.download = `${baseName}_transformed.png`;
|
||||
a.download = res.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || 'rotated.jpg';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "stegasoo"
|
||||
version = "4.2.0"
|
||||
version = "4.2.1"
|
||||
description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -106,7 +106,7 @@ Remove SD card, insert into your Linux machine:
|
||||
lsblk
|
||||
|
||||
# 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.
|
||||
@@ -173,5 +173,5 @@ curl -k https://localhost:5000
|
||||
sudo /opt/stegasoo/rpi/sanitize-for-image.sh
|
||||
|
||||
# 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
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -80,9 +80,9 @@ if [ -z "$1" ]; then
|
||||
echo "Supported formats: .img, .img.zst, .img.xz, .img.gz, .img.zst.zip"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 stegasoo-rpi-4.2.0.img.zst # auto-detect SD card"
|
||||
echo " $0 stegasoo-rpi-4.2.0.img.zst.zip # from GitHub release"
|
||||
echo " $0 stegasoo-rpi-4.2.0.img.zst /dev/sdb # specify device"
|
||||
echo " $0 stegasoo-rpi-4.2.1.img.zst # auto-detect SD card"
|
||||
echo " $0 stegasoo-rpi-4.2.1.img.zst.zip # from GitHub release"
|
||||
echo " $0 stegasoo-rpi-4.2.1.img.zst /dev/sdb # specify device"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Resizes rootfs to 16GB for consistent image size, then pulls
|
||||
#
|
||||
# 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
|
||||
|
||||
@@ -15,7 +15,7 @@ NC='\033[0m'
|
||||
|
||||
if [ $# -ne 2 ]; then
|
||||
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
|
||||
fi
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ fi
|
||||
|
||||
# Pre-built venv tarball (skips pip compile time)
|
||||
PREBUILT_TARBALL="$INSTALL_DIR/rpi/stegasoo-rpi-venv-arm64.tar.zst"
|
||||
PREBUILT_URL="${PREBUILT_URL:-https://github.com/adlee-was-taken/stegasoo/releases/download/v4.2.0/stegasoo-rpi-venv-arm64.tar.zst}"
|
||||
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 local tarball if present, otherwise will download
|
||||
|
||||
@@ -7,7 +7,7 @@ Changes in v4.0.0:
|
||||
- encode() and decode() now accept channel_key parameter
|
||||
"""
|
||||
|
||||
__version__ = "4.2.0"
|
||||
__version__ = "4.2.1"
|
||||
|
||||
# Core functionality
|
||||
# Channel key management (v4.0.0)
|
||||
|
||||
@@ -241,8 +241,20 @@ def encode(
|
||||
with open(carrier, "rb") as f:
|
||||
carrier_data = f.read()
|
||||
|
||||
# Determine output path
|
||||
output = output or f"{Path(carrier).stem}_encoded.png"
|
||||
# Determine output path and format
|
||||
# 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:
|
||||
if file_payload:
|
||||
@@ -253,6 +265,8 @@ def encode(
|
||||
carrier_image=carrier_data,
|
||||
passphrase=passphrase,
|
||||
pin=pin,
|
||||
embed_mode=EMBED_MODE_DCT if use_dct else EMBED_MODE_LSB,
|
||||
dct_output_format="jpeg" if use_dct else "png",
|
||||
)
|
||||
else:
|
||||
# Encode message
|
||||
@@ -262,6 +276,8 @@ def encode(
|
||||
carrier_image=carrier_data,
|
||||
passphrase=passphrase,
|
||||
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
|
||||
@@ -1297,6 +1313,203 @@ def tools_exif(image, clear, set_fields, output, as_json):
|
||||
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)
|
||||
# =============================================================================
|
||||
@@ -1455,6 +1668,301 @@ def admin_generate_key(show_qr):
|
||||
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():
|
||||
"""Entry point for CLI."""
|
||||
cli(obj={})
|
||||
|
||||
@@ -31,7 +31,7 @@ from pathlib import Path
|
||||
# VERSION
|
||||
# ============================================================================
|
||||
|
||||
__version__ = "4.2.0"
|
||||
__version__ = "4.2.1"
|
||||
|
||||
# ============================================================================
|
||||
# FILE FORMAT
|
||||
|
||||
@@ -35,7 +35,7 @@ from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
# Check for scipy availability (for PNG/DCT mode)
|
||||
# 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:
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
gray = img.convert("L")
|
||||
@@ -763,6 +802,10 @@ def embed_in_dct(
|
||||
if color_mode not in ("color", "grayscale"):
|
||||
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:
|
||||
return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file)
|
||||
|
||||
@@ -1173,24 +1216,251 @@ def _embed_jpegio(
|
||||
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(
|
||||
stego_image: bytes,
|
||||
seed: bytes,
|
||||
progress_file: str | None = None,
|
||||
) -> bytes:
|
||||
"""Extract data from DCT stego image."""
|
||||
img = Image.open(io.BytesIO(stego_image))
|
||||
fmt = img.format
|
||||
img.close()
|
||||
"""
|
||||
Extract data from DCT stego image.
|
||||
|
||||
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:
|
||||
return _extract_jpegio(stego_image, seed, progress_file)
|
||||
except ValueError:
|
||||
pass
|
||||
img = Image.open(io.BytesIO(image_to_decode))
|
||||
fmt = img.format
|
||||
img.close()
|
||||
|
||||
_check_scipy()
|
||||
return _extract_scipy_dct_safe(stego_image, seed, progress_file)
|
||||
if fmt == "JPEG" and HAS_JPEGIO:
|
||||
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(
|
||||
|
||||
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