513 lines
15 KiB
Python
513 lines
15 KiB
Python
# ==============================================================================
|
|
# Formeleon - Multi-tenant Form Generator
|
|
# Author: Björn Runden <br@runden.net>
|
|
# 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/<form_id> - Submit form data
|
|
GET /api/v1/form/<form_id> - 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/<form_id>', 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/<form_id>', 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/<tenant_id>')
|
|
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]
|
|
}
|
|
})
|