# ==============================================================================
# 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]
}
})