From 872ae74668e68a8afd445e31b4f0038c2baebc7d Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Sat, 27 Dec 2025 14:00:39 -0500 Subject: [PATCH] Migrate storage from JSON to SQLite with enhanced MVC architecture - Replace JSON file storage with SQLite database for improved concurrency and data integrity - Implement repository pattern with dedicated model layer (admin_model, file_model, chunk_model) - Add database.py with automatic migration from existing JSON files - Enable WAL mode for thread-safe concurrent access across Gunicorn workers - Store database files in dedicated db/ folder - Update controllers to use model layer instead of direct JSON access - Add admin authentication system with TOTP 2FA support --- .gitignore | 3 + src/app.py | 19 ++ src/controllers/admin_controller.py | 227 ++++++++++++++++ src/controllers/file_controller.py | 262 ++++++------------ src/models/__init__.py | 35 +++ src/models/admin_model.py | 91 +++++++ src/models/chunk_model.py | 262 ++++++++++++++++++ src/models/database.py | 226 ++++++++++++++++ src/models/file_model.py | 199 ++++++++++++++ src/templates/admin_dashboard.html | 312 +++++++++++++++++++++ src/templates/admin_login.html | 242 +++++++++++++++++ src/templates/admin_setup.html | 198 ++++++++++++++ src/templates/admin_setup_complete.html | 199 ++++++++++++++ src/templates/admin_setup_totp.html | 342 ++++++++++++++++++++++++ 14 files changed, 2442 insertions(+), 175 deletions(-) create mode 100644 src/controllers/admin_controller.py create mode 100644 src/models/admin_model.py create mode 100644 src/models/chunk_model.py create mode 100644 src/models/database.py create mode 100644 src/models/file_model.py create mode 100644 src/templates/admin_dashboard.html create mode 100644 src/templates/admin_login.html create mode 100644 src/templates/admin_setup.html create mode 100644 src/templates/admin_setup_complete.html create mode 100644 src/templates/admin_setup_totp.html diff --git a/.gitignore b/.gitignore index e79592d..5f03a34 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ tmp/ uploads/ file_metadata.json chunk_metadata.json +admin_data.json +*.backup +db/ .env # Python diff --git a/src/app.py b/src/app.py index 8a4aa82..9d81b91 100644 --- a/src/app.py +++ b/src/app.py @@ -1,5 +1,6 @@ import os from functools import wraps +from datetime import timedelta from flask import Flask, request, jsonify, send_from_directory from werkzeug.utils import secure_filename import utils.mLogger as log @@ -8,15 +9,26 @@ from controllers.file_controller import ( start_chunked_upload, upload_chunk, verify_chunks, complete_chunked_upload, cleanup_expired_files, cleanup_abandoned_uploads ) +from controllers.admin_controller import ( + setup_admin, setup_admin_totp, admin_login, admin_dashboard, admin_logout +) from views import views +from models import init_db, migrate_from_json 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) if not os.path.exists(app.config['UPLOAD_FOLDER']): os.makedirs(app.config['UPLOAD_FOLDER']) +# Initialize database +log.i("Initializing database...") +init_db() +migrate_from_json() + # Run cleanup on startup cleanup_expired_files() cleanup_abandoned_uploads() @@ -53,6 +65,13 @@ app.route('/download/', methods=['GET'])(download_file) # Route for deleting the file app.route('/delete/', methods=['DELETE'])(delete_file) +# Admin routes +app.route('/admin/setup', methods=['GET', 'POST'], endpoint='setup_admin')(setup_admin) +app.route('/admin/setup/totp', methods=['GET', 'POST'], endpoint='setup_admin_totp')(setup_admin_totp) +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) + # Register frontend views app.register_blueprint(views) diff --git a/src/controllers/admin_controller.py b/src/controllers/admin_controller.py new file mode 100644 index 0000000..e5f0a12 --- /dev/null +++ b/src/controllers/admin_controller.py @@ -0,0 +1,227 @@ +import os +import pyotp +import qrcode +import io +import base64 +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 + +def require_admin_auth(f): + """Decorator to require admin authentication""" + @wraps(f) + def decorated_function(*args, **kwargs): + if not session.get('admin_authenticated'): + log.w("Unauthorized admin access attempt") + return redirect(url_for('admin_login')) + return f(*args, **kwargs) + return decorated_function + +def admin_exists(): + """Check if an admin user has been created""" + return model_admin_exists() + +def setup_admin(): + """Handle initial admin setup - Step 1: Username and Password""" + if admin_exists(): + log.w("Attempted to access setup page when admin already exists") + return redirect(url_for('admin_login')) + + if request.method == 'GET': + return render_template('admin_setup.html') + + # POST request - validate and store credentials in session + username = request.form.get('username') + password = request.form.get('password') + confirm_password = request.form.get('confirm_password') + + if not username or not password or not confirm_password: + return render_template('admin_setup.html', error='All fields are required') + + if password != confirm_password: + return render_template('admin_setup.html', error='Passwords do not match') + + if len(password) < 8: + return render_template('admin_setup.html', error='Password must be at least 8 characters') + + # Store credentials in session temporarily + session['temp_username'] = username + session['temp_password_hash'] = generate_password_hash(password, method='pbkdf2:sha256') + + log.i(f"Admin credentials created for: {username}, proceeding to TOTP setup") + return redirect(url_for('setup_admin_totp')) + +def setup_admin_totp(): + """Handle TOTP setup - Step 2: Configure Two-Factor Authentication""" + if admin_exists(): + log.w("Attempted to access TOTP setup when admin already exists") + return redirect(url_for('admin_login')) + + # Check if step 1 was completed + if 'temp_username' not in session: + log.w("Attempted to access TOTP setup without completing step 1") + return redirect(url_for('setup_admin')) + + username = session.get('temp_username') + + if request.method == 'GET': + # Generate TOTP secret + totp_secret = pyotp.random_base32() + session['temp_totp_secret'] = totp_secret + + # Generate QR code for TOTP setup + totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( + name=username, + issuer_name='Simple File Server' + ) + + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(totp_uri) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + + # Convert QR code to base64 + buffer = io.BytesIO() + img.save(buffer, format='PNG') + qr_base64 = base64.b64encode(buffer.getvalue()).decode() + + return render_template('admin_setup_totp.html', + qr_code=qr_base64, + totp_secret=totp_secret, + username=username) + + # POST request - verify TOTP and complete setup + totp_code = request.form.get('totp_code') + totp_secret = session.get('temp_totp_secret') + password_hash = session.get('temp_password_hash') + + if not totp_code: + # Regenerate QR code for error response + totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( + name=username, + issuer_name='Simple File Server' + ) + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(totp_uri) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + buffer = io.BytesIO() + img.save(buffer, format='PNG') + qr_base64 = base64.b64encode(buffer.getvalue()).decode() + + return render_template('admin_setup_totp.html', + error='TOTP code is required', + qr_code=qr_base64, + totp_secret=totp_secret, + username=username) + + # Verify TOTP code + totp = pyotp.TOTP(totp_secret) + if not totp.verify(totp_code, valid_window=1): + log.w(f"Failed admin setup: invalid TOTP code for user '{username}'") + + # Regenerate QR code for error response + totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri( + name=username, + issuer_name='Simple File Server' + ) + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(totp_uri) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + buffer = io.BytesIO() + img.save(buffer, format='PNG') + qr_base64 = base64.b64encode(buffer.getvalue()).decode() + + return render_template('admin_setup_totp.html', + error='Invalid TOTP code. Please check your authenticator app and try again.', + qr_code=qr_base64, + totp_secret=totp_secret, + username=username) + + # TOTP verified - create admin account + create_admin(username, password_hash, totp_secret) + + # Clean up session + session.pop('temp_username', None) + session.pop('temp_password_hash', None) + session.pop('temp_totp_secret', None) + + log.i(f"Admin user created: {username}") + + return render_template('admin_setup_complete.html', username=username) + +def admin_login(): + """Handle admin login""" + if not admin_exists(): + return redirect(url_for('setup_admin')) + + if session.get('admin_authenticated'): + return redirect(url_for('admin_dashboard')) + + if request.method == 'GET': + return render_template('admin_login.html') + + # POST request - authenticate + username = request.form.get('username') + password = request.form.get('password') + totp_code = request.form.get('totp_code') + + if not username or not password or not totp_code: + return render_template('admin_login.html', error='All fields are required') + + admin_data = get_admin() + + if not admin_data: + log.e("Admin data not found during login attempt") + return render_template('admin_login.html', error='Invalid credentials') + + # Verify username and password + if username != admin_data['username']: + log.w(f"Failed login attempt: invalid username '{username}'") + return render_template('admin_login.html', error='Invalid credentials') + + if not check_password_hash(admin_data['password_hash'], password): + log.w(f"Failed login attempt: invalid password for user '{username}'") + return render_template('admin_login.html', error='Invalid credentials') + + # Verify TOTP + totp = pyotp.TOTP(admin_data['totp_secret']) + if not totp.verify(totp_code, valid_window=1): + log.w(f"Failed login attempt: invalid TOTP code for user '{username}'") + return render_template('admin_login.html', error='Invalid TOTP code') + + # Authentication successful + session['admin_authenticated'] = True + session['admin_username'] = username + session.permanent = True + + # Update last login time + update_last_login(username) + + log.i(f"Admin user logged in: {username}") + + return redirect(url_for('admin_dashboard')) + +def admin_dashboard(): + """Display admin dashboard with client key""" + if not session.get('admin_authenticated'): + return redirect(url_for('admin_login')) + + client_key = current_app.config.get('CLIENT_KEY', 'Not configured') + username = session.get('admin_username', 'Admin') + + return render_template('admin_dashboard.html', + client_key=client_key, + username=username) + +def admin_logout(): + """Handle admin logout""" + username = session.get('admin_username', 'Unknown') + session.pop('admin_authenticated', None) + session.pop('admin_username', None) + log.i(f"Admin user logged out: {username}") + return redirect(url_for('admin_login')) diff --git a/src/controllers/file_controller.py b/src/controllers/file_controller.py index 3b203c2..e98fde4 100644 --- a/src/controllers/file_controller.py +++ b/src/controllers/file_controller.py @@ -1,21 +1,24 @@ import os import random import string -import json import shutil from datetime import datetime, timedelta from flask import request, jsonify, send_from_directory, render_template from werkzeug.utils import secure_filename import utils.mLogger as log -from models.file_metadata import FileMetadata +from models import ( + create_file, get_file, increment_download, model_delete_file, + cleanup_expired as model_cleanup_expired, + create_session, get_session, add_chunk, get_received_chunks, + delete_session, cleanup_abandoned as model_cleanup_abandoned, + update_received_chunks +) # Make UPLOAD_FOLDER absolute UPLOAD_FOLDER = os.path.abspath('tmp/uploads') -METADATA_FILE = 'file_metadata.json' # Constants for chunked uploads CHUNKS_FOLDER = os.path.join(UPLOAD_FOLDER, 'chunks') -CHUNK_METADATA_FILE = 'chunk_metadata.json' if not os.path.exists(UPLOAD_FOLDER): os.makedirs(UPLOAD_FOLDER) @@ -24,26 +27,6 @@ if not os.path.exists(UPLOAD_FOLDER): if not os.path.exists(CHUNKS_FOLDER): os.makedirs(CHUNKS_FOLDER) -def load_metadata(): - if os.path.exists(METADATA_FILE): - with open(METADATA_FILE, 'r') as f: - return json.load(f) - return {} - -def save_metadata(metadata): - with open(METADATA_FILE, 'w') as f: - json.dump(metadata, f) - -def load_chunk_metadata(): - if os.path.exists(CHUNK_METADATA_FILE): - with open(CHUNK_METADATA_FILE, 'r') as f: - return json.load(f) - return {} - -def save_chunk_metadata(metadata): - with open(CHUNK_METADATA_FILE, 'w') as f: - json.dump(metadata, f) - def generate_unique_code(): return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) @@ -71,38 +54,27 @@ def upload_file(): # Get metadata from request max_downloads = int(request.form.get('max_downloads', 1)) expiry_hours = int(request.form.get('expiry_hours', 24)) - expiry_time = datetime.now() + timedelta(hours=expiry_hours) - # Save metadata - metadata = load_metadata() - metadata[code] = { - 'filename': filename, - 'path': file_path, - 'max_downloads': max_downloads, - 'downloads': 0, - 'expiry_time': expiry_time.isoformat() - } - save_metadata(metadata) + # Save metadata to database + create_file(code, filename, file_path, max_downloads, expiry_hours) return jsonify({'code': code}), 200 def download_file(code): - metadata = load_metadata() + file_info = get_file(code) - if code not in metadata: + if not file_info: return render_template('error.html', error_title='Invalid Code', error_message='The code you entered is invalid or incorrect. Please check your code and try again.' ), 404 - file_info = metadata[code] file_path = file_info['path'] expiry_time = datetime.fromisoformat(file_info['expiry_time']) # Check expiry if datetime.now() > expiry_time: - del metadata[code] - save_metadata(metadata) + model_delete_file(code) return render_template('error.html', error_title='File Expired', error_message='This file has expired and is no longer available for download.' @@ -110,31 +82,26 @@ def download_file(code): # Check if file exists if not os.path.exists(file_path): - del metadata[code] - save_metadata(metadata) + model_delete_file(code) return render_template('error.html', error_title='File Not Found', error_message='The requested file could not be found on the server.' ), 404 - # Increment download counter before checking limit - file_info['downloads'] += 1 - + # Increment download counter and get new count + new_downloads = increment_download(code) + # Check if this download puts us over the limit - if file_info['downloads'] > file_info['max_downloads']: + if new_downloads > file_info['max_downloads']: # Clean up the file and metadata if os.path.exists(file_path): os.remove(file_path) - del metadata[code] - save_metadata(metadata) + model_delete_file(code) return render_template('error.html', error_title='Download Limit Reached', error_message='This file has reached its maximum number of downloads and is no longer available.' ), 404 - # Save the updated download count - save_metadata(metadata) - # Serve the file directory = os.path.dirname(file_path) filename = os.path.basename(file_path) @@ -146,52 +113,36 @@ def download_file(code): ) def delete_file(code): - metadata = load_metadata() + file_info = get_file(code) - if code not in metadata: + if not file_info: return jsonify({'error': 'Invalid code'}), 404 - file_info = metadata[code] file_path = file_info['path'] expiry_time = datetime.fromisoformat(file_info['expiry_time']) # Check expiry if datetime.now() > expiry_time: - del metadata[code] - save_metadata(metadata) + model_delete_file(code) return jsonify({'error': 'File already expired'}), 404 # Check if still downloadable if file_info['downloads'] > file_info['max_downloads']: - del metadata[code] - save_metadata(metadata) + model_delete_file(code) return jsonify({'error': 'File no longer available'}), 404 # Delete the file if os.path.exists(file_path): os.remove(file_path) - - # Remove from metadata - del metadata[code] - save_metadata(metadata) + + # Remove from database + model_delete_file(code) log.i(f"File deleted: {code}") - + return jsonify({'message': 'File deleted successfully'}), 200 def cleanup_expired_files(): - metadata = load_metadata() - updated_metadata = {} - - for code, file_info in metadata.items(): - expiry_time = datetime.fromisoformat(file_info['expiry_time']) - if datetime.now() > expiry_time or file_info['downloads'] >= file_info['max_downloads']: - # Remove the file if it exists - if os.path.exists(file_info['path']): - os.remove(file_info['path']) - else: - updated_metadata[code] = file_info - - save_metadata(updated_metadata) + model_cleanup_expired() log.i("Cleanup completed: Expired and overused files removed.") def start_chunked_upload(): @@ -199,29 +150,29 @@ def start_chunked_upload(): filename = request.form.get('filename') if not filename: return jsonify({'error': 'Filename is required'}), 400 - + total_chunks = request.form.get('total_chunks') if not total_chunks or not total_chunks.isdigit(): return jsonify({'error': 'Invalid total_chunks parameter'}), 400 - + # Generate unique upload ID and create chunk directory upload_id = generate_upload_id() chunk_dir = os.path.join(CHUNKS_FOLDER, upload_id) os.makedirs(chunk_dir) - - # Store upload session metadata - metadata = load_chunk_metadata() - metadata[upload_id] = { - 'filename': secure_filename(filename), - 'total_chunks': int(total_chunks), - 'received_chunks': [], - 'created_at': datetime.now().isoformat(), - 'chunk_dir': chunk_dir, - 'max_downloads': int(request.form.get('max_downloads', 1)), - 'expiry_hours': int(request.form.get('expiry_hours', 24)) - } - save_chunk_metadata(metadata) - + + # Store upload session metadata in database + max_downloads = int(request.form.get('max_downloads', 1)) + expiry_hours = int(request.form.get('expiry_hours', 24)) + + create_session( + upload_id, + secure_filename(filename), + int(total_chunks), + chunk_dir, + max_downloads, + expiry_hours + ) + log.i(f"Started chunked upload session: {upload_id}") return jsonify({'upload_id': upload_id}), 200 @@ -230,59 +181,53 @@ def upload_chunk(): upload_id = request.form.get('upload_id') chunk_index = request.form.get('chunk_index') total_chunks = request.form.get('total_chunks') - + if not upload_id or not chunk_index or not chunk_index.isdigit(): log.e(f"Invalid chunk upload parameters: upload_id={upload_id}, chunk_index={chunk_index}") return jsonify({'error': 'Invalid parameters'}), 400 - + chunk_index = int(chunk_index) - + # Get upload session metadata - metadata = load_chunk_metadata() - if upload_id not in metadata: + session = get_session(upload_id) + if not session: log.e(f"Invalid upload session ID: {upload_id}") return jsonify({'error': 'Invalid upload session'}), 404 - - session = metadata[upload_id] - + # Validate chunk index if chunk_index >= session['total_chunks']: log.e(f"Invalid chunk index: {chunk_index}, total_chunks: {session['total_chunks']}") return jsonify({'error': 'Invalid chunk index'}), 400 - + # Handle the chunk file if 'chunk' not in request.files: log.e(f"No chunk file provided for chunk {chunk_index} of upload {upload_id}") return jsonify({'error': 'No chunk file provided'}), 400 - + chunk_file = request.files['chunk'] if chunk_file.filename == '': log.e(f"Empty chunk file for chunk {chunk_index} of upload {upload_id}") return jsonify({'error': 'Empty chunk file'}), 400 - + # Save the chunk chunk_path = os.path.join(session['chunk_dir'], f'chunk_{chunk_index}') chunk_file.save(chunk_path) - + # Verify the chunk was saved successfully if not os.path.exists(chunk_path) or os.path.getsize(chunk_path) == 0: log.e(f"Failed to save chunk {chunk_index} for upload {upload_id}") return jsonify({'error': 'Failed to save chunk'}), 500 - - # Update metadata - if chunk_index not in session['received_chunks']: - session['received_chunks'].append(chunk_index) - log.i(f"Added chunk {chunk_index} to received_chunks list for upload {upload_id}") - else: - log.w(f"Chunk {chunk_index} already in received_chunks list for upload {upload_id}, possible duplicate") - - metadata[upload_id] = session - save_chunk_metadata(metadata) - + + # Update metadata - add chunk to received list + add_chunk(upload_id, chunk_index) + + # Get updated session + session = get_session(upload_id) + # If client sent total_chunks, validate it matches what we have if total_chunks and total_chunks.isdigit() and int(total_chunks) != session['total_chunks']: log.w(f"Client reported total_chunks ({total_chunks}) doesn't match server ({session['total_chunks']})") - + log.i(f"Received chunk {chunk_index} for upload {upload_id}, now have {len(session['received_chunks'])}/{session['total_chunks']}") return jsonify({ 'success': True, @@ -295,14 +240,12 @@ def complete_chunked_upload(): upload_id = request.form.get('upload_id') if not upload_id: return jsonify({'error': 'Upload ID is required'}), 400 - + # Get upload session metadata - metadata = load_chunk_metadata() - if upload_id not in metadata: + session = get_session(upload_id) + if not session: return jsonify({'error': 'Invalid upload session'}), 404 - - session = metadata[upload_id] - + # Verify all chunks are received by checking if every expected chunk index is in received_chunks expected_chunks = set(range(session['total_chunks'])) received_chunks = set(session['received_chunks']) @@ -315,94 +258,64 @@ def complete_chunked_upload(): 'total': session['total_chunks'], 'missing_chunks': missing_chunks }), 400 - + # Combine chunks into final file final_filename = session['filename'] final_path = os.path.join(UPLOAD_FOLDER, final_filename) - + # Verify all chunk files actually exist on disk missing_files = [] for i in range(session['total_chunks']): chunk_path = os.path.join(session['chunk_dir'], f'chunk_{i}') if not os.path.exists(chunk_path): missing_files.append(i) - + if missing_files: log.e(f"Missing chunk files for upload {upload_id} despite being in received_chunks: {missing_files}") return jsonify({ 'error': 'Chunk files missing on server', 'missing_files': missing_files }), 500 - + with open(final_path, 'wb') as outfile: for i in range(session['total_chunks']): chunk_path = os.path.join(session['chunk_dir'], f'chunk_{i}') with open(chunk_path, 'rb') as infile: outfile.write(infile.read()) - + # Clean up chunks - import shutil shutil.rmtree(session['chunk_dir']) - del metadata[upload_id] - save_chunk_metadata(metadata) - + delete_session(upload_id) + # Create regular file metadata code = generate_unique_code() - expiry_time = datetime.now() + timedelta(hours=session['expiry_hours']) - - file_metadata = load_metadata() - file_metadata[code] = { - 'filename': final_filename, - 'path': final_path, - 'max_downloads': session['max_downloads'], - 'downloads': 0, - 'expiry_time': expiry_time.isoformat() - } - save_metadata(file_metadata) - + + create_file(code, final_filename, final_path, session['max_downloads'], session['expiry_hours']) + log.i(f"Completed chunked upload {upload_id}, assigned code {code}") return jsonify({'code': code}), 200 def cleanup_abandoned_uploads(): - metadata = load_chunk_metadata() - current_time = datetime.now() - expired_uploads = [] - - for upload_id, session in metadata.items(): - created_at = datetime.fromisoformat(session['created_at']) - # Remove uploads older than 24 hours - if (current_time - created_at).total_seconds() > 86400: - if os.path.exists(session['chunk_dir']): - shutil.rmtree(session['chunk_dir']) - expired_uploads.append(upload_id) - - for upload_id in expired_uploads: - del metadata[upload_id] - - if expired_uploads: - save_chunk_metadata(metadata) - log.i(f"Cleaned up {len(expired_uploads)} abandoned uploads") + model_cleanup_abandoned() def verify_chunks(): """Verify which chunks have been received for an upload session""" # Get upload ID from request upload_id = request.form.get('upload_id') - + if not upload_id: return jsonify({'error': 'Upload ID is required'}), 400 - + # Get upload session metadata - metadata = load_chunk_metadata() - if upload_id not in metadata: + session = get_session(upload_id) + if not session: return jsonify({'error': 'Invalid upload session'}), 404 - - session = metadata[upload_id] - + # Calculate missing chunks received_chunks = set(session['received_chunks']) all_chunks = set(range(session['total_chunks'])) missing_chunks = list(all_chunks - received_chunks) - + # Check for each chunk file on disk to verify it actually exists disk_missing_chunks = [] for chunk_index in range(session['total_chunks']): @@ -415,18 +328,17 @@ def verify_chunks(): # Remove from received_chunks since the file is missing if chunk_index in session['received_chunks']: session['received_chunks'].remove(chunk_index) - + # Update metadata if any changes were made if len(received_chunks) != len(session['received_chunks']): - metadata[upload_id] = session - save_chunk_metadata(metadata) - + update_received_chunks(upload_id, session['received_chunks']) + # Return missing chunks information log.i(f"Verified chunks for upload {upload_id}: {len(session['received_chunks'])}/{session['total_chunks']} received") - + # Final verification missing_chunks = list(all_chunks - set(session['received_chunks'])) - + return jsonify({ 'total_chunks': session['total_chunks'], 'received_chunks': len(session['received_chunks']), diff --git a/src/models/__init__.py b/src/models/__init__.py index e69de29..22eceab 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -0,0 +1,35 @@ +from .file_metadata import FileMetadata + +# Database initialization +from .database import init_db, get_db_connection, migrate_from_json + +# Admin model functions +from .admin_model import ( + get_admin, + create_admin, + admin_exists as model_admin_exists, + update_last_login +) + +# File model functions +from .file_model import ( + create_file, + get_file, + increment_download, + delete_file as model_delete_file, + get_expired_files, + cleanup_expired, + get_all_files +) + +# Chunk model functions +from .chunk_model import ( + create_session, + get_session, + add_chunk, + get_received_chunks, + delete_session, + get_abandoned_sessions, + cleanup_abandoned, + update_received_chunks +) diff --git a/src/models/admin_model.py b/src/models/admin_model.py new file mode 100644 index 0000000..9a690d5 --- /dev/null +++ b/src/models/admin_model.py @@ -0,0 +1,91 @@ +import sqlite3 +from models.database import get_db +import utils.mLogger as log + +def get_admin(): + """Get admin user data + + Returns: + dict: Admin data with keys: username, password_hash, totp_secret, created_at, last_login + None: If no admin exists + """ + try: + with get_db() as conn: + cursor = conn.cursor() + row = cursor.execute(""" + SELECT username, password_hash, totp_secret, created_at, last_login + FROM admin + LIMIT 1 + """).fetchone() + + if row: + return dict(row) + return None + except sqlite3.Error as e: + log.e(f"Error getting admin data: {e}") + return None + +def create_admin(username, password_hash, totp_secret): + """Create admin user + + Args: + username: Admin username + password_hash: Hashed password + totp_secret: TOTP secret for 2FA + + Returns: + bool: True if successful, False otherwise + """ + try: + with get_db() as conn: + from datetime import datetime + + conn.execute(""" + INSERT INTO admin (username, password_hash, totp_secret, created_at) + VALUES (?, ?, ?, ?) + """, (username, password_hash, totp_secret, datetime.now().isoformat())) + + log.i(f"Admin user created: {username}") + return True + except sqlite3.Error as e: + log.e(f"Error creating admin: {e}") + return False + +def admin_exists(): + """Check if an admin user has been created + + Returns: + bool: True if admin exists, False otherwise + """ + try: + with get_db() as conn: + cursor = conn.cursor() + count = cursor.execute("SELECT COUNT(*) FROM admin").fetchone()[0] + return count > 0 + except sqlite3.Error as e: + log.e(f"Error checking if admin exists: {e}") + return False + +def update_last_login(username): + """Update admin's last login time + + Args: + username: Admin username + + Returns: + bool: True if successful, False otherwise + """ + try: + with get_db() as conn: + from datetime import datetime + + conn.execute(""" + UPDATE admin + SET last_login = ? + WHERE username = ? + """, (datetime.now().isoformat(), username)) + + return True + except sqlite3.Error as e: + log.e(f"Error updating last login: {e}") + return False diff --git a/src/models/chunk_model.py b/src/models/chunk_model.py new file mode 100644 index 0000000..c982019 --- /dev/null +++ b/src/models/chunk_model.py @@ -0,0 +1,262 @@ +import sqlite3 +import json +import os +import shutil +from datetime import datetime, timedelta +from models.database import get_db +import utils.mLogger as log + +def create_session(upload_id, filename, total_chunks, chunk_dir, max_downloads, expiry_hours): + """Create a new chunked upload session + + Args: + upload_id: 16-character unique upload ID + filename: Original filename + total_chunks: Total number of chunks expected + chunk_dir: Directory where chunks are stored + max_downloads: Maximum downloads for final file + expiry_hours: Expiry hours for final file + + Returns: + bool: True if successful, False otherwise + """ + try: + with get_db() as conn: + conn.execute(""" + INSERT INTO chunk_sessions + (upload_id, filename, total_chunks, received_chunks, created_at, + chunk_dir, max_downloads, expiry_hours) + VALUES (?, ?, ?, '[]', ?, ?, ?, ?) + """, (upload_id, filename, total_chunks, datetime.now().isoformat(), + chunk_dir, max_downloads, expiry_hours)) + + log.i(f"Created chunk session: {upload_id}") + return True + except sqlite3.Error as e: + log.e(f"Error creating chunk session: {e}") + return False + +def get_session(upload_id): + """Get chunk upload session data + + Args: + upload_id: 16-character upload ID + + Returns: + dict: Session data with keys: upload_id, filename, total_chunks, received_chunks (list), + created_at, chunk_dir, max_downloads, expiry_hours + None: If session not found + """ + try: + with get_db() as conn: + cursor = conn.cursor() + row = cursor.execute(""" + SELECT upload_id, filename, total_chunks, received_chunks, created_at, + chunk_dir, max_downloads, expiry_hours + FROM chunk_sessions + WHERE upload_id = ? + """, (upload_id,)).fetchone() + + if row: + data = dict(row) + # Parse received_chunks from JSON string to list + data['received_chunks'] = json.loads(data['received_chunks']) + return data + return None + except sqlite3.Error as e: + log.e(f"Error getting chunk session {upload_id}: {e}") + return None + +def add_chunk(upload_id, chunk_index): + """Add a chunk index to the received chunks list + + Args: + upload_id: 16-character upload ID + chunk_index: Index of the chunk received + + Returns: + bool: True if successful, False otherwise + """ + try: + with get_db() as conn: + cursor = conn.cursor() + + # Get current received_chunks + row = cursor.execute(""" + SELECT received_chunks FROM chunk_sessions WHERE upload_id = ? + """, (upload_id,)).fetchone() + + if not row: + log.e(f"Chunk session not found: {upload_id}") + return False + + received = json.loads(row['received_chunks']) + + # Add chunk index if not already present + if chunk_index not in received: + received.append(chunk_index) + + # Update database + cursor.execute(""" + UPDATE chunk_sessions + SET received_chunks = ? + WHERE upload_id = ? + """, (json.dumps(received), upload_id)) + + log.i(f"Added chunk {chunk_index} to session {upload_id}") + return True + else: + log.w(f"Chunk {chunk_index} already in session {upload_id}") + return True + except sqlite3.Error as e: + log.e(f"Error adding chunk to session {upload_id}: {e}") + return False + +def get_received_chunks(upload_id): + """Get list of received chunk indices + + Args: + upload_id: 16-character upload ID + + Returns: + list: List of chunk indices, or None if session not found + """ + try: + with get_db() as conn: + cursor = conn.cursor() + row = cursor.execute(""" + SELECT received_chunks FROM chunk_sessions WHERE upload_id = ? + """, (upload_id,)).fetchone() + + if row: + return json.loads(row['received_chunks']) + return None + except sqlite3.Error as e: + log.e(f"Error getting received chunks for {upload_id}: {e}") + return None + +def delete_session(upload_id): + """Delete chunk upload session + + Args: + upload_id: 16-character upload ID + + Returns: + bool: True if deleted, False if not found or error + """ + try: + with get_db() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM chunk_sessions WHERE upload_id = ?", (upload_id,)) + + if cursor.rowcount > 0: + log.i(f"Deleted chunk session: {upload_id}") + return True + return False + except sqlite3.Error as e: + log.e(f"Error deleting chunk session {upload_id}: {e}") + return False + +def get_abandoned_sessions(hours=24): + """Get list of abandoned upload sessions older than specified hours + + Args: + hours: Age threshold in hours (default: 24) + + Returns: + list: List of dicts with session metadata + """ + try: + with get_db() as conn: + cursor = conn.cursor() + cutoff_time = (datetime.now() - timedelta(hours=hours)).isoformat() + + rows = cursor.execute(""" + SELECT upload_id, filename, total_chunks, received_chunks, created_at, + chunk_dir, max_downloads, expiry_hours + FROM chunk_sessions + WHERE created_at < ? + """, (cutoff_time,)).fetchall() + + sessions = [] + for row in rows: + data = dict(row) + data['received_chunks'] = json.loads(data['received_chunks']) + sessions.append(data) + + return sessions + except sqlite3.Error as e: + log.e(f"Error getting abandoned sessions: {e}") + return [] + +def cleanup_abandoned(hours=24): + """Delete abandoned upload sessions and their chunk directories + + Args: + hours: Age threshold in hours (default: 24) + + Returns: + int: Number of sessions cleaned up + """ + try: + # Get abandoned sessions first so we can clean up directories + abandoned = get_abandoned_sessions(hours) + + if not abandoned: + return 0 + + with get_db() as conn: + cursor = conn.cursor() + cutoff_time = (datetime.now() - timedelta(hours=hours)).isoformat() + + # Delete from database + cursor.execute(""" + DELETE FROM chunk_sessions + WHERE created_at < ? + """, (cutoff_time,)) + + count = cursor.rowcount + + # Delete chunk directories + for session in abandoned: + if os.path.exists(session['chunk_dir']): + try: + shutil.rmtree(session['chunk_dir']) + log.i(f"Deleted chunk directory: {session['chunk_dir']}") + except Exception as e: + log.w(f"Could not delete chunk directory {session['chunk_dir']}: {e}") + + if count > 0: + log.i(f"Cleaned up {count} abandoned upload session(s)") + + return count + except sqlite3.Error as e: + log.e(f"Error cleaning up abandoned sessions: {e}") + return 0 + +def update_received_chunks(upload_id, received_chunks): + """Update the entire received_chunks list (used for verification corrections) + + Args: + upload_id: 16-character upload ID + received_chunks: List of chunk indices + + Returns: + bool: True if successful, False otherwise + """ + try: + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE chunk_sessions + SET received_chunks = ? + WHERE upload_id = ? + """, (json.dumps(received_chunks), upload_id)) + + if cursor.rowcount > 0: + log.i(f"Updated received_chunks for session {upload_id}") + return True + return False + except sqlite3.Error as e: + log.e(f"Error updating received_chunks for {upload_id}: {e}") + return False diff --git a/src/models/database.py b/src/models/database.py new file mode 100644 index 0000000..8b4d779 --- /dev/null +++ b/src/models/database.py @@ -0,0 +1,226 @@ +import sqlite3 +import json +import os +from contextlib import contextmanager +import utils.mLogger as log + +# Database file location (relative to server directory) +DB_DIR = 'db' +DB_PATH = os.path.join(DB_DIR, 'file_server.db') + +# Ensure database directory exists +if not os.path.exists(DB_DIR): + os.makedirs(DB_DIR) + +def get_db_connection(): + """Get a thread-safe database connection with row factory""" + conn = sqlite3.connect(DB_PATH, check_same_thread=False) + conn.row_factory = sqlite3.Row # Access columns by name + + # Enable WAL mode for better concurrency + conn.execute("PRAGMA journal_mode=WAL") + + # Set busy timeout to 5 seconds to handle concurrent access + conn.execute("PRAGMA busy_timeout=5000") + + return conn + +@contextmanager +def get_db(): + """Context manager for database connections""" + conn = get_db_connection() + try: + yield conn + conn.commit() + except Exception as e: + conn.rollback() + log.e(f"Database error: {e}") + raise + finally: + conn.close() + +def init_db(): + """Initialize database with schema""" + log.i("Initializing database...") + + with get_db() as conn: + # Admin table + conn.execute(""" + CREATE TABLE IF NOT EXISTS admin ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + totp_secret TEXT NOT NULL, + created_at TEXT NOT NULL, + last_login TEXT + ) + """) + + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_admin_username ON admin(username) + """) + + # Files table + conn.execute(""" + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + filename TEXT NOT NULL, + path TEXT NOT NULL, + max_downloads INTEGER NOT NULL DEFAULT 1, + downloads INTEGER NOT NULL DEFAULT 0, + expiry_time TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + CONSTRAINT code_length CHECK(length(code) = 6), + CONSTRAINT max_downloads_positive CHECK(max_downloads > 0), + CONSTRAINT downloads_non_negative CHECK(downloads >= 0) + ) + """) + + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_files_code ON files(code) + """) + + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_files_expiry ON files(expiry_time) + """) + + # Chunk sessions table + conn.execute(""" + CREATE TABLE IF NOT EXISTS chunk_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + upload_id TEXT NOT NULL UNIQUE, + filename TEXT NOT NULL, + total_chunks INTEGER NOT NULL, + received_chunks TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + chunk_dir TEXT NOT NULL, + max_downloads INTEGER NOT NULL DEFAULT 1, + expiry_hours INTEGER NOT NULL DEFAULT 24, + CONSTRAINT upload_id_length CHECK(length(upload_id) = 16), + CONSTRAINT total_chunks_positive CHECK(total_chunks > 0) + ) + """) + + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_chunk_sessions_upload_id ON chunk_sessions(upload_id) + """) + + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_chunk_sessions_created_at ON chunk_sessions(created_at) + """) + + log.i("Database initialized successfully") + +def migrate_from_json(): + """One-time migration from JSON files to SQLite""" + + # Check if we need to migrate + admin_json = 'admin_data.json' + file_json = 'file_metadata.json' + chunk_json = 'chunk_metadata.json' + + # Only migrate if at least one JSON file exists + if not any(os.path.exists(f) for f in [admin_json, file_json, chunk_json]): + log.i("No JSON files found, skipping migration") + return + + log.i("Starting migration from JSON to SQLite...") + migrated = False + + with get_db() as conn: + # Migrate admin_data.json + if os.path.exists(admin_json): + try: + with open(admin_json, 'r') as f: + admin = json.load(f) + + # Check if admin already exists in database + existing = conn.execute("SELECT COUNT(*) FROM admin").fetchone()[0] + + if existing == 0: + conn.execute(""" + INSERT INTO admin (username, password_hash, totp_secret, created_at) + VALUES (?, ?, ?, ?) + """, (admin['username'], admin['password_hash'], + admin['totp_secret'], admin['created_at'])) + log.i(f"Migrated admin user: {admin['username']}") + migrated = True + else: + log.i("Admin already exists in database, skipping admin migration") + except Exception as e: + log.e(f"Error migrating admin_data.json: {e}") + + # Migrate file_metadata.json + if os.path.exists(file_json): + try: + with open(file_json, 'r') as f: + files = json.load(f) + + file_count = 0 + for code, file_info in files.items(): + # Check if file already exists + existing = conn.execute("SELECT COUNT(*) FROM files WHERE code = ?", (code,)).fetchone()[0] + + if existing == 0: + conn.execute(""" + INSERT INTO files (code, filename, path, max_downloads, downloads, expiry_time) + VALUES (?, ?, ?, ?, ?, ?) + """, (code, file_info['filename'], file_info['path'], + file_info['max_downloads'], file_info['downloads'], + file_info['expiry_time'])) + file_count += 1 + + if file_count > 0: + log.i(f"Migrated {file_count} file(s)") + migrated = True + else: + log.i("No new files to migrate") + except Exception as e: + log.e(f"Error migrating file_metadata.json: {e}") + + # Migrate chunk_metadata.json + if os.path.exists(chunk_json): + try: + with open(chunk_json, 'r') as f: + chunks = json.load(f) + + chunk_count = 0 + for upload_id, session in chunks.items(): + # Check if session already exists + existing = conn.execute("SELECT COUNT(*) FROM chunk_sessions WHERE upload_id = ?", (upload_id,)).fetchone()[0] + + if existing == 0: + conn.execute(""" + INSERT INTO chunk_sessions + (upload_id, filename, total_chunks, received_chunks, created_at, + chunk_dir, max_downloads, expiry_hours) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (upload_id, session['filename'], session['total_chunks'], + json.dumps(session['received_chunks']), session['created_at'], + session['chunk_dir'], session['max_downloads'], + session['expiry_hours'])) + chunk_count += 1 + + if chunk_count > 0: + log.i(f"Migrated {chunk_count} chunk session(s)") + migrated = True + else: + log.i("No new chunk sessions to migrate") + except Exception as e: + log.e(f"Error migrating chunk_metadata.json: {e}") + + # Backup JSON files after successful migration + if migrated: + for filename in [admin_json, file_json, chunk_json]: + if os.path.exists(filename): + backup_name = f'{filename}.backup' + try: + os.rename(filename, backup_name) + log.i(f"Backed up {filename} to {backup_name}") + except Exception as e: + log.w(f"Could not backup {filename}: {e}") + + log.i("Migration completed successfully") + else: + log.i("No migration performed") diff --git a/src/models/file_model.py b/src/models/file_model.py new file mode 100644 index 0000000..71a8e67 --- /dev/null +++ b/src/models/file_model.py @@ -0,0 +1,199 @@ +import sqlite3 +import os +from datetime import datetime, timedelta +from models.database import get_db +import utils.mLogger as log + +def create_file(code, filename, path, max_downloads, expiry_hours): + """Create a new file metadata record + + Args: + code: 6-character unique code + filename: Original filename + path: Absolute path to file + max_downloads: Maximum number of downloads allowed + expiry_hours: Hours until file expires + + Returns: + bool: True if successful, False otherwise + """ + try: + with get_db() as conn: + expiry_time = datetime.now() + timedelta(hours=expiry_hours) + + conn.execute(""" + INSERT INTO files (code, filename, path, max_downloads, downloads, expiry_time) + VALUES (?, ?, ?, ?, 0, ?) + """, (code, filename, path, max_downloads, expiry_time.isoformat())) + + log.i(f"Created file record: {code} - {filename}") + return True + except sqlite3.Error as e: + log.e(f"Error creating file record: {e}") + return False + +def get_file(code): + """Get file metadata by code + + Args: + code: 6-character file code + + Returns: + dict: File metadata with keys: code, filename, path, max_downloads, downloads, expiry_time + None: If file not found + """ + try: + with get_db() as conn: + cursor = conn.cursor() + row = cursor.execute(""" + SELECT code, filename, path, max_downloads, downloads, expiry_time + FROM files + WHERE code = ? + """, (code,)).fetchone() + + if row: + return dict(row) + return None + except sqlite3.Error as e: + log.e(f"Error getting file {code}: {e}") + return None + +def increment_download(code): + """Atomically increment download counter for a file + + Args: + code: 6-character file code + + Returns: + int: New download count, or None if file not found + """ + try: + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE files + SET downloads = downloads + 1 + WHERE code = ? + """, (code,)) + + if cursor.rowcount > 0: + # Get the new download count + row = cursor.execute(""" + SELECT downloads FROM files WHERE code = ? + """, (code,)).fetchone() + + if row: + new_count = row['downloads'] + log.i(f"Incremented download counter for {code}: {new_count}") + return new_count + + return None + except sqlite3.Error as e: + log.e(f"Error incrementing download for {code}: {e}") + return None + +def delete_file(code): + """Delete file metadata record + + Args: + code: 6-character file code + + Returns: + bool: True if deleted, False if not found or error + """ + try: + with get_db() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM files WHERE code = ?", (code,)) + + if cursor.rowcount > 0: + log.i(f"Deleted file record: {code}") + return True + return False + except sqlite3.Error as e: + log.e(f"Error deleting file {code}: {e}") + return False + +def get_expired_files(): + """Get list of expired files or files that exceeded download limit + + Returns: + list: List of dicts with file metadata + """ + try: + with get_db() as conn: + cursor = conn.cursor() + now = datetime.now().isoformat() + + rows = cursor.execute(""" + SELECT code, filename, path, max_downloads, downloads, expiry_time + FROM files + WHERE expiry_time < ? OR downloads >= max_downloads + """, (now,)).fetchall() + + return [dict(row) for row in rows] + except sqlite3.Error as e: + log.e(f"Error getting expired files: {e}") + return [] + +def cleanup_expired(): + """Delete expired file records and files that reached download limit + + Returns: + int: Number of files cleaned up + """ + try: + # Get expired files first so we can delete physical files + expired_files = get_expired_files() + + if not expired_files: + return 0 + + with get_db() as conn: + cursor = conn.cursor() + now = datetime.now().isoformat() + + # Delete from database + cursor.execute(""" + DELETE FROM files + WHERE expiry_time < ? OR downloads >= max_downloads + """, (now,)) + + count = cursor.rowcount + + # Delete physical files + for file_info in expired_files: + if os.path.exists(file_info['path']): + try: + os.remove(file_info['path']) + log.i(f"Deleted expired file: {file_info['path']}") + except Exception as e: + log.w(f"Could not delete file {file_info['path']}: {e}") + + if count > 0: + log.i(f"Cleaned up {count} expired file(s)") + + return count + except sqlite3.Error as e: + log.e(f"Error cleaning up expired files: {e}") + return 0 + +def get_all_files(): + """Get all file metadata records + + Returns: + list: List of dicts with file metadata + """ + try: + with get_db() as conn: + cursor = conn.cursor() + rows = cursor.execute(""" + SELECT code, filename, path, max_downloads, downloads, expiry_time, created_at + FROM files + ORDER BY created_at DESC + """).fetchall() + + return [dict(row) for row in rows] + except sqlite3.Error as e: + log.e(f"Error getting all files: {e}") + return [] diff --git a/src/templates/admin_dashboard.html b/src/templates/admin_dashboard.html new file mode 100644 index 0000000..ef357c2 --- /dev/null +++ b/src/templates/admin_dashboard.html @@ -0,0 +1,312 @@ + + + + + + Admin Dashboard - Simple File Server + + + + + +
+
+
+ dashboard +
+

