Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
76.92% covered (warning)
76.92%
10 / 13
CRAP
87.25% covered (warning)
87.25%
89 / 102
MultipartAction
0.00% covered (danger)
0.00%
0 / 1
76.92% covered (warning)
76.92%
10 / 13
40.99
87.25% covered (warning)
87.25%
89 / 102
 __construct
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
3 / 3
 setLogger
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 baseExecute
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
9 / 9
 execute
n/a
0 / 0
1
n/a
0 / 0
 getActionName
n/a
0 / 0
1
n/a
0 / 0
 getRequiredParam
0.00% covered (danger)
0.00%
0 / 1
2.03
80.00% covered (warning)
80.00%
4 / 5
 getParam
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 getConfig
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 forgetConfig
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 getHeader
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
3 / 3
 error
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 processInput
0.00% covered (danger)
0.00%
0 / 1
20.29
74.42% covered (warning)
74.42%
32 / 43
 processFile
0.00% covered (danger)
0.00%
0 / 1
2.01
87.50% covered (warning)
87.50%
7 / 8
 writeResult
100.00% covered (success)
100.00%
1 / 1
5
100.00% covered (success)
100.00%
23 / 23
 getReceivedFileNames
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
<?php
namespace Shellbox\Action;
use GuzzleHttp\Psr7\MultipartStream;
use GuzzleHttp\Psr7\Stream;
use GuzzleHttp\Psr7\Utils;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shellbox\FileUtils;
use Shellbox\Multipart\MultipartReader;
use Shellbox\Multipart\MultipartUtils;
use Shellbox\Server;
use Shellbox\Shellbox;
use Shellbox\ShellboxError;
use Shellbox\TempDirManager;
/**
 * Base class for actions that share a specific input/output protocol.
 *
 * @todo Protocol documentation
 */
