Move client key to database with admin UI for key management and QR code generation

- Store client key in SQLite database instead of environment variable
- Add database migration from CLIENT_KEY environment variable to preserve existing keys
- Add admin UI with tabbed interface (Configuration and QR Code tabs)
- Implement QR code generation containing server config (address, port, key) for Android app
- Add functionality to regenerate client key with warning dialog
- Add buttons to download QR code as PNG and copy QR image to clipboard
- Add manual input fields for server address and port configuration
- Update requirements.txt with pyotp and qrcode dependencies
This commit is contained in:
2025-12-27 14:29:58 -05:00
parent 872ae74668
commit 164e2162a2
7 changed files with 786 additions and 32 deletions

View File

@@ -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)

View File

@@ -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!'))

View File

@@ -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
)

157
src/models/config_model.py Normal file
View File

@@ -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

View File

@@ -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()

View File

@@ -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;
}
</style>
<script>
function copyToClipboard() {
const keyText = document.querySelector('.key-display').textContent;
navigator.clipboard.writeText(keyText).then(() => {
const successMsg = document.querySelector('.success-message');
const successMsg = document.getElementById('copy-success');
successMsg.style.display = 'flex';
setTimeout(() => {
successMsg.style.display = 'none';
}, 3000);
});
}
function validateConfig() {
const address = document.getElementById('server_address').value.trim();
const port = parseInt(document.getElementById('server_port').value);
if (!address) {
alert('Server address is required');
return false;
}
if (isNaN(port) || port < 1 || port > 65535) {
alert('Port must be between 1 and 65535');
return false;
}
return true;
}
function confirmRegenerate() {
const confirmed = confirm(
'WARNING: Regenerating the client key will immediately invalidate the current key.\n\n' +
'All Android apps and clients using the old key will be unable to upload files until they are updated with the new key.\n\n' +
'Are you sure you want to continue?'
);
if (confirmed) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/admin/regenerate-key';
document.body.appendChild(form);
form.submit();
}
}
function switchTab(tabName) {
// Hide all tab contents
const tabContents = document.querySelectorAll('.tab-content');
tabContents.forEach(content => content.classList.remove('active'));
// Remove active class from all tabs
const tabs = document.querySelectorAll('.tab');
tabs.forEach(tab => tab.classList.remove('active'));
// Show selected tab content
const selectedContent = document.getElementById(tabName + '-tab');
if (selectedContent) {
selectedContent.classList.add('active');
}
// Add active class to clicked tab
event.target.closest('.tab').classList.add('active');
}
function downloadQRCode() {
const qrImage = document.querySelector('.qr-image');
if (!qrImage) return;
// Convert base64 to blob and download
const base64Data = qrImage.src.split(',')[1];
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: 'image/png' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'server-config-qr.png';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
// Show success message
showCopySuccess('QR code downloaded!');
}
async function copyQRConfig() {
const qrImage = document.querySelector('.qr-image');
if (!qrImage) return;
try {
// Convert base64 to blob
const base64Data = qrImage.src.split(',')[1];
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: 'image/png' });
// Copy to clipboard
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob
})
]);
showCopySuccess('QR code copied to clipboard!');
} catch (err) {
console.error('Failed to copy QR code:', err);
alert('Failed to copy QR code to clipboard. Your browser may not support this feature.');
}
}
function showCopySuccess(message) {
const successMsg = document.getElementById('copy-success');
const messageSpan = successMsg.querySelector('span:last-child');
if (messageSpan) {
const originalMessage = messageSpan.textContent;
messageSpan.textContent = message;
successMsg.style.display = 'flex';
setTimeout(() => {
successMsg.style.display = 'none';
messageSpan.textContent = originalMessage;
}, 3000);
}
}
</script>
</head>
<body>
@@ -273,11 +567,83 @@
</a>
</div>
<div class="success-message">
<!-- Error Message -->
{% if error %}
<div class="error-message">
<span class="material-icons">error</span>
{{ error }}
</div>
{% endif %}
<!-- Success Message -->
{% if success %}
<div class="success-message" style="display: flex;">
<span class="material-icons">check_circle</span>
Client key copied to clipboard!
{{ success }}
</div>
{% endif %}
<div class="success-message" id="copy-success">
<span class="material-icons">check_circle</span>
<span>Client key copied to clipboard!</span>
</div>
<!-- Tabs Navigation -->
<div class="tabs">
<button class="tab active" onclick="switchTab('config')">
<span class="material-icons" style="font-size: 1.2rem; vertical-align: middle;">settings</span>
Configuration
</button>
<button class="tab" onclick="switchTab('qrcode')" {% if not qr_code %}disabled style="opacity: 0.5; cursor: not-allowed;"{% endif %}>
<span class="material-icons" style="font-size: 1.2rem; vertical-align: middle;">qr_code_2</span>
QR Code
</button>
</div>
<!-- Configuration Tab -->
<div id="config-tab" class="tab-content active">
<div class="cards-grid">
<!-- Server Configuration Card -->
<div class="card">
<div class="card-title">
<span class="material-icons">settings</span>
Server Configuration
</div>
<form method="POST" action="/admin/config" onsubmit="return validateConfig()">
<div class="form-group">
<label for="server_address">Server Address</label>
<input type="text"
id="server_address"
name="server_address"
class="form-input"
value="{{ server_address }}"
placeholder="e.g., 192.168.1.100 or myserver.com"
required>
<small class="form-hint">IP address or hostname where the server is accessible</small>
</div>
<div class="form-group">
<label for="server_port">Port</label>
<input type="number"
id="server_port"
name="server_port"
class="form-input"
value="{{ server_port }}"
min="1"
max="65535"
required>
<small class="form-hint">Port number (default: 7777)</small>
</div>
<button type="submit" class="btn btn-primary">
<span class="material-icons">save</span>
Save Configuration
</button>
</form>
</div>
<!-- Client Key Card -->
<div class="card">
<div class="card-title">
<span class="material-icons">vpn_key</span>
@@ -289,17 +655,73 @@
<span class="material-icons">content_copy</span>
</button>
</div>
</div>
<div class="info-box">
<span class="material-icons">info</span>
<div>
<strong>What is the Client Key?</strong><br>
This key is used by clients to authenticate API requests to the file server.
It should be included in the <code>X-Client-Key</code> header for all upload operations.
Keep this key secure and don't share it publicly.
<div class="info-box">
<span class="material-icons">info</span>
<div>
<strong>What is the Client Key?</strong><br>
This key is used by clients to authenticate API requests to the file server.
It should be included in the <code>X-Client-Key</code> header for all upload operations.
Keep this key secure and don't share it publicly.
</div>
</div>
<div class="key-actions">
<button type="button" class="btn btn-danger" onclick="confirmRegenerate()">
<span class="material-icons">refresh</span>
Regenerate Key
</button>
</div>
</div>
</div><!-- end cards-grid -->
</div><!-- end config-tab -->
<!-- QR Code Tab -->
<div id="qrcode-tab" class="tab-content">
{% if qr_code %}
<div class="card">
<div class="card-title">
<span class="material-icons">qr_code_2</span>
QR Code for Android App
</div>
<div class="qr-container">
<img src="data:image/png;base64,{{ qr_code }}" alt="Configuration QR Code" class="qr-image">
</div>
<div class="info-box">
<span class="material-icons">info</span>
<div>
Scan this QR code with the Simple File Upload Android app to automatically configure:
<ul style="margin: 0.5rem 0 0 1.5rem;">
<li>Server address: <strong>{{ server_address }}</strong></li>
<li>Port: <strong>{{ server_port }}</strong></li>
<li>Client key: <strong>(hidden)</strong></li>
</ul>
</div>
</div>
<div class="qr-actions">
<button class="btn-icon btn-download" onclick="downloadQRCode()">
<span class="material-icons">download</span>
Download PNG
</button>
<button class="btn-icon btn-copy-config" onclick="copyQRConfig()">
<span class="material-icons">content_copy</span>
Copy QR Code
</button>
</div>
</div>
{% else %}
<div class="info-box" style="background: #fef5e7; border-left-color: #f39c12; color: #7d6608;">
<span class="material-icons" style="color: #f39c12;">warning</span>
<div>
<strong>Configure server settings to generate QR code</strong><br>
Enter your server address and port above to generate a QR code for the Android app.
</div>
</div>
{% endif %}
</div><!-- end qrcode-tab -->
<div class="actions">
<a href="/" class="btn btn-secondary">