""" Configs API blueprint. Handles endpoints for managing scan configuration files, including CSV/YAML upload, template download, and config management. """ import logging import io from flask import Blueprint, jsonify, request, send_file from werkzeug.utils import secure_filename from web.auth.decorators import api_auth_required from web.services.config_service import ConfigService bp = Blueprint('configs', __name__) logger = logging.getLogger(__name__) @bp.route('', methods=['GET']) @api_auth_required def list_configs(): """ List all config files with metadata. Returns: JSON response with list of configs: { "configs": [ { "filename": "prod-scan.yaml", "title": "Prod Scan", "path": "/app/configs/prod-scan.yaml", "created_at": "2025-11-15T10:30:00Z", "size_bytes": 1234, "used_by_schedules": ["Daily Scan"] } ] } """ try: config_service = ConfigService() configs = config_service.list_configs() logger.info(f"Listed {len(configs)} config files") return jsonify({ 'configs': configs }) except Exception as e: logger.error(f"Unexpected error listing configs: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500 @bp.route('/', methods=['GET']) @api_auth_required def get_config(filename: str): """ Get config file content and parsed data. Args: filename: Config filename Returns: JSON response with config content: { "filename": "prod-scan.yaml", "content": "title: Prod Scan\n...", "parsed": {"title": "Prod Scan", "sites": [...]} } """ try: # Sanitize filename filename = secure_filename(filename) config_service = ConfigService() config_data = config_service.get_config(filename) logger.info(f"Retrieved config file: {filename}") return jsonify(config_data) except FileNotFoundError as e: logger.warning(f"Config file not found: {filename}") return jsonify({ 'error': 'Not found', 'message': str(e) }), 404 except ValueError as e: logger.warning(f"Invalid config file: {filename} - {str(e)}") return jsonify({ 'error': 'Invalid config', 'message': str(e) }), 400 except Exception as e: logger.error(f"Unexpected error getting config {filename}: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500 @bp.route('/create-from-cidr', methods=['POST']) @api_auth_required def create_from_cidr(): """ Create config from CIDR range. Request: JSON with: { "title": "My Scan", "cidr": "10.0.0.0/24", "site_name": "Production" (optional), "ping_default": false (optional) } Returns: JSON response with created config info: { "success": true, "filename": "my-scan.yaml", "preview": "title: My Scan\n..." } """ try: data = request.get_json() if not data: return jsonify({ 'error': 'Bad request', 'message': 'Request body must be JSON' }), 400 # Validate required fields if 'title' not in data: return jsonify({ 'error': 'Bad request', 'message': 'Missing required field: title' }), 400 if 'cidr' not in data: return jsonify({ 'error': 'Bad request', 'message': 'Missing required field: cidr' }), 400 title = data['title'] cidr = data['cidr'] site_name = data.get('site_name', None) ping_default = data.get('ping_default', False) # Validate title if not title or not title.strip(): return jsonify({ 'error': 'Validation error', 'message': 'Title cannot be empty' }), 400 # Create config from CIDR config_service = ConfigService() filename, yaml_preview = config_service.create_from_cidr( title=title, cidr=cidr, site_name=site_name, ping_default=ping_default ) logger.info(f"Created config from CIDR {cidr}: {filename}") return jsonify({ 'success': True, 'filename': filename, 'preview': yaml_preview }) except ValueError as e: logger.warning(f"CIDR validation failed: {str(e)}") return jsonify({ 'error': 'Validation error', 'message': str(e) }), 400 except Exception as e: logger.error(f"Unexpected error creating config from CIDR: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500 @bp.route('/upload-yaml', methods=['POST']) @api_auth_required def upload_yaml(): """ Upload YAML config file directly. Request: multipart/form-data with 'file' field containing YAML file Optional 'filename' field for custom filename Returns: JSON response with created config info: { "success": true, "filename": "prod-scan.yaml" } """ try: # Check if file is present if 'file' not in request.files: return jsonify({ 'error': 'Bad request', 'message': 'No file provided' }), 400 file = request.files['file'] # Check if file is selected if file.filename == '': return jsonify({ 'error': 'Bad request', 'message': 'No file selected' }), 400 # Check file extension if not (file.filename.endswith('.yaml') or file.filename.endswith('.yml')): return jsonify({ 'error': 'Bad request', 'message': 'File must be a YAML file (.yaml or .yml extension)' }), 400 # Read YAML content yaml_content = file.read().decode('utf-8') # Get filename (use uploaded filename or custom) filename = request.form.get('filename', file.filename) filename = secure_filename(filename) # Create config from YAML config_service = ConfigService() final_filename = config_service.create_from_yaml(filename, yaml_content) logger.info(f"Created config from YAML upload: {final_filename}") return jsonify({ 'success': True, 'filename': final_filename }) except ValueError as e: logger.warning(f"YAML validation failed: {str(e)}") return jsonify({ 'error': 'Validation error', 'message': str(e) }), 400 except UnicodeDecodeError: logger.warning("YAML file encoding error") return jsonify({ 'error': 'Encoding error', 'message': 'YAML file must be UTF-8 encoded' }), 400 except Exception as e: logger.error(f"Unexpected error uploading YAML: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500 @bp.route('//download', methods=['GET']) @api_auth_required def download_config(filename: str): """ Download existing config file. Args: filename: Config filename Returns: YAML file download """ try: # Sanitize filename filename = secure_filename(filename) config_service = ConfigService() config_data = config_service.get_config(filename) # Create file-like object yaml_file = io.BytesIO(config_data['content'].encode('utf-8')) yaml_file.seek(0) logger.info(f"Config file downloaded: {filename}") # Send file return send_file( yaml_file, mimetype='application/x-yaml', as_attachment=True, download_name=filename ) except FileNotFoundError as e: logger.warning(f"Config file not found: {filename}") return jsonify({ 'error': 'Not found', 'message': str(e) }), 404 except Exception as e: logger.error(f"Unexpected error downloading config {filename}: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500 @bp.route('/', methods=['PUT']) @api_auth_required def update_config(filename: str): """ Update existing config file with new YAML content. Args: filename: Config filename Request: JSON with: { "content": "title: My Scan\nsites: ..." } Returns: JSON response with success status: { "success": true, "message": "Config updated successfully" } Error responses: - 400: Invalid YAML or config structure - 404: Config file not found - 500: Internal server error """ try: # Sanitize filename filename = secure_filename(filename) data = request.get_json() if not data or 'content' not in data: return jsonify({ 'error': 'Bad request', 'message': 'Missing required field: content' }), 400 yaml_content = data['content'] # Update config config_service = ConfigService() config_service.update_config(filename, yaml_content) logger.info(f"Updated config file: {filename}") return jsonify({ 'success': True, 'message': 'Config updated successfully' }) except FileNotFoundError as e: logger.warning(f"Config file not found: {filename}") return jsonify({ 'error': 'Not found', 'message': str(e) }), 404 except ValueError as e: logger.warning(f"Invalid config content for {filename}: {str(e)}") return jsonify({ 'error': 'Validation error', 'message': str(e) }), 400 except Exception as e: logger.error(f"Unexpected error updating config {filename}: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500 @bp.route('/', methods=['DELETE']) @api_auth_required def delete_config(filename: str): """ Delete config file and cascade delete associated schedules. When a config is deleted, all schedules using that config (both enabled and disabled) are automatically deleted as well. Args: filename: Config filename Returns: JSON response with success status: { "success": true, "message": "Config deleted successfully" } Error responses: - 404: Config file not found - 500: Internal server error """ try: # Sanitize filename filename = secure_filename(filename) config_service = ConfigService() config_service.delete_config(filename) logger.info(f"Deleted config file: {filename}") return jsonify({ 'success': True, 'message': 'Config deleted successfully' }) except FileNotFoundError as e: logger.warning(f"Config file not found: {filename}") return jsonify({ 'error': 'Not found', 'message': str(e) }), 404 except Exception as e: logger.error(f"Unexpected error deleting config {filename}: {str(e)}", exc_info=True) return jsonify({ 'error': 'Internal server error', 'message': 'An unexpected error occurred' }), 500