Files
citrus-cms/app/Http/Controllers/Api/EndpointController.php
T
2026-04-28 21:14:25 +03:00

435 lines
13 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
/**
* @group Endpoints
*
* API endpoints for serving admin-ajax blade content.
* These endpoints provide access to backend blade files that return JSON or HTML responses.
*/
class EndpointController extends Controller
{
/**
* List Available Endpoints
*
* Returns a list of all available admin-ajax endpoints that can be accessed via the API.
* Each endpoint corresponds to a blade file in the admin-ajax directory.
*
* The response includes all endpoints with their names, URLs, HTTP methods, response types, and categories.
*
* @responseFile storage/app/scribe/endpoints-response.json
*/
public function index()
{
$endpoints = $this->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": "<div>Content here</div>",
* "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 <?php // @api-readonly
return Str::contains($content, '@api-readonly');
}
/**
* Get the file path for an endpoint
*/
protected function getEndpointPath(string $endpoint): ?string
{
// Check main directory
$path = resource_path("views/admin-ajax/{$endpoint}.blade.php");
if (File::exists($path)) {
return $path;
}
// Check subdirectories
$parts = explode('/', $endpoint);
if (count($parts) > 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';
}
}