validateCsrf()) { if ($this->isJsonRequest()) { $this->json(['error' => 'CSRF token invalid'], 403); } else { $this->text('CSRF token invalid', 403); } exit; } } private function isJsonRequest(): bool { $accept = $_SERVER['HTTP_ACCEPT'] ?? ''; $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; return str_contains($accept, 'application/json') || str_contains($contentType, 'application/json'); } protected function view(string $name, array $data = []): void { $data['csrfField'] = $this->csrfField(); $data['csrfToken'] = $this->csrfToken(); extract($data); $file = VIEW_PATH . '/' . str_replace('.', '/', $name) . '.php'; if (file_exists($file)) { require $file; } else { throw new \Exception("View not found: {$name}"); } } protected function json(mixed $data, int $status = 200): void { http_response_code($status); header('Content-Type: application/json'); echo json_encode($data, JSON_UNESCAPED_UNICODE); } protected function redirect(string $url, int $status = 302): never { http_response_code($status); header("Location: {$url}"); exit; } /** * Render a partial template (for HTMX responses). * * @param string $name Partial name (e.g., 'chat/message' or 'content/version') * @param array $data Data to pass to partial */ protected function partial(string $name, array $data = []): void { $data['csrfField'] = $this->csrfField(); $data['csrfToken'] = $this->csrfToken(); extract($data); // Try module-specific partials first, then global partials $paths = [ VIEW_PATH . '/' . str_replace('.', '/', $name) . '.php', VIEW_PATH . '/partials/' . str_replace('.', '/', $name) . '.php', ]; foreach ($paths as $file) { if (file_exists($file)) { require $file; return; } } throw new \Exception("Partial not found: {$name}"); } /** * Output an HTMX-compatible alert message. * * @param string $type Alert type: 'success', 'error', 'warning', 'info' * @param string $message The message to display */ protected function htmxAlert(string $type, string $message): void { $escapedMessage = htmlspecialchars($message, ENT_QUOTES, 'UTF-8'); echo "
{$escapedMessage}
"; } /** * Output success alert for HTMX. */ protected function htmxSuccess(string $message): void { $this->htmxAlert('success', $message); } /** * Output error alert for HTMX. */ protected function htmxError(string $message): void { $this->htmxAlert('error', $message); } /** * HTMX redirect via header. */ protected function htmxRedirect(string $url): void { header('HX-Redirect: ' . $url); $this->text('OK'); } /** * Output plain text response. */ protected function text(string $content, int $status = 200): void { http_response_code($status); header('Content-Type: text/plain; charset=utf-8'); print $content; } /** * Output HTML response. */ protected function html(string $content, int $status = 200): void { http_response_code($status); header('Content-Type: text/html; charset=utf-8'); print $content; } /** * Output file download response. */ protected function download(string $content, string $filename, string $contentType = 'application/octet-stream'): void { header('Content-Type: ' . $contentType . '; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Length: ' . strlen($content)); print $content; } /** * @return array */ protected function getJsonInput(): array { $input = file_get_contents('php://input'); if ($input === false || $input === '') { return []; } $decoded = json_decode($input, true); if (!is_array($decoded)) { return []; } return $decoded; } protected function jsonError(string $message, int $status = 500): void { $this->json(['success' => false, 'error' => $message], $status); } /** * Return 404 Not Found response and exit. * Automatically detects JSON requests. */ protected function notFound(string $message = 'Nicht gefunden'): never { if ($this->isJsonRequest()) { $this->json(['error' => $message], 404); } else { $this->text("404 - {$message}", 404); } exit; } /** * Decode JSON string to array with safe defaults. * * @return array */ protected function decodeJson(?string $json): array { if ($json === null || $json === '') { return []; } $decoded = json_decode($json, true); return is_array($decoded) ? $decoded : []; } /** * Get input value from GET or POST. */ protected function getInput(string $key, mixed $default = null): mixed { return $_GET[$key] ?? $_POST[$key] ?? $default; } /** * Get trimmed string input. */ protected function getString(string $key, string $default = ''): string { $value = $this->getInput($key, $default); return is_string($value) ? trim($value) : $default; } /** * Get integer input. */ protected function getInt(string $key, int $default = 0): int { return (int) ($this->getInput($key, $default)); } /** * Get current page number from request. */ protected function getPage(): int { return max(1, $this->getInt('page', 1)); } /** * Get limit with bounds. */ protected function getLimit(int $max = 50, int $default = 10): int { return min($max, max(1, $this->getInt('limit', $default))); } /** * Calculate offset from page and limit. */ protected function getOffset(int $limit): int { return ($this->getPage() - 1) * $limit; } /** * Create Pagination from request parameters. * This keeps HTTP-related code in Infrastructure (Controller) layer. */ protected function getPagination(int $defaultLimit = 50, int $maxLimit = 100): \Domain\ValueObject\Pagination { $page = $this->getPage(); $limit = $this->getLimit($maxLimit, $defaultLimit); return \Domain\ValueObject\Pagination::create($page, $limit, $maxLimit); } }