mirror of
https://github.com/mattintech/simplefileupload-server.git
synced 2026-07-11 14:21:53 +00:00
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:
@@ -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
|
||||||
|
|||||||
18
src/app.py
18
src/app.py
@@ -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)
|
||||||
|
|||||||
@@ -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!'))
|
||||||
|
|||||||
@@ -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
157
src/models/config_model.py
Normal 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
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user