Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
128 / 128
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
JsErrorLogApiController
100.00% covered (success)
100.00%
127 / 127
100.00% covered (success)
100.00%
8 / 8
25
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 log
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
3
 logCsp
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
1 / 1
4
 list
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 clear
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 extractPayload
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 appendLog
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 createJsonResponse
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Logs\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Audit\Application\Service\SecurityAuditLogger;
12use App\Core\Audit\Domain\Model\SecurityEventSeverity;
13use App\Core\Audit\Domain\Model\SecurityEventType;
14use Psr\Http\Message\ResponseFactoryInterface;
15use Psr\Http\Message\ResponseInterface;
16use Psr\Http\Message\ServerRequestInterface;
17
18/**
19 * JavaScript and CSP Error Logging REST API Controller.
20 *
21 * Receives client-side browser errors and CSP violation reports,
22 * writing structured logs to storage/logs/ for debugging and telemetry.
23 *
24 * @package App\Modules\Logs\Presentation\Api
25 */
26final readonly class JsErrorLogApiController
27{
28    private const int MAX_LOG_FILE_SIZE = 5242880; // 5MB
29
30    /**
31     * JsErrorLogApiController constructor.
32     *
33     * @param ResponseFactoryInterface  $responseFactory PSR-7 Response factory.
34     * @param string                    $logDirectory    Log directory path.
35     * @param SecurityAuditLogger|null  $securityLogger  Optional security audit logger.
36     */
37    public function __construct(
38        private ResponseFactoryInterface $responseFactory,
39        private string                   $logDirectory,
40        private ?SecurityAuditLogger     $securityLogger = null,
41    ) {
42    }
43
44    /**
45     * Handles browser JS error submission.
46     *
47     * @param ServerRequestInterface $request PSR-7 Server request.
48     * @return ResponseInterface PSR-7 JSON response.
49     */
50    public function log(ServerRequestInterface $request): ResponseInterface
51    {
52        $body = $this->extractPayload($request);
53
54        $message = mb_substr(trim((string)($body['message'] ?? 'Unknown JavaScript Error')), 0, 1000);
55        $url = mb_substr(trim((string)($body['url'] ?? 'Unknown URL')), 0, 500);
56        $line = (int)($body['line'] ?? 0);
57        $col = (int)($body['column'] ?? 0);
58        $stack = mb_substr(trim((string)($body['stack'] ?? '')), 0, 5000);
59
60        $timestamp = date('Y-m-d H:i:s');
61        $userAgent = mb_substr((string)($request->getHeaderLine('User-Agent') ?: 'Browser'), 0, 500);
62        $ipAddress = (string)($request->getServerParams()['REMOTE_ADDR'] ?? '127.0.0.1');
63
64        $entry = sprintf(
65            "[%s] [JS_ERROR] [IP: %s] [UA: %s]\n" .
66            "Message: %s\nURL: %s (Line: %d, Col: %d)\n" .
67            "Stack:\n%s\n--------------------------------------------------\n",
68            $timestamp,
69            $ipAddress,
70            $userAgent,
71            $message,
72            $url,
73            $line,
74            $col,
75            $stack !== '' ? $stack : 'None'
76        );
77
78        $this->appendLog('js_errors.log', $entry);
79        $this->appendLog('client_errors.log', $entry);
80
81        return $this->createJsonResponse(201, [
82            'status' => true,
83            'message' => 'JS error logged successfully',
84        ]);
85    }
86
87    /**
88     * Handles CSP violation reports sent by browsers.
89     *
90     * @param ServerRequestInterface $request PSR-7 Server request.
91     * @return ResponseInterface PSR-7 JSON response.
92     */
93    public function logCsp(ServerRequestInterface $request): ResponseInterface
94    {
95        $payload = $this->extractPayload($request);
96        /** @var array<string, mixed> $report */
97        $report = (array)($payload['csp-report'] ?? $payload);
98
99        $documentUri = mb_substr(
100            trim((string)($report['document-uri'] ?? $report['documentURI'] ?? 'Unknown')),
101            0,
102            500
103        );
104        $blockedUri = mb_substr(trim((string)($report['blocked-uri'] ?? $report['blockedURI'] ?? 'Unknown')), 0, 500);
105        $violatedDirective = mb_substr(
106            trim((string)($report['violated-directive'] ?? $report['violatedDirective'] ?? 'Unknown')),
107            0,
108            200
109        );
110        $effectiveDirective = mb_substr(
111            trim((string)($report['effective-directive'] ?? $report['effectiveDirective'] ?? '')),
112            0,
113            200
114        );
115        $originalPolicy = mb_substr(
116            trim((string)($report['original-policy'] ?? $report['originalPolicy'] ?? '')),
117            0,
118            1000
119        );
120
121        $timestamp = date('Y-m-d H:i:s');
122        $userAgent = mb_substr((string)($request->getHeaderLine('User-Agent') ?: 'Browser'), 0, 500);
123        $ipAddress = (string)($request->getServerParams()['REMOTE_ADDR'] ?? '127.0.0.1');
124
125        $entry = sprintf(
126            "[%s] [CSP_VIOLATION] [IP: %s] [UA: %s]\n" .
127            "Document URI: %s\nBlocked URI: %s\nViolated Directive: %s (Effective: %s)\n" .
128            "Original Policy: %s\n--------------------------------------------------\n",
129            $timestamp,
130            $ipAddress,
131            $userAgent,
132            $documentUri,
133            $blockedUri,
134            $violatedDirective,
135            $effectiveDirective !== '' ? $effectiveDirective : 'N/A',
136            $originalPolicy !== '' ? $originalPolicy : 'N/A'
137        );
138
139        $this->appendLog('csp_violations.log', $entry);
140        $this->appendLog('client_errors.log', $entry);
141
142        $this->securityLogger?->log(
143            SecurityEventType::CSP_VIOLATION,
144            SecurityEventSeverity::HIGH,
145            "CSP violation on {$documentUri}: blocked '{$blockedUri}', directive '{$violatedDirective}'",
146            [
147                'document_uri'        => $documentUri,
148                'blocked_uri'         => $blockedUri,
149                'violated_directive'  => $violatedDirective,
150                'effective_directive' => $effectiveDirective,
151            ],
152            $request
153        );
154
155        return $this->createJsonResponse(201, [
156            'status' => true,
157            'message' => 'CSP violation logged successfully',
158        ]);
159    }
160
161    /**
162     * Returns recent client logs for developer/agent inspection.
163     *
164     * @param ServerRequestInterface $request PSR-7 Server request.
165     * @return ResponseInterface PSR-7 JSON response.
166     */
167    public function list(ServerRequestInterface $request): ResponseInterface
168    {
169        $params = $request->getQueryParams();
170        $type = (string)($params['type'] ?? 'all');
171        $fileName = match ($type) {
172            'csp' => 'csp_violations.log',
173            'js' => 'js_errors.log',
174            default => 'client_errors.log',
175        };
176
177        $logPath = rtrim($this->logDirectory, '/\\') . '/' . $fileName;
178        $content = file_exists($logPath) ? (string)@file_get_contents($logPath) : '';
179
180        return $this->createJsonResponse(200, [
181            'status' => true,
182            'type' => $type,
183            'content' => $content,
184        ]);
185    }
186
187    /**
188     * Clears client logs.
189     *
190     * @return ResponseInterface PSR-7 JSON response.
191     */
192    public function clear(): ResponseInterface
193    {
194        $logDir = rtrim($this->logDirectory, '/\\');
195        $files = ['js_errors.log', 'csp_violations.log', 'client_errors.log'];
196        foreach ($files as $file) {
197            $filePath = $logDir . '/' . $file;
198            if (file_exists($filePath)) {
199                @unlink($filePath);
200            }
201        }
202
203        return $this->createJsonResponse(200, [
204            'status' => true,
205            'message' => 'Client logs cleared successfully',
206        ]);
207    }
208
209    /**
210     * Extracts request body array supporting parsed bodies, raw JSON, and CSP reports.
211     *
212     * @param ServerRequestInterface $request PSR-7 Server request.
213     * @return array<string, mixed> Parsed body array.
214     */
215    private function extractPayload(ServerRequestInterface $request): array
216    {
217        $body = (array)($request->getParsedBody() ?? []);
218        if (!empty($body)) {
219            /** @var array<string, mixed> $body */
220            return $body;
221        }
222
223        $rawContent = (string)$request->getBody();
224        if ($rawContent === '') {
225            return [];
226        }
227
228        /** @var array<string, mixed>|null $decoded */
229        $decoded = json_decode($rawContent, true);
230        return is_array($decoded) ? $decoded : [];
231    }
232
233    /**
234     * Appends an entry to a specific log file, managing rotation.
235     *
236     * @param string $fileName Log file name.
237     * @param string $entry Log string entry.
238     */
239    private function appendLog(string $fileName, string $entry): void
240    {
241        $logDir = rtrim($this->logDirectory, '/\\');
242        if (!is_dir($logDir)) {
243            @mkdir($logDir, 0775, true);
244        }
245
246        $logFile = $logDir . '/' . $fileName;
247        if (file_exists($logFile) && (int)@filesize($logFile) > self::MAX_LOG_FILE_SIZE) {
248            @rename($logFile, $logFile . '.1');
249        }
250
251        @file_put_contents($logFile, $entry, FILE_APPEND | LOCK_EX);
252    }
253
254    /**
255     * Creates a standardized JSON response.
256     *
257     * @param int $status HTTP status code.
258     * @param array<string, mixed> $data Response payload.
259     * @return ResponseInterface PSR-7 JSON response.
260     */
261    private function createJsonResponse(int $status, array $data): ResponseInterface
262    {
263        $response = $this->responseFactory->createResponse($status);
264        $response->getBody()->write((string) json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
265        return $response->withHeader('Content-Type', 'application/json');
266    }
267}