123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236 |
- <?php
- /**
- * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
- namespace OCA\DAV\BulkUpload;
- use OCP\AppFramework\Http;
- use Psr\Log\LoggerInterface;
- use Sabre\DAV\Exception;
- use Sabre\DAV\Exception\BadRequest;
- use Sabre\DAV\Exception\LengthRequired;
- use Sabre\HTTP\RequestInterface;
- class MultipartRequestParser {
- /** @var resource */
- private $stream;
- /** @var string */
- private $boundary = '';
- /** @var string */
- private $lastBoundary = '';
- /**
- * @throws BadRequest
- */
- public function __construct(
- RequestInterface $request,
- protected LoggerInterface $logger,
- ) {
- $stream = $request->getBody();
- $contentType = $request->getHeader('Content-Type');
- if (!is_resource($stream)) {
- throw new BadRequest('Body should be of type resource');
- }
- if ($contentType === null) {
- throw new BadRequest('Content-Type can not be null');
- }
- $this->stream = $stream;
- $boundary = $this->parseBoundaryFromHeaders($contentType);
- $this->boundary = '--'.$boundary."\r\n";
- $this->lastBoundary = '--'.$boundary."--\r\n";
- }
- /**
- * Parse the boundary from the Content-Type header.
- * Example: Content-Type: "multipart/related; boundary=boundary_bf38b9b4b10a303a28ed075624db3978"
- *
- * @throws BadRequest
- */
- private function parseBoundaryFromHeaders(string $contentType): string {
- try {
- [$mimeType, $boundary] = explode(';', $contentType);
- [$boundaryKey, $boundaryValue] = explode('=', $boundary);
- } catch (\Exception $e) {
- throw new BadRequest('Error while parsing boundary in Content-Type header.', Http::STATUS_BAD_REQUEST, $e);
- }
- $boundaryValue = trim($boundaryValue);
- // Remove potential quotes around boundary value.
- if (str_starts_with($boundaryValue, '"') && str_ends_with($boundaryValue, '"')) {
- $boundaryValue = substr($boundaryValue, 1, -1);
- }
- if (trim($mimeType) !== 'multipart/related') {
- throw new BadRequest('Content-Type must be multipart/related');
- }
- if (trim($boundaryKey) !== 'boundary') {
- throw new BadRequest('Boundary is invalid');
- }
- return $boundaryValue;
- }
- /**
- * Check whether the stream's cursor is sitting right before the provided string.
- *
- * @throws Exception
- */
- private function isAt(string $expectedContent): bool {
- $expectedContentLength = strlen($expectedContent);
- $content = fread($this->stream, $expectedContentLength);
- if ($content === false) {
- throw new Exception('An error occurred while checking content');
- }
- $seekBackResult = fseek($this->stream, -$expectedContentLength, SEEK_CUR);
- if ($seekBackResult === -1) {
- throw new Exception('Unknown error while seeking content', Http::STATUS_INTERNAL_SERVER_ERROR);
- }
- return $expectedContent === $content;
- }
- /**
- * Check whether the stream's cursor is sitting right before the boundary.
- */
- private function isAtBoundary(): bool {
- return $this->isAt($this->boundary);
- }
- /**
- * Check whether the stream's cursor is sitting right before the last boundary.
- */
- public function isAtLastBoundary(): bool {
- return $this->isAt($this->lastBoundary);
- }
- /**
- * Parse and return the next part of the multipart headers.
- *
- * Example:
- * --boundary_azertyuiop
- * Header1: value
- * Header2: value
- *
- * Content of
- * the part
- *
- */
- public function parseNextPart(): array {
- $this->readBoundary();
- $headers = $this->readPartHeaders();
- $content = $this->readPartContent($headers['content-length'], $headers['x-file-md5']);
- return [$headers, $content];
- }
- /**
- * Read the boundary and check its content.
- *
- * @throws BadRequest
- */
- private function readBoundary(): string {
- if (!$this->isAtBoundary()) {
- throw new BadRequest('Boundary not found where it should be.');
- }
- return fread($this->stream, strlen($this->boundary));
- }
- /**
- * Return the headers of a part of the multipart body.
- *
- * @throws Exception
- * @throws BadRequest
- * @throws LengthRequired
- */
- private function readPartHeaders(): array {
- $headers = [];
- while (($line = fgets($this->stream)) !== "\r\n") {
- if ($line === false) {
- throw new Exception('An error occurred while reading headers of a part');
- }
- if (!str_contains($line, ':')) {
- $this->logger->error('Header missing ":" on bulk request: ' . json_encode($line));
- throw new Exception('An error occurred while reading headers of a part', Http::STATUS_BAD_REQUEST);
- }
- try {
- [$key, $value] = explode(':', $line, 2);
- $headers[strtolower(trim($key))] = trim($value);
- } catch (\Exception $e) {
- throw new BadRequest('An error occurred while parsing headers of a part', Http::STATUS_BAD_REQUEST, $e);
- }
- }
- if (!isset($headers['content-length'])) {
- throw new LengthRequired('The Content-Length header must not be null.');
- }
- if (!isset($headers['x-file-md5'])) {
- throw new BadRequest('The X-File-MD5 header must not be null.');
- }
- return $headers;
- }
- /**
- * Return the content of a part of the multipart body.
- *
- * @throws Exception
- * @throws BadRequest
- */
- private function readPartContent(int $length, string $md5): string {
- $computedMd5 = $this->computeMd5Hash($length);
- if ($md5 !== $computedMd5) {
- throw new BadRequest('Computed md5 hash is incorrect.');
- }
- if ($length === 0) {
- $content = '';
- } else {
- $content = stream_get_line($this->stream, $length);
- }
- if ($content === false) {
- throw new Exception("Fail to read part's content.");
- }
- if ($length !== 0 && feof($this->stream)) {
- throw new Exception('Unexpected EOF while reading stream.');
- }
- // Read '\r\n'.
- stream_get_contents($this->stream, 2);
- return $content;
- }
- /**
- * Compute the MD5 hash of the next x bytes.
- */
- private function computeMd5Hash(int $length): string {
- $context = hash_init('md5');
- hash_update_stream($context, $this->stream, $length);
- fseek($this->stream, -$length, SEEK_CUR);
- return hash_final($context);
- }
- }
|