Raiju/includes/forms.php
touche 3c591a287a
Some checks are pending
Raiju CI / PHP ${{ matrix.php }} (8.1) (push) Waiting to run
Raiju CI / PHP ${{ matrix.php }} (8.4) (push) Waiting to run
Raiju CI / PHP ${{ matrix.php }} (8.5) (push) Waiting to run
Raiju CI / Apache routing / PHP-FPM (push) Waiting to run
Raiju CI / Chromium (push) Waiting to run
Brand: capitalize Raiju in content, UI strings and prompts
- Starter content and admin language files: Raiju, not raiju
- Test email, backup messages, mailer header and AI prompts use Raiju
- Meta generator and footer fallback use Raiju
- Technical identifiers (paths, CSS classes, user agents, temp prefixes) unchanged
2026-10-25 06:30:17 +03:00

425 lines
17 KiB
PHP

<?php
/**
* JSON-backed public forms for Raiju.
*
* Form definitions live in content/forms/*.json. The renderer intentionally
* stays small: generated/vibecoded templates can define the structure, while
* admins can safely edit labels, placeholders, options, required state and
* column widths.
*/
require_once __DIR__ . '/form-protection.php';
if (!defined('RAIJU_FORMS_DIR')) {
define('RAIJU_FORMS_DIR', dirname(__DIR__) . '/content/forms');
}
if (!defined('RAIJU_MAILS_PATH')) {
define('RAIJU_MAILS_PATH', dirname(__DIR__) . '/content/mails.json');
}
function raijuFormIdClean(string $id): string
{
$id = strtolower(trim($id));
$id = preg_replace('/[^a-z0-9_-]+/', '-', $id);
$id = trim((string)$id, '-_');
return $id !== '' ? substr($id, 0, 80) : 'form';
}
function raijuFormsEnsureDir(): void
{
if (!is_dir(RAIJU_FORMS_DIR)) {
mkdir(RAIJU_FORMS_DIR, 0755, true);
}
}
function raijuFormPath(string $id): string
{
return RAIJU_FORMS_DIR . '/' . raijuFormIdClean($id) . '.json';
}
function raijuFormsReadJson(string $path): array
{
if (!is_file($path)) {
return [];
}
$data = json_decode((string)file_get_contents($path), true);
return is_array($data) ? $data : [];
}
function raijuFormsWriteJson(string $path, array $data): bool
{
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
return file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), LOCK_EX) !== false;
}
function raijuFormDefaultDefinition(string $id = 'contact'): array
{
$id = raijuFormIdClean($id);
return [
'id' => $id,
'label' => $id === 'contact' ? 'Kontaktformular' : ucfirst(str_replace('-', ' ', $id)),
'description' => '',
'enabled' => true,
'submit' => [
'store' => true,
'email' => true,
'subject' => '{form}: {name}',
'successText' => 'Vielen Dank für deine Nachricht.',
],
'fields' => [
['key' => 'name', 'type' => 'text', 'label' => 'Name', 'required' => true, 'width' => 6],
['key' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true, 'width' => 6],
['key' => 'phone', 'type' => 'tel', 'label' => 'Telefon', 'required' => false, 'width' => 12],
['key' => 'message', 'type' => 'textarea', 'label' => 'Nachricht', 'required' => true, 'width' => 12],
],
];
}
function raijuFormNormalizeField(array $field, int $index = 0): array
{
$allowedTypes = ['text', 'email', 'tel', 'textarea', 'select', 'radio', 'checkbox', 'date', 'time', 'hidden', 'heading', 'note'];
$key = preg_replace('/[^a-zA-Z0-9_-]+/', '_', (string)($field['key'] ?? 'field_' . ($index + 1)));
$key = trim((string)$key, '_-') ?: 'field_' . ($index + 1);
$type = in_array(($field['type'] ?? 'text'), $allowedTypes, true) ? $field['type'] : 'text';
$width = (int)($field['width'] ?? 12);
if (!in_array($width, [3, 4, 6, 8, 12], true)) {
$width = 12;
}
$options = [];
if (is_array($field['options'] ?? null)) {
foreach ($field['options'] as $option) {
if (is_array($option)) {
$value = (string)($option['value'] ?? $option['label'] ?? '');
$label = (string)($option['label'] ?? $value);
} else {
$value = (string)$option;
$label = (string)$option;
}
if ($value !== '' || $label !== '') {
$options[] = ['value' => $value, 'label' => $label];
}
}
}
return [
'key' => $key,
'type' => $type,
'label' => (string)($field['label'] ?? $key),
'placeholder' => (string)($field['placeholder'] ?? ''),
'help' => (string)($field['help'] ?? ''),
'required' => !empty($field['required']),
'width' => $width,
'options' => $options,
'value' => (string)($field['value'] ?? ''),
];
}
function raijuFormNormalize(array $form, string $fallbackId = 'form'): array
{
$id = raijuFormIdClean((string)($form['id'] ?? $fallbackId));
$normalized = [
'id' => $id,
'label' => trim((string)($form['label'] ?? '')) ?: ucfirst(str_replace('-', ' ', $id)),
'description' => (string)($form['description'] ?? ''),
'enabled' => !array_key_exists('enabled', $form) || !empty($form['enabled']),
'submit' => array_replace([
'store' => true,
'email' => true,
'subject' => '{form}: {name}',
'successText' => 'Vielen Dank für deine Nachricht.',
], is_array($form['submit'] ?? null) ? $form['submit'] : []),
'fields' => [],
];
foreach (($form['fields'] ?? []) as $index => $field) {
if (is_array($field)) {
$normalized['fields'][] = raijuFormNormalizeField($field, (int)$index);
}
}
if (!$normalized['fields']) {
$normalized['fields'] = raijuFormDefaultDefinition($id)['fields'];
}
return $normalized;
}
function raijuFormExists(string $id): bool
{
return is_file(raijuFormPath($id));
}
function raijuFormLoad(string $id): ?array
{
$path = raijuFormPath($id);
if (!is_file($path)) {
return null;
}
return raijuFormNormalize(raijuFormsReadJson($path), $id);
}
function raijuFormSave(array $form): array
{
$normalized = raijuFormNormalize($form, (string)($form['id'] ?? 'form'));
raijuFormsEnsureDir();
raijuFormsWriteJson(raijuFormPath($normalized['id']), $normalized);
return $normalized;
}
function raijuFormsList(): array
{
raijuFormsEnsureDir();
$forms = [];
foreach (glob(RAIJU_FORMS_DIR . '/*.json') ?: [] as $file) {
$form = raijuFormNormalize(raijuFormsReadJson($file), pathinfo($file, PATHINFO_FILENAME));
$forms[] = [
'id' => $form['id'],
'label' => $form['label'],
'description' => $form['description'],
'enabled' => $form['enabled'],
'store' => !empty($form['submit']['store']),
'fieldCount' => count($form['fields']),
];
}
usort($forms, fn($a, $b) => strcasecmp((string)$a['label'], (string)$b['label']));
return $forms;
}
function raijuFormEscape(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
function raijuFormRenderField(array $field, string $formId): string
{
$key = (string)$field['key'];
$id = 'raiju-form-' . $formId . '-' . $key;
$label = raijuFormEscape((string)$field['label']);
$required = !empty($field['required']);
$requiredAttr = $required ? ' required' : '';
$requiredMark = $required ? ' *' : '';
$placeholder = raijuFormEscape((string)($field['placeholder'] ?? ''));
$width = (int)($field['width'] ?? 12);
$type = (string)($field['type'] ?? 'text');
$help = trim((string)($field['help'] ?? ''));
if ($type === 'hidden') {
return '<input type="hidden" name="' . raijuFormEscape($key) . '" value="' . raijuFormEscape((string)($field['value'] ?? '')) . '">';
}
if ($type === 'heading') {
return '<div class="form-group form-group--span-' . $width . ' raiju-form-heading"><h3>' . $label . '</h3></div>';
}
if ($type === 'note') {
return '<div class="form-group form-group--span-' . $width . ' raiju-form-note"><p>' . raijuFormEscape((string)($field['help'] ?: $field['label'])) . '</p></div>';
}
$html = '<div class="form-group form-group--span-' . $width . '">';
if ($type !== 'checkbox') {
$html .= '<label for="' . raijuFormEscape($id) . '">' . $label . $requiredMark . '</label>';
}
if ($type === 'textarea') {
$html .= '<textarea id="' . raijuFormEscape($id) . '" name="' . raijuFormEscape($key) . '" rows="5" placeholder="' . $placeholder . '"' . $requiredAttr . '></textarea>';
} elseif ($type === 'select') {
$html .= '<select id="' . raijuFormEscape($id) . '" name="' . raijuFormEscape($key) . '"' . $requiredAttr . '>';
$html .= '<option value=""></option>';
foreach (($field['options'] ?? []) as $option) {
$html .= '<option value="' . raijuFormEscape((string)$option['value']) . '">' . raijuFormEscape((string)$option['label']) . '</option>';
}
$html .= '</select>';
} elseif ($type === 'radio') {
$html .= '<div class="raiju-form-choice-list">';
foreach (($field['options'] ?? []) as $option) {
$html .= '<label><input type="radio" name="' . raijuFormEscape($key) . '" value="' . raijuFormEscape((string)$option['value']) . '"' . $requiredAttr . '> <span>' . raijuFormEscape((string)$option['label']) . '</span></label>';
}
$html .= '</div>';
} elseif ($type === 'checkbox') {
$html .= '<label class="raiju-form-checkbox"><input type="checkbox" id="' . raijuFormEscape($id) . '" name="' . raijuFormEscape($key) . '" value="1"' . $requiredAttr . '> <span>' . $label . $requiredMark . '</span></label>';
} else {
$inputType = in_array($type, ['email', 'tel', 'date', 'time'], true) ? $type : 'text';
$html .= '<input type="' . $inputType . '" id="' . raijuFormEscape($id) . '" name="' . raijuFormEscape($key) . '" placeholder="' . $placeholder . '"' . $requiredAttr . '>';
}
if ($help !== '') {
$html .= '<small class="form-hint">' . raijuFormEscape($help) . '</small>';
}
$html .= '</div>';
return $html;
}
function raijuFormRender(string $formId, array $options = []): string
{
$form = raijuFormLoad($formId);
if (!$form || empty($form['enabled'])) {
return '';
}
$basePath = raijuSafeFormBasePath((string)($options['basePath'] ?? ($GLOBALS['basePath'] ?? '')));
$lang = (string)($options['lang'] ?? ($GLOBALS['currentLang'] ?? 'en'));
$lazy = array_key_exists('lazy', $options) ? !empty($options['lazy']) : true;
if ($lazy && !raijuIsLazyFormRender()) {
return raijuLazyFormPlaceholder($form['id'], [
'basePath' => $basePath,
'params' => [
'lang' => $lang,
'basePath' => $basePath,
],
]);
}
$html = '<section class="contact-form-section raiju-json-form-section" data-form-id="' . raijuFormEscape($form['id']) . '">';
$html .= '<h2>' . raijuFormEscape((string)$form['label']) . '</h2>';
if (!empty($form['description'])) {
$html .= '<p class="raiju-form-description">' . raijuFormEscape((string)$form['description']) . '</p>';
}
$html .= '<form id="raijuForm-' . raijuFormEscape($form['id']) . '" class="contact-form raiju-json-form" action="' . raijuFormEscape($basePath . 'api/form-submit.php') . '" method="post">';
$html .= '<input type="hidden" name="lang" value="' . raijuFormEscape($lang) . '">';
$html .= raijuFormProtectionFields($form['id']);
$html .= '<div class="raiju-form-grid">';
foreach ($form['fields'] as $field) {
$html .= raijuFormRenderField($field, $form['id']);
}
$html .= '</div>';
$send = (string)($options['submitLabel'] ?? ($lang === 'de' ? 'Absenden' : 'Send'));
$sending = (string)($options['sendingLabel'] ?? ($lang === 'de' ? 'Wird gesendet…' : 'Sending…'));
$success = (string)($form['submit']['successText'] ?? 'Vielen Dank.');
$html .= '<div class="form-actions"><button type="submit" class="btn btn-gradient"><span class="btn-text">' . raijuFormEscape($send) . '</span><span class="btn-loading" style="display:none">' . raijuFormEscape($sending) . '</span></button></div>';
$error = $lang === 'de' ? 'Fehler beim Senden. Bitte versuch es später noch mal.' : 'Could not send the form. Please try again later.';
$html .= '<div class="form-feedback" data-success="' . raijuFormEscape($success) . '" data-error="' . raijuFormEscape($error) . '"></div>';
$html .= '</form></section>';
return $html;
}
function raijuForm(string $formId, array $options = []): string
{
return raijuFormRender($formId, $options);
}
function raijuFormSubmittedValue(array $field): string
{
$key = (string)$field['key'];
if (($field['type'] ?? '') === 'checkbox') {
return !empty($_POST[$key]) ? '1' : '';
}
$value = $_POST[$key] ?? '';
if (is_array($value)) {
$value = implode(', ', array_map('strval', $value));
}
return trim((string)$value);
}
function raijuFormValidateSubmission(array $form, string $lang = 'en'): array
{
$errors = [];
$values = [];
foreach ($form['fields'] as $field) {
$type = (string)($field['type'] ?? 'text');
if (in_array($type, ['heading', 'note', 'hidden'], true)) {
continue;
}
$value = raijuFormSubmittedValue($field);
$values[$field['key']] = $value;
if (!empty($field['required']) && $value === '') {
$errors[] = ($field['label'] ?? $field['key']) . ($lang === 'de' ? ' ist erforderlich' : ' is required');
continue;
}
if ($type === 'email' && $value !== '' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[] = $lang === 'de' ? 'Gültige E-Mail ist erforderlich' : 'Valid email is required';
}
}
return [$errors, $values];
}
function raijuFormsApplySubjectTemplate(string $template, array $form, array $values): string
{
$subject = $template ?: '{form}';
$replacements = [
'{form}' => (string)$form['label'],
'{name}' => (string)($values['name'] ?? ''),
'{email}' => (string)($values['email'] ?? ''),
];
return trim(strtr($subject, $replacements)) ?: (string)$form['label'];
}
function raijuFormsSaveSubmission(array $submission): bool
{
return raijuJsonUpdate(RAIJU_MAILS_PATH, function (array &$mails) use ($submission): void {
array_unshift($mails, $submission);
$mails = array_slice($mails, 0, 500);
});
}
function raijuFormsLoadEmailSettings(): array
{
$settingsPath = dirname(__DIR__) . '/content/settings.json';
$settings = raijuFormsReadJson($settingsPath);
return is_array($settings['email'] ?? null) ? $settings['email'] : [];
}
function raijuFormsParseEmailList($value): array
{
$items = is_array($value) ? $value : explode(',', (string)$value);
$emails = [];
foreach ($items as $item) {
foreach (explode(',', (string)$item) as $email) {
$email = trim(str_replace(["\r", "\n", "\t"], '', (string)$email));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$emails[] = $email;
}
}
}
return array_values(array_unique($emails));
}
function raijuFormsSendNotification(array $form, array $values, string $subject, string $body): bool
{
$emailConfig = raijuFormsLoadEmailSettings();
$method = $emailConfig['method'] ?? 'inactive';
$toRecipients = raijuFormsParseEmailList($emailConfig['recipientEmail'] ?? '');
if ($method === 'inactive' || !$toRecipients) {
return false;
}
require_once dirname(__DIR__) . '/api/SmtpMailer.php';
$fromEmail = trim((string)($emailConfig['fromEmail'] ?? '')) ?: ($toRecipients[0] ?? '');
$fromName = trim(str_replace(["\r", "\n", "\t"], '', (string)($emailConfig['fromName'] ?? '')));
$replyTo = filter_var(($values['email'] ?? ''), FILTER_VALIDATE_EMAIL) ? (string)$values['email'] : '';
$bccRecipients = raijuFormsParseEmailList($emailConfig['bccEmail'] ?? '');
if ($method === 'smtp') {
$mailer = new SmtpMailer(
$emailConfig['smtpHost'] ?? '',
(int)($emailConfig['smtpPort'] ?? 587),
$emailConfig['smtpUsername'] ?? '',
$emailConfig['smtpPassword'] ?? '',
$emailConfig['smtpEncryption'] ?? 'tls'
);
return $mailer->send($toRecipients, $subject, $body, $fromEmail, $fromName, $replyTo, $bccRecipients);
}
if ($method === 'sendmail') {
$headers = [];
$headers[] = 'From: ' . ($fromName ? "=?UTF-8?B?" . base64_encode($fromName) . "?= <$fromEmail>" : $fromEmail);
if ($replyTo) {
$headers[] = 'Reply-To: ' . $replyTo;
}
$headers[] = 'Content-Type: text/plain; charset=UTF-8';
$headers[] = 'X-Mailer: Raiju CMS';
$sent = @mail(implode(', ', $toRecipients), '=?UTF-8?B?' . base64_encode($subject) . '?=', $body, implode("\r\n", $headers));
foreach ($bccRecipients as $bccRecipient) {
$sent = @mail($bccRecipient, '=?UTF-8?B?' . base64_encode($subject) . '?=', $body, implode("\r\n", $headers)) && $sent;
}
return $sent;
}
return false;
}
function raijuFormsBuildBody(array $form, array $values): string
{
$body = 'Neue Einsendung über Formular: ' . $form['label'] . "\n";
$body .= str_repeat('-', 40) . "\n\n";
foreach ($form['fields'] as $field) {
if (in_array(($field['type'] ?? ''), ['heading', 'note', 'hidden'], true)) {
continue;
}
$body .= ($field['label'] ?? $field['key']) . ': ' . ($values[$field['key']] ?? '') . "\n";
}
return $body;
}