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
This commit is contained in:
2025-12-27 14:00:39 -05:00
parent 8eac7b90c6
commit 872ae74668
14 changed files with 2442 additions and 175 deletions

3
.gitignore vendored
View File

@@ -3,6 +3,9 @@ tmp/
uploads/ uploads/
file_metadata.json file_metadata.json
chunk_metadata.json chunk_metadata.json
admin_data.json
*.backup
db/
.env .env
# Python # Python

View File

@@ -1,5 +1,6 @@
import os import os
from functools import wraps from functools import wraps
from datetime import timedelta
from flask import Flask, request, jsonify, send_from_directory from flask import Flask, request, jsonify, send_from_directory
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
import utils.mLogger as log import utils.mLogger as log
@@ -8,15 +9,26 @@ from controllers.file_controller import (
start_chunked_upload, upload_chunk, verify_chunks, complete_chunked_upload, start_chunked_upload, upload_chunk, verify_chunks, complete_chunked_upload,
cleanup_expired_files, cleanup_abandoned_uploads 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 views import views
from models import init_db, migrate_from_json
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['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']): if not os.path.exists(app.config['UPLOAD_FOLDER']):
os.makedirs(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 # Run cleanup on startup
cleanup_expired_files() cleanup_expired_files()
cleanup_abandoned_uploads() cleanup_abandoned_uploads()
@@ -53,6 +65,13 @@ app.route('/download/<code>', methods=['GET'])(download_file)
# Route for deleting the file # Route for deleting the file
app.route('/delete/<code>', methods=['DELETE'])(delete_file) app.route('/delete/<code>', 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 # Register frontend views
app.register_blueprint(views) app.register_blueprint(views)

View File

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

View File

@@ -1,21 +1,24 @@
import os import os
import random import random
import string import string
import json
import shutil import shutil
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import request, jsonify, send_from_directory, render_template from flask import request, jsonify, send_from_directory, render_template
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
import utils.mLogger as log 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 # Make UPLOAD_FOLDER absolute
UPLOAD_FOLDER = os.path.abspath('tmp/uploads') UPLOAD_FOLDER = os.path.abspath('tmp/uploads')
METADATA_FILE = 'file_metadata.json'
# Constants for chunked uploads # Constants for chunked uploads
CHUNKS_FOLDER = os.path.join(UPLOAD_FOLDER, 'chunks') CHUNKS_FOLDER = os.path.join(UPLOAD_FOLDER, 'chunks')
CHUNK_METADATA_FILE = 'chunk_metadata.json'
if not os.path.exists(UPLOAD_FOLDER): if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER) os.makedirs(UPLOAD_FOLDER)
@@ -24,26 +27,6 @@ if not os.path.exists(UPLOAD_FOLDER):
if not os.path.exists(CHUNKS_FOLDER): if not os.path.exists(CHUNKS_FOLDER):
os.makedirs(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(): def generate_unique_code():
return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
@@ -71,38 +54,27 @@ def upload_file():
# Get metadata from request # Get metadata from request
max_downloads = int(request.form.get('max_downloads', 1)) max_downloads = int(request.form.get('max_downloads', 1))
expiry_hours = int(request.form.get('expiry_hours', 24)) expiry_hours = int(request.form.get('expiry_hours', 24))
expiry_time = datetime.now() + timedelta(hours=expiry_hours)
# Save metadata # Save metadata to database
metadata = load_metadata() create_file(code, filename, file_path, max_downloads, expiry_hours)
metadata[code] = {
'filename': filename,
'path': file_path,
'max_downloads': max_downloads,
'downloads': 0,
'expiry_time': expiry_time.isoformat()
}
save_metadata(metadata)
return jsonify({'code': code}), 200 return jsonify({'code': code}), 200
def download_file(code): 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', return render_template('error.html',
error_title='Invalid Code', error_title='Invalid Code',
error_message='The code you entered is invalid or incorrect. Please check your code and try again.' error_message='The code you entered is invalid or incorrect. Please check your code and try again.'
), 404 ), 404
file_info = metadata[code]
file_path = file_info['path'] file_path = file_info['path']
expiry_time = datetime.fromisoformat(file_info['expiry_time']) expiry_time = datetime.fromisoformat(file_info['expiry_time'])
# Check expiry # Check expiry
if datetime.now() > expiry_time: if datetime.now() > expiry_time:
del metadata[code] model_delete_file(code)
save_metadata(metadata)
return render_template('error.html', return render_template('error.html',
error_title='File Expired', error_title='File Expired',
error_message='This file has expired and is no longer available for download.' 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 # Check if file exists
if not os.path.exists(file_path): if not os.path.exists(file_path):
del metadata[code] model_delete_file(code)
save_metadata(metadata)
return render_template('error.html', return render_template('error.html',
error_title='File Not Found', error_title='File Not Found',
error_message='The requested file could not be found on the server.' error_message='The requested file could not be found on the server.'
), 404 ), 404
# Increment download counter before checking limit # Increment download counter and get new count
file_info['downloads'] += 1 new_downloads = increment_download(code)
# Check if this download puts us over the limit # 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 # Clean up the file and metadata
if os.path.exists(file_path): if os.path.exists(file_path):
os.remove(file_path) os.remove(file_path)
del metadata[code] model_delete_file(code)
save_metadata(metadata)
return render_template('error.html', return render_template('error.html',
error_title='Download Limit Reached', error_title='Download Limit Reached',
error_message='This file has reached its maximum number of downloads and is no longer available.' error_message='This file has reached its maximum number of downloads and is no longer available.'
), 404 ), 404
# Save the updated download count
save_metadata(metadata)
# Serve the file # Serve the file
directory = os.path.dirname(file_path) directory = os.path.dirname(file_path)
filename = os.path.basename(file_path) filename = os.path.basename(file_path)
@@ -146,52 +113,36 @@ def download_file(code):
) )
def delete_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 return jsonify({'error': 'Invalid code'}), 404
file_info = metadata[code]
file_path = file_info['path'] file_path = file_info['path']
expiry_time = datetime.fromisoformat(file_info['expiry_time']) expiry_time = datetime.fromisoformat(file_info['expiry_time'])
# Check expiry # Check expiry
if datetime.now() > expiry_time: if datetime.now() > expiry_time:
del metadata[code] model_delete_file(code)
save_metadata(metadata)
return jsonify({'error': 'File already expired'}), 404 return jsonify({'error': 'File already expired'}), 404
# Check if still downloadable # Check if still downloadable
if file_info['downloads'] > file_info['max_downloads']: if file_info['downloads'] > file_info['max_downloads']:
del metadata[code] model_delete_file(code)
save_metadata(metadata)
return jsonify({'error': 'File no longer available'}), 404 return jsonify({'error': 'File no longer available'}), 404
# Delete the file # Delete the file
if os.path.exists(file_path): if os.path.exists(file_path):
os.remove(file_path) os.remove(file_path)
# Remove from metadata # Remove from database
del metadata[code] model_delete_file(code)
save_metadata(metadata)
log.i(f"File deleted: {code}") log.i(f"File deleted: {code}")
return jsonify({'message': 'File deleted successfully'}), 200 return jsonify({'message': 'File deleted successfully'}), 200
def cleanup_expired_files(): def cleanup_expired_files():
metadata = load_metadata() model_cleanup_expired()
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)
log.i("Cleanup completed: Expired and overused files removed.") log.i("Cleanup completed: Expired and overused files removed.")
def start_chunked_upload(): def start_chunked_upload():
@@ -209,18 +160,18 @@ def start_chunked_upload():
chunk_dir = os.path.join(CHUNKS_FOLDER, upload_id) chunk_dir = os.path.join(CHUNKS_FOLDER, upload_id)
os.makedirs(chunk_dir) os.makedirs(chunk_dir)
# Store upload session metadata # Store upload session metadata in database
metadata = load_chunk_metadata() max_downloads = int(request.form.get('max_downloads', 1))
metadata[upload_id] = { expiry_hours = int(request.form.get('expiry_hours', 24))
'filename': secure_filename(filename),
'total_chunks': int(total_chunks), create_session(
'received_chunks': [], upload_id,
'created_at': datetime.now().isoformat(), secure_filename(filename),
'chunk_dir': chunk_dir, int(total_chunks),
'max_downloads': int(request.form.get('max_downloads', 1)), chunk_dir,
'expiry_hours': int(request.form.get('expiry_hours', 24)) max_downloads,
} expiry_hours
save_chunk_metadata(metadata) )
log.i(f"Started chunked upload session: {upload_id}") log.i(f"Started chunked upload session: {upload_id}")
return jsonify({'upload_id': upload_id}), 200 return jsonify({'upload_id': upload_id}), 200
@@ -238,13 +189,11 @@ def upload_chunk():
chunk_index = int(chunk_index) chunk_index = int(chunk_index)
# Get upload session metadata # Get upload session metadata
metadata = load_chunk_metadata() session = get_session(upload_id)
if upload_id not in metadata: if not session:
log.e(f"Invalid upload session ID: {upload_id}") log.e(f"Invalid upload session ID: {upload_id}")
return jsonify({'error': 'Invalid upload session'}), 404 return jsonify({'error': 'Invalid upload session'}), 404
session = metadata[upload_id]
# Validate chunk index # Validate chunk index
if chunk_index >= session['total_chunks']: if chunk_index >= session['total_chunks']:
log.e(f"Invalid chunk index: {chunk_index}, total_chunks: {session['total_chunks']}") log.e(f"Invalid chunk index: {chunk_index}, total_chunks: {session['total_chunks']}")
@@ -269,15 +218,11 @@ def upload_chunk():
log.e(f"Failed to save chunk {chunk_index} for upload {upload_id}") log.e(f"Failed to save chunk {chunk_index} for upload {upload_id}")
return jsonify({'error': 'Failed to save chunk'}), 500 return jsonify({'error': 'Failed to save chunk'}), 500
# Update metadata # Update metadata - add chunk to received list
if chunk_index not in session['received_chunks']: add_chunk(upload_id, chunk_index)
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 # Get updated session
save_chunk_metadata(metadata) session = get_session(upload_id)
# If client sent total_chunks, validate it matches what we have # 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']: if total_chunks and total_chunks.isdigit() and int(total_chunks) != session['total_chunks']:
@@ -297,12 +242,10 @@ def complete_chunked_upload():
return jsonify({'error': 'Upload ID is required'}), 400 return jsonify({'error': 'Upload ID is required'}), 400
# Get upload session metadata # Get upload session metadata
metadata = load_chunk_metadata() session = get_session(upload_id)
if upload_id not in metadata: if not session:
return jsonify({'error': 'Invalid upload session'}), 404 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 # Verify all chunks are received by checking if every expected chunk index is in received_chunks
expected_chunks = set(range(session['total_chunks'])) expected_chunks = set(range(session['total_chunks']))
received_chunks = set(session['received_chunks']) received_chunks = set(session['received_chunks'])
@@ -341,47 +284,19 @@ def complete_chunked_upload():
outfile.write(infile.read()) outfile.write(infile.read())
# Clean up chunks # Clean up chunks
import shutil
shutil.rmtree(session['chunk_dir']) shutil.rmtree(session['chunk_dir'])
del metadata[upload_id] delete_session(upload_id)
save_chunk_metadata(metadata)
# Create regular file metadata # Create regular file metadata
code = generate_unique_code() code = generate_unique_code()
expiry_time = datetime.now() + timedelta(hours=session['expiry_hours'])
file_metadata = load_metadata() create_file(code, final_filename, final_path, session['max_downloads'], session['expiry_hours'])
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)
log.i(f"Completed chunked upload {upload_id}, assigned code {code}") log.i(f"Completed chunked upload {upload_id}, assigned code {code}")
return jsonify({'code': code}), 200 return jsonify({'code': code}), 200
def cleanup_abandoned_uploads(): def cleanup_abandoned_uploads():
metadata = load_chunk_metadata() model_cleanup_abandoned()
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")
def verify_chunks(): def verify_chunks():
"""Verify which chunks have been received for an upload session""" """Verify which chunks have been received for an upload session"""
@@ -392,12 +307,10 @@ def verify_chunks():
return jsonify({'error': 'Upload ID is required'}), 400 return jsonify({'error': 'Upload ID is required'}), 400
# Get upload session metadata # Get upload session metadata
metadata = load_chunk_metadata() session = get_session(upload_id)
if upload_id not in metadata: if not session:
return jsonify({'error': 'Invalid upload session'}), 404 return jsonify({'error': 'Invalid upload session'}), 404
session = metadata[upload_id]
# Calculate missing chunks # Calculate missing chunks
received_chunks = set(session['received_chunks']) received_chunks = set(session['received_chunks'])
all_chunks = set(range(session['total_chunks'])) all_chunks = set(range(session['total_chunks']))
@@ -418,8 +331,7 @@ def verify_chunks():
# Update metadata if any changes were made # Update metadata if any changes were made
if len(received_chunks) != len(session['received_chunks']): if len(received_chunks) != len(session['received_chunks']):
metadata[upload_id] = session update_received_chunks(upload_id, session['received_chunks'])
save_chunk_metadata(metadata)
# Return missing chunks information # Return missing chunks information
log.i(f"Verified chunks for upload {upload_id}: {len(session['received_chunks'])}/{session['total_chunks']} received") log.i(f"Verified chunks for upload {upload_id}: {len(session['received_chunks'])}/{session['total_chunks']} received")