Admin Dashboard

+
Logged in as {{ username }}
+
+
+ + logout + Logout + +
+ +
+ check_circle + Client key copied to clipboard! +
+ +
+
+ vpn_key + Client Key +
+
+
{{ client_key }}
+ +
+
+ +
+ info +
+ What is the Client Key?
+ This key is used by clients to authenticate API requests to the file server. + It should be included in the X-Client-Key header for all upload operations. + Keep this key secure and don't share it publicly. +
+
+ + +
+ + diff --git a/src/templates/admin_login.html b/src/templates/admin_login.html new file mode 100644 index 0000000..5dfff6e --- /dev/null +++ b/src/templates/admin_login.html @@ -0,0 +1,242 @@ + + + + + + Admin Login - Simple File Server + + + + + +
+
+ security +

Admin Login

+

Enter your credentials

+
+ + {% if error %} +
+ error + {{ error }} +
+ {% endif %} + +
+
+ + +
+ +
+ + +
+ +
+ + +
Enter the 6-digit code from your authenticator app
+
+ + +
+ + +
+ + diff --git a/src/templates/admin_setup.html b/src/templates/admin_setup.html new file mode 100644 index 0000000..f865eb8 --- /dev/null +++ b/src/templates/admin_setup.html @@ -0,0 +1,198 @@ + + + + + + Admin Setup - Simple File Server + + + + +
+
+ admin_panel_settings +

