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

@@ -11,6 +11,8 @@ itsdangerous==2.2.0
Jinja2==3.1.4 Jinja2==3.1.4
MarkupSafe==3.0.0 MarkupSafe==3.0.0
packaging==24.1 packaging==24.1
pyotp==2.9.0
qrcode==7.4.2
requests==2.32.3 requests==2.32.3
urllib3==2.2.3 urllib3==2.2.3
Werkzeug==3.0.4 Werkzeug==3.0.4

View File

@@ -10,14 +10,14 @@ from controllers.file_controller import (
cleanup_expired_files, cleanup_abandoned_uploads cleanup_expired_files, cleanup_abandoned_uploads
) )
from controllers.admin_controller import ( 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 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 = Flask(__name__)
app.config['UPLOAD_FOLDER'] = 'tmp/uploads' 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['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(24).hex())
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=24) 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...") log.i("Initializing database...")
init_db() init_db()
migrate_from_json() migrate_from_json()
migrate_client_key_to_db()
# Run cleanup on startup # Run cleanup on startup
cleanup_expired_files() cleanup_expired_files()
@@ -41,7 +42,14 @@ def require_client_key(f):
log.e("No client key provided in request headers") log.e("No client key provided in request headers")
return jsonify({'error': 'Missing client key'}), 401 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: if client_key != expected_key:
log.e(f"Invalid client key provided. Expected length: {len(expected_key)}, Got length: {len(client_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 return jsonify({'error': 'Invalid client key'}), 401
@@ -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/login', methods=['GET', 'POST'], endpoint='admin_login')(admin_login)
app.route('/admin', methods=['GET'], endpoint='admin_dashboard')(admin_dashboard) app.route('/admin', methods=['GET'], endpoint='admin_dashboard')(admin_dashboard)
app.route('/admin/logout', methods=['GET'], endpoint='admin_logout')(admin_logout) 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 # Register frontend views
app.register_blueprint(views) app.register_blueprint(views)

View File

@@ -3,12 +3,16 @@ import pyotp
import qrcode import qrcode
import io import io
import base64 import base64
import json
from functools import wraps from functools import wraps
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import request, jsonify, render_template, session, redirect, url_for, current_app from flask import request, jsonify, render_template, session, redirect, url_for, current_app
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
import utils.mLogger as log 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): def require_admin_auth(f):
"""Decorator to require admin authentication""" """Decorator to require admin authentication"""
@@ -207,16 +211,37 @@ def admin_login():
return redirect(url_for('admin_dashboard')) return redirect(url_for('admin_dashboard'))
def 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'): if not session.get('admin_authenticated'):
return redirect(url_for('admin_login')) 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') 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', return render_template('admin_dashboard.html',
client_key=client_key, 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(): def admin_logout():
"""Handle admin logout""" """Handle admin logout"""
@@ -225,3 +250,94 @@ def admin_logout():
session.pop('admin_username', None) session.pop('admin_username', None)
log.i(f"Admin user logged out: {username}") log.i(f"Admin user logged out: {username}")
return redirect(url_for('admin_login')) 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 from .file_metadata import FileMetadata
# Database initialization # 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 # Admin model functions
from .admin_model import ( from .admin_model import (
@@ -33,3 +33,13 @@ from .chunk_model import (
cleanup_abandoned, cleanup_abandoned,
update_received_chunks 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) 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") log.i("Database initialized successfully")
def migrate_from_json(): def migrate_from_json():
@@ -224,3 +237,27 @@ def migrate_from_json():
log.i("Migration completed successfully") log.i("Migration completed successfully")
else: else:
log.i("No migration performed") 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 { .container {
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
padding: 2.5rem; padding: 2rem;
border-radius: 1rem; border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%; width: 90%;
max-width: 600px; max-width: 900px;
backdrop-filter: blur(10px); 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 { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -90,9 +139,8 @@
.card { .card {
background: #f7fafc; background: #f7fafc;
padding: 1.5rem; padding: 1.25rem;
border-radius: 0.75rem; border-radius: 0.75rem;
margin-bottom: 1.5rem;
border-left: 4px solid #667eea; border-left: 4px solid #667eea;
} }
@@ -160,13 +208,14 @@
.info-box { .info-box {
background: #ebf8ff; background: #ebf8ff;
color: #2c5282; color: #2c5282;
padding: 1rem; padding: 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
border-left: 4px solid #4299e1; border-left: 3px solid #4299e1;
font-size: 0.85rem; font-size: 0.8rem;
line-height: 1.6; line-height: 1.5;
display: flex; display: flex;
gap: 0.75rem; gap: 0.5rem;
margin-top: 1rem;
} }
.info-box .material-icons { .info-box .material-icons {
@@ -205,7 +254,7 @@
.actions { .actions {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
margin-top: 1.5rem; margin-top: 2rem;
} }
.btn { .btn {
@@ -243,18 +292,263 @@
.btn-secondary:hover { .btn-secondary:hover {
background: #f7fafc; 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> </style>
<script> <script>
function copyToClipboard() { function copyToClipboard() {
const keyText = document.querySelector('.key-display').textContent; const keyText = document.querySelector('.key-display').textContent;
navigator.clipboard.writeText(keyText).then(() => { navigator.clipboard.writeText(keyText).then(() => {
const successMsg = document.querySelector('.success-message'); const successMsg = document.getElementById('copy-success');
successMsg.style.display = 'flex'; successMsg.style.display = 'flex';
setTimeout(() => { setTimeout(() => {
successMsg.style.display = 'none'; successMsg.style.display = 'none';
}, 3000); }, 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> </script>
</head> </head>
<body> <body>
@@ -273,11 +567,83 @@
</a> </a>
</div> </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> <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> </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">
<div class="card-title"> <div class="card-title">
<span class="material-icons">vpn_key</span> <span class="material-icons">vpn_key</span>
@@ -289,17 +655,73 @@
<span class="material-icons">content_copy</span> <span class="material-icons">content_copy</span>
</button> </button>
</div> </div>
</div>
<div class="info-box"> <div class="info-box">
<span class="material-icons">info</span> <span class="material-icons">info</span>
<div> <div>
<strong>What is the Client Key?</strong><br> <strong>What is the Client Key?</strong><br>
This key is used by clients to authenticate API requests to the file server. 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. 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. 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> </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"> <div class="actions">
<a href="/" class="btn btn-secondary"> <a href="/" class="btn btn-secondary">