i18 drin
mail drin
fehlende routen drin
prod-reife erreicht.
TODOS:
Deine aktuellen offenen TODOs
1. Rate-Limiting (Spam-Schutz VOR dem reCAPTCHA)
Das reCAPTCHA hält zwar Bots ab, aber wenn jemand böswillig ein Script schreibt, das 1.000 POST-Requests pro Sekunde an /api/v1/submissions sendet, muss dein Backend jedes Mal Google kontaktieren und die Datenbank befragen. Das frisst Server-Ressourcen.
Todo: Einbinden von Flask-Limiter und Absichern der API-Route (z.B. max. 10 Submissions pro Minute pro IP-Adresse oder Tenant).
2. Billing & Feature-Enforcement (Limits pro Tenant durchsetzen)
Die Mandanten-Architektur und das config-Feld im JSON existieren bereits. Aber wir erzwingen diese Limits aktuell nicht im Backend.
Todo: Im Dashboard (templates/admin/tenants/view.html) UI-Controls einbauen, um Feature-Flags für einen Tenant festzulegen:
max_forms (z.B. 1 für Free-User, 10 für Pro-User).
allow_instant_mail (Dürfen E-Mails an den Tenant gesendet werden?).
allow_autoresponder (Darf der Autoresponder für Endkunden genutzt werden?).
Todo: Diese Limits hart im Backend (beim Erstellen von Forms und beim Mailversand) kontrollieren. (Der Mailer prüft das zwar schon halbwegs, das UI zum Pflegen fehlt aber noch komplett).
3. E-Mail Templates aufwerten (HTML statt Plain-Text)
Bisher versendet der Service E-Mails als reinen Text (plain). Für eine professionelle SaaS-Lösung sollten diese Mails ansprechend aussehen.
Todo: Im services/mailer.py den MIMEMultipart so erweitern, dass er saubere HTML-Mails aus einem Jinja2-Template (templates/emails/...) generiert, inklusive Branding/Logo deines Services.
4. Frontend-Snippet "Graceful Degradation"
Das embed.js arbeitet aktuell sehr gut. Aber was ist, wenn das Formeleon-Backend mal für 5 Minuten down ist?
Todo: Einen sauberen Timeout im fetch einbauen und dem Endkunden auf der Webseite eine schöne, fallback HTML-Meldung anzeigen ("Entschuldigung, das Formular ist momentan nicht erreichbar"), anstatt dass sich das Script lautlos in der Console mit einem CORS/Network-Error verabschiedet.
5. CI/CD Pipeline (Deployment)
Wenn der Code fertig ist, willst du ihn sicher nicht immer händisch per git pull auf deinen Produktionsserver schieben und dort docker compose up tippen.
Todo: Eine simple Pipeline (Github Actions / Gitea Actions) anlegen, die den Code testet und automatisch auf dem Zielserver ausrollt.
This commit is contained in:
1 parent
7f81932fa5
commit
5fff17531d
31 files changed
+2683
-550
No files matched your search
+141
-360
@@ -2,386 +2,167 @@
|
||||
# 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.
|
||||
# Description: Public API Blueprint for handling form handshakes and submissions.
|
||||
# ==============================================================================
|
||||
|
||||
"""
|
||||
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
|
||||
from services.tenant_service import TenantService
|
||||
from services.submission_service import SubmissionService
|
||||
|
||||
from services.mailer import MailerService
|
||||
from services.recaptcha import get_recaptcha_service
|
||||
import uuid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create API blueprint
|
||||
api_bp = Blueprint('api', __name__)
|
||||
# Create public API blueprint
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api/v1')
|
||||
|
||||
|
||||
@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_options_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'
|
||||
def _get_tenant_from_auth():
|
||||
"""Helper to validate Bearer token and return Tenant."""
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return None
|
||||
|
||||
# Handle OPTIONS requests
|
||||
if request.method == 'OPTIONS':
|
||||
return current_app.make_default_options_response()
|
||||
public_key = auth_header.replace('Bearer ', '').strip()
|
||||
tenant = Tenant.query.filter_by(public_key=public_key).first()
|
||||
return tenant
|
||||
|
||||
|
||||
@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():
|
||||
@api_bp.route('/forms/<form_id>', methods=['GET'])
|
||||
def get_form_config(form_id):
|
||||
"""
|
||||
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
|
||||
Handshake Endpoint.
|
||||
Client snippet requests form configuration using its public key.
|
||||
"""
|
||||
# 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)
|
||||
|
||||
tenant = _get_tenant_from_auth()
|
||||
if not tenant:
|
||||
logger.warning(f'Handshake failed: Invalid public key {public_key[:8]}...')
|
||||
raise Unauthorized('Invalid public key')
|
||||
|
||||
# Validate origin
|
||||
return jsonify({'error': 'Unauthorized. Invalid public key.'}), 401
|
||||
|
||||
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()
|
||||
|
||||
return jsonify({'error': 'Tenant is inactive.'}), 403
|
||||
|
||||
# Check origin (CORS / allowed domains)
|
||||
origin = request.headers.get('Origin')
|
||||
if origin and not tenant.is_origin_allowed(origin):
|
||||
logger.warning(f"Origin rejected: {origin} for tenant {tenant.id}")
|
||||
return jsonify({'error': 'Origin not allowed'}), 403
|
||||
|
||||
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')
|
||||
|
||||
return jsonify({'error': 'Form not found or access denied.'}), 404
|
||||
|
||||
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)
|
||||
return jsonify({'error': 'Form is inactive.'}), 403
|
||||
|
||||
recaptcha_service = get_recaptcha_service()
|
||||
|
||||
@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 = 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/<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 = 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/<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 = 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]
|
||||
'id': form.id,
|
||||
'name': form.name,
|
||||
'description': form.description,
|
||||
'form_structure': form.form_structure,
|
||||
'recaptcha': {
|
||||
'enabled': recaptcha_service.is_enabled() and not recaptcha_service.is_dev_mode(),
|
||||
'site_key': recaptcha_service.get_site_key()
|
||||
}
|
||||
})
|
||||
}), 200
|
||||
|
||||
|
||||
@api_bp.route('/submissions', methods=['POST'])
|
||||
def submit_form():
|
||||
"""
|
||||
Submission Endpoint.
|
||||
Receives JSON payload, validates reCAPTCHA (mocked in dev), and saves to DB.
|
||||
"""
|
||||
tenant = _get_tenant_from_auth()
|
||||
if not tenant:
|
||||
return jsonify({'error': 'Unauthorized. Invalid public key.'}), 401
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'Invalid JSON payload.'}), 400
|
||||
|
||||
form_id = data.get('form_id')
|
||||
payload = data.get('payload')
|
||||
recaptcha_token = data.get('recaptcha_token')
|
||||
|
||||
if not form_id or not payload:
|
||||
return jsonify({'error': 'Missing form_id or payload.'}), 400
|
||||
|
||||
form = FormConfig.query.filter_by(id=form_id, tenant_id=tenant.id).first()
|
||||
if not form:
|
||||
return jsonify({'error': 'Form not found.'}), 404
|
||||
|
||||
# --- reCAPTCHA Validation ---
|
||||
recaptcha_service = get_recaptcha_service()
|
||||
|
||||
# Get client IP for additional reCAPTCHA validation
|
||||
client_ip = request.headers.get('X-Forwarded-For', request.remote_addr)
|
||||
if client_ip and ',' in client_ip:
|
||||
client_ip = client_ip.split(',')[0].strip()
|
||||
|
||||
recaptcha_passed, recaptcha_score = recaptcha_service.verify(recaptcha_token, client_ip)
|
||||
|
||||
status = SubmissionStatus.PENDING if recaptcha_passed else SubmissionStatus.SPAM
|
||||
|
||||
# Save Submission
|
||||
sub_id = f"sub_{uuid.uuid4().hex[:12]}"
|
||||
new_submission = Submission(
|
||||
id=sub_id,
|
||||
form_config_id=form.id,
|
||||
tenant_id=tenant.id,
|
||||
payload=payload,
|
||||
recaptcha_score=recaptcha_score,
|
||||
recaptcha_passed=recaptcha_passed,
|
||||
status=status,
|
||||
user_agent=request.headers.get('User-Agent'),
|
||||
referer=request.headers.get('Referer')
|
||||
)
|
||||
|
||||
db.session.add(new_submission)
|
||||
db.session.commit()
|
||||
|
||||
# --- Trigger Email Notifications (if not spam) ---
|
||||
if recaptcha_passed:
|
||||
# Check Tenant Level Permissions
|
||||
allow_instant_mail = tenant.config.get('features', {}).get('instant_notifications', True) # Default to true for now
|
||||
allow_autoresponder = tenant.config.get('features', {}).get('submitter_autoresponder', True)
|
||||
|
||||
if form.settings:
|
||||
# 1. Admin Notification
|
||||
notif_settings = form.settings.get('notifications', {})
|
||||
if allow_instant_mail and notif_settings.get('enabled'):
|
||||
send_to = notif_settings.get('send_to', [])
|
||||
if send_to:
|
||||
# In a production app, you might want to queue this with Celery instead of blocking the request
|
||||
try:
|
||||
MailerService.send_admin_notification(form.name, send_to, payload)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send admin notification: {e}")
|
||||
|
||||
# 2. Submitter Autoresponder
|
||||
auto_settings = form.settings.get('autoresponder', {})
|
||||
if allow_autoresponder and auto_settings.get('enabled'):
|
||||
target_field = auto_settings.get('email_field_name')
|
||||
target_email = payload.get(target_field) if target_field else None
|
||||
|
||||
if target_email and '@' in str(target_email):
|
||||
try:
|
||||
MailerService.send_autoresponder(
|
||||
to_address=str(target_email),
|
||||
subject_template=auto_settings.get('subject', 'Thank you!'),
|
||||
body_template=auto_settings.get('body_template', ''),
|
||||
payload=payload
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send autoresponder to {target_email}: {e}")
|
||||
|
||||
if not recaptcha_passed:
|
||||
return jsonify({'error': 'Spam detected. Submission rejected.'}), 400
|
||||
|
||||
return jsonify({
|
||||
'message': 'Submission received successfully',
|
||||
'submission_id': sub_id
|
||||
}), 201
|
||||
|
||||
Reference in new issue
Block a user