# ============================================================================== # Formeleon - Multi-tenant Form Generator # Author: Björn Runden # Module: routes/api.py # Description: Public Form API for handling form submissions from client websites. # Provides endpoints for form handshake, submission, and validation. # ============================================================================== """ Public Form API for Formeleon. This module provides the public API endpoints for: - Client handshake and token exchange - Form submission processing - reCAPTCHA validation - Form configuration retrieval All endpoints are prefixed with /api/v1/ and are designed to be consumed by JavaScript snippets embedded in client websites. Authentication: - Public key handshake for tenant identification - Origin/Referer validation - reCAPTCHA v3 token validation (optional) Routes: POST /api/v1/handshake - Initiate handshake and get form config POST /api/v1/submit/ - Submit form data GET /api/v1/form/ - Get form configuration POST /api/v1/validate/recaptcha - Validate reCAPTCHA token """ import hashlib import json import logging import uuid from datetime import datetime, timezone from typing import Optional, Dict, Any from flask import Blueprint, request, jsonify, current_app from werkzeug.exceptions import BadRequest, Unauthorized, Forbidden, NotFound from models import db, Tenant, FormConfig, Submission, SubmissionStatus from services.recaptcha import RecaptchaService logger = logging.getLogger(__name__) # Create API blueprint api_bp = Blueprint('api', __name__) def validate_origin(origin: str, tenant: Tenant) -> bool: """ Validate that the request origin matches allowed origins for the tenant. :param origin: Request origin/referer :param tenant: Tenant object to check against :return: True if origin is allowed, False otherwise """ if not origin: return False # Remove trailing slash and protocol origin = origin.rstrip('/') allowed_origins = tenant.get_allowed_origins_list() # Check for wildcard if '*' in allowed_origins: return True # Exact match if origin in allowed_origins: return True # Check if origin matches any allowed domain (with subdomains) for allowed in allowed_origins: if allowed.startswith('*'): # Wildcard subdomain match domain = allowed[1:] # Remove leading * if origin.endswith(domain): return True return False def get_tenant_by_public_key(public_key: str) -> Optional[Tenant]: """ Get tenant by public key. :param public_key: Public API key :return: Tenant object or None """ return Tenant.query.filter_by(public_key=public_key).first() def hash_ip(ip: str) -> str: """ Create a hash of an IP address for privacy-preserving storage. :param ip: IP address string :return: SHA256 hash of the IP """ if not ip: return '' return hashlib.sha256(ip.encode()).hexdigest() @api_bp.before_request def before_api_request(): """Before request handler for API routes.""" # Set CORS headers for API routes if current_app.config.get('ENABLE_CORS', True): response = current_app.make_default_response() response.headers['Access-Control-Allow-Origin'] = '*' response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-Public-Key, X-Request-ID' # Handle OPTIONS requests if request.method == 'OPTIONS': return jsonify({'status': 'ok'}), 200 @api_bp.errorhandler(BadRequest) def handle_bad_request(error): """Handle bad request errors.""" return jsonify({ 'error': 'bad_request', 'message': str(error.description) if error.description else 'Invalid request' }), 400 @api_bp.errorhandler(Unauthorized) def handle_unauthorized(error): """Handle unauthorized errors.""" return jsonify({ 'error': 'unauthorized', 'message': str(error.description) if error.description else 'Authentication required' }), 401 @api_bp.errorhandler(Forbidden) def handle_forbidden(error): """Handle forbidden errors.""" return jsonify({ 'error': 'forbidden', 'message': str(error.description) if error.description else 'Access denied' }), 403 @api_bp.errorhandler(NotFound) def handle_not_found(error): """Handle not found errors.""" return jsonify({ 'error': 'not_found', 'message': str(error.description) if error.description else 'Resource not found' }), 404 @api_bp.route('/handshake', methods=['POST']) def handshake(): """ Handshake endpoint for client snippets. Validates the client's public key and origin, then returns the form configuration for rendering. Request Headers: X-Public-Key: Tenant's public API key Origin: Client origin URL Referer: Client referer URL (fallback) Request Body: form_id: The ID of the form to retrieve :return: JSON with handshake token and form configuration """ # Get public key from header or form data public_key = request.headers.get('X-Public-Key') or request.form.get('public_key') if not public_key: raise BadRequest('Public key required') # Get origin/referer origin = request.headers.get('Origin') or request.headers.get('Referer') or '' # Find tenant by public key tenant = get_tenant_by_public_key(public_key) if not tenant: logger.warning(f'Handshake failed: Invalid public key {public_key[:8]}...') raise Unauthorized('Invalid public key') # Validate origin if not tenant.is_active(): raise Forbidden('Tenant is not active') if not validate_origin(origin, tenant): logger.warning(f'Handshake failed: Invalid origin {origin} for tenant {tenant.id}') raise Forbidden('Origin not allowed for this tenant') # Get form ID from request form_id = request.form.get('form_id') or request.json.get('form_id') if request.json else None if not form_id: raise BadRequest('form_id is required') # Get form configuration form = FormConfig.query.filter_by( id=form_id, tenant_id=tenant.id ).first() if not form: raise NotFound('Form not found or does not belong to tenant') if not form.is_active: raise Forbidden('Form is not active') # Generate handshake token (can be used for subsequent requests) handshake_token = str(uuid.uuid4()) # Return handshake response response = { 'status': 'success', 'tenant_id': tenant.id, 'form_id': form.id, 'form_name': form.name, 'form_config': form.form_structure, 'settings': form.settings or {}, 'handshake_token': handshake_token, 'recaptcha_enabled': form.settings.get('recaptcha_enabled', True) if form.settings else True } logger.info(f'Handshake successful for tenant {tenant.id}, form {form.id}') return jsonify(response) @api_bp.route('/form/', methods=['GET']) def get_form(form_id): """ Get form configuration. :param form_id: ID of the form to retrieve :return: JSON with form configuration """ # Get public key from header public_key = request.headers.get('X-Public-Key') if not public_key: raise BadRequest('Public key required') # Find tenant by public key tenant = get_tenant_by_public_key(public_key) if not tenant: raise Unauthorized('Invalid public key') if not tenant.is_active(): raise Forbidden('Tenant is not active') # Get form form = FormConfig.query.filter_by( id=form_id, tenant_id=tenant.id ).first() if not form: raise NotFound('Form not found') if not form.is_active: raise Forbidden('Form is not active') return jsonify({ 'status': 'success', 'form': form.to_dict() }) @api_bp.route('/submit/', methods=['POST']) def submit_form(form_id): """ Submit form data. Handles form submissions from client websites with: - Origin validation - Public key authentication - reCAPTCHA validation (optional) - Data validation against form structure :param form_id: ID of the form being submitted :return: JSON with submission result """ # Get public key from header public_key = request.headers.get('X-Public-Key') if not public_key: raise BadRequest('Public key required') # Find tenant by public key tenant = get_tenant_by_public_key(public_key) if not tenant: logger.warning(f'Submission failed: Invalid public key {public_key[:8]}...') raise Unauthorized('Invalid public key') if not tenant.is_active(): raise Forbidden('Tenant is not active') # Validate origin origin = request.headers.get('Origin') or request.headers.get('Referer') or '' if not validate_origin(origin, tenant): logger.warning(f'Submission failed: Invalid origin {origin} for tenant {tenant.id}') raise Forbidden('Origin not allowed') # Get form form = FormConfig.query.filter_by( id=form_id, tenant_id=tenant.id ).first() if not form: raise NotFound('Form not found') if not form.is_active: raise Forbidden('Form is not active') # Get form data from request try: if request.content_type == 'application/json': payload = request.get_json() else: payload = request.form.to_dict() except Exception as e: logger.error(f'Failed to parse form data: {e}') raise BadRequest('Invalid form data format') if not payload: raise BadRequest('No form data provided') # Validate reCAPTCHA if enabled recaptcha_score = None recaptcha_passed = None if form.settings.get('recaptcha_enabled', True) if form.settings else True: recaptcha_token = payload.get('g-recaptcha-response') or request.headers.get('X-ReCAPTCHA-Token') if recaptcha_token: recaptcha_service = RecaptchaService() is_valid, score = recaptcha_service.verify( recaptcha_token, request.remote_addr ) recaptcha_score = score recaptcha_passed = is_valid if not is_valid: logger.warning(f'Submission rejected: reCAPTCHA failed with score {score}') else: # If no token provided, use dev mode score if in dev mode if current_app.config.get('DEV_MODE', False): recaptcha_score = current_app.config.get('MOCK_RECAPTCHA_SCORE', 0.9) recaptcha_passed = True # Create submission submission_id = f'sub_{form_id}_{str(uuid.uuid4())[:8]}' submission = Submission( id=submission_id, form_config_id=form.id, tenant_id=tenant.id, payload=payload, ip_hash=hash_ip(request.remote_addr), user_agent=request.user_agent.string if request.user_agent else None, referer=origin, recaptcha_score=recaptcha_score, recaptcha_passed=recaptcha_passed, submission_metadata={ 'origin': origin, 'user_agent': request.user_agent.string if request.user_agent else None, 'content_type': request.content_type } ) # Set status based on reCAPTCHA if recaptcha_passed is False: submission.status = SubmissionStatus.SPAM else: submission.status = SubmissionStatus.PENDING db.session.add(submission) db.session.commit() logger.info(f'Form submission: {submission_id} for form {form_id}, tenant {tenant.id}') # Prepare response response = { 'status': 'success', 'submission_id': submission.id, 'message': form.settings.get('success_message', 'Form submitted successfully!') if form.settings else 'Form submitted successfully!' } # If reCAPTCHA failed, indicate in response if submission.status == SubmissionStatus.SPAM: response['status'] = 'spam' response['message'] = 'Submission flagged as spam' logger.warning(f'Spam submission: {submission_id}') return jsonify(response) @api_bp.route('/validate/recaptcha', methods=['POST']) def validate_recaptcha(): """ Validate a reCAPTCHA token. This endpoint can be used by client snippets to pre-validate reCAPTCHA tokens before form submission. :return: JSON with validation result """ recaptcha_token = request.form.get('token') or request.json.get('token') if request.json else None if not recaptcha_token: raise BadRequest('reCAPTCHA token required') recaptcha_service = RecaptchaService() if not recaptcha_service.is_enabled(): # If reCAPTCHA not configured, return success in dev mode if current_app.config.get('DEV_MODE', False): return jsonify({ 'status': 'success', 'score': current_app.config.get('MOCK_RECAPTCHA_SCORE', 0.9), 'passed': True, 'dev_mode': True }) else: raise BadRequest('reCAPTCHA not configured') is_valid, score = recaptcha_service.verify( recaptcha_token, request.remote_addr ) return jsonify({ 'status': 'success', 'score': score, 'passed': is_valid, 'threshold': recaptcha_service.get_threshold(), 'dev_mode': recaptcha_service.is_dev_mode() }) @api_bp.route('/status') def api_status(): """ API health check endpoint. :return: JSON with API status """ return jsonify({ 'status': 'ok', 'version': '1.0.0', 'dev_mode': current_app.config.get('DEV_MODE', False), 'timestamp': datetime.now(timezone.utc).isoformat() }) @api_bp.route('/stats/') def get_tenant_stats(tenant_id): """ Get statistics for a tenant. :param tenant_id: Tenant ID :return: JSON with tenant statistics """ # Authenticate with public key public_key = request.headers.get('X-Public-Key') if not public_key: raise BadRequest('Public key required') tenant = get_tenant_by_public_key(public_key) if not tenant or tenant.id != tenant_id: raise Unauthorized('Invalid public key or tenant mismatch') if not tenant.is_active(): raise Forbidden('Tenant is not active') # Get stats form_count = FormConfig.query.filter_by(tenant_id=tenant.id).count() submission_count = Submission.query.filter_by(tenant_id=tenant.id).count() # Recent submissions recent_submissions = Submission.query.filter_by( tenant_id=tenant.id ).order_by(Submission.created_at.desc()).limit(5).all() return jsonify({ 'status': 'success', 'tenant_id': tenant.id, 'tenant_name': tenant.name, 'stats': { 'forms': form_count, 'submissions': submission_count, 'recent_submissions': [s.to_dict() for s in recent_submissions] } })