View File

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

91
src/models/admin_model.py Normal file
View File

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

262
src/models/chunk_model.py Normal file
View File

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

226
src/models/database.py Normal file
View File

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

199
src/models/file_model.py Normal file
View File

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

View File

@@ -0,0 +1,312 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard - Simple File Server</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
color: #2d3748;
padding: 2rem 1rem;
}
.container {
background: rgba(255, 255, 255, 0.95);
padding: 2.5rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 600px;
backdrop-filter: blur(10px);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 2px solid #e2e8f0;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.icon {
color: #667eea;
font-size: 2.5rem;
}
.header-text h1 {
color: #2d3748;
font-size: 1.75rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.header-text .username {
color: #718096;
font-size: 0.9rem;
}
.logout-btn {
background: transparent;
color: #718096;
padding: 0.5rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.logout-btn:hover {
border-color: #e53e3e;
color: #e53e3e;
background: #fff5f5;
}
.card {
background: #f7fafc;
padding: 1.5rem;
border-radius: 0.75rem;
margin-bottom: 1.5rem;
border-left: 4px solid #667eea;
}
.card-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: #2d3748;
margin-bottom: 1rem;
font-size: 1.1rem;
}
.card-title .material-icons {
color: #667eea;
font-size: 1.5rem;
}
.key-container {
background: white;
padding: 1rem;
border-radius: 0.5rem;
border: 2px solid #e2e8f0;
position: relative;
}
.key-display {
font-family: 'Courier New', monospace;
font-size: 0.95rem;
word-break: break-all;
color: #2d3748;
padding-right: 3rem;
line-height: 1.6;
}
.copy-btn {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: #667eea;
color: white;
border: none;
border-radius: 0.375rem;
padding: 0.5rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.copy-btn:hover {
background: #5a67d8;
transform: scale(1.05);
}
.copy-btn:active {
transform: scale(0.95);
}
.copy-btn .material-icons {
font-size: 1.25rem;
}
.info-box {
background: #ebf8ff;
color: #2c5282;
padding: 1rem;
border-radius: 0.5rem;
border-left: 4px solid #4299e1;
font-size: 0.85rem;
line-height: 1.6;
display: flex;
gap: 0.75rem;
}
.info-box .material-icons {
color: #4299e1;
font-size: 1.5rem;
flex-shrink: 0;
}
.success-message {
background: #f0fff4;
color: #2f855a;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
display: none;
align-items: center;
gap: 0.5rem;
animation: slideIn 0.3s ease;
}
.success-message .material-icons {
color: #48bb78;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
.btn {
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;
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: white;
color: #667eea;
border: 2px solid #667eea;
}
.btn-secondary:hover {
background: #f7fafc;
}
</style>
<script>
function copyToClipboard() {
const keyText = document.querySelector('.key-display').textContent;
navigator.clipboard.writeText(keyText).then(() => {
const successMsg = document.querySelector('.success-message');
successMsg.style.display = 'flex';
setTimeout(() => {
successMsg.style.display = 'none';
}, 3000);
});
}
</script>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-left">
<span class="material-icons icon">dashboard</span>
<div class="header-text">
<h1>Admin Dashboard</h1>
<div class="username">Logged in as {{ username }}</div>
</div>
</div>
<a href="/admin/logout" class="logout-btn">
<span class="material-icons" style="font-size: 1.25rem;">logout</span>
Logout
</a>
</div>
<div class="success-message">
<span class="material-icons">check_circle</span>
Client key copied to clipboard!
</div>
<div class="card">
<div class="card-title">
<span class="material-icons">vpn_key</span>
Client Key
</div>
<div class="key-container">
<div class="key-display">{{ client_key }}</div>
<button class="copy-btn" onclick="copyToClipboard()" title="Copy to clipboard">
<span class="material-icons">content_copy</span>
</button>
</div>
</div>
<div class="info-box">
<span class="material-icons">info</span>
<div>
<strong>What is the Client Key?</strong><br>
This key is used by clients to authenticate API requests to the file server.
It should be included in the <code>X-Client-Key</code> header for all upload operations.
Keep this key secure and don't share it publicly.
</div>
</div>
<div class="actions">
<a href="/" class="btn btn-secondary">
<span class="material-icons">home</span>
Home
</a>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - Simple File Server</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
color: #2d3748;
}
.container {
background: rgba(255, 255, 255, 0.95);
padding: 2.5rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 450px;
backdrop-filter: blur(10px);
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.icon {
color: #667eea;
font-size: 3rem;
margin-bottom: 1rem;
}
h1 {
color: #2d3748;
font-size: 1.75rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.subtitle {
color: #718096;
font-size: 0.9rem;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
color: #4a5568;
margin-bottom: 0.5rem;
}
label .material-icons {
font-size: 1.2rem;
color: #667eea;
}
input {
width: 100%;
padding: 0.75rem;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 1rem;
transition: all 0.2s;
background: white;
}
input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.totp-input {
text-align: center;
font-size: 1.5rem;
letter-spacing: 0.5rem;
font-family: 'Courier New', monospace;
}
button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.875rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
button:active {
transform: translateY(0);
}
.error {
background-color: #fff5f5;
color: #c53030;
padding: 0.75rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #feb2b2;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.error .material-icons {
color: #c53030;
}
.totp-hint {
font-size: 0.8rem;
color: #718096;
margin-top: 0.25rem;
text-align: center;
}
.back-link {
text-align: center;
margin-top: 1rem;
}
.back-link a {
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.back-link a:hover {
text-decoration: underline;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
const totpInput = document.querySelector('.totp-input');
// Auto-submit on 6 digits
totpInput.addEventListener('input', (e) => {
e.target.value = e.target.value.replace(/\D/g, '');
if (e.target.value.length === 6) {
// Optional: auto-submit
// document.querySelector('form').submit();
}
});
});
</script>
</head>
<body>
<div class="container">
<div class="header">
<span class="material-icons icon">security</span>
<h1>Admin Login</h1>
<p class="subtitle">Enter your credentials</p>
</div>
{% if error %}
<div class="error">
<span class="material-icons">error</span>
<span>{{ error }}</span>
</div>
{% endif %}
<form method="POST">
<div class="form-group">
<label>
<span class="material-icons">person</span>
Username
</label>
<input type="text" name="username" required autocomplete="username" autofocus>
</div>
<div class="form-group">
<label>
<span class="material-icons">lock</span>
Password
</label>
<input type="password" name="password" required autocomplete="current-password">
</div>
<div class="form-group">
<label>
<span class="material-icons">pin</span>
Two-Factor Code
</label>
<input type="text" name="totp_code" class="totp-input" required
maxlength="6" pattern="[0-9]{6}" inputmode="numeric"
autocomplete="one-time-code" placeholder="000000">
<div class="totp-hint">Enter the 6-digit code from your authenticator app</div>
</div>
<button type="submit">
<span class="material-icons">login</span>
Login
</button>
</form>
<div class="back-link">
<a href="/">
<span class="material-icons" style="font-size: 1rem;">home</span>
Back to Home
</a>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Setup - Simple File Server</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
color: #2d3748;
}
.container {
background: rgba(255, 255, 255, 0.95);
padding: 2.5rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 450px;
backdrop-filter: blur(10px);
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.icon {
color: #667eea;
font-size: 3rem;
margin-bottom: 1rem;
}
h1 {
color: #2d3748;
font-size: 1.75rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.subtitle {
color: #718096;
font-size: 0.9rem;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
color: #4a5568;
margin-bottom: 0.5rem;
}
label .material-icons {
font-size: 1.2rem;
color: #667eea;
}
input {
width: 100%;
padding: 0.75rem;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 1rem;
transition: all 0.2s;
background: white;
}
input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.875rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
button:active {
transform: translateY(0);
}
.error {
background-color: #fff5f5;
color: #c53030;
padding: 0.75rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #feb2b2;
font-size: 0.9rem;
}
.info-box {
background-color: #ebf4ff;
color: #2c5282;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
border-left: 4px solid #4299e1;
font-size: 0.85rem;
line-height: 1.5;
}
.password-hint {
font-size: 0.8rem;
color: #718096;
margin-top: 0.25rem;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<span class="material-icons icon">admin_panel_settings</span>
<h1>Admin Setup</h1>
<p class="subtitle">Create your administrator account</p>
</div>
<div class="info-box">
Step 1 of 2: Create your admin credentials. You'll configure two-factor authentication on the next step.
</div>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="POST">
<div class="form-group">
<label>
<span class="material-icons">person</span>
Username
</label>
<input type="text" name="username" required autocomplete="username" autofocus>
</div>
<div class="form-group">
<label>
<span class="material-icons">lock</span>
Password
</label>
<input type="password" name="password" required autocomplete="new-password" minlength="8">
<div class="password-hint">Minimum 8 characters</div>
</div>
<div class="form-group">
<label>
<span class="material-icons">lock</span>
Confirm Password
</label>
<input type="password" name="confirm_password" required autocomplete="new-password" minlength="8">
</div>
<button type="submit">
<span class="material-icons">arrow_forward</span>
Continue to Two-Factor Setup
</button>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Setup Complete - Simple File Server</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
color: #2d3748;
}
.container {
background: rgba(255, 255, 255, 0.95);
padding: 2.5rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 500px;
backdrop-filter: blur(10px);
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.icon {
color: #48bb78;
font-size: 3.5rem;
margin-bottom: 1rem;
}
h1 {
color: #2d3748;
font-size: 1.75rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.subtitle {
color: #718096;
font-size: 0.9rem;
}
.step {
background: #f7fafc;
padding: 1.5rem;
border-radius: 0.75rem;
margin-bottom: 1.5rem;
border-left: 4px solid #667eea;
}
.step-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: #2d3748;
margin-bottom: 1rem;
font-size: 1.1rem;
}
.step-title .material-icons {
color: #667eea;
font-size: 1.5rem;
}
.qr-container {
text-align: center;
margin: 1rem 0;
background: white;
padding: 1rem;
border-radius: 0.5rem;
}
.qr-code {
max-width: 200px;
height: auto;
}
.secret-code {
background: white;
padding: 1rem;
border-radius: 0.5rem;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
word-break: break-all;
border: 2px dashed #cbd5e0;
margin: 0.5rem 0;
text-align: center;
font-weight: bold;
color: #2d3748;
}
.warning {
background-color: #fffaf0;
color: #744210;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
border-left: 4px solid #ed8936;
font-size: 0.85rem;
display: flex;
align-items: start;
gap: 0.75rem;
}
.warning .material-icons {
color: #ed8936;
font-size: 1.5rem;
}
button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.875rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.step-content {
color: #4a5568;
line-height: 1.6;
}
ol {
margin-left: 1.25rem;
margin-top: 0.5rem;
}
li {
margin-bottom: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<span class="material-icons icon">check_circle</span>
<h1>Setup Complete!</h1>
<p class="subtitle">Your admin account has been created</p>
</div>
<div class="step">
<div class="step-content" style="text-align: center;">
<p style="font-size: 1.1rem; color: #2d3748; margin-bottom: 1rem;">
Welcome, <strong>{{ username }}</strong>!
</p>
<p style="color: #4a5568; line-height: 1.6;">
Your administrator account has been successfully created and your authenticator app is configured.
You can now login to access the admin dashboard.
</p>
</div>
</div>
<div class="warning">
<span class="material-icons">info</span>
<div>
<strong>Important:</strong> Make sure you've saved your authenticator app configuration.
You'll need it every time you log in to the admin panel.
</div>
</div>
<button onclick="window.location.href='/admin/login'">
<span class="material-icons">login</span>
Continue to Login
</button>
</div>
</body>
</html>

View File

@@ -0,0 +1,342 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Setup Two-Factor Authentication - Simple File Server</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
color: #2d3748;
padding: 2rem 1rem;
}
.container {
background: rgba(255, 255, 255, 0.95);
padding: 2.5rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 550px;
backdrop-filter: blur(10px);
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.icon {
color: #667eea;
font-size: 3rem;
margin-bottom: 1rem;
}
h1 {
color: #2d3748;
font-size: 1.75rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.subtitle {
color: #718096;
font-size: 0.9rem;
}
.step-indicator {
background: #ebf4ff;
color: #2c5282;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
border-left: 4px solid #4299e1;
font-size: 0.85rem;
font-weight: 500;
}
.info-box {
background-color: #fffaf0;
color: #744210;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
border-left: 4px solid #ed8936;
font-size: 0.85rem;
line-height: 1.6;
display: flex;
align-items: start;
gap: 0.75rem;
}
.info-box .material-icons {
color: #ed8936;
font-size: 1.5rem;
flex-shrink: 0;
}
.error {
background-color: #fff5f5;
color: #c53030;
padding: 0.75rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #feb2b2;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.error .material-icons {
color: #c53030;
}
.totp-setup-section {
background: #f7fafc;
padding: 1.5rem;
border-radius: 0.75rem;
margin-bottom: 1.5rem;
border: 2px solid #e2e8f0;
}
.section-title {
font-size: 1.1rem;
margin-bottom: 1rem;
color: #2d3748;
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
}
.section-title .material-icons {
color: #667eea;
}
.qr-container {
text-align: center;
margin: 1rem 0;
background: white;
padding: 1.5rem;
border-radius: 0.5rem;
}
.qr-code {
max-width: 220px;
height: auto;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
padding: 0.5rem;
}
.secret-display {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 2px dashed #e2e8f0;
}
.secret-label {
font-size: 0.85rem;
color: #4a5568;
margin-bottom: 0.5rem;
font-weight: 500;
}
.secret-code {
background: white;
padding: 0.875rem;
border-radius: 0.5rem;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
word-break: break-all;
border: 2px dashed #cbd5e0;
text-align: center;
font-weight: bold;
color: #2d3748;
margin-bottom: 0.5rem;
}
.account-info {
font-size: 0.8rem;
color: #718096;
text-align: center;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
color: #4a5568;
margin-bottom: 0.5rem;
}
label .material-icons {
font-size: 1.2rem;
color: #667eea;
}
.totp-input {
width: 100%;
padding: 0.75rem;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 1.5rem;
transition: all 0.2s;
background: white;
text-align: center;
letter-spacing: 0.5rem;
font-family: 'Courier New', monospace;
}
.totp-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.totp-hint {
font-size: 0.8rem;
color: #718096;
margin-top: 0.25rem;
text-align: center;
}
button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.875rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
button:active {
transform: translateY(0);
}
.steps-list {
list-style: none;
padding: 0;
margin: 0;
}
.steps-list li {
padding: 0.5rem 0;
color: #4a5568;
font-size: 0.9rem;
line-height: 1.5;
}
.steps-list li:before {
content: "→";
color: #667eea;
font-weight: bold;
margin-right: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<span class="material-icons icon">security</span>
<h1>Two-Factor Authentication</h1>
<p class="subtitle">Secure your admin account</p>
</div>
<div class="step-indicator">
Step 2 of 2: Configure your authenticator app
</div>
<div class="info-box">
<span class="material-icons">info</span>
<div>
<strong>Required:</strong> You need an authenticator app to continue.
Download Google Authenticator, Authy, or any TOTP-compatible app before proceeding.
</div>
</div>
{% if error %}
<div class="error">
<span class="material-icons">error</span>
<span>{{ error }}</span>
</div>
{% endif %}
<div class="totp-setup-section">
<div class="section-title">
<span class="material-icons">qr_code_scanner</span>
Scan QR Code
</div>
<div class="qr-container">
<img class="qr-code" src="data:image/png;base64,{{ qr_code }}" alt="TOTP QR Code">
</div>
<div class="secret-display">
<div class="section-title" style="font-size: 1rem; margin-bottom: 0.75rem;">
<span class="material-icons" style="font-size: 1.25rem;">vpn_key</span>
Or Enter Manually
</div>
<div class="secret-label">Secret Key:</div>
<div class="secret-code">{{ totp_secret }}</div>
<div class="account-info">
Account: {{ username }}<br>
Issuer: Simple File Server
</div>
</div>
</div>
<form method="POST">
<div class="form-group">
<label>
<span class="material-icons">verified_user</span>
Verify Authentication Code
</label>
<input type="text" name="totp_code" class="totp-input" required
maxlength="6" pattern="[0-9]{6}" inputmode="numeric"
autocomplete="off" placeholder="000000" autofocus>
<div class="totp-hint">Enter the 6-digit code from your authenticator app</div>
</div>
<button type="submit">
<span class="material-icons">check_circle</span>
Complete Setup
</button>
</form>
</div>
</body>
</html>