mirror of https://github.com/nextcloud/server.git
Merge pull request #28991 from nextcloud/feature/bulk_upload
commit
6204c63308
@ -0,0 +1,100 @@
|
||||
<?php
|
||||
/**
|
||||
* @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me>
|
||||
*
|
||||
* @author Louis Chemineau <louis@chmn.me>
|
||||
*
|
||||
* @license AGPL-3.0
|
||||
*
|
||||
* This code is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, version 3,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License, version 3,
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\DAV\BulkUpload;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Sabre\DAV\Server;
|
||||
use Sabre\DAV\ServerPlugin;
|
||||
use Sabre\HTTP\RequestInterface;
|
||||
use Sabre\HTTP\ResponseInterface;
|
||||
use OCP\Files\Folder;
|
||||
use OCP\AppFramework\Http;
|
||||
|
||||
class BulkUploadPlugin extends ServerPlugin {
|
||||
|
||||
/** @var Folder */
|
||||
private $userFolder;
|
||||
|
||||
/** @var LoggerInterface */
|
||||
private $logger;
|
||||
|
||||
public function __construct(Folder $userFolder, LoggerInterface $logger) {
|
||||
$this->userFolder = $userFolder;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register listener on POST requests with the httpPost method.
|
||||
*/
|
||||
public function initialize(Server $server): void {
|
||||
$server->on('method:POST', [$this, 'httpPost'], 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle POST requests on /dav/bulk
|
||||
* - parsing is done with a MultipartContentsParser object
|
||||
* - writing is done with the userFolder service
|
||||
*
|
||||
* Will respond with an object containing an ETag for every written files.
|
||||
*/
|
||||
public function httpPost(RequestInterface $request, ResponseInterface $response): bool {
|
||||
// Limit bulk upload to the /dav/bulk endpoint
|
||||
if ($request->getPath() !== "bulk") {
|
||||
return true;
|
||||
}
|
||||
|
||||
$multiPartParser = new MultipartRequestParser($request);
|
||||
$writtenFiles = [];
|
||||
|
||||
while (!$multiPartParser->isAtLastBoundary()) {
|
||||
try {
|
||||
[$headers, $content] = $multiPartParser->parseNextPart();
|
||||
} catch (\Exception $e) {
|
||||
// Return early if an error occurs during parsing.
|
||||
$this->logger->error($e->getMessage());
|
||||
$response->setStatus(Http::STATUS_BAD_REQUEST);
|
||||
$response->setBody(json_encode($writtenFiles));
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$node = $this->userFolder->newFile($headers['x-file-path'], $content);
|
||||
$writtenFiles[$headers['x-file-path']] = [
|
||||
"error" => false,
|
||||
"etag" => $node->getETag(),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error($e->getMessage(), ['path' => $headers['x-file-path']]);
|
||||
$writtenFiles[$headers['x-file-path']] = [
|
||||
"error" => true,
|
||||
"message" => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$response->setStatus(Http::STATUS_OK);
|
||||
$response->setBody(json_encode($writtenFiles));
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
@ -0,0 +1,239 @@
|
||||
<?php
|
||||
/**
|
||||
* @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me>
|
||||
*
|
||||
* @author Louis Chemineau <louis@chmn.me>
|
||||
*
|
||||
* @license AGPL-3.0
|
||||
*
|
||||
* This code is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, version 3,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License, version 3,
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\DAV\BulkUpload;
|
||||
|
||||
use Sabre\HTTP\RequestInterface;
|
||||
use Sabre\DAV\Exception;
|
||||
use Sabre\DAV\Exception\BadRequest;
|
||||
use Sabre\DAV\Exception\LengthRequired;
|
||||
use OCP\AppFramework\Http;
|
||||
|
||||
class MultipartRequestParser {
|
||||
|
||||
/** @var resource */
|
||||
private $stream;
|
||||
|
||||
/** @var string */
|
||||
private $boundary = "";
|
||||
|
||||
/** @var string */
|
||||
private $lastBoundary = "";
|
||||
|
||||
/**
|
||||
* @throws BadRequest
|
||||
*/
|
||||
public function __construct(RequestInterface $request) {
|
||||
$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 (substr($boundaryValue, 0, 1) == '"' && substr($boundaryValue, -1) == '"') {
|
||||
$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');
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
$content = stream_get_line($this->stream, $length);
|
||||
|
||||
if ($content === false) {
|
||||
throw new Exception("Fail to read part's content.");
|
||||
}
|
||||
|
||||
if (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);
|
||||
}
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -eu
|
||||
|
||||
# benchmark.sh
|
||||
|
||||
export KB=1000
|
||||
export MB=$((KB*1000))
|
||||
|
||||
MAX_UPLOAD_SIZE=$((512*KB))
|
||||
|
||||
export CONCURRENCY=5
|
||||
export BANDWIDTH=$((100*MB/CONCURRENCY))
|
||||
|
||||
FILE_SIZES=($((1*KB)) $((10*KB)) $((100*KB)))
|
||||
|
||||
echo "Concurrency: $CONCURRENCY"
|
||||
echo "Bandwidth: $BANDWIDTH"
|
||||
|
||||
md_output="# Bulk upload benchmark\n"
|
||||
md_output+="\n"
|
||||
md_output+="- Concurrency: $CONCURRENCY\n"
|
||||
md_output+="- Bandwidth: ${BANDWIDTH}B\n"
|
||||
md_output+="\n"
|
||||
md_output+="| Nb | Size (B) | Bundle (sec) | Single (sec) |\n"
|
||||
md_output+="|---|---|---|---|\n"
|
||||
|
||||
requests_count='1 2 3 4 5'
|
||||
|
||||
for size in "${FILE_SIZES[@]}"
|
||||
do
|
||||
nb=$((MAX_UPLOAD_SIZE/size))
|
||||
|
||||
echo "- Upload of $nb tiny file of ${size}B"
|
||||
echo " - Bundled"
|
||||
start=$(date +%s)
|
||||
echo "$requests_count" | xargs -d ' ' -P $CONCURRENCY -I{} ./bulk_upload.sh "$nb" "$size"
|
||||
end=$(date +%s)
|
||||
bulk_exec_time=$((end-start))
|
||||
echo "${bulk_exec_time}s"
|
||||
|
||||
echo " - Single"
|
||||
start=$(date +%s)
|
||||
echo "$requests_count" | xargs -d ' ' -P $CONCURRENCY -I{} ./single_upload.sh "$nb" "$size"
|
||||
end=$(date +%s)
|
||||
single_exec_time=$((end-start))
|
||||
echo "${single_exec_time}s"
|
||||
|
||||
md_output+="| $nb | $size | $bulk_exec_time | $single_exec_time |\n"
|
||||
done
|
||||
|
||||
echo -en "$md_output"
|
@ -0,0 +1,78 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -eu
|
||||
|
||||
# bulk_upload.sh <nb-of-files> <size-of-files>
|
||||
|
||||
KB=${KB:-100}
|
||||
MB=${MB:-$((KB*1000))}
|
||||
|
||||
NB=$1
|
||||
SIZE=$2
|
||||
|
||||
CONCURRENCY=${CONCURRENCY:-1}
|
||||
BANDWIDTH=${BANDWIDTH:-$((100*MB/CONCURRENCY))}
|
||||
|
||||
USER="admin"
|
||||
PASS="password"
|
||||
SERVER="nextcloud.test"
|
||||
UPLOAD_PATH="/tmp/bulk_upload_request_$(openssl rand --hex 8).txt"
|
||||
BOUNDARY="boundary_$(openssl rand --hex 8)"
|
||||
LOCAL_FOLDER="/tmp/bulk_upload/${BOUNDARY}_${NB}_${SIZE}"
|
||||
REMOTE_FOLDER="/bulk_upload/${BOUNDARY}_${NB}_${SIZE}"
|
||||
|
||||
mkdir --parent "$LOCAL_FOLDER"
|
||||
|
||||
for ((i=1; i<="$NB"; i++))
|
||||
do
|
||||
file_name=$(openssl rand --hex 8)
|
||||
file_local_path="$LOCAL_FOLDER/$file_name.txt"
|
||||
file_remote_path="$REMOTE_FOLDER/$file_name.txt"
|
||||
head -c "$SIZE" /dev/urandom > "$file_local_path"
|
||||
file_mtime=$(stat -c %Y "$file_local_path")
|
||||
file_hash=$(md5sum "$file_local_path" | awk '{ print $1 }')
|
||||
file_size=$(du -sb "$file_local_path" | awk '{ print $1 }')
|
||||
|
||||
{
|
||||
echo -en "--$BOUNDARY\r\n"
|
||||
# echo -en "Content-ID: $file_name\r\n"
|
||||
echo -en "X-File-Path: $file_remote_path\r\n"
|
||||
echo -en "X-File-Mtime: $file_mtime\r\n"
|
||||
# echo -en "X-File-Id: $file_id\r\n"
|
||||
echo -en "X-File-Md5: $file_hash\r\n"
|
||||
echo -en "Content-Length: $file_size\r\n"
|
||||
echo -en "\r\n" >> "$UPLOAD_PATH"
|
||||
|
||||
cat "$file_local_path"
|
||||
echo -en "\r\n" >> "$UPLOAD_PATH"
|
||||
} >> "$UPLOAD_PATH"
|
||||
done
|
||||
|
||||
echo -en "--$BOUNDARY--\r\n" >> "$UPLOAD_PATH"
|
||||
|
||||
echo "Creating folder /bulk_upload"
|
||||
curl \
|
||||
-X MKCOL \
|
||||
-k \
|
||||
"https://$USER:$PASS@$SERVER/remote.php/dav/files/$USER/bulk_upload" > /dev/null
|
||||
|
||||
echo "Creating folder $REMOTE_FOLDER"
|
||||
curl \
|
||||
-X MKCOL \
|
||||
-k \
|
||||
"https://$USER:$PASS@$SERVER/remote.php/dav/files/$USER/$REMOTE_FOLDER"
|
||||
|
||||
echo "Uploading $NB files with total size: $(du -sh "$UPLOAD_PATH" | cut -d ' ' -f1)"
|
||||
echo "Local file is: $UPLOAD_PATH"
|
||||
curl \
|
||||
-X POST \
|
||||
-k \
|
||||
--progress-bar \
|
||||
--limit-rate "${BANDWIDTH}k" \
|
||||
--cookie "XDEBUG_PROFILE=true;path=/;" \
|
||||
-H "Content-Type: multipart/related; boundary=$BOUNDARY" \
|
||||
--data-binary "@$UPLOAD_PATH" \
|
||||
"https://$USER:$PASS@$SERVER/remote.php/dav/bulk"
|
||||
|
||||
rm -rf "${LOCAL_FOLDER:?}"
|
||||
rm "$UPLOAD_PATH"
|
@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -eu
|
||||
|
||||
# single_upload.sh <nb-of-files> <size-of-files>
|
||||
|
||||
export KB=${KB:-100}
|
||||
export MB=${MB:-$((KB*1000))}
|
||||
|
||||
export NB=$1
|
||||
export SIZE=$2
|
||||
|
||||
export CONCURRENCY=${CONCURRENCY:-1}
|
||||
export BANDWIDTH=${BANDWIDTH:-$((100*MB/CONCURRENCY))}
|
||||
|
||||
export USER="admin"
|
||||
export PASS="password"
|
||||
export SERVER="nextcloud.test"
|
||||
export UPLOAD_ID="single_$(openssl rand --hex 8)"
|
||||
export LOCAL_FOLDER="/tmp/single_upload/${UPLOAD_ID}_${NB}_${SIZE}"
|
||||
export REMOTE_FOLDER="/single_upload/${UPLOAD_ID}_${NB}_${SIZE}"
|
||||
|
||||
mkdir --parent "$LOCAL_FOLDER"
|
||||
|
||||
curl \
|
||||
-X MKCOL \
|
||||
-k \
|
||||
"https://$USER:$PASS@$SERVER/remote.php/dav/files/$USER/bulk_upload" > /dev/null
|
||||
|
||||
curl \
|
||||
-X MKCOL \
|
||||
-k \
|
||||
--cookie "XDEBUG_SESSION=true;path=/;" \
|
||||
"https://$USER:$PASS@$SERVER/remote.php/dav/files/$USER/$REMOTE_FOLDER"
|
||||
|
||||
upload_file() {
|
||||
file_name=$(openssl rand --hex 8)
|
||||
file_local_path="$LOCAL_FOLDER/$file_name.txt"
|
||||
file_remote_path="$REMOTE_FOLDER/$file_name.txt"
|
||||
head -c "$SIZE" /dev/urandom > "$file_local_path"
|
||||
|
||||
curl \
|
||||
-X PUT \
|
||||
-k \
|
||||
--limit-rate "${BANDWIDTH}k" \
|
||||
--data-binary @"$file_local_path" "https://$USER:$PASS@$SERVER/remote.php/webdav/$file_remote_path"
|
||||
}
|
||||
export -f upload_file
|
||||
|
||||
file_list=''
|
||||
for ((i=1; i<"$NB"; i++))
|
||||
do
|
||||
file_list+="$i "
|
||||
done
|
||||
file_list+=$NB
|
||||
|
||||
echo "$file_list" | xargs -d ' ' -P "$((CONCURRENCY/5))" -I{} bash -c "upload_file {}"
|
||||
|
||||
printf "\n"
|
||||
|
||||
rm -rf "${LOCAL_FOLDER:?}"/*
|
@ -0,0 +1,281 @@
|
||||
<?php
|
||||
/**
|
||||
* @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me>
|
||||
*
|
||||
* @author Louis Chemineau <louis@chmn.me>
|
||||
*
|
||||
* @license AGPL-3.0
|
||||
*
|
||||
* This code is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, version 3,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License, version 3,
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\DAV\Tests\unit\DAV;
|
||||
|
||||
use Test\TestCase;
|
||||
use \OCA\DAV\BulkUpload\MultipartRequestParser;
|
||||
|
||||
class MultipartRequestParserTest extends TestCase {
|
||||
private function getValidBodyObject() {
|
||||
return [
|
||||
[
|
||||
"headers" => [
|
||||
"Content-Length" => 7,
|
||||
"X-File-MD5" => "4f2377b4d911f7ec46325fe603c3af03",
|
||||
"X-File-Path" => "/coucou.txt"
|
||||
],
|
||||
"content" => "Coucou\n"
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
private function getMultipartParser(array $parts, array $headers = [], string $boundary = "boundary_azertyuiop"): MultipartRequestParser {
|
||||
$request = $this->getMockBuilder('Sabre\HTTP\RequestInterface')
|
||||
->disableOriginalConstructor()
|
||||
->getMock();
|
||||
|
||||
$headers = array_merge(['Content-Type' => 'multipart/related; boundary='.$boundary], $headers);
|
||||
$request->expects($this->any())
|
||||
->method('getHeader')
|
||||
->willReturnCallback(function (string $key) use (&$headers) {
|
||||
return $headers[$key];
|
||||
});
|
||||
|
||||
$body = "";
|
||||
foreach ($parts as $part) {
|
||||
$body .= '--'.$boundary."\r\n";
|
||||
|
||||
foreach ($part['headers'] as $headerKey => $headerPart) {
|
||||
$body .= $headerKey.": ".$headerPart."\r\n";
|
||||
}
|
||||
|
||||
$body .= "\r\n";
|
||||
$body .= $part['content']."\r\n";
|
||||
}
|
||||
|
||||
$body .= '--'.$boundary."--";
|
||||
|
||||
$stream = fopen('php://temp','r+');
|
||||
fwrite($stream, $body);
|
||||
rewind($stream);
|
||||
|
||||
$request->expects($this->any())
|
||||
->method('getBody')
|
||||
->willReturn($stream);
|
||||
|
||||
return new MultipartRequestParser($request);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Test validation of the request's body type
|
||||
*/
|
||||
public function testBodyTypeValidation() {
|
||||
$bodyStream = "I am not a stream, but pretend to be";
|
||||
$request = $this->getMockBuilder('Sabre\HTTP\RequestInterface')
|
||||
->disableOriginalConstructor()
|
||||
->getMock();
|
||||
$request->expects($this->any())
|
||||
->method('getBody')
|
||||
->willReturn($bodyStream);
|
||||
|
||||
$this->expectExceptionMessage('Body should be of type resource');
|
||||
new MultipartRequestParser($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with valid request.
|
||||
* - valid boundary
|
||||
* - valid md5 hash
|
||||
* - valid content-length
|
||||
* - valid file content
|
||||
* - valid file path
|
||||
*/
|
||||
public function testValidRequest() {
|
||||
$multipartParser = $this->getMultipartParser(
|
||||
$this->getValidBodyObject()
|
||||
);
|
||||
|
||||
[$headers, $content] = $multipartParser->parseNextPart();
|
||||
|
||||
$this->assertSame((int)$headers["content-length"], 7, "Content-Length header should be the same as provided.");
|
||||
$this->assertSame($headers["x-file-md5"], "4f2377b4d911f7ec46325fe603c3af03", "X-File-MD5 header should be the same as provided.");
|
||||
$this->assertSame($headers["x-file-path"], "/coucou.txt", "X-File-Path header should be the same as provided.");
|
||||
|
||||
$this->assertSame($content, "Coucou\n", "Content should be the same");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with invalid md5 hash.
|
||||
*/
|
||||
public function testInvalidMd5Hash() {
|
||||
$bodyObject = $this->getValidBodyObject();
|
||||
$bodyObject["0"]["headers"]["X-File-MD5"] = "f2377b4d911f7ec46325fe603c3af03";
|
||||
$multipartParser = $this->getMultipartParser(
|
||||
$bodyObject
|
||||
);
|
||||
|
||||
$this->expectExceptionMessage('Computed md5 hash is incorrect.');
|
||||
$multipartParser->parseNextPart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with a null md5 hash.
|
||||
*/
|
||||
public function testNullMd5Hash() {
|
||||
$bodyObject = $this->getValidBodyObject();
|
||||
unset($bodyObject["0"]["headers"]["X-File-MD5"]);
|
||||
$multipartParser = $this->getMultipartParser(
|
||||
$bodyObject
|
||||
);
|
||||
|
||||
$this->expectExceptionMessage('The X-File-MD5 header must not be null.');
|
||||
$multipartParser->parseNextPart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with a null Content-Length.
|
||||
*/
|
||||
public function testNullContentLength() {
|
||||
$bodyObject = $this->getValidBodyObject();
|
||||
unset($bodyObject["0"]["headers"]["Content-Length"]);
|
||||
$multipartParser = $this->getMultipartParser(
|
||||
$bodyObject
|
||||
);
|
||||
|
||||
$this->expectExceptionMessage('The Content-Length header must not be null.');
|
||||
$multipartParser->parseNextPart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with a lower Content-Length.
|
||||
*/
|
||||
public function testLowerContentLength() {
|
||||
$bodyObject = $this->getValidBodyObject();
|
||||
$bodyObject["0"]["headers"]["Content-Length"] = 6;
|
||||
$multipartParser = $this->getMultipartParser(
|
||||
$bodyObject
|
||||
);
|
||||
|
||||
$this->expectExceptionMessage('Computed md5 hash is incorrect.');
|
||||
$multipartParser->parseNextPart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with a higher Content-Length.
|
||||
*/
|
||||
public function testHigherContentLength() {
|
||||
$bodyObject = $this->getValidBodyObject();
|
||||
$bodyObject["0"]["headers"]["Content-Length"] = 8;
|
||||
$multipartParser = $this->getMultipartParser(
|
||||
$bodyObject
|
||||
);
|
||||
|
||||
$this->expectExceptionMessage('Computed md5 hash is incorrect.');
|
||||
$multipartParser->parseNextPart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with wrong boundary in body.
|
||||
*/
|
||||
public function testWrongBoundary() {
|
||||
$bodyObject = $this->getValidBodyObject();
|
||||
$multipartParser = $this->getMultipartParser(
|
||||
$bodyObject,
|
||||
['Content-Type' => 'multipart/related; boundary=boundary_poiuytreza']
|
||||
);
|
||||
|
||||
$this->expectExceptionMessage('Boundary not found where it should be.');
|
||||
$multipartParser->parseNextPart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with no boundary in request headers.
|
||||
*/
|
||||
public function testNoBoundaryInHeader() {
|
||||
$bodyObject = $this->getValidBodyObject();
|
||||
$this->expectExceptionMessage('Error while parsing boundary in Content-Type header.');
|
||||
$this->getMultipartParser(
|
||||
$bodyObject,
|
||||
['Content-Type' => 'multipart/related']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with no boundary in the request's headers.
|
||||
*/
|
||||
public function testNoBoundaryInBody() {
|
||||
$bodyObject = $this->getValidBodyObject();
|
||||
$multipartParser = $this->getMultipartParser(
|
||||
$bodyObject,
|
||||
['Content-Type' => 'multipart/related; boundary=boundary_azertyuiop'],
|
||||
''
|
||||
);
|
||||
|
||||
$this->expectExceptionMessage('Boundary not found where it should be.');
|
||||
$multipartParser->parseNextPart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with a boundary with quotes in the request's headers.
|
||||
*/
|
||||
public function testBoundaryWithQuotes() {
|
||||
$bodyObject = $this->getValidBodyObject();
|
||||
$multipartParser = $this->getMultipartParser(
|
||||
$bodyObject,
|
||||
['Content-Type' => 'multipart/related; boundary="boundary_azertyuiop"'],
|
||||
);
|
||||
|
||||
$multipartParser->parseNextPart();
|
||||
|
||||
// Dummy assertion, we just want to test that the parsing works.
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with a wrong Content-Type in the request's headers.
|
||||
*/
|
||||
public function testWrongContentType() {
|
||||
$bodyObject = $this->getValidBodyObject();
|
||||
$this->expectExceptionMessage('Content-Type must be multipart/related');
|
||||
$this->getMultipartParser(
|
||||
$bodyObject,
|
||||
['Content-Type' => 'multipart/form-data; boundary="boundary_azertyuiop"'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with a wrong key after the content type in the request's headers.
|
||||
*/
|
||||
public function testWrongKeyInContentType() {
|
||||
$bodyObject = $this->getValidBodyObject();
|
||||
$this->expectExceptionMessage('Boundary is invalid');
|
||||
$this->getMultipartParser(
|
||||
$bodyObject,
|
||||
['Content-Type' => 'multipart/related; wrongkey="boundary_azertyuiop"'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with a null Content-Type in the request's headers.
|
||||
*/
|
||||
public function testNullContentType() {
|
||||
$bodyObject = $this->getValidBodyObject();
|
||||
$this->expectExceptionMessage('Content-Type can not be null');
|
||||
$this->getMultipartParser(
|
||||
$bodyObject,
|
||||
['Content-Type' => null],
|
||||
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue