diff --git a/requirements.txt b/requirements.txt index 1d85079..1e3e363 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,8 @@ itsdangerous==2.2.0 Jinja2==3.1.4 MarkupSafe==3.0.0 packaging==24.1 +pyotp==2.9.0 +qrcode==7.4.2 requests==2.32.3 urllib3==2.2.3 Werkzeug==3.0.4 diff --git a/src/app.py b/src/app.py index 9d81b91..ab77985 100644 --- a/src/app.py +++ b/src/app.py @@ -10,14 +10,14 @@ from controllers.file_controller import ( cleanup_expired_files, cleanup_abandoned_uploads ) from controllers.admin_controller import ( - setup_admin, setup_admin_totp, admin_login, admin_dashboard, admin_logout + setup_admin, setup_admin_totp, admin_login, admin_dashboard, admin_logout, + update_config, regenerate_key ) from views import views -from models import init_db, migrate_from_json +from models import init_db, migrate_from_json, migrate_client_key_to_db app = Flask(__name__) app.config['UPLOAD_FOLDER'] = 'tmp/uploads' -app.config['CLIENT_KEY'] = os.environ.get('CLIENT_KEY', 'default-secret-key') app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(24).hex()) app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=24) @@ -28,6 +28,7 @@ if not os.path.exists(app.config['UPLOAD_FOLDER']): log.i("Initializing database...") init_db() migrate_from_json() +migrate_client_key_to_db() # Run cleanup on startup cleanup_expired_files() @@ -40,12 +41,19 @@ def require_client_key(f): if not client_key: log.e("No client key provided in request headers") return jsonify({'error': 'Missing client key'}), 401 - - expected_key = app.config['CLIENT_KEY'] + + # Get expected key from database + from models import get_client_key + expected_key = get_client_key() + + if not expected_key: + log.e("Server configuration error: no client key in database") + return jsonify({'error': 'Server configuration error'}), 500 + if client_key != expected_key: log.e(f"Invalid client key provided. Expected length: {len(expected_key)}, Got length: {len(client_key)}") return jsonify({'error': 'Invalid client key'}), 401 - + log.i("Client key authentication successful") return f(*args, **kwargs) return decorated_function @@ -71,6 +79,8 @@ app.route('/admin/setup/totp', methods=['GET', 'POST'], endpoint='setup_admin_to app.route('/admin/login', methods=['GET', 'POST'], endpoint='admin_login')(admin_login) app.route('/admin', methods=['GET'], endpoint='admin_dashboard')(admin_dashboard) app.route('/admin/logout', methods=['GET'], endpoint='admin_logout')(admin_logout) +app.route('/admin/config', methods=['POST'], endpoint='update_config')(update_config) +app.route('/admin/regenerate-key', methods=['POST'], endpoint='regenerate_key')(regenerate_key) # Register frontend views app.register_blueprint(views) diff --git a/src/controllers/admin_controller.py b/src/controllers/admin_controller.py index e5f0a12..1d073c1 100644 --- a/src/controllers/admin_controller.py +++ b/src/controllers/admin_controller.py @@ -3,12 +3,16 @@ import pyotp import qrcode import io import base64 +import json from functools import wraps from datetime import datetime, timedelta from flask import request, jsonify, render_template, session, redirect, url_for, current_app from werkzeug.security import generate_password_hash, check_password_hash import utils.mLogger as log -from models import get_admin, create_admin, model_admin_exists, update_last_login +from models import ( + get_admin, create_admin, model_admin_exists, update_last_login, + get_config, update_server_address, update_server_port, regenerate_client_key +) def require_admin_auth(f): """Decorator to require admin authentication""" @@ -207,16 +211,37 @@ def admin_login(): return redirect(url_for('admin_dashboard')) def admin_dashboard(): - """Display admin dashboard with client key""" + """Display admin dashboard with client key and QR code""" if not session.get('admin_authenticated'): return redirect(url_for('admin_login')) - client_key = current_app.config.get('CLIENT_KEY', 'Not configured') + # Get server configuration from database + config = get_config() + + if not config: + log.e("Server configuration not found in database") + return render_template('admin_dashboard.html', + error='Server configuration error', + username=session.get('admin_username', 'Admin')) + + client_key = config['client_key'] + server_address = config.get('server_address') or '' + server_port = config.get('server_port') or 7777 username = session.get('admin_username', 'Admin') + # Generate QR code if server address and port are configured + qr_code_base64 = None + if server_address and server_port: + qr_code_base64 = generate_config_qr(server_address, server_port, client_key) + return render_template('admin_dashboard.html', client_key=client_key, - username=username) + server_address=server_address, + server_port=server_port, + qr_code=qr_code_base64, + username=username, + success=request.args.get('success'), + error=request.args.get('error')) def admin_logout(): """Handle admin logout""" @@ -225,3 +250,94 @@ def admin_logout(): session.pop('admin_username', None) log.i(f"Admin user logged out: {username}") return redirect(url_for('admin_login')) + + +def generate_config_qr(server_address, server_port, client_key): + """Generate QR code containing server configuration + + Args: + server_address: Server IP or hostname + server_port: Server port number + client_key: Client authentication key + + Returns: + str: Base64-encoded PNG image of QR code + None: If error occurred + """ + try: + # Create JSON configuration + config_data = { + "server": server_address, + "port": server_port, + "key": client_key + } + config_json = json.dumps(config_data) + + # Generate QR code + qr = qrcode.QRCode( + version=None, # Auto-size + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=10, + border=4 + ) + qr.add_data(config_json) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + + # Convert to base64 + buffer = io.BytesIO() + img.save(buffer, format='PNG') + qr_base64 = base64.b64encode(buffer.getvalue()).decode() + + return qr_base64 + except Exception as e: + log.e(f"Error generating QR code: {e}") + return None + + +@require_admin_auth +def update_config(): + """Handle server configuration updates from admin dashboard""" + if request.method != 'POST': + return redirect(url_for('admin_dashboard')) + + server_address = request.form.get('server_address', '').strip() + server_port = request.form.get('server_port', '').strip() + + # Validate inputs + if not server_address: + return redirect(url_for('admin_dashboard', error='Server address is required')) + + try: + port = int(server_port) + if port < 1 or port > 65535: + raise ValueError("Port out of range") + except (ValueError, TypeError): + return redirect(url_for('admin_dashboard', error='Invalid port number')) + + # Update database + if not update_server_address(server_address): + return redirect(url_for('admin_dashboard', error='Failed to update server address')) + + if not update_server_port(port): + return redirect(url_for('admin_dashboard', error='Failed to update server port')) + + log.i(f"Server configuration updated: {server_address}:{port}") + return redirect(url_for('admin_dashboard', success='Configuration updated successfully')) + + +@require_admin_auth +def regenerate_key(): + """Handle client key regeneration""" + if request.method != 'POST': + return redirect(url_for('admin_dashboard')) + + # Regenerate the key + new_key = regenerate_client_key() + + if not new_key: + return redirect(url_for('admin_dashboard', error='Failed to regenerate client key')) + + log.w(f"Client key regenerated by admin: {session.get('admin_username')}") + return redirect(url_for('admin_dashboard', success='Client key regenerated successfully. All clients must update!')) diff --git a/src/models/__init__.py b/src/models/__init__.py index 22eceab..08757d3 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -1,7 +1,7 @@ from .file_metadata import FileMetadata # Database initialization -from .database import init_db, get_db_connection, migrate_from_json +from .database import init_db, get_db_connection, migrate_from_json, migrate_client_key_to_db # Admin model functions from .admin_model import ( @@ -33,3 +33,13 @@ from .chunk_model import ( cleanup_abandoned, update_received_chunks ) + +# Config model functions +from .config_model import ( + get_config, + get_client_key, + init_config, + update_server_address, + update_server_port, + regenerate_client_key +) diff --git a/src/models/config_model.py b/src/models/config_model.py new file mode 100644 index 0000000..aa26f52 --- /dev/null +++ b/src/models/config_model.py @@ -0,0 +1,157 @@ +import sqlite3 +import secrets +from datetime import datetime +from models.database import get_db +import utils.mLogger as log + + +def generate_client_key(): + """Generate a random 64-character hex string (256 bits)""" + return secrets.token_hex(32) # 32 bytes = 64 hex chars + + +def get_config(): + """Get server configuration (singleton) + + Returns: + dict: Configuration with client_key, server_address, server_port, timestamps + None: If config doesn't exist or error occurred + """ + try: + with get_db() as conn: + cursor = conn.cursor() + row = cursor.execute(""" + SELECT client_key, server_address, server_port, created_at, updated_at + FROM server_config + WHERE id = 1 + """).fetchone() + + if row: + return dict(row) + return None + except sqlite3.Error as e: + log.e(f"Error getting config: {e}") + return None + + +def get_client_key(): + """Get only the client key for authentication + + Returns: + str: The client key + None: If config doesn't exist or error occurred + """ + config = get_config() + return config['client_key'] if config else None + + +def init_config(client_key=None): + """Initialize server config with provided or generated key + + Args: + client_key (str, optional): Client key to use. If None, generates new key + + Returns: + bool: True if successful, False otherwise + """ + try: + with get_db() as conn: + # Check if config already exists + existing = conn.execute("SELECT COUNT(*) FROM server_config WHERE id = 1").fetchone()[0] + + if existing > 0: + log.i("Server config already exists, skipping initialization") + return True + + # Generate key if not provided + if not client_key: + client_key = generate_client_key() + log.i("Generated new client key") + else: + log.i("Using provided client key from environment") + + # Insert initial config + now = datetime.now().isoformat() + + conn.execute(""" + INSERT INTO server_config (id, client_key, created_at, updated_at) + VALUES (1, ?, ?, ?) + """, (client_key, now, now)) + + log.i("Server config initialized successfully") + return True + except sqlite3.Error as e: + log.e(f"Error initializing config: {e}") + return False + + +def update_server_address(address): + """Update server address + + Args: + address (str): Server address (IP or hostname) + + Returns: + bool: True if successful, False otherwise + """ + try: + with get_db() as conn: + conn.execute(""" + UPDATE server_config + SET server_address = ?, updated_at = ? + WHERE id = 1 + """, (address, datetime.now().isoformat())) + + log.i(f"Updated server address: {address}") + return True + except sqlite3.Error as e: + log.e(f"Error updating server address: {e}") + return False + + +def update_server_port(port): + """Update server port + + Args: + port (int): Server port number + + Returns: + bool: True if successful, False otherwise + """ + try: + with get_db() as conn: + conn.execute(""" + UPDATE server_config + SET server_port = ?, updated_at = ? + WHERE id = 1 + """, (port, datetime.now().isoformat())) + + log.i(f"Updated server port: {port}") + return True + except sqlite3.Error as e: + log.e(f"Error updating server port: {e}") + return False + + +def regenerate_client_key(): + """Generate and set a new client key + + Returns: + str: The new client key + None: If error occurred + """ + try: + new_key = generate_client_key() + + with get_db() as conn: + conn.execute(""" + UPDATE server_config + SET client_key = ?, updated_at = ? + WHERE id = 1 + """, (new_key, datetime.now().isoformat())) + + log.w("Client key regenerated - all clients must update!") + return new_key + except sqlite3.Error as e: + log.e(f"Error regenerating client key: {e}") + return None diff --git a/src/models/database.py b/src/models/database.py index 8b4d779..80e4e3b 100644 --- a/src/models/database.py +++ b/src/models/database.py @@ -110,6 +110,19 @@ def init_db(): CREATE INDEX IF NOT EXISTS idx_chunk_sessions_created_at ON chunk_sessions(created_at) """) + # Server configuration table (singleton) + conn.execute(""" + CREATE TABLE IF NOT EXISTS server_config ( + id INTEGER PRIMARY KEY CHECK(id = 1), + client_key TEXT NOT NULL, + server_address TEXT, + server_port INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + CONSTRAINT client_key_length CHECK(length(client_key) = 64) + ) + """) + log.i("Database initialized successfully") def migrate_from_json(): @@ -224,3 +237,27 @@ def migrate_from_json(): log.i("Migration completed successfully") else: log.i("No migration performed") + + +def migrate_client_key_to_db(): + """Migrate CLIENT_KEY from environment to database (one-time)""" + from models.config_model import init_config + + # Only run if config doesn't exist + with get_db() as conn: + existing = conn.execute("SELECT COUNT(*) FROM server_config WHERE id = 1").fetchone()[0] + + if existing > 0: + log.i("Server config already exists in database, skipping migration") + return + + # Get CLIENT_KEY from environment if set + env_key = os.environ.get('CLIENT_KEY') + + # Initialize config (will generate new key if env_key is None or default) + if env_key and env_key != 'default-secret-key': + log.i("Migrating CLIENT_KEY from environment to database") + init_config(client_key=env_key) + else: + log.i("No valid CLIENT_KEY in environment, generating new key") + init_config() diff --git a/src/templates/admin_dashboard.html b/src/templates/admin_dashboard.html index ef357c2..5b36454 100644 --- a/src/templates/admin_dashboard.html +++ b/src/templates/admin_dashboard.html @@ -27,14 +27,63 @@ .container { background: rgba(255, 255, 255, 0.95); - padding: 2.5rem; + padding: 2rem; border-radius: 1rem; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); width: 90%; - max-width: 600px; + max-width: 900px; backdrop-filter: blur(10px); } + .tabs { + display: flex; + gap: 0.5rem; + border-bottom: 2px solid #e2e8f0; + margin-bottom: 1.5rem; + } + + .tab { + padding: 0.75rem 1.5rem; + cursor: pointer; + border: none; + background: transparent; + color: #718096; + font-weight: 500; + font-size: 0.95rem; + transition: all 0.2s; + border-bottom: 3px solid transparent; + margin-bottom: -2px; + } + + .tab:hover { + color: #667eea; + } + + .tab.active { + color: #667eea; + border-bottom-color: #667eea; + } + + .tab-content { + display: none; + } + + .tab-content.active { + display: block; + } + + .cards-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; + } + + @media (min-width: 768px) { + .cards-grid { + grid-template-columns: 1fr 1fr; + } + } + .header { display: flex; justify-content: space-between; @@ -90,9 +139,8 @@ .card { background: #f7fafc; - padding: 1.5rem; + padding: 1.25rem; border-radius: 0.75rem; - margin-bottom: 1.5rem; border-left: 4px solid #667eea; } @@ -160,13 +208,14 @@ .info-box { background: #ebf8ff; color: #2c5282; - padding: 1rem; + padding: 0.75rem; border-radius: 0.5rem; - border-left: 4px solid #4299e1; - font-size: 0.85rem; - line-height: 1.6; + border-left: 3px solid #4299e1; + font-size: 0.8rem; + line-height: 1.5; display: flex; - gap: 0.75rem; + gap: 0.5rem; + margin-top: 1rem; } .info-box .material-icons { @@ -205,7 +254,7 @@ .actions { display: flex; gap: 1rem; - margin-top: 1.5rem; + margin-top: 2rem; } .btn { @@ -243,18 +292,263 @@ .btn-secondary:hover { background: #f7fafc; } + + .form-group { + margin-bottom: 1rem; + } + + .form-group:last-of-type { + margin-bottom: 1.25rem; + } + + .form-group label { + display: block; + font-weight: 500; + margin-bottom: 0.5rem; + color: #2d3748; + } + + .form-input { + width: 100%; + padding: 0.75rem; + border: 2px solid #e2e8f0; + border-radius: 0.5rem; + font-size: 0.95rem; + transition: border-color 0.2s; + } + + .form-input:focus { + outline: none; + border-color: #667eea; + } + + .form-hint { + display: block; + color: #718096; + font-size: 0.85rem; + margin-top: 0.25rem; + } + + .key-actions { + margin-top: 1rem; + } + + .btn-danger { + background: #fff5f5; + color: #e53e3e; + border: 2px solid #e53e3e; + } + + .btn-danger:hover { + background: #e53e3e; + color: white; + } + + .qr-container { + display: flex; + justify-content: center; + padding: 1rem; + background: white; + border-radius: 0.5rem; + border: 2px solid #e2e8f0; + margin-bottom: 0.75rem; + } + + .qr-image { + max-width: 250px; + width: 100%; + height: auto; + } + + .error-message { + background: #fff5f5; + color: #c53030; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; + animation: slideIn 0.3s ease; + } + + .error-message .material-icons { + color: #e53e3e; + } + + .qr-actions { + display: flex; + gap: 0.75rem; + margin-top: 1rem; + } + + .btn-icon { + flex: 1; + padding: 0.75rem; + border: none; + border-radius: 0.5rem; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + } + + .btn-download { + background: #667eea; + color: white; + } + + .btn-download:hover { + background: #5a67d8; + transform: translateY(-2px); + } + + .btn-copy-config { + background: white; + color: #667eea; + border: 2px solid #667eea; + } + + .btn-copy-config:hover { + background: #f7fafc; + } @@ -273,11 +567,83 @@ -
+ + {% if error %} +
+ error + {{ error }} +
+ {% endif %} + + + {% if success %} +
check_circle - Client key copied to clipboard! + {{ success }} +
+ {% endif %} + +
+ check_circle + Client key copied to clipboard!
+ +
+ + +
+ + +
+
+ +
+
+ settings + Server Configuration +
+ +
+
+ + + IP address or hostname where the server is accessible +
+ +
+ + + Port number (default: 7777) +
+ + +
+
+ +
vpn_key @@ -289,17 +655,73 @@ content_copy
-
-
- info -
- What is the Client Key?
- This key is used by clients to authenticate API requests to the file server. - It should be included in the X-Client-Key header for all upload operations. - Keep this key secure and don't share it publicly. +
+ info +
+ What is the Client Key?
+ This key is used by clients to authenticate API requests to the file server. + It should be included in the X-Client-Key header for all upload operations. + Keep this key secure and don't share it publicly. +
+
+ +
+
+
+
+ + +
+ {% if qr_code %} +
+
+ qr_code_2 + QR Code for Android App +
+ +
+ Configuration QR Code +
+ +
+ info +
+ Scan this QR code with the Simple File Upload Android app to automatically configure: +
    +
  • Server address: {{ server_address }}
  • +
  • Port: {{ server_port }}
  • +
  • Client key: (hidden)
  • +
+
+
+ +
+ + +
+
+ {% else %} +
+ warning +
+ Configure server settings to generate QR code
+ Enter your server address and port above to generate a QR code for the Android app. +
+
+ {% endif %} +