bkdotcom/PHPDebugConsole

View on GitHub
src/Debug/Plugin/LogReqRes.php

Summary

Maintainability
B
4 hrs
Test Coverage
A
94%
<?php

/**
 * This file is part of PHPDebugConsole
 *
 * @package   PHPDebugConsole
 * @author    Brad Kent <bkfake-github@yahoo.com>
 * @license   http://opensource.org/licenses/MIT MIT
 * @copyright 2014-2022 Brad Kent
 * @version   v3.0
 */

namespace bdk\Debug\Plugin;

use bdk\Debug;
use bdk\PubSub\Event;
use bdk\PubSub\SubscriberInterface;

/**
 * Log Request/Response
 * Display in dedicated tab
 *
 * Response will only avail if one of the following
 *   Debug::response obj avail (set via Debug::writeToResponse or setting 'response' service)
 *   output triggered via shutdown
 */
class LogReqRes implements SubscriberInterface
{
    protected $cfg = array(
        'channelName' => 'Request / Response',
        'channelOpts' => array(
            'channelIcon' => 'fa fa-exchange',
            'channelSort' => 10,
            'nested' => false,
        ),
    );
    private $debug;
    private $headerStyle = 'display:block; font-size:110%; font-weight:bold; padding:0.25em 0.5em; text-indent:0; border-bottom:#31708f 1px solid; background: linear-gradient(0deg, rgba(0,0,0,0.1) 0%, rgba(255,255,255,0.1) 100%);';

    /**
     * {@inheritDoc}
     */
    public function getSubscriptions()
    {
        return array(
            Debug::EVENT_BOOTSTRAP => 'onBootstrap',
            Debug::EVENT_OUTPUT => array('logResponse', PHP_INT_MAX),
        );
    }

    /**
     * Debug::EVENT_BOOTSTRAP subscriber
     *
     * @param Event $event Debug::EVENT_BOOTSTRAP Event instance
     *
     * @return void
     */
    public function onBootstrap(Event $event)
    {
        $this->debug = $event->getSubject();
        $this->setChannel();
        $collectWas = $this->debug->setCfg('collect', true);
        $this->logRequest();
        $this->debug->setCfg('collect', $collectWas, Debug::CONFIG_NO_RETURN);
    }

    /**
     * Log request headers, cookie, post, & files data
     *
     * @return void
     */
    public function logRequest()
    {
        if ($this->testLogRequest() === false) {
            return;
        }
        $this->debug->log(
            'Request',
            $this->debug->meta(array(
                'attribs' => array(
                    'style' => $this->headerStyle,
                ),
                'icon' => 'fa fa-arrow-right',
            ))
        );
        $this->debug->alert(
            '%c%s%c %s',
            'font-weight:bold;',
            $this->debug->serverRequest->getMethod(),
            '',
            $this->debug->serverRequest->getRequestTarget(),
            $this->debug->meta('level', 'info')
        );
        $this->logRequestHeaders();
        $this->logRequestCookies();
        $this->logPostOrInput();
        $this->logFiles();
    }

    /**
     * log response headers & body/content
     *
     * @return void
     */
    public function logResponse()
    {
        if ($this->testLogResponse() === false) {
            return;
        }
        $this->debug->log(
            'Response',
            $this->debug->meta(array(
                'attribs' => array(
                    'style' => $this->headerStyle,
                ),
                'icon' => 'fa fa-arrow-left',
            ))
        );
        $this->logResponseHeaders();
        $contentType = $this->debug->getResponseHeader('Content-Type');
        if (\preg_match('#\b(json|xml)\b#', $contentType) !== 1) {
            // we're not interested in logging response
            $this->debug->log(
                $contentType
                    ? 'Not logging response body for Content-Type "' . $contentType . '"'
                    : 'Content-Type unknown:  Not logging response body.'
            );
            return;
        }
        $this->logResponseContent($contentType);
    }

