From 823b8824eaf71aeb65c54ccc5282c2a509e6b29d Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sat, 3 Jan 2026 23:47:59 -0500 Subject: [PATCH] Add saved channel keys feature for Web UI users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Database: Add user_channel_keys table with CASCADE delete - Auth: Add CRUD functions for channel key management (10 keys/user limit) - Routes: Add key save/delete/rename endpoints and JSON API - Account page: Add saved keys section with add/rename/delete UI - Encode/Decode: Add saved keys to channel key dropdown (optgroup) - About page: Add Channel Key QR generator for sharing keys - Track last_used_at when saved keys are used 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontends/web/app.py | 101 +++++++++++++ frontends/web/auth.py | 214 +++++++++++++++++++++++++++ frontends/web/static/js/stegasoo.js | 8 + frontends/web/templates/about.html | 102 ++++++++++++- frontends/web/templates/account.html | 108 +++++++++++++- frontends/web/templates/decode.html | 9 +- frontends/web/templates/encode.html | 9 +- 7 files changed, 541 insertions(+), 10 deletions(-) diff --git a/frontends/web/app.py b/frontends/web/app.py index 5d1ba1a..b0da964 100644 --- a/frontends/web/app.py +++ b/frontends/web/app.py @@ -30,18 +30,23 @@ import time from pathlib import Path from auth import ( + MAX_CHANNEL_KEYS, MAX_USERS, admin_required, can_create_user, + can_save_channel_key, change_password, create_admin_user, create_user, + delete_channel_key, delete_user, generate_temp_password, get_all_users, + get_channel_key_by_id, get_current_user, get_non_admin_count, get_user_by_id, + get_user_channel_keys, get_username, is_admin, is_authenticated, @@ -49,6 +54,9 @@ from auth import ( login_user, logout_user, reset_user_password, + save_channel_key, + update_channel_key_last_used, + update_channel_key_name, user_exists, verify_user_password, ) @@ -195,6 +203,13 @@ def inject_globals(): # Get channel status (v4.0.0) channel_status = get_channel_status() + # Get saved channel keys for authenticated users (v4.2.0) + saved_channel_keys = [] + if is_authenticated(): + current_user = get_current_user() + if current_user: + saved_channel_keys = get_user_channel_keys(current_user.id) + return { "version": __version__, "max_message_chars": MAX_MESSAGE_CHARS, @@ -220,6 +235,8 @@ def inject_globals(): "username": get_username() if is_authenticated() else None, # NEW in v4.1.0 - Admin state "is_admin": is_admin(), + # NEW in v4.2.0 - Saved channel keys + "saved_channel_keys": saved_channel_keys, } @@ -1413,14 +1430,98 @@ def account(): success, message = change_password(current_user.id, current, new) flash(message, "success" if success else "error") + # Get saved channel keys + channel_keys = get_user_channel_keys(current_user.id) + return render_template( "account.html", username=current_user.username, user=current_user, is_admin=current_user.is_admin, + channel_keys=channel_keys, + max_channel_keys=MAX_CHANNEL_KEYS, + can_save_key=can_save_channel_key(current_user.id), ) +# ============================================================================ +# CHANNEL KEY MANAGEMENT ROUTES (v4.2.0) +# ============================================================================ + + +@app.route("/account/keys/save", methods=["POST"]) +@login_required +def account_save_key(): + """Save a new channel key.""" + current_user = get_current_user() + name = request.form.get("key_name", "").strip() + channel_key = request.form.get("channel_key", "").strip() + + # Normalize key format (remove dashes if present) + channel_key = channel_key.replace("-", "").lower() + + success, message, key = save_channel_key(current_user.id, name, channel_key) + flash(message, "success" if success else "error") + return redirect(url_for("account")) + + +@app.route("/account/keys//delete", methods=["POST"]) +@login_required +def account_delete_key(key_id): + """Delete a saved channel key.""" + current_user = get_current_user() + success, message = delete_channel_key(key_id, current_user.id) + flash(message, "success" if success else "error") + return redirect(url_for("account")) + + +@app.route("/account/keys//rename", methods=["POST"]) +@login_required +def account_rename_key(key_id): + """Rename a saved channel key.""" + current_user = get_current_user() + new_name = request.form.get("new_name", "").strip() + success, message = update_channel_key_name(key_id, current_user.id, new_name) + flash(message, "success" if success else "error") + return redirect(url_for("account")) + + +@app.route("/api/channel/keys") +@login_required +def api_channel_keys(): + """Get saved channel keys for current user (JSON API).""" + current_user = get_current_user() + keys = get_user_channel_keys(current_user.id) + return jsonify({ + "success": True, + "keys": [ + { + "id": k.id, + "name": k.name, + "fingerprint": f"{k.channel_key[:4]}...{k.channel_key[-4:]}", + "channel_key": k.channel_key, + "last_used_at": k.last_used_at, + } + for k in keys + ], + "can_save": can_save_channel_key(current_user.id), + "max_keys": MAX_CHANNEL_KEYS, + }) + + +@app.route("/api/channel/keys//use", methods=["POST"]) +@login_required +def api_channel_key_use(key_id): + """Mark a channel key as used (updates last_used_at).""" + current_user = get_current_user() + key = get_channel_key_by_id(key_id, current_user.id) + if not key: + return jsonify({"success": False, "error": "Key not found"}), 404 + + update_channel_key_last_used(key_id, current_user.id) + return jsonify({"success": True}) + + # ============================================================================ # ADMIN ROUTES (v4.1.0) # ============================================================================ diff --git a/frontends/web/auth.py b/frontends/web/auth.py index b8ad29e..ccb5754 100644 --- a/frontends/web/auth.py +++ b/frontends/web/auth.py @@ -31,6 +31,7 @@ ph = PasswordHasher( # Constants MAX_USERS = 16 # Plus 1 admin = 17 total +MAX_CHANNEL_KEYS = 10 # Per user ROLE_ADMIN = "admin" ROLE_USER = "user" @@ -92,6 +93,9 @@ def init_db(): elif not has_new_table: # Fresh install - create new schema _create_schema(db) + else: + # Existing install - check for new tables (channel_keys migration) + _ensure_channel_keys_table(db) def _create_schema(db: sqlite3.Connection): @@ -108,6 +112,19 @@ def _create_schema(db: sqlite3.Connection): CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); + + CREATE TABLE IF NOT EXISTS user_channel_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + channel_key TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + last_used_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, channel_key) + ); + + CREATE INDEX IF NOT EXISTS idx_channel_keys_user ON user_channel_keys(user_id); """) db.commit() @@ -137,6 +154,29 @@ def _migrate_from_single_user(db: sqlite3.Connection): db.commit() +def _ensure_channel_keys_table(db: sqlite3.Connection): + """Ensure user_channel_keys table exists (migration for existing installs).""" + cursor = db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='user_channel_keys'" + ) + if cursor.fetchone() is None: + db.executescript(""" + CREATE TABLE IF NOT EXISTS user_channel_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + name TEXT NOT NULL, + channel_key TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + last_used_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, channel_key) + ); + + CREATE INDEX IF NOT EXISTS idx_channel_keys_user ON user_channel_keys(user_id); + """) + db.commit() + + # ============================================================================= # User Queries # ============================================================================= @@ -551,6 +591,180 @@ def is_session_valid() -> bool: return True +# ============================================================================= +# Channel Keys +# ============================================================================= + + +@dataclass +class ChannelKey: + """Saved channel key data class.""" + + id: int + user_id: int + name: str + channel_key: str + created_at: str + last_used_at: str | None + + +def get_user_channel_keys(user_id: int) -> list[ChannelKey]: + """Get all saved channel keys for a user, most recently used first.""" + db = get_db() + rows = db.execute( + """ + SELECT id, user_id, name, channel_key, created_at, last_used_at + FROM user_channel_keys + WHERE user_id = ? + ORDER BY last_used_at DESC NULLS LAST, created_at DESC + """, + (user_id,), + ).fetchall() + return [ + ChannelKey( + id=row["id"], + user_id=row["user_id"], + name=row["name"], + channel_key=row["channel_key"], + created_at=row["created_at"], + last_used_at=row["last_used_at"], + ) + for row in rows + ] + + +def get_channel_key_by_id(key_id: int, user_id: int) -> ChannelKey | None: + """Get a specific channel key (ensures user owns it).""" + db = get_db() + row = db.execute( + """ + SELECT id, user_id, name, channel_key, created_at, last_used_at + FROM user_channel_keys + WHERE id = ? AND user_id = ? + """, + (key_id, user_id), + ).fetchone() + if row: + return ChannelKey( + id=row["id"], + user_id=row["user_id"], + name=row["name"], + channel_key=row["channel_key"], + created_at=row["created_at"], + last_used_at=row["last_used_at"], + ) + return None + + +def get_channel_key_count(user_id: int) -> int: + """Get count of saved channel keys for a user.""" + db = get_db() + result = db.execute( + "SELECT COUNT(*) FROM user_channel_keys WHERE user_id = ?", (user_id,) + ).fetchone() + return result[0] if result else 0 + + +def can_save_channel_key(user_id: int) -> bool: + """Check if user can save more channel keys (within limit).""" + return get_channel_key_count(user_id) < MAX_CHANNEL_KEYS + + +def save_channel_key( + user_id: int, name: str, channel_key: str +) -> tuple[bool, str, ChannelKey | None]: + """ + Save a channel key for a user. + + Returns (success, message, key). + """ + # Validate name + name = name.strip() + if not name: + return False, "Key name is required", None + if len(name) > 50: + return False, "Key name must be at most 50 characters", None + + # Validate channel key format (hex string) + channel_key = channel_key.strip().lower() + if not channel_key: + return False, "Channel key is required", None + if not all(c in "0123456789abcdef" for c in channel_key): + return False, "Invalid channel key format", None + + # Check limit + if not can_save_channel_key(user_id): + return False, f"Maximum of {MAX_CHANNEL_KEYS} saved keys reached", None + + db = get_db() + try: + cursor = db.execute( + """ + INSERT INTO user_channel_keys (user_id, name, channel_key) + VALUES (?, ?, ?) + """, + (user_id, name, channel_key), + ) + db.commit() + + key = get_channel_key_by_id(cursor.lastrowid, user_id) + return True, "Channel key saved", key + except sqlite3.IntegrityError: + return False, "This channel key is already saved", None + + +def update_channel_key_name( + key_id: int, user_id: int, new_name: str +) -> tuple[bool, str]: + """Update the name of a saved channel key.""" + new_name = new_name.strip() + if not new_name: + return False, "Key name is required" + if len(new_name) > 50: + return False, "Key name must be at most 50 characters" + + key = get_channel_key_by_id(key_id, user_id) + if not key: + return False, "Channel key not found" + + db = get_db() + db.execute( + "UPDATE user_channel_keys SET name = ? WHERE id = ? AND user_id = ?", + (new_name, key_id, user_id), + ) + db.commit() + return True, "Key name updated" + + +def update_channel_key_last_used(key_id: int, user_id: int): + """Update the last_used_at timestamp for a channel key.""" + db = get_db() + db.execute( + """ + UPDATE user_channel_keys + SET last_used_at = CURRENT_TIMESTAMP + WHERE id = ? AND user_id = ? + """, + (key_id, user_id), + ) + db.commit() + + +def delete_channel_key(key_id: int, user_id: int) -> tuple[bool, str]: + """Delete a saved channel key.""" + key = get_channel_key_by_id(key_id, user_id) + if not key: + return False, "Channel key not found" + + db = get_db() + db.execute( + "DELETE FROM user_channel_keys WHERE id = ? AND user_id = ?", + (key_id, user_id), + ) + db.commit() + return True, f"Key '{key.name}' deleted" + + # ============================================================================= # Decorators # ============================================================================= diff --git a/frontends/web/static/js/stegasoo.js b/frontends/web/static/js/stegasoo.js index f368295..29655b1 100644 --- a/frontends/web/static/js/stegasoo.js +++ b/frontends/web/static/js/stegasoo.js @@ -819,6 +819,14 @@ const Stegasoo = { // Set the select value to the actual key for form submission select.value = keyInput.value; } + + // Track saved key usage (fire-and-forget) + const selectedOption = select?.selectedOptions?.[0]; + const keyId = selectedOption?.dataset?.keyId; + if (keyId) { + fetch(`/api/channel/keys/${keyId}/use`, { method: 'POST' }).catch(() => {}); + } + return true; }, diff --git a/frontends/web/templates/about.html b/frontends/web/templates/about.html index 85032b6..957dd36 100644 --- a/frontends/web/templates/about.html +++ b/frontends/web/templates/about.html @@ -316,19 +316,55 @@ {% if channel_configured %} -
+
This server has a channel key configured: {{ channel_fingerprint }} ({{ channel_source }})
{% else %} -
+
- This server is running in public mode. + This server is running in public mode. Set STEGASOO_CHANNEL_KEY to enable server-wide channel isolation.
{% endif %} + + +
+
+ Share Channel Key via QR +
+
+

Generate a QR code to share a channel key with others.

+
+
+ +
+ + +
+
+
+ +
+
+
+ +
+ +
+
+
+
@@ -527,3 +563,63 @@ {% endblock %} + +{% block scripts %} + + + + +{% endblock %} diff --git a/frontends/web/templates/account.html b/frontends/web/templates/account.html index d31c3c0..3d4bf2b 100644 --- a/frontends/web/templates/account.html +++ b/frontends/web/templates/account.html @@ -78,13 +78,105 @@ -
- - - Logout - + + +
+
+
Saved Channel Keys
+ {{ channel_keys|length }} / {{ max_channel_keys }} +
+
+ {% if channel_keys %} +
+ {% for key in channel_keys %} +
+
+ {{ key.name }} +
+ {{ key.channel_key[:4] }}...{{ key.channel_key[-4:] }} + {% if key.last_used_at %} + Last used: {{ key.last_used_at[:10] }} + {% endif %} +
+
+ +
+ +
+
+
+ {% endfor %} +
+ {% else %} +

No saved channel keys. Save keys for quick access on encode/decode pages.

+ {% endif %} + + {% if can_save_key %} +
+
Add New Key
+
+
+
+ +
+
+ +
+
+ +
+ {% else %} +
+ + Maximum of {{ max_channel_keys }} keys reached. Delete a key to add more. +
+ {% endif %} +
+
+ + + + + + + + {% endblock %} @@ -93,5 +185,11 @@ {% endblock %} diff --git a/frontends/web/templates/decode.html b/frontends/web/templates/decode.html index 44c5c87..28be6f2 100644 --- a/frontends/web/templates/decode.html +++ b/frontends/web/templates/decode.html @@ -351,7 +351,14 @@ diff --git a/frontends/web/templates/encode.html b/frontends/web/templates/encode.html index b8ba4fe..56d5018 100644 --- a/frontends/web/templates/encode.html +++ b/frontends/web/templates/encode.html @@ -418,7 +418,14 @@