i18 drin
Formeleon Deployment Pipeline / Deploy to Dev Environment (push) Successful in 1m1s
Formeleon Deployment Pipeline / Deploy to Production Environment (push) Skipped

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:
br committed 2026-09-29 05:16:01 +02:00
1 parent 7f81932fa5
commit 5fff17531d
31 files changed
+2683 -550

No files matched your search

+252
View File
@@ -0,0 +1,252 @@
/** =============================================================================
* Formeleon - Multi-tenant Form Generator
* Author: Björn Runden <br@runden.net>
* Script: embed.js
* Description: Client-side embed script for injecting forms into tenant websites.
* Handles handshake, UI rendering, and form submission via fetch API.
* =============================================================================
*/
(function() {
// Current script tag to extract data attributes
const scripts = document.getElementsByTagName('script');
const currentScript = scripts[scripts.length - 1];
// Configuration
const formId = currentScript.getAttribute('data-form-id');
const publicKey = currentScript.getAttribute('data-public-key');
// We assume backend API is hosted at the same origin as this script
const apiBaseUrl = new URL(currentScript.src).origin + '/api/v1';
const containerId = 'formeleon-form';
// Wait for DOM
document.addEventListener("DOMContentLoaded", init);
function init() {
const container = document.getElementById(containerId);
if (!container) {
console.error(`[Formeleon] Container <div id="${containerId}"></div> not found.`);
return;
}
if (!formId || !publicKey) {
container.innerHTML = '<p style="color:red;">[Formeleon] Missing form ID or public key configuration.</p>';
return;
}
// Show loading state
container.innerHTML = '<p>Loading form...</p>';
// Step 1: Handshake (Fetch form config & reCAPTCHA config)
fetch(`${apiBaseUrl}/forms/${formId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${publicKey}`,
'Accept': 'application/json'
}
})
.then(response => {
if (!response.ok) {
throw new Error(`Failed to load form (Status: ${response.status})`);
}
return response.json();
})
.then(data => {
renderForm(container, data);
})
.catch(err => {
console.error('[Formeleon] Handshake Error:', err);
container.innerHTML = `<p style="color:red;">[Formeleon Error] ${err.message}</p>`;
});
}
/**
* Helper to extract localized text based on the document lang
*/
function getLocalizedString(fieldProperty, fallback) {
if (!fieldProperty) return fallback || '';
// If it's a simple string, return it directly
if (typeof fieldProperty === 'string') return fieldProperty;
// If it's an object with language keys (e.g., {"en": "Name", "de": "Name"})
if (typeof fieldProperty === 'object') {
const pageLang = document.documentElement.lang || 'en';
// Try exact match (e.g. 'en-US')
if (fieldProperty[pageLang]) return fieldProperty[pageLang];
// Try base language match (e.g. 'en')
const baseLang = pageLang.split('-')[0];
if (fieldProperty[baseLang]) return fieldProperty[baseLang];
// Fallback to English if available, or just take the first available value
if (fieldProperty['en']) return fieldProperty['en'];
return Object.values(fieldProperty)[0] || fallback;
}
return fallback || '';
}
/**
* Renders the HTML form based on JSON structure
*/
function renderForm(container, formData) {
if (!formData || !formData.form_structure) {
container.innerHTML = '<p>Invalid form configuration received.</p>';
return;
}
// Load reCAPTCHA script if enabled
if (formData.recaptcha && formData.recaptcha.enabled && formData.recaptcha.site_key) {
window._formeleonRecaptchaSiteKey = formData.recaptcha.site_key;
const rcScript = document.createElement('script');
rcScript.src = `https://www.google.com/recaptcha/api.js?render=${formData.recaptcha.site_key}`;
document.head.appendChild(rcScript);
}
const struct = formData.form_structure;
const fields = struct.fields || [];
let html = `<form id="formeleon-submit-form" class="formeleon-container">`;
// Render Title/Desc
if (formData.name) html += `<h3>${formData.name}</h3>`;
if (formData.description) html += `<p>${formData.description}</p>`;
html += `<div id="formeleon-alert" style="display:none; padding:10px; margin-bottom:15px; border-radius:4px;"></div>`;
// Render Fields
fields.forEach(field => {
const required = field.required ? 'required' : '';
const reqStar = field.required ? '<span style="color:red">*</span>' : '';
// i18n resolution for label and placeholder
const labelText = getLocalizedString(field.label, field.name);
const placeholderText = getLocalizedString(field.placeholder, '');
html += `<div class="formeleon-field-group" style="margin-bottom: 15px;">`;
html += `<label for="${field.name}" style="display:block; margin-bottom:5px; font-weight:bold;">${labelText} ${reqStar}</label>`;
// Extract custom CSS classes
const customCss = field.css_classes ? `class="${field.css_classes}"` : '';
if (field.type === 'textarea') {
html += `<textarea id="${field.name}" name="${field.name}" ${customCss} ${required} style="width:100%; padding:8px;" rows="${field.rows || 3}" placeholder="${placeholderText}"></textarea>`;
} else if (field.type === 'select') {
html += `<select id="${field.name}" name="${field.name}" ${customCss} ${required} style="width:100%; padding:8px;">`;
html += `<option value="">-- Please select --</option>`;
if (field.options && Array.isArray(field.options)) {
field.options.forEach(opt => {
const optText = getLocalizedString(opt, opt);
// We use the fallback text as value, but the localized text for display
html += `<option value="${typeof opt === 'string' ? opt : optText}">${optText}</option>`;
});
}
html += `</select>`;
} else {
html += `<input type="${field.type || 'text'}" id="${field.name}" name="${field.name}" ${customCss} ${required} placeholder="${placeholderText}" style="width:100%; padding:8px;">`;
}
html += `</div>`;
});
// Submit Button
const btnText = (struct.submit_button && struct.submit_button.text) ? getLocalizedString(struct.submit_button.text, 'Submit') : 'Submit';
const btnCss = (struct.submit_button && struct.submit_button.css_classes) ? `class="${struct.submit_button.css_classes}"` : 'style="padding:10px 20px; cursor:pointer;"';
html += `<button type="submit" id="formeleon-submit-btn" ${btnCss}>${btnText}</button>`;
html += `</form>`;
container.innerHTML = html;
// Attach Submit Handler
const formEl = document.getElementById('formeleon-submit-form');
formEl.addEventListener('submit', handleFormSubmit);
}
/**
* Handles the form submission process
*/
function handleFormSubmit(e) {
e.preventDefault();
const formEl = e.target;
const btn = document.getElementById('formeleon-submit-btn');
const alertBox = document.getElementById('formeleon-alert');
// Hide previous alerts
alertBox.style.display = 'none';
// Extract Data
const formData = new FormData(formEl);
const payload = {};
formData.forEach((value, key) => {
payload[key] = value;
});
// Update UI State
btn.disabled = true;
btn.innerText = 'Submitting...';
// Helper to send data
const sendPayload = (recaptchaToken) => {
fetch(`${apiBaseUrl}/submissions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${publicKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
form_id: formId,
payload: payload,
recaptcha_token: recaptchaToken
})
})
.then(response => {
return response.json().then(data => ({status: response.status, body: data}));
})
.then(res => {
if (res.status >= 200 && res.status < 300) {
// Success
alertBox.style.display = 'block';
alertBox.style.backgroundColor = '#d4edda';
alertBox.style.color = '#155724';
alertBox.style.border = '1px solid #c3e6cb';
alertBox.innerHTML = res.body.message || 'Thank you! Your submission has been received.';
formEl.reset();
} else {
// Error
throw new Error(res.body.error || 'Submission failed.');
}
})
.catch(err => {
// Error Display
alertBox.style.display = 'block';
alertBox.style.backgroundColor = '#f8d7da';
alertBox.style.color = '#721c24';
alertBox.style.border = '1px solid #f5c6cb';
alertBox.innerHTML = err.message;
})
.finally(() => {
btn.disabled = false;
btn.innerText = 'Submit';
});
};
// Execute reCAPTCHA if enabled
if (window._formeleonRecaptchaSiteKey && typeof grecaptcha !== 'undefined') {
grecaptcha.ready(function() {
grecaptcha.execute(window._formeleonRecaptchaSiteKey, {action: 'submit'}).then(function(token) {
sendPayload(token);
}).catch(function(err) {
console.error('[Formeleon] reCAPTCHA execution failed', err);
sendPayload(null); // Send anyway, backend will evaluate
});
});
} else {
// Dev mode or not configured
sendPayload('mock_token_for_dev');
}
}
})();