    /**
     * Log $_FILES
     *
     * If using ServerRequestInterface, will log result of `getUploadedFiles()`
     *
     * @return void
     */
    private function logFiles()
    {
        if (!$this->debug->getCfg('logRequestInfo.files', Debug::CONFIG_DEBUG)) {
            return;
        }
        $files = $this->debug->serverRequest->getUploadedFiles();
        if (!$files) {
            return;
        }
        $files = $this->debug->arrayUtil->mapRecursive(static function ($uploadedFile) {
            return array(
                'error' => $uploadedFile->getError(),
                'name' => $uploadedFile->getClientFilename(),
                'size' => $uploadedFile->getSize(),
                'tmp_name' => $uploadedFile->getError() === UPLOAD_ERR_OK
                    ? $uploadedFile->getStream()->getMetadata('uri')
                    : '',
                'type' => $uploadedFile->getClientMediaType(),
            );
        }, $files);
        $this->debug->log('$_FILES', $files);
    }

    /**
     * Log php://input
     *
     * @param string $method      Http method
     * @param string $contentType Content-Type value
     *
     * @return void
     */
    private function logInput($method, $contentType)
    {
        // Not POST, empty $_POST, or not application/x-www-form-urlencoded or multipart/form-data
        $request = $this->debug->serverRequest;
        $input = $this->debug->utility->getStreamContents($request->getBody());
        $methodHasBody = $this->debug->utility->httpMethodHasBody($method);
        $logInput = $input
            || $methodHasBody
            || $request->getHeaderLine('Content-Length')
            || $request->getHeaderLine('Transfer-Encoding');
        if ($logInput === false) {
            return;
        }
        $meta = $this->debug->meta(array(
            'detectFiles' => false,
            'file' => null,
            'line' => null,
        ));
        if ($input) {
            if ($methodHasBody === false) {
                $this->debug->warn($method . ' request with body', $meta);
            }
            $this->debug->log(
                'php://input',
                $this->debug->prettify($input, $contentType),
                $this->debug->meta('redact')
            );
        } elseif (!$request->getUploadedFiles()) {
            $this->debug->warn($method . ' request with no body', $meta);
        }
    }

    /**
     * Log $_POST or php://input
     *
     * @return void
     */
    private function logPostOrInput()
    {
        if (!$this->debug->getCfg('logRequestInfo.post', Debug::CONFIG_DEBUG)) {
            return;
        }
        $request = $this->debug->serverRequest;
        $method = $request->getMethod();
        $contentType = $request->getHeaderLine('Content-Type');
        $havePostVals = $method === 'POST'
            ? $this->logPostMethod($contentType)
            : false;
        if ($havePostVals === false) {
            $this->logInput($method, $contentType);
        }
    }

    /**
     * Log $_POST information when http method = POST
     *
     * @param string $contentType Content-Type value
     *
     * @return bool
     */
    private function logPostMethod($contentType)
    {
        $havePostVals = false;
        $isCorrectContentType = $this->testPostContentType($contentType);
        $post = $this->debug->serverRequest->getParsedBody();
        if (!$isCorrectContentType) {
            $this->debug->warn(
                'It appears ' . $contentType . ' was posted with the wrong Content-Type' . "\n"
                    . 'Pay no attention to $_POST and instead use php://input',
                $this->debug->meta(array(
                    'detectFiles' => false,
                    'file' => null,
                    'line' => null,
                ))
            );
        } elseif ($post) {
            $havePostVals = true;
            $this->debug->log('$_POST', $post, $this->debug->meta('redact'));
        }
        return $havePostVals;
    }

    /**
     * Log Request Cookies
     *
     * @return void
     */
    private function logRequestCookies()
    {
        if ($this->debug->getCfg('logRequestInfo.cookies', Debug::CONFIG_DEBUG) === false) {
            return;
        }
        $cookieVals = \array_map(function ($val) {
            if (\is_numeric($val)) {
                $val = $this->debug->abstracter->crateWithVals($val, array(
                    'attribs' => array(
                        'class' => 'text-left',
                    ),
                ));
            }
            return $val;
        }, $this->debug->serverRequest->getCookieParams());
        \ksort($cookieVals, SORT_NATURAL);
        if ($cookieVals) {
            $this->debug->table('$_COOKIE', $cookieVals, $this->debug->meta('redact'));
        }
    }

