v4.0.2: Add Web UI authentication and optional HTTPS
- Add single-admin login with SQLite3 user storage - First-run setup wizard for admin account creation - Account management page for password changes - Optional HTTPS with auto-generated self-signed certificates - Configurable via STEGASOO_AUTH_ENABLED, STEGASOO_HTTPS_ENABLED env vars - UI improvements: larger QR previews, consistent panel styling - Update docker-compose.yml with auth config and persistent volumes - Update all documentation for v4.0.2 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,8 +29,31 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, flash, jsonify, redirect, render_template, request, send_file, url_for
|
||||
from auth import (
|
||||
change_password,
|
||||
create_user,
|
||||
get_username,
|
||||
is_authenticated,
|
||||
login_required,
|
||||
user_exists,
|
||||
verify_password,
|
||||
)
|
||||
from auth import (
|
||||
init_app as init_auth,
|
||||
)
|
||||
from flask import (
|
||||
Flask,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from PIL import Image
|
||||
from ssl_utils import ensure_certs
|
||||
|
||||
os.environ["NUMPY_MADVISE_HUGEPAGE"] = "0"
|
||||
os.environ["OMP_NUM_THREADS"] = "1"
|
||||
@@ -124,6 +147,13 @@ app = Flask(__name__)
|
||||
app.secret_key = secrets.token_hex(32)
|
||||
app.config["MAX_CONTENT_LENGTH"] = MAX_FILE_SIZE
|
||||
|
||||
# Auth configuration from environment
|
||||
app.config["AUTH_ENABLED"] = os.environ.get("STEGASOO_AUTH_ENABLED", "true").lower() == "true"
|
||||
app.config["HTTPS_ENABLED"] = os.environ.get("STEGASOO_HTTPS_ENABLED", "false").lower() == "true"
|
||||
|
||||
# Initialize auth module
|
||||
init_auth(app)
|
||||
|
||||
# Temporary file storage for sharing (file_id -> {data, timestamp, filename})
|
||||
TEMP_FILES: dict[str, dict] = {}
|
||||
THUMBNAIL_FILES: dict[str, bytes] = {}
|
||||
@@ -159,6 +189,10 @@ def inject_globals():
|
||||
"channel_configured": channel_status["configured"],
|
||||
"channel_fingerprint": channel_status.get("fingerprint"),
|
||||
"channel_source": channel_status.get("source"),
|
||||
# NEW in v4.0.2 - Auth state
|
||||
"auth_enabled": app.config.get("AUTH_ENABLED", True),
|
||||
"is_authenticated": is_authenticated(),
|
||||
"username": get_username() if is_authenticated() else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -296,6 +330,7 @@ def index():
|
||||
|
||||
|
||||
@app.route("/api/channel/status")
|
||||
@login_required
|
||||
def api_channel_status():
|
||||
"""
|
||||
Get current channel key status (v4.0.0).
|
||||
@@ -330,6 +365,7 @@ def api_channel_status():
|
||||
|
||||
|
||||
@app.route("/api/channel/validate", methods=["POST"])
|
||||
@login_required
|
||||
def api_channel_validate():
|
||||
"""
|
||||
Validate a channel key format (v4.0.0).
|
||||
@@ -366,6 +402,7 @@ def api_channel_validate():
|
||||
|
||||
|
||||
@app.route("/generate", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def generate():
|
||||
if request.method == "POST":
|
||||
# v3.2.0: Changed from words_per_phrase to words_per_passphrase, default increased to 4
|
||||
@@ -450,6 +487,7 @@ def generate():
|
||||
|
||||
|
||||
@app.route("/generate/qr/<token>")
|
||||
@login_required
|
||||
def generate_qr(token):
|
||||
"""Generate QR code for RSA key."""
|
||||
if not HAS_QRCODE:
|
||||
@@ -473,6 +511,7 @@ def generate_qr(token):
|
||||
|
||||
|
||||
@app.route("/generate/qr-download/<token>")
|
||||
@login_required
|
||||
def generate_qr_download(token):
|
||||
"""Download QR code as PNG file."""
|
||||
if not HAS_QRCODE:
|
||||
@@ -501,6 +540,7 @@ def generate_qr_download(token):
|
||||
|
||||
|
||||
@app.route("/qr/crop", methods=["POST"])
|
||||
@login_required
|
||||
def qr_crop():
|
||||
"""
|
||||
Detect and crop QR code from an image.
|
||||
@@ -538,6 +578,7 @@ def qr_crop():
|
||||
|
||||
|
||||
@app.route("/generate/download-key", methods=["POST"])
|
||||
@login_required
|
||||
def download_key():
|
||||
"""Download RSA key as password-protected PEM file."""
|
||||
key_pem = request.form.get("key_pem", "")
|
||||
@@ -570,6 +611,7 @@ def download_key():
|
||||
|
||||
|
||||
@app.route("/extract-key-from-qr", methods=["POST"])
|
||||
@login_required
|
||||
def extract_key_from_qr_route():
|
||||
"""
|
||||
Extract RSA key from uploaded QR code image.
|
||||
@@ -609,6 +651,7 @@ def extract_key_from_qr_route():
|
||||
|
||||
|
||||
@app.route("/api/compare-capacity", methods=["POST"])
|
||||
@login_required
|
||||
def api_compare_capacity():
|
||||
"""
|
||||
Compare LSB and DCT capacity for an uploaded carrier image.
|
||||
@@ -652,6 +695,7 @@ def api_compare_capacity():
|
||||
|
||||
|
||||
@app.route("/api/check-fit", methods=["POST"])
|
||||
@login_required
|
||||
def api_check_fit():
|
||||
"""
|
||||
Check if a payload will fit in the carrier with selected mode.
|
||||
@@ -705,6 +749,7 @@ def api_check_fit():
|
||||
|
||||
|
||||
@app.route("/encode", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def encode_page():
|
||||
if request.method == "POST":
|
||||
try:
|
||||
@@ -926,6 +971,7 @@ def encode_page():
|
||||
|
||||
|
||||
@app.route("/encode/result/<file_id>")
|
||||
@login_required
|
||||
def encode_result(file_id):
|
||||
if file_id not in TEMP_FILES:
|
||||
flash("File expired or not found. Please encode again.", "error")
|
||||
@@ -956,6 +1002,7 @@ def encode_result(file_id):
|
||||
|
||||
|
||||
@app.route("/encode/thumbnail/<thumb_id>")
|
||||
@login_required
|
||||
def encode_thumbnail(thumb_id):
|
||||
"""Serve thumbnail image."""
|
||||
if thumb_id not in THUMBNAIL_FILES:
|
||||
@@ -967,6 +1014,7 @@ def encode_thumbnail(thumb_id):
|
||||
|
||||
|
||||
@app.route("/encode/download/<file_id>")
|
||||
@login_required
|
||||
def encode_download(file_id):
|
||||
if file_id not in TEMP_FILES:
|
||||
flash("File expired or not found.", "error")
|
||||
@@ -984,6 +1032,7 @@ def encode_download(file_id):
|
||||
|
||||
|
||||
@app.route("/encode/file/<file_id>")
|
||||
@login_required
|
||||
def encode_file_route(file_id):
|
||||
"""Serve file for Web Share API."""
|
||||
if file_id not in TEMP_FILES:
|
||||
@@ -1001,6 +1050,7 @@ def encode_file_route(file_id):
|
||||
|
||||
|
||||
@app.route("/encode/cleanup/<file_id>", methods=["POST"])
|
||||
@login_required
|
||||
def encode_cleanup(file_id):
|
||||
"""Manually cleanup a file after sharing."""
|
||||
TEMP_FILES.pop(file_id, None)
|
||||
@@ -1018,6 +1068,7 @@ def encode_cleanup(file_id):
|
||||
|
||||
|
||||
@app.route("/decode", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def decode_page():
|
||||
if request.method == "POST":
|
||||
try:
|
||||
@@ -1170,6 +1221,7 @@ def decode_page():
|
||||
|
||||
|
||||
@app.route("/decode/download/<file_id>")
|
||||
@login_required
|
||||
def decode_download(file_id):
|
||||
"""Download decoded file."""
|
||||
if file_id not in TEMP_FILES:
|
||||
@@ -1245,9 +1297,117 @@ def test_capacity_nopil():
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AUTHENTICATION ROUTES (v4.0.2)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""Login page."""
|
||||
if not app.config.get("AUTH_ENABLED", True):
|
||||
return redirect(url_for("index"))
|
||||
|
||||
if not user_exists():
|
||||
return redirect(url_for("setup"))
|
||||
|
||||
if is_authenticated():
|
||||
return redirect(url_for("index"))
|
||||
|
||||
if request.method == "POST":
|
||||
password = request.form.get("password", "")
|
||||
if verify_password(password):
|
||||
session["authenticated"] = True
|
||||
session.permanent = True
|
||||
flash("Login successful", "success")
|
||||
return redirect(url_for("index"))
|
||||
else:
|
||||
flash("Invalid password", "error")
|
||||
|
||||
return render_template("login.html", username=get_username())
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
"""Logout and clear session."""
|
||||
session.clear()
|
||||
flash("Logged out successfully", "success")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
@app.route("/setup", methods=["GET", "POST"])
|
||||
def setup():
|
||||
"""First-run setup page."""
|
||||
if not app.config.get("AUTH_ENABLED", True):
|
||||
return redirect(url_for("index"))
|
||||
|
||||
if user_exists():
|
||||
return redirect(url_for("login"))
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "admin")
|
||||
password = request.form.get("password", "")
|
||||
password_confirm = request.form.get("password_confirm", "")
|
||||
|
||||
if len(password) < 8:
|
||||
flash("Password must be at least 8 characters", "error")
|
||||
elif password != password_confirm:
|
||||
flash("Passwords do not match", "error")
|
||||
else:
|
||||
try:
|
||||
create_user(username, password)
|
||||
session["authenticated"] = True
|
||||
session.permanent = True
|
||||
flash("Admin account created successfully!", "success")
|
||||
return redirect(url_for("index"))
|
||||
except Exception as e:
|
||||
flash(f"Error creating account: {e}", "error")
|
||||
|
||||
return render_template("setup.html")
|
||||
|
||||
|
||||
@app.route("/account", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def account():
|
||||
"""Account management page."""
|
||||
if request.method == "POST":
|
||||
current = request.form.get("current_password", "")
|
||||
new = request.form.get("new_password", "")
|
||||
new_confirm = request.form.get("new_password_confirm", "")
|
||||
|
||||
if new != new_confirm:
|
||||
flash("New passwords do not match", "error")
|
||||
else:
|
||||
success, message = change_password(current, new)
|
||||
flash(message, "success" if success else "error")
|
||||
|
||||
return render_template("account.html", username=get_username())
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MAIN
|
||||
# ============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||
base_dir = Path(__file__).parent
|
||||
|
||||
# HTTPS configuration
|
||||
ssl_context = None
|
||||
if app.config.get("HTTPS_ENABLED", False):
|
||||
hostname = os.environ.get("STEGASOO_HOSTNAME", "localhost")
|
||||
cert_path, key_path = ensure_certs(base_dir, hostname)
|
||||
ssl_context = (str(cert_path), str(key_path))
|
||||
print(f"HTTPS enabled with self-signed certificate for {hostname}")
|
||||
|
||||
# Auth status
|
||||
if app.config.get("AUTH_ENABLED", True):
|
||||
print("Authentication enabled")
|
||||
else:
|
||||
print("Authentication disabled")
|
||||
|
||||
app.run(
|
||||
host="0.0.0.0",
|
||||
port=5000,
|
||||
debug=False,
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
|
||||
162
frontends/web/auth.py
Normal file
162
frontends/web/auth.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Stegasoo Authentication Module
|
||||
|
||||
Single-admin authentication with Argon2 password hashing.
|
||||
Uses Flask sessions for authentication state and SQLite3 for storage.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from flask import current_app, g, redirect, session, url_for
|
||||
|
||||
# Argon2 password hasher (lighter than stegasoo's 256MB for faster login)
|
||||
ph = PasswordHasher(
|
||||
time_cost=3,
|
||||
memory_cost=65536, # 64MB
|
||||
parallelism=4,
|
||||
hash_len=32,
|
||||
salt_len=16,
|
||||
)
|
||||
|
||||
|
||||
def get_db_path() -> Path:
|
||||
"""Get database path in Flask instance folder."""
|
||||
instance_path = Path(current_app.instance_path)
|
||||
instance_path.mkdir(parents=True, exist_ok=True)
|
||||
return instance_path / "stegasoo.db"
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
"""Get database connection, cached on Flask g object."""
|
||||
if "db" not in g:
|
||||
g.db = sqlite3.connect(get_db_path())
|
||||
g.db.row_factory = sqlite3.Row
|
||||
return g.db
|
||||
|
||||
|
||||
def close_db(e=None):
|
||||
"""Close database connection at end of request."""
|
||||
db = g.pop("db", None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Initialize database schema."""
|
||||
db = get_db()
|
||||
db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS admin_user (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
username TEXT NOT NULL DEFAULT 'admin',
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""")
|
||||
db.commit()
|
||||
|
||||
|
||||
def user_exists() -> bool:
|
||||
"""Check if admin user has been created."""
|
||||
db = get_db()
|
||||
result = db.execute("SELECT 1 FROM admin_user WHERE id = 1").fetchone()
|
||||
return result is not None
|
||||
|
||||
|
||||
def create_user(username: str, password: str):
|
||||
"""Create admin user (first-run setup)."""
|
||||
if user_exists():
|
||||
raise ValueError("Admin user already exists")
|
||||
|
||||
password_hash = ph.hash(password)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO admin_user (id, username, password_hash) VALUES (1, ?, ?)",
|
||||
(username, password_hash),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_username() -> str:
|
||||
"""Get the admin username."""
|
||||
db = get_db()
|
||||
row = db.execute("SELECT username FROM admin_user WHERE id = 1").fetchone()
|
||||
return row["username"] if row else "admin"
|
||||
|
||||
|
||||
def verify_password(password: str) -> bool:
|
||||
"""Verify password against stored hash."""
|
||||
db = get_db()
|
||||
row = db.execute("SELECT password_hash FROM admin_user WHERE id = 1").fetchone()
|
||||
if not row:
|
||||
return False
|
||||
try:
|
||||
ph.verify(row["password_hash"], password)
|
||||
# Rehash if parameters changed
|
||||
if ph.check_needs_rehash(row["password_hash"]):
|
||||
new_hash = ph.hash(password)
|
||||
db.execute(
|
||||
"UPDATE admin_user SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1",
|
||||
(new_hash,),
|
||||
)
|
||||
db.commit()
|
||||
return True
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
|
||||
def change_password(current_password: str, new_password: str) -> tuple[bool, str]:
|
||||
"""Change admin password. Returns (success, message)."""
|
||||
if not verify_password(current_password):
|
||||
return False, "Current password is incorrect"
|
||||
|
||||
if len(new_password) < 8:
|
||||
return False, "New password must be at least 8 characters"
|
||||
|
||||
new_hash = ph.hash(new_password)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE admin_user SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1",
|
||||
(new_hash,),
|
||||
)
|
||||
db.commit()
|
||||
return True, "Password changed successfully"
|
||||
|
||||
|
||||
def is_authenticated() -> bool:
|
||||
"""Check if current session is authenticated."""
|
||||
return session.get("authenticated", False)
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""Decorator to require login for a route."""
|
||||
|
||||
@functools.wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Check if auth is enabled
|
||||
if not current_app.config.get("AUTH_ENABLED", True):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Check for first-run setup
|
||||
if not user_exists():
|
||||
return redirect(url_for("setup"))
|
||||
|
||||
# Check authentication
|
||||
if not is_authenticated():
|
||||
return redirect(url_for("login"))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def init_app(app):
|
||||
"""Initialize auth module with Flask app."""
|
||||
app.teardown_appcontext(close_db)
|
||||
|
||||
with app.app_context():
|
||||
init_db()
|
||||
111
frontends/web/ssl_utils.py
Normal file
111
frontends/web/ssl_utils.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
SSL Certificate Utilities
|
||||
|
||||
Auto-generates self-signed certificates for HTTPS.
|
||||
Uses cryptography library (already a dependency).
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
|
||||
def get_cert_paths(base_dir: Path) -> tuple[Path, Path]:
|
||||
"""Get paths for cert and key files."""
|
||||
cert_dir = base_dir / "certs"
|
||||
cert_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cert_dir / "server.crt", cert_dir / "server.key"
|
||||
|
||||
|
||||
def certs_exist(base_dir: Path) -> bool:
|
||||
"""Check if both cert files exist."""
|
||||
cert_path, key_path = get_cert_paths(base_dir)
|
||||
return cert_path.exists() and key_path.exists()
|
||||
|
||||
|
||||
def generate_self_signed_cert(
|
||||
base_dir: Path,
|
||||
hostname: str = "localhost",
|
||||
days_valid: int = 365,
|
||||
) -> tuple[Path, Path]:
|
||||
"""
|
||||
Generate self-signed SSL certificate.
|
||||
|
||||
Args:
|
||||
base_dir: Base directory for certs folder
|
||||
hostname: Server hostname for certificate
|
||||
days_valid: Certificate validity in days
|
||||
|
||||
Returns:
|
||||
Tuple of (cert_path, key_path)
|
||||
"""
|
||||
cert_path, key_path = get_cert_paths(base_dir)
|
||||
|
||||
# Generate RSA key
|
||||
key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
)
|
||||
|
||||
# Create certificate
|
||||
subject = issuer = x509.Name([
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Stegasoo"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, hostname),
|
||||
])
|
||||
|
||||
# Subject Alternative Names
|
||||
san_list = [
|
||||
x509.DNSName(hostname),
|
||||
x509.DNSName("localhost"),
|
||||
x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
|
||||
]
|
||||
# Add the hostname as IP if it looks like one
|
||||
try:
|
||||
san_list.append(x509.IPAddress(ipaddress.IPv4Address(hostname)))
|
||||
except ipaddress.AddressValueError:
|
||||
pass
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(issuer)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now)
|
||||
.not_valid_after(now + datetime.timedelta(days=days_valid))
|
||||
.add_extension(
|
||||
x509.SubjectAlternativeName(san_list),
|
||||
critical=False,
|
||||
)
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
|
||||
# Write key file (chmod 600)
|
||||
key_path.write_bytes(
|
||||
key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
key_path.chmod(0o600)
|
||||
|
||||
# Write cert file
|
||||
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||
|
||||
return cert_path, key_path
|
||||
|
||||
|
||||
def ensure_certs(base_dir: Path, hostname: str = "localhost") -> tuple[Path, Path]:
|
||||
"""Ensure certificates exist, generating if needed."""
|
||||
if certs_exist(base_dir):
|
||||
return get_cert_paths(base_dir)
|
||||
|
||||
print(f"Generating self-signed SSL certificate for {hostname}...")
|
||||
return generate_self_signed_cert(base_dir, hostname)
|
||||
@@ -724,6 +724,7 @@ footer {
|
||||
.scan-data-value {
|
||||
color: rgba(0, 255, 170, 1);
|
||||
font-weight: 600;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.scan-hash-preview {
|
||||
@@ -744,7 +745,7 @@ footer {
|
||||
border: 1px solid rgba(0, 255, 170, 0.4);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.5rem;
|
||||
font-size: 0.65rem;
|
||||
color: rgba(0, 255, 170, 1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
@@ -1001,6 +1002,7 @@ footer {
|
||||
.pixel-data-value {
|
||||
color: #d4e157;
|
||||
font-weight: 600;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.pixel-status-badge {
|
||||
@@ -1010,7 +1012,7 @@ footer {
|
||||
border: 1px solid rgba(212, 225, 87, 0.4);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.55rem;
|
||||
font-size: 0.65rem;
|
||||
color: #d4e157;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
@@ -1047,10 +1049,10 @@ footer {
|
||||
/* Expand drop zone when showing scanned QR result */
|
||||
#rsaQrSection .drop-zone:has(.qr-scan-container:not(.d-none)) {
|
||||
width: auto;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
min-width: 280px;
|
||||
max-width: 400px;
|
||||
height: auto;
|
||||
min-height: 200px;
|
||||
min-height: 280px;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
@@ -1070,9 +1072,9 @@ footer {
|
||||
overflow: visible;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
min-height: 160px;
|
||||
min-width: 160px;
|
||||
padding: 10px;
|
||||
min-height: 220px;
|
||||
min-width: 220px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -1090,10 +1092,10 @@ footer {
|
||||
|
||||
/* Cropped image - hidden until loaded, scales UP to fill container */
|
||||
.qr-scan-container .qr-cropped {
|
||||
max-height: 180px;
|
||||
max-width: 180px;
|
||||
min-width: 140px;
|
||||
min-height: 140px;
|
||||
max-height: 240px;
|
||||
max-width: 240px;
|
||||
min-width: 180px;
|
||||
min-height: 180px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
@@ -1255,11 +1257,11 @@ footer {
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background: linear-gradient(to top,
|
||||
rgba(10, 15, 30, 0.95) 0%,
|
||||
rgba(10, 15, 30, 0.6) 80%,
|
||||
background: linear-gradient(to top,
|
||||
rgba(10, 15, 30, 0.95) 0%,
|
||||
rgba(10, 15, 30, 0.6) 80%,
|
||||
transparent 100%);
|
||||
padding: 4px 6px 3px 6px;
|
||||
padding: 8px 10px 6px 10px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
border-radius: 0 0 6px 6px;
|
||||
@@ -1282,10 +1284,10 @@ footer {
|
||||
/* QR Data Panel text styles */
|
||||
.qr-data-filename {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.6rem;
|
||||
font-size: 0.7rem;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1301,7 +1303,7 @@ footer {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.5rem;
|
||||
font-size: 0.6rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1310,9 +1312,9 @@ footer {
|
||||
align-items: center;
|
||||
background: rgba(0, 255, 170, 0.15);
|
||||
border: 1px solid rgba(0, 255, 170, 0.4);
|
||||
border-radius: 2px;
|
||||
padding: 1px 4px;
|
||||
font-size: 0.45rem;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.65rem;
|
||||
color: rgba(0, 255, 170, 1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
@@ -1321,7 +1323,7 @@ footer {
|
||||
.qr-data-value {
|
||||
color: rgba(0, 255, 170, 1);
|
||||
font-weight: 600;
|
||||
font-size: 0.5rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------------
|
||||
|
||||
102
frontends/web/templates/account.html
Normal file
102
frontends/web/templates/account.html
Normal file
@@ -0,0 +1,102 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Account - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-person-gear me-2"></i>Account Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted mb-4">
|
||||
Logged in as <strong>{{ username }}</strong>
|
||||
</p>
|
||||
|
||||
<h6 class="text-muted mb-3">Change Password</h6>
|
||||
|
||||
<form method="POST" action="{{ url_for('account') }}" id="accountForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key me-1"></i> Current Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="current_password" class="form-control"
|
||||
id="currentPasswordInput" required>
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="togglePassword('currentPasswordInput', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key-fill me-1"></i> New Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="new_password" class="form-control"
|
||||
id="newPasswordInput" required minlength="8">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="togglePassword('newPasswordInput', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Minimum 8 characters</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key-fill me-1"></i> Confirm New Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="new_password_confirm" class="form-control"
|
||||
id="newPasswordConfirmInput" required minlength="8">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="togglePassword('newPasswordConfirmInput', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-check-lg me-2"></i>Update Password
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-outline-danger w-100">
|
||||
<i class="bi bi-box-arrow-left me-2"></i>Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function togglePassword(inputId, btn) {
|
||||
const input = document.getElementById(inputId);
|
||||
const icon = btn.querySelector('i');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('accountForm')?.addEventListener('submit', function(e) {
|
||||
const newPass = document.getElementById('newPasswordInput').value;
|
||||
const confirm = document.getElementById('newPasswordConfirmInput').value;
|
||||
if (newPass !== confirm) {
|
||||
e.preventDefault();
|
||||
alert('New passwords do not match');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -24,20 +24,38 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/"><i class="bi bi-house me-1"></i> Home</a>
|
||||
</li>
|
||||
{% if not auth_enabled or is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/encode"><i class="bi bi-lock me-1"></i> Encode</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/decode"><i class="bi bi-unlock me-1"></i> Decode</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/generate"><i class="bi bi-key me-1"></i> Generate</a>
|
||||
</li>
|
||||
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/about"><i class="bi bi-info-circle me-1"></i> About</a>
|
||||
</li>
|
||||
{% if auth_enabled %}
|
||||
{% if is_authenticated %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle me-1"></i> {{ username }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
||||
<li><a class="dropdown-item" href="/account"><i class="bi bi-gear me-2"></i>Account</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="/logout"><i class="bi bi-box-arrow-left me-2"></i>Logout</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/login"><i class="bi bi-box-arrow-in-right me-1"></i> Login</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -327,11 +327,11 @@
|
||||
|
||||
<!-- PIN + Channel Row -->
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
|
||||
<div class="input-group pin-input-container">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
@@ -340,7 +340,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8 mb-3">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-broadcast me-1"></i> Channel
|
||||
|
||||
@@ -394,11 +394,11 @@
|
||||
|
||||
<!-- PIN + Channel Row -->
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
|
||||
<div class="input-group pin-input-container">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
|
||||
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9">
|
||||
<button class="btn btn-outline-secondary" type="button" data-toggle-password="pinInput">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
@@ -407,7 +407,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8 mb-3">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="security-box h-100">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-broadcast me-1"></i> Channel
|
||||
|
||||
61
frontends/web/templates/login.html
Normal file
61
frontends/web/templates/login.html
Normal file
@@ -0,0 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<i class="bi bi-shield-lock fs-1 d-block mb-2"></i>
|
||||
<h5 class="mb-0">Login</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('login') }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-person me-1"></i> Username
|
||||
</label>
|
||||
<input type="text" name="username" class="form-control"
|
||||
value="{{ username }}" readonly>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key me-1"></i> Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="password" class="form-control"
|
||||
id="passwordInput" required autofocus>
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="togglePassword('passwordInput', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function togglePassword(inputId, btn) {
|
||||
const input = document.getElementById(inputId);
|
||||
const icon = btn.querySelector('i');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
94
frontends/web/templates/setup.html
Normal file
94
frontends/web/templates/setup.html
Normal file
@@ -0,0 +1,94 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Setup - Stegasoo{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<i class="bi bi-gear-fill fs-1 d-block mb-2"></i>
|
||||
<h5 class="mb-0">Initial Setup</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted text-center mb-4">
|
||||
Welcome to Stegasoo! Create your admin account to get started.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('setup') }}" id="setupForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-person me-1"></i> Username
|
||||
</label>
|
||||
<input type="text" name="username" class="form-control"
|
||||
value="admin" required minlength="3">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key me-1"></i> Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="password" class="form-control"
|
||||
id="passwordInput" required minlength="8">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="togglePassword('passwordInput', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Minimum 8 characters</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-key-fill me-1"></i> Confirm Password
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<input type="password" name="password_confirm" class="form-control"
|
||||
id="passwordConfirmInput" required minlength="8">
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
onclick="togglePassword('passwordConfirmInput', this)">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-check-lg me-2"></i>Create Admin Account
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-4 small">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
This is a single-user setup. The admin account has full access to all features.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function togglePassword(inputId, btn) {
|
||||
const input = document.getElementById(inputId);
|
||||
const icon = btn.querySelector('i');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
icon.classList.replace('bi-eye', 'bi-eye-slash');
|
||||
} else {
|
||||
input.type = 'password';
|
||||
icon.classList.replace('bi-eye-slash', 'bi-eye');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('setupForm')?.addEventListener('submit', function(e) {
|
||||
const pass = document.getElementById('passwordInput').value;
|
||||
const confirm = document.getElementById('passwordConfirmInput').value;
|
||||
if (pass !== confirm) {
|
||||
e.preventDefault();
|
||||
alert('Passwords do not match');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user