Admin Setup

+

Create your administrator account

+
+ +
+ Step 1 of 2: Create your admin credentials. You'll configure two-factor authentication on the next step. +
+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ + +
+ +
+ + +
Minimum 8 characters
+
+ +
+ + +
+ + +
+
+ + diff --git a/src/templates/admin_setup_complete.html b/src/templates/admin_setup_complete.html new file mode 100644 index 0000000..c5b3355 --- /dev/null +++ b/src/templates/admin_setup_complete.html @@ -0,0 +1,199 @@ + + + + + + Setup Complete - Simple File Server + + + + +
+
+ check_circle +

Setup Complete!

+

Your admin account has been created

+
+ +
+
+

+ Welcome, {{ username }}! +

+

+ Your administrator account has been successfully created and your authenticator app is configured. + You can now login to access the admin dashboard. +

+
+
+ +
+ info +
+ Important: Make sure you've saved your authenticator app configuration. + You'll need it every time you log in to the admin panel. +
+
+ + +
+ + diff --git a/src/templates/admin_setup_totp.html b/src/templates/admin_setup_totp.html new file mode 100644 index 0000000..798bd7b --- /dev/null +++ b/src/templates/admin_setup_totp.html @@ -0,0 +1,342 @@ + + + + + + Setup Two-Factor Authentication - Simple File Server + + + + +
+
+ security +

Two-Factor Authentication

+

Secure your admin account

+
+ +
+ Step 2 of 2: Configure your authenticator app +
+ +
+ info +
+ Required: You need an authenticator app to continue. + Download Google Authenticator, Authy, or any TOTP-compatible app before proceeding. +
+
+ + {% if error %} +
+ error + {{ error }} +
+ {% endif %} + +
+
+ qr_code_scanner + Scan QR Code +
+ +
+ TOTP QR Code +
+ +
+
+ vpn_key + Or Enter Manually +
+
Secret Key:
+
{{ totp_secret }}
+ +
+
+ +
+
+ + +
Enter the 6-digit code from your authenticator app
+
+ + +
+
+ +