67 lines
2.5 KiB
Python
67 lines
2.5 KiB
Python
"""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/<filename>', 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
|