diff --git a/src/API/Logs/Index.php b/src/API/Logs/Index.php new file mode 100644 index 00000000..81fd36d3 --- /dev/null +++ b/src/API/Logs/Index.php @@ -0,0 +1,205 @@ +getUri()->withHost('')->withPort(0)->withScheme(''); + parse_str($apiUrl->getquery(), $query); + $query['stream'] = 1; + $query = http_build_query($query); + + foreach (glob($path . '/*.*.log') as $file) { + preg_match('/(\w+)\.(\w+)\.log/i', basename($file), $matches); + $url = $apiUrl->withPath(parseConfigValue(self::URL . "/" . basename($file))); + + $builder = [ + 'type' => $matches[1] ?? '??', + 'date' => $matches[2] ?? '??', + 'size' => filesize($file), + 'modified' => makeDate(filemtime($file))->format('Y-m-d H:i:s T'), + 'urls' => [ + 'self' => (string)$url, + 'stream' => (string)$url->withQuery($query), + ], + ]; + + $list[] = $builder; + } + + return api_response(HTTP_STATUS::HTTP_OK, ['logs' => $list]); + } + + #[Get(Index::URL . '/{filename}[/]', name: 'logs.view')] + public function logView(iRequest $request, array $args = []): iResponse + { + if (null === ($filename = ag($args, 'filename'))) { + return api_error('Invalid value for id path parameter.', HTTP_STATUS::HTTP_BAD_REQUEST); + } + + $path = realpath(fixPath(Config::get('tmpDir') . '/logs')); + + $filePath = realpath($path . '/' . $filename); + + if (false === $filePath) { + return api_error('File not found.', HTTP_STATUS::HTTP_NOT_FOUND); + } + + if (false === str_starts_with($filePath, $path)) { + return api_error('Invalid file path.', HTTP_STATUS::HTTP_BAD_REQUEST); + } + + $params = DataUtil::fromArray($request->getQueryParams()); + + $file = new SplFileObject($filePath, 'r'); + + if ($params->get('stream')) { + return $this->stream($filePath); + } + + if ($file->getSize() < 1) { + return api_response(HTTP_STATUS::HTTP_OK); + } + + $limit = (int)$params->get('limit', self::DEFAULT_LIMIT); + $limit = $limit < 1 ? self::DEFAULT_LIMIT : $limit; + + $file->seek(PHP_INT_MAX); + + $lastLine = $file->key(); + + $it = new LimitIterator($file, max(0, $lastLine - $limit), $lastLine); + + $stream = new Stream(fopen('php://memory', 'w')); + + foreach ($it as $line) { + $line = trim((string)$line); + + if (empty($line)) { + continue; + } + + $stream->write($line . PHP_EOL); + } + + $stream->rewind(); + + return new Response( + status: HTTP_STATUS::HTTP_OK->value, + headers: ['Content-Type' => 'text/plain'], + body: $stream + ); + } + + private function stream(string $filePath): iResponse + { + ini_set('max_execution_time', '3601'); + + $callable = function () use ($filePath) { + ignore_user_abort(true); + + try { + $cmd = 'exec tail --lines 0 -F ' . escapeshellarg($filePath); + + $process = Process::fromShellCommandline($cmd); + $process->setTimeout(3600); + + $process->start(callback: function ($type, $data) use ($process) { + echo "event: data\n"; + $data = trim((string)$data); + echo implode( + PHP_EOL, + array_map( + function ($data) { + if (!is_string($data)) { + return null; + } + return 'data: ' . trim($data); + }, + (array)preg_split("/\R/", $data) + ) + ); + echo "\n\n"; + + flush(); + + $this->counter = 3; + + if (ob_get_length() > 0) { + ob_end_flush(); + } + + if (connection_aborted()) { + $process->stop(1, 9); + } + }); + + while ($process->isRunning()) { + sleep(1); + $this->counter--; + + if ($this->counter > 1) { + continue; + } + + $this->counter = 3; + + echo "event: ping\n"; + echo 'data: ' . makeDate() . "\n\n"; + flush(); + + if (ob_get_length() > 0) { + ob_end_flush(); + } + + if (connection_aborted()) { + $process->stop(1, 9); + } + } + } catch (ProcessTimedOutException) { + } + + return ''; + }; + + return (new Response( + status: HTTP_STATUS::HTTP_OK->value, + headers: [ + 'Content-Type' => 'text/event-stream; charset=UTF-8', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no', + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Credentials' => 'true', + ], + body: StreamClosure::create($callable) + ))->withoutHeader('Content-Length'); + } +} diff --git a/src/Libs/Emitter.php b/src/Libs/Emitter.php index de223bd0..376a96da 100644 --- a/src/Libs/Emitter.php +++ b/src/Libs/Emitter.php @@ -9,6 +9,13 @@ use Psr\Http\Message\ResponseInterface as IResponse; final readonly class Emitter { + /** + * @param int $maxBufferLength int Maximum output buffering size for each iteration. + */ + public function __construct(protected int $maxBufferLength = 8192) + { + } + /** * Emit a response. * @@ -23,9 +30,7 @@ final readonly class Emitter // -- should be called after `emitHeaders()` in order to prevent PHP from changing the status code. $this->emitStatusLine($response); - if ($response->getBody()->getSize() > 0) { - echo $response->getBody(); - } + $this->emitBody($response); } /** @@ -100,6 +105,31 @@ final readonly class Emitter } } + /** + * Emit the message body. + */ + private function emitBody(IResponse $response): void + { + $body = $response->getBody(); + + if ($body->isSeekable()) { + $body->rewind(); + } + + if (!$body->isReadable()) { + echo $body; + return; + } + + while (!$body->eof()) { + echo $body->read($this->maxBufferLength); + flush(); + if (CONNECTION_NORMAL !== connection_status()) { + break; + } + } + } + private function header(string $headerName, bool $replace, int $statusCode): void { header($headerName, $replace, $statusCode); diff --git a/src/Libs/StreamClosure.php b/src/Libs/StreamClosure.php new file mode 100644 index 00000000..0f5aa45a --- /dev/null +++ b/src/Libs/StreamClosure.php @@ -0,0 +1,104 @@ +callback = $callback; + } + + public static function create(Closure $callback): StreamInterface + { + return new self($callback); + } + + public function __destruct() + { + } + + public function __toString() + { + return $this->getContents(); + } + + public function close(): void + { + } + + public function detach(): null + { + return null; + } + + public function getSize(): ?int + { + return null; + } + + public function tell(): int + { + return 0; + } + + public function eof(): bool + { + return false; + } + + public function isSeekable(): bool + { + return false; + } + + public function seek($offset, $whence = SEEK_SET): void + { + } + + public function rewind(): void + { + } + + public function isWritable(): bool + { + return false; + } + + public function write($string): int + { + throw new RuntimeException('Unable to write to a non-writable stream.'); + } + + public function isReadable(): bool + { + return true; + } + + public function read($length): string + { + return $this->getContents(); + } + + public function getContents(): string + { + $func = $this->callback; + + return (string)$func(); + } + + public function getMetadata($key = null): null + { + return null; + } +}