    /**
     * Log Request Headers
     *
     * @return void
     */
    private function logRequestHeaders()
    {
        if ($this->debug->getCfg('logRequestInfo.headers', Debug::CONFIG_DEBUG) === false) {
            return;
        }
        $headers = $this->debug->serverRequest->getHeaders();
        $headers = $this->debug->redactHeaders($headers);
        $headers = \array_map(function ($vals) {
            $val = \join(', ', $vals);
            if (\is_numeric($val)) {
                $val = $this->debug->abstracter->crateWithVals($val, array(
                    'attribs' => array(
                        'class' => 'text-left',
                    ),
                ));
            }
            return $val;
        }, $headers);
        if ($headers) {
            \ksort($headers, SORT_NATURAL);
            $this->debug->table('request headers', $headers);
        }
    }

    /**
     * log response body/content
     *
     * @param string $contentType Content-Type
     *
     * @return void
     */
    private function logResponseContent($contentType)
    {
        $maxLen = $this->debug->getCfg('logResponseMaxLen', Debug::CONFIG_DEBUG);
        $maxLen = $this->debug->utility->getBytes($maxLen, true);
        /*
            get the contents of the output buffer we started to collect response
            Note that we don't clear, echo, flush, or end the buffer here
        */
        $response = \ob_get_contents();
        $contentLength = \strlen($response);
        if ($this->debug->response) {
            $response = '';
            $stream = $this->debug->response->getBody();
            $contentLength = $stream->getSize(); // likely returns null (unknown)
            if ($contentLength <= $maxLen) {
                $response = $this->debug->utility->getStreamContents($stream);
                $contentLength = \strlen($response);
            }
        }
        if ($maxLen && $contentLength > $maxLen) {
            $this->debug->log('response too large to output (' . $contentLength . ')');
            return;
        }
        $this->debug->log(
            'response content (%c%s%c)',
            'font-family: monospace;',
            $contentType,
            '',
            $this->debug->prettify($response, $contentType),
            $this->debug->meta('redact')
        );
    }

    /**
     * log response headers
     *
     * @return void
     */
    private function logResponseHeaders()
    {
        $headers = \array_map(static function ($vals) {
            return \implode("\n", $vals);
        }, $this->debug->getResponseHeaders());
        $this->debug->table('response headers', $headers);
    }

    /**
     * Set `$this->debug` to "Request / Response" channel
     *
     * @return void
     */
    private function setChannel()
    {
        if ($this->debug->parentInstance) {
            return;
        }
        $this->debug = $this->debug->getChannel($this->cfg['channelName'], $this->cfg['channelOpts']);
    }

    /**
     * Check if we should log request
     *
     * @return bool
     */
    private function testLogRequest()
    {
        $isHttp = \strpos($this->debug->getInterface(), 'http') === 0;
        $logRequest = \count(\array_filter($this->debug->getCfg('logRequestInfo', Debug::CONFIG_DEBUG))) > 0;
        return $isHttp && $logRequest;
    }

    /**
     * Check if we should log response
     *
     * @return bool
     */
    private function testLogResponse()
    {
        $isHttp = \strpos($this->debug->getInterface(), 'http') === 0;
        $logResponse = $this->debug->rootInstance->getCfg('logResponse', Debug::CONFIG_DEBUG);
        return $isHttp && $logResponse;
    }

    /**
     * Test if $_POST is properly populated or not
     *
     * If JSON or XML is posted using the default application/x-www-form-urlencoded Content-Type
     * $_POST will be improperly populated
     *
     * @param string $contentType Will get populated with detected content type
     *
     * @return bool
     */
    private function testPostContentType(&$contentType)
    {
        $contentTypeRaw = $this->debug->serverRequest->getHeaderLine('Content-Type');
        if ($contentTypeRaw) {
            // remove charset/encoding if pressent
            $contentType = \preg_replace('/\s*[;,].*$/', '', $contentTypeRaw);
        }
        if (!$this->debug->serverRequest->getParsedBody()) {
            // nothing in $_POST means it can't be wrong
            return true;
        }
        /*
        $_POST is populated...
            which means Content-Type was application/x-www-form-urlencoded or multipart/form-data
            if we detect php://input is json or XML, then must have been
            posted with wrong Content-Type
        */
        $input = $this->debug->utility->getStreamContents($this->debug->serverRequest->getBody());
        $json = \json_decode($input, true);
        $isJson = \json_last_error() === JSON_ERROR_NONE && \is_array($json);
        if ($isJson) {
            $contentType = 'application/json';
            return false;
        }
        if ($this->debug->stringUtil->isXml($input)) {
            $contentType = 'text/xml';
            return false;
        }
        return true;
    }
}