abstract class MultipartAction {
    /** @var TempDirManager */
    protected $tempDirManager;
    /** @var array */
    private $structuredData;
    /** @var string[] */
    private $binaryData;
    /** @var Server */
    private $server;
    /** @var string[] */
    private $headers;
    /** @var LoggerInterface */
    protected $logger;
    /** @var string[] */
    private $inputFiles = [];
    private const COPY_BUFFER_SIZE = 65536;
    /**
     * @param Server $server
     */
    public function __construct( Server $server ) {
        $this->server = $server;
        $this->logger = new NullLogger;
    }
    /**
     * @param LoggerInterface $logger
     */
    public function setLogger( LoggerInterface $logger ) {
        $this->logger = $logger;
    }
    /**
     * The entry point for execution of the action
     *
     * @param string[] $pathParts
     */
    public function baseExecute( $pathParts ) {
        try {
            $this->tempDirManager = Shellbox::createTempDirManager(
                $this->getConfig( 'tempDir' ) );
            $this->tempDirManager->setLogger( $this->logger );
            $this->processInput();
            $this->execute( $pathParts );
        } finally {
            if ( $this->tempDirManager ) {
                $this->tempDirManager->teardown();
            }
        }
    }
    /**
     * Override this to implement the action.
     *
     * @param string[] $pathParts
     */
    abstract protected function execute( $pathParts );
    /**
     * Override this to provide the action name as used in the URL.
     *
     * @return string
     */
    abstract protected function getActionName();
    /**
     * Get a parameter from the request, or throw if it isn't present
     *
     * @param string $name
     * @return mixed
     * @throws ShellboxError
     */
    protected function getRequiredParam( $name ) {
        $nonexistent = new \stdClass;
        $result = $this->getParam( $name, $nonexistent );
        if ( $result === $nonexistent ) {
            $this->error( "The $name parameter is required" );
        }
        return $result;
    }
    /**
     * Get a parameter from the request. Return the specified default if it
     * isn't present.
     *
     * @param string $name
     * @param mixed $default
     * @return mixed
     */
    protected function getParam( $name, $default = null ) {
        return $this->structuredData[$name] ?? $this->binaryData[$name] ?? $default;
    }
    /**
     * Get a configuration option, or throw if it doesn't exist.
     *
     * @param string $name
     * @return mixed
     * @throws ShellboxError
     */
    protected function getConfig( $name ) {
        return $this->server->getConfig( $name );
    }
    /**
     * Erase a configuration option
     *
     * @param string $name
     */
    protected function forgetConfig( $name ) {
        $this->server->forgetConfig( $name );
    }
    /**
     * Get a request header of a given name, or null if there was no such header.
     *
     * @param string $name
     * @return string|null
     */
    protected function getHeader( $name ) {
        if ( $this->headers === null ) {
            $this->headers = array_change_key_case( getallheaders(), CASE_LOWER );
        }
        return $this->headers[strtolower( $name )] ?? null;
    }
    /**
     * Throw an error exception
     *
     * @param string $message
     * @param int $code
     * @throws ShellboxError
     * @return never
     */
    protected function error( $message, $code = 500 ) {
        throw new ShellboxError( $message, $code );
    }
    /**
     * Process the request. Read the POST data, do some generic validation,
     * create input files and populate the parameter arrays.
     */
    private function processInput() {
        if ( $_SERVER['REQUEST_METHOD'] !== 'POST' ) {
            $this->error( 'The POST method must be used', 405 );
        }
        $auth = $this->getHeader( 'Authorization' );
        if ( $auth === null ) {
            $this->error( 'An Authorization header is required' );
        }
        // Phan doesn't understand functions that always throw, so thinks that
        // $auth could still be null. Maybe fixable -- see comment in
        // BlockExitStatusChecker::computeStatusOfCall().
        // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal
        if ( !preg_match( '/^sha256 ([0-9a-z]+)$/', $auth, $m ) ) {
            $this->error( 'Invalid Authorization header' );
        }
        $authHash = $m[1];
        $ctype = $this->getHeader( 'Content-Type' );
        if ( !preg_match( '/^multipart\/mixed/i', $ctype, $m ) ) {
            $this->error( 'The Content-Type must be multipart/mixed', 415 );
        }
        $boundary = MultipartUtils::extractBoundary( $ctype );
        if ( $boundary === false || $boundary === '' ) {
            $this->error( 'boundary is a required parameter of Content-Type' );
        }
        $inputFile = FileUtils::openInputFile( 'php://input' );
        $multipartReader = new MultipartReader(
            new Stream( $inputFile ),
            $boundary,
            $this->getConfig( 'secretKey' ) );
        $preamble = $multipartReader->readPreamble();
        if ( trim( $preamble ) !== '' ) {
            $this->error( 'The multipart preamble must be empty, otherwise a ' .
                'fraudulent boundary with a replayed body could be used to send ' .
                'unauthorized requests' );
        }
        // phpcs:ignore MediaWiki.ControlStructures.AssignmentInControlStructures
        while ( ( $headers = $multipartReader->readPartHeaders() ) !== false ) {
            if ( !isset( $headers['content-disposition'] ) ) {
                $this->error( 'Part has no Content-Disposition', 400 );
            }
            $disposition = $headers['content-disposition'];
            if ( $disposition['type'] === 'json-data' ) {
                $this->structuredData = $multipartReader->readPartAsJson( $headers );
            } elseif ( $disposition['type'] === 'form-data' ) {
                if ( !isset( $disposition['name'] ) ) {
                    $this->error( "multipart form-data requires name" );
                }
                $name = $disposition['name'];
                $this->binaryData[$name] = $multipartReader->readPartAsString();
            } elseif ( $disposition['type'] === 'attachment' ) {
                $this->processFile( $multipartReader, $headers );
            } else {
                $this->error( "Unknown content disposition type" );
            }
        }
        $multipartReader->readEpilogue();
        if ( $this->getRequiredParam( 'action' ) !== $this->getActionName() ) {
            $this->error( "The URL action must match the HMAC-verified parameter" );
        }
        if ( !hash_equals( $multipartReader->getHash(), $authHash ) ) {
            $this->error( "HMAC signature verification failed" );
        }
    }
    /**
     * Extract a single file from the MultipartReader
     *
     * @param MultipartReader $multipartReader
     * @param array $headers The part headers
     */
    private function processFile( $multipartReader, $headers ) {
        if ( !isset( $headers['content-disposition']['filename'] ) ) {
            $this->error( 'Part has no filename' );
        }
        $fileName = Shellbox::normalizePath( $headers['content-disposition']['filename'] );
        $path = $this->tempDirManager->preparePath( $fileName );
        $file = FileUtils::openOutputFile( $path );
        $multipartReader->copyPartToStream( Utils::streamFor( $file ) );
        $this->inputFiles[] = $fileName;
    }
    /**
     * Write a standard result
     *
     * @param array $structuredData JSON serializable data
     * @param string[] $binaryData An array of strings to be sent as multipart
     *   parts
     * @param string[] $files The names of the output files relative to the
     *   working directory
     */
    protected function writeResult( $structuredData, $binaryData = [], $files = [] ) {
        $boundary = Shellbox::getUniqueString();
        header( "Content-Type: multipart/mixed; boundary=\"$boundary\"" );
        $parts = [];
        $structuredData['log'] = $this->server->flushLogBuffer();
        $parts[] = [
            'name' => 'json-data',
            'headers' => [
                'Content-Type' => 'application/json',
                'Content-Disposition' => 'json-data',
            ],
            'contents' => Shellbox::jsonEncode( $structuredData )
        ];
        foreach ( $binaryData as $name => $value ) {
            $parts[] = [
                'name' => $name,
                'contents' => $value
            ];
        }
        foreach ( $files as $name ) {
            // phpcs:ignore Generic.PHP.NoSilencedErrors
            $stream = @fopen( $this->tempDirManager->getPath( $name ), 'r' );
            if ( $stream ) {
                $parts[] = [
                    'name' => $name,
                    'headers' => [
                        'Content-Type' => 'application/octet-stream',
                        'Content-Disposition' => "attachment; name=\"$name\""
                    ],
                    'contents' => Utils::streamFor( $stream )
                ];
            }
        }
        $multipartStream = new MultipartStream( $parts, $boundary );
        while ( !$multipartStream->eof() ) {
            echo $multipartStream->read( self::COPY_BUFFER_SIZE );
        }
    }
    /**
     * Get the names of the received files, relative to the temporary directory
     *
     * @return string[]
     */
    protected function getReceivedFileNames() {
        return $this->inputFiles;
    }
}