"""File upload and serving routes.""" import os import secrets from flask import Blueprint, jsonify, send_file, request, current_app from werkzeug.utils import secure_filename from database import get_db from decorators import token_required files_bp = Blueprint('files', __name__, url_prefix='/api') MAX_UPLOAD_SIZE = 100 * 1024 * 1024 def process_file_upload(file, current_app): """Upload and store file with secure token-based filename.""" file_bytes = file.read() size = len(file_bytes) if size > MAX_UPLOAD_SIZE: raise ValueError("File exceeds maximum allowed size.") upload_dir = os.path.join(current_app.root_path, 'static', 'uploads') os.makedirs(upload_dir, exist_ok=True) orig = secure_filename(file.filename) fname = f"{secrets.token_hex(8)}_{orig}" path = os.path.join(upload_dir, fname) with open(path, 'wb') as f: f.write(file_bytes) return { 'filename': file.filename, 'url': f'/api/files/{fname}', 'mimetype': file.mimetype, 'size': size } @files_bp.route('/files/', methods=['GET']) @token_required def serve_file(filename): """Serve uploaded files with secure headers to prevent execution/rendering.""" # Validate filename to prevent directory traversal if '/' in filename or '\\' in filename or filename.startswith('.'): return jsonify({'error': 'Invalid filename'}), 400 file_path = os.path.join(current_app.root_path, 'static', 'uploads', filename) # Ensure file exists and is within uploads directory if not os.path.exists(file_path) or not os.path.isfile(file_path): return jsonify({'error': 'File not found'}), 404 # Verify the file is in the uploads directory real_path = os.path.realpath(file_path) uploads_dir = os.path.realpath(os.path.join(current_app.root_path, 'static', 'uploads')) if not real_path.startswith(uploads_dir): return jsonify({'error': 'Access denied'}), 403 # Serve file with secure headers response = send_file( file_path, as_attachment=True, # Forces download instead of rendering download_name=filename.split('_', 1)[-1] if '_' in filename else filename ) # Additional security headers response.headers['X-Content-Type-Options'] = 'nosniff' # Prevent MIME type sniffing response.headers['X-Frame-Options'] = 'DENY' # Prevent clickjacking response.headers['Content-Security-Policy'] = "default-src 'none'" return response