getAvailableEndpoints(); return response()->json([ 'status' => 'success', 'data' => [ 'endpoints' => $endpoints, 'total' => count($endpoints) ] ]); } /** * Execute Endpoint * * Executes the specified admin-ajax endpoint and returns the response. * The response type is automatically detected: * - If the output is valid JSON, returns JSON response * - Otherwise, returns HTML response * * @urlParam endpoint string required The endpoint name (e.g., users, ndt-calculation). Example: users * * @queryParam _format string Optional. Force response format: 'json' or 'html'. Example: json * * @response 200 scenario="JSON Response" { * "status": "success", * "data": [{"id": 1, "name": "John Doe"}], * "format": "json" * } * * @response 200 scenario="HTML Response" { * "status": "success", * "html": "
Content here
", * "format": "html" * } * * @response 404 { * "status": "error", * "message": "Endpoint not found" * } */ public function handle(Request $request, string $endpoint) { // Sanitize endpoint name $endpoint = $this->sanitizeEndpoint($endpoint); // Check if endpoint exists if (!$this->endpointExists($endpoint)) { return response()->json([ 'status' => 'error', 'message' => 'Endpoint not found', 'endpoint' => $endpoint ], 404); } // SECURITY: Only allow readonly endpoints via API if (!$this->isReadonlyEndpoint($endpoint)) { return response()->json([ 'status' => 'error', 'message' => 'This endpoint is not available via API. Only readonly endpoints are accessible.', 'endpoint' => $endpoint ], 403); } try { // Render the blade view and capture output $output = $this->renderEndpoint($endpoint, $request); // Detect response format $format = $request->input('_format'); if ($format === 'html') { return $this->htmlResponse($output); } if ($format === 'json' || $this->isJson($output)) { return $this->jsonResponse($output); } return $this->htmlResponse($output); } catch (\Exception $e) { return response()->json([ 'status' => 'error', 'message' => $e->getMessage(), 'endpoint' => $endpoint ], 500); } } /** * Get Endpoint Info * * Returns detailed information about a specific endpoint. * * @urlParam endpoint string required The endpoint name. Example: users * * @response 200 { * "status": "success", * "data": { * "name": "users", * "url": "/api/endpoints/users", * "methods": ["GET", "POST"], * "exists": true, * "api_accessible": true, * "path": "resources/views/admin-ajax/users.blade.php" * } * } */ public function info(Request $request, string $endpoint) { $endpoint = $this->sanitizeEndpoint($endpoint); $exists = $this->endpointExists($endpoint); $isReadonly = $exists ? $this->isReadonlyEndpoint($endpoint) : false; return response()->json([ 'status' => 'success', 'data' => [ 'name' => $endpoint, 'url' => "/api/endpoints/{$endpoint}", 'methods' => ['GET', 'POST'], 'exists' => $exists, 'api_accessible' => $isReadonly, 'path' => $exists ? "resources/views/admin-ajax/{$endpoint}.blade.php" : null, 'note' => !$isReadonly && $exists ? 'This endpoint is not marked as @api-readonly and cannot be accessed via API' : null ] ]); } /** * Sanitize endpoint name to prevent directory traversal */ protected function sanitizeEndpoint(string $endpoint): string { // Remove any path traversal attempts $endpoint = str_replace(['..', '/', '\\'], '', $endpoint); // Convert to lowercase and replace spaces with dashes $endpoint = Str::slug($endpoint); return $endpoint; } /** * Check if endpoint blade file exists */ protected function endpointExists(string $endpoint): bool { return View::exists("admin-ajax.{$endpoint}"); } /** * Check if endpoint is marked as readonly (safe for API access) * * Endpoints must have the annotation {{-- @api-readonly --}} at the beginning * to be accessible via the API. This prevents write/delete operations from * being exposed externally. */ protected function isReadonlyEndpoint(string $endpoint): bool { $path = resource_path("views/admin-ajax/{$endpoint}.blade.php"); if (!File::exists($path)) { return false; } $content = File::get($path); // Check for @api-readonly annotation // Supports: {{-- @api-readonly --}} or 1) { $subPath = resource_path("views/admin-ajax/" . implode('/', $parts) . ".blade.php"); if (File::exists($subPath)) { return $subPath; } } return null; } /** * Render the endpoint blade file */ protected function renderEndpoint(string $endpoint, Request $request): string { // Start output buffering to capture any echo/print statements ob_start(); try { // Render the view with request data $view = View::make("admin-ajax.{$endpoint}", [ 'request' => $request ]); // Get rendered content $rendered = $view->render(); // Get any buffered output (from echo statements in blade) $buffered = ob_get_clean(); // Return buffered output if exists, otherwise rendered content return !empty($buffered) ? $buffered : $rendered; } catch (\Exception $e) { ob_end_clean(); throw $e; } } /** * Check if string is valid JSON */ protected function isJson(string $string): bool { if (empty($string)) { return false; } $string = trim($string); // Quick check for JSON structure if (!in_array($string[0], ['{', '['])) { return false; } json_decode($string); return json_last_error() === JSON_ERROR_NONE; } /** * Return JSON response */ protected function jsonResponse(string $output) { $data = json_decode($output, true); // If already has status wrapper, return as-is if (is_array($data) && isset($data['status'])) { return response()->json($data); } return response()->json([ 'status' => 'success', 'data' => $data, 'format' => 'json' ]); } /** * Return HTML response */ protected function htmlResponse(string $output) { return response()->json([ 'status' => 'success', 'html' => $output, 'format' => 'html' ]); } /** * Get all available endpoints from admin-ajax directory * Only returns endpoints marked with @api-readonly annotation */ public function getAvailableEndpoints(): array { $endpoints = []; $path = resource_path('views/admin-ajax'); if (!File::isDirectory($path)) { return $endpoints; } // Scan all blade files recursively $this->scanEndpointsRecursive($path, '', $endpoints); // Sort alphabetically usort($endpoints, fn($a, $b) => strcmp($a['name'], $b['name'])); return $endpoints; } /** * Recursively scan directory for readonly endpoints */ protected function scanEndpointsRecursive(string $basePath, string $prefix, array &$endpoints): void { $files = File::files($basePath); foreach ($files as $file) { $filename = $file->getFilename(); // Only process .blade.php files if (!Str::endsWith($filename, '.blade.php')) { continue; } // Extract endpoint name $name = str_replace('.blade.php', '', $filename); $fullName = $prefix ? "{$prefix}/{$name}" : $name; // Check file content for @api-readonly annotation $content = File::get($file->getPathname()); // SECURITY: Only include endpoints marked as readonly if (!Str::contains($content, '@api-readonly')) { continue; } $endpoints[] = [ 'name' => $fullName, 'url' => "/api/endpoints/{$fullName}", 'methods' => ['GET', 'POST'] ]; } // Scan subdirectories $directories = File::directories($basePath); foreach ($directories as $dir) { $dirName = basename($dir); $newPrefix = $prefix ? "{$prefix}/{$dirName}" : $dirName; $this->scanEndpointsRecursive($dir, $newPrefix, $endpoints); } } /** * Get endpoints grouped by category (for documentation) */ public function getEndpointsByCategory(): array { $endpoints = $this->getAvailableEndpoints(); $categories = []; foreach ($endpoints as $endpoint) { $name = $endpoint['name']; // Determine category based on prefix $category = $this->categorizeEndpoint($name); if (!isset($categories[$category])) { $categories[$category] = []; } $categories[$category][] = $endpoint; } ksort($categories); return $categories; } /** * Categorize endpoint based on name prefix */ protected function categorizeEndpoint(string $name): string { $prefixes = [ 'ndt-' => 'NDT Operations', 'welder-' => 'Welder Management', 'weld' => 'Welding', 'register-' => 'Register Creator', 'test-package' => 'Test Packages', 'tp-' => 'Test Packages', 'document-' => 'Document Management', 'report-' => 'Reports', 'summary' => 'Summaries', 'excel-' => 'Excel Operations', 'pdf' => 'PDF Operations', 'user' => 'User Management', 'permission' => 'Permissions', 'content-' => 'Content Management', 'spool-' => 'Spool Operations', 'material-' => 'Materials', 'paint-' => 'Paint Operations', 'repair-' => 'Repair Operations', 'rfi-' => 'RFI Operations', 'punch-' => 'Punch Lists', 'mto' => 'MTO Operations', ]; foreach ($prefixes as $prefix => $category) { if (Str::startsWith($name, $prefix)) { return $category; } } return 'General'; } }