is_array($entry) && (int)($entry['created'] ?? 0) > 0 && $now - (int)$entry['created'] <= RAIJU_FORM_TOKEN_MAX_AGE); $tokens[raijuFormTokenHash($token)] = ['form' => $formId, 'created' => $now]; }); if (!$saved) throw new RuntimeException('Could not create form token.'); return $token; } function raijuFormProtectionFields(string $formId = 'contact'): string { $token = raijuCreateFormToken($formId); return sprintf( '' . "\n" . ' ' . "\n" . '
', htmlspecialchars($formId, ENT_QUOTES, 'UTF-8'), htmlspecialchars($token, ENT_QUOTES, 'UTF-8') ); } function raijuIsLazyFormRender(): bool { return defined('RAIJU_RENDER_LAZY_FORM') && RAIJU_RENDER_LAZY_FORM === true; } function raijuSafeFormBasePath(string $basePath): string { if (preg_match('/[\x00-\x1F\x7F<>"\'\\\\]/', $basePath)) { return ''; } if (preg_match('#^[a-z][a-z0-9+.-]*:#i', $basePath)) { return ''; } return $basePath; } function raijuLazyFormPlaceholder(string $formId, array $options = []): string { $basePath = raijuSafeFormBasePath((string) ($options['basePath'] ?? '')); $endpoint = $options['endpoint'] ?? ($basePath . 'api/form.php'); $delay = (int) ($options['delay'] ?? 3500); $attrs = [ 'data-raiju-lazy-form' => $formId, 'data-endpoint' => $endpoint, 'data-delay' => (string) max(0, $delay), ]; foreach (($options['params'] ?? []) as $key => $value) { if (!preg_match('/^[a-z0-9_-]+$/i', (string) $key)) { continue; } $attrs['data-param-' . strtolower((string) $key)] = (string) $value; } $htmlAttrs = ''; foreach ($attrs as $key => $value) { $htmlAttrs .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES, 'UTF-8') . '"'; } return ''; } function raijuValidateFormToken(?string $token, string $formId = 'contact'): array { $token = trim((string)$token); if ($token === '') return ['valid' => false, 'reason' => 'missing']; $hash = raijuFormTokenHash($token); $result = ['valid' => false, 'reason' => 'invalid']; $saved = raijuJsonUpdate(RAIJU_FORM_TOKEN_FILE, function (array &$tokens) use ($hash, $formId, &$result): bool { $entry = $tokens[$hash] ?? null; if (!is_array($entry) || ($entry['form'] ?? '') !== $formId) return false; unset($tokens[$hash]); $age = time() - (int)($entry['created'] ?? 0); $reason = $age < RAIJU_FORM_TOKEN_MIN_AGE ? 'too_fast' : ($age > RAIJU_FORM_TOKEN_MAX_AGE ? 'expired' : 'ok'); $result = ['valid' => $reason === 'ok', 'reason' => $reason]; return true; }); return !$saved && $result['valid'] ? ['valid' => false, 'reason' => 'storage'] : $result; } function raijuFormClientKey(): string { $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? ''; $salt = __DIR__; return hash('sha256', $ip . '|' . $userAgent . '|' . $salt); } function raijuFormPruneRate(array &$rate, string $clientKey): void { $now = time(); foreach ($rate as $key => $entry) { if (!is_array($entry)) { unset($rate[$key]); continue; } foreach (['submissions', 'failures', 'pending'] as $kind) { $rate[$key][$kind] = array_values(array_filter($entry[$kind] ?? [], static fn($timestamp) => $now - (int)$timestamp <= RAIJU_FORM_RATE_WINDOW)); } if (!$rate[$key]['submissions'] && !$rate[$key]['failures'] && !$rate[$key]['pending'] && $key !== $clientKey) unset($rate[$key]); } $rate[$clientKey] = $rate[$clientKey] ?? ['submissions' => [], 'failures' => [], 'pending' => []]; } function raijuFormRateState(string $clientKey): array { $state = ['submissions' => [], 'failures' => []]; $saved = raijuJsonUpdate(RAIJU_FORM_RATE_FILE, function (array &$rate) use ($clientKey, &$state): void { raijuFormPruneRate($rate, $clientKey); $state = $rate[$clientKey]; }); if (!$saved) return ['submissions' => array_fill(0, RAIJU_FORM_RATE_MAX_SUBMISSIONS, time()), 'failures' => []]; return $state; } function raijuFormIsRateLimited(string $clientKey): bool { $state = raijuFormRateState($clientKey); return count($state['submissions'] ?? []) + count($state['pending'] ?? []) >= RAIJU_FORM_RATE_MAX_SUBMISSIONS || count($state['failures'] ?? []) >= RAIJU_FORM_RATE_MAX_FAILURES; } /** Reserve a slot before processing so parallel submissions cannot evade limits. */ function raijuFormReserveRequest(string $clientKey): bool { return raijuJsonUpdate(RAIJU_FORM_RATE_FILE, function (array &$rate) use ($clientKey): bool { raijuFormPruneRate($rate, $clientKey); $state = &$rate[$clientKey]; if (count($state['submissions']) + count($state['pending']) >= RAIJU_FORM_RATE_MAX_SUBMISSIONS || count($state['failures']) >= RAIJU_FORM_RATE_MAX_FAILURES) return false; $state['pending'][] = time(); return true; }); } function raijuFormRecordAttempt(string $clientKey, bool $success): void { raijuJsonUpdate(RAIJU_FORM_RATE_FILE, function (array &$rate) use ($clientKey, $success): void { raijuFormPruneRate($rate, $clientKey); array_shift($rate[$clientKey]['pending']); $rate[$clientKey][$success ? 'submissions' : 'failures'][] = time(); }); } function raijuFormLooksLikeSpam(array $fields): bool { $text = implode("\n", array_map('strval', $fields)); preg_match_all('#https?://|www\.#i', $text, $links); if (count($links[0]) > 6) { return true; } if (preg_match('/\[url=|<\/a>|