# ============================================================================== # 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 from services.tenant_service import TenantService from services.submission_service import SubmissionService logger = logging.getLogger(__name__) # Create API blueprint api_bp = Blueprint('api', __name__) @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 = TenantService.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 TenantService.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 = TenantService.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 = TenantService.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 TenantService.validate_origin(origin, tenant): logger.warning(f'Submission failed: Invalid origin {origin} for tenant {tenant.id}') raise Forbidden('Origin not allowed') # 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') recaptcha_token = payload.get('g-recaptcha-response') or request.headers.get('X-ReCAPTCHA-Token') if payload else None response, status_code = SubmissionService.process_submission( form_id=form_id, tenant=tenant, origin=origin, payload=payload, ip_address=request.remote_addr, user_agent=request.user_agent.string if request.user_agent else None, recaptcha_token=recaptcha_token, content_type=request.content_type ) return jsonify(response), status_code @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 = TenantService.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] } })