mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-09-13 14:18:38 -04:00
* test: update php dependencies Signed-off-by: Saw-jan <saw.jan.grg3e@gmail.com> * test: use custom tus client Signed-off-by: Saw-jan <saw.jan.grg3e@gmail.com> * test: fix php code style and commands Signed-off-by: Saw-jan <saw.jan.grg3e@gmail.com> * test: use doctrine coding standard Signed-off-by: Saw-jan <saw.jan.grg3e@gmail.com> * test: convert headers value to string Signed-off-by: Saw-jan <saw.jan.grg3e@gmail.com> * test: initialize phpunit config Signed-off-by: Saw-jan <saw.jan.grg3e@gmail.com> * test: fix tus upload Signed-off-by: Saw-jan <saw.jan.grg3e@gmail.com> * test: fix php code style Signed-off-by: Saw-jan <saw.jan.grg3e@gmail.com> --------- Signed-off-by: Saw-jan <saw.jan.grg3e@gmail.com>
581 lines
12 KiB
PHP
581 lines
12 KiB
PHP
<?php declare(strict_types=1);
|
|
/**
|
|
* @author Sajan Gurung <sajan@jankaritech.com>
|
|
* @copyright Copyright (c) 2026 Sajan Gurung sajan@jankaritech.com
|
|
*
|
|
* This code is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License,
|
|
* as published by the Free Software Foundation;
|
|
* either version 3 of the License, or any later version.
|
|
*
|
|
* 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
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
|
*
|
|
*/
|
|
|
|
/**
|
|
* Most of the code is copied from https://github.com/ankitpokhrel/tus-php/blob/main/src/Tus/Client.php
|
|
*/
|
|
|
|
namespace TestHelpers;
|
|
|
|
use GuzzleHttp\Client as GuzzleClient;
|
|
use GuzzleHttp\Exception\ClientException;
|
|
use GuzzleHttp\Exception\GuzzleException;
|
|
use GuzzleHttp\Exception\ConnectException;
|
|
use Symfony\Component\HttpFoundation\Response as HttpResponse;
|
|
|
|
/**
|
|
* TUS client for uploading files using the TUS protocol.
|
|
*/
|
|
class TUSClient {
|
|
/**
|
|
* @const string Tus protocol version.
|
|
*/
|
|
public const TUS_PROTOCOL_VERSION = '1.0.0';
|
|
|
|
/**
|
|
* @const string Header Content Type
|
|
*/
|
|
protected const HEADER_CONTENT_TYPE = 'application/offset+octet-stream';
|
|
|
|
/**
|
|
* @const Input stream
|
|
*/
|
|
public const INPUT_STREAM = 'php://input';
|
|
|
|
/**
|
|
* @const Read binary mode
|
|
*/
|
|
public const READ_BINARY = 'rb';
|
|
|
|
/**
|
|
* @var GuzzleClient
|
|
*/
|
|
protected $client;
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
protected $apiPath = '/files';
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
protected $filePath;
|
|
|
|
/**
|
|
* @var int
|
|
*/
|
|
protected $fileSize = 0;
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
protected $fileName;
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
protected $url;
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
protected $checksum;
|
|
|
|
/**
|
|
* @var int
|
|
*/
|
|
protected $partialOffset = -1;
|
|
|
|
/**
|
|
* @var string
|
|
*/
|
|
protected $checksumAlgorithm = 'sha256';
|
|
|
|
/**
|
|
* @var array
|
|
*/
|
|
protected $metadata = [];
|
|
|
|
/**
|
|
* @var array
|
|
*/
|
|
protected $headers = [];
|
|
|
|
/**
|
|
* Client constructor.
|
|
*
|
|
* @param string $baseUri
|
|
* @param array $options
|
|
*
|
|
* @throws \ReflectionException
|
|
*/
|
|
public function __construct(string $baseUri, array $options = []) {
|
|
$this->headers = $options['headers'] ?? [];
|
|
$options['headers'] = [
|
|
'Tus-Resumable' => self::TUS_PROTOCOL_VERSION,
|
|
] + ($this->headers);
|
|
|
|
$this->client = new GuzzleClient(
|
|
['base_uri' => $baseUri] + $options
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get guzzle client.
|
|
*
|
|
* @return GuzzleClient
|
|
*/
|
|
public function getClient(): GuzzleClient {
|
|
return $this->client;
|
|
}
|
|
|
|
/**
|
|
* Get file size.
|
|
*
|
|
* @return int
|
|
*/
|
|
public function getFileSize(): int {
|
|
return $this->fileSize;
|
|
}
|
|
|
|
/**
|
|
* Get file path.
|
|
*
|
|
* @return string|null
|
|
*/
|
|
public function getFilePath(): ?string {
|
|
return $this->filePath;
|
|
}
|
|
|
|
/**
|
|
* Get url.
|
|
*
|
|
* @return string|null
|
|
*/
|
|
public function getUrl(): ?string {
|
|
return $this->url;
|
|
}
|
|
|
|
/**
|
|
* Set API path.
|
|
*
|
|
* @param string $path
|
|
*
|
|
* @return self
|
|
*/
|
|
public function setApiPath(string $path): self {
|
|
$this->apiPath = $path;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Set checksum algorithm.
|
|
*
|
|
* @param string $algorithm
|
|
*
|
|
* @return Client
|
|
*/
|
|
public function setChecksumAlgorithm(string $algorithm): self {
|
|
$this->checksumAlgorithm = $algorithm;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Get checksum algorithm.
|
|
*
|
|
* @return string
|
|
*/
|
|
public function getChecksumAlgorithm(): string {
|
|
return $this->checksumAlgorithm;
|
|
}
|
|
|
|
/**
|
|
* Set checksum.
|
|
*
|
|
* @param string $checksum
|
|
*
|
|
* @return Client
|
|
*/
|
|
public function setChecksum(string $checksum): self {
|
|
$this->checksum = $checksum;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Get checksum.
|
|
*
|
|
* @return string
|
|
*/
|
|
public function getChecksum(): string {
|
|
if (empty($this->checksum)) {
|
|
$this->setChecksum(hash_file($this->getChecksumAlgorithm(), $this->getFilePath()));
|
|
}
|
|
|
|
return $this->checksum;
|
|
}
|
|
|
|
/**
|
|
* Get upload checksum header.
|
|
*
|
|
* @return string
|
|
*/
|
|
protected function getUploadChecksumHeader(): string {
|
|
return $this->getChecksumAlgorithm() . ' ' . base64_encode($this->getChecksum());
|
|
}
|
|
|
|
/**
|
|
* Set metadata.
|
|
*
|
|
* @param array $items
|
|
*
|
|
* @return Client
|
|
*/
|
|
public function setMetadata(array $items): self {
|
|
$items = array_map('base64_encode', $items);
|
|
|
|
$this->metadata = $items;
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Get metadata.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getMetadata(): array {
|
|
return $this->metadata;
|
|
}
|
|
|
|
/**
|
|
* Add metadata.
|
|
*
|
|
* @param string $key
|
|
* @param string $value
|
|
*
|
|
* @return Client
|
|
*/
|
|
public function addMetadata(string $key, string $value): self {
|
|
$this->metadata[$key] = base64_encode($value);
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Get metadata for Upload-Metadata header.
|
|
*
|
|
* @return string
|
|
*/
|
|
protected function getUploadMetadataHeader(): string {
|
|
$metadata = [];
|
|
|
|
foreach ($this->getMetadata() as $key => $value) {
|
|
$metadata[] = "{$key} {$value}";
|
|
}
|
|
|
|
return implode(',', $metadata);
|
|
}
|
|
|
|
/**
|
|
* Set file properties.
|
|
*
|
|
* @param string $file File path.
|
|
* @param string|null $name File name.
|
|
*
|
|
* @return Client
|
|
*/
|
|
public function file(string $file, string $name = null): self {
|
|
$this->filePath = $file;
|
|
|
|
if (! file_exists($file) || ! is_readable($file)) {
|
|
throw new FileException('Cannot read file: ' . $file);
|
|
}
|
|
|
|
$this->fileName = $name ?? basename($this->filePath);
|
|
$this->fileSize = filesize($file);
|
|
|
|
$this->addMetadata('filename', $this->fileName);
|
|
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Handle client exception during patch request.
|
|
*
|
|
* @param ClientException $e
|
|
*
|
|
* @return \Exception
|
|
*/
|
|
protected function handleClientException(ClientException $e) {
|
|
$response = $e->getResponse();
|
|
$statusCode = $response !== null ? $response->getStatusCode() : HttpResponse::HTTP_INTERNAL_SERVER_ERROR;
|
|
|
|
if ($statusCode === HttpResponse::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE) {
|
|
return new FileException('The uploaded file is corrupt.');
|
|
}
|
|
|
|
if ($statusCode === HttpResponse::HTTP_CONTINUE) {
|
|
return new ConnectionException('Connection aborted by user.');
|
|
}
|
|
|
|
if ($statusCode === HttpResponse::HTTP_UNSUPPORTED_MEDIA_TYPE) {
|
|
return new TusException('Unsupported media types.');
|
|
}
|
|
|
|
return new TusException((string) $response->getBody(), $statusCode);
|
|
}
|
|
|
|
/**
|
|
* Send HEAD request.
|
|
*
|
|
* @throws FileException
|
|
* @throws GuzzleException
|
|
*
|
|
* @return int
|
|
*/
|
|
protected function sendHeadRequest(): int {
|
|
if (!$this->getUrl()) {
|
|
throw new FileException('Upload URL not found.');
|
|
}
|
|
|
|
$response = $this->getClient()->head($this->getUrl());
|
|
$statusCode = $response->getStatusCode();
|
|
|
|
if ($statusCode !== HttpResponse::HTTP_OK) {
|
|
throw new FileException('File not found.');
|
|
}
|
|
|
|
return (int) current($response->getHeader('upload-offset'));
|
|
}
|
|
|
|
/**
|
|
* Send PATCH request.
|
|
*
|
|
* @param int $bytes
|
|
* @param int $offset
|
|
*
|
|
* @throws TusException
|
|
* @throws FileException
|
|
* @throws GuzzleException
|
|
* @throws ConnectionException
|
|
*
|
|
* @return int
|
|
*/
|
|
protected function sendPatchRequest(int $bytes, int $offset): int {
|
|
$data = $this->getData($offset, $bytes);
|
|
$headers = $this->headers + [
|
|
'Content-Type' => self::HEADER_CONTENT_TYPE,
|
|
'Content-Length' => (string)\strlen($data),
|
|
'Upload-Checksum' => $this->getUploadChecksumHeader(),
|
|
'Upload-Offset' => (string)$offset,
|
|
];
|
|
|
|
try {
|
|
$response = $this->getClient()->patch(
|
|
$this->getUrl(),
|
|
[
|
|
'body' => $data,
|
|
'headers' => $headers,
|
|
]
|
|
);
|
|
|
|
return (int) current($response->getHeader('upload-offset'));
|
|
} catch (ClientException $e) {
|
|
throw $this->handleClientException($e);
|
|
} catch (ConnectException $e) {
|
|
throw new ConnectionException("Couldn't connect to server.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if file to read exists.
|
|
*
|
|
* @param string $filePath
|
|
* @param string $mode
|
|
*
|
|
* @throws FileException
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function exists(string $filePath, string $mode = self::READ_BINARY): bool {
|
|
if ($filePath === self::INPUT_STREAM) {
|
|
return true;
|
|
}
|
|
|
|
if ($mode === self::READ_BINARY && ! file_exists($filePath)) {
|
|
throw new FileException('File not found.');
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get X bytes of data from file.
|
|
*
|
|
* @param int $offset
|
|
* @param int $bytes
|
|
*
|
|
* @return string
|
|
*/
|
|
protected function getData(int $offset, int $bytes): string {
|
|
$filePath = $this->getFilePath();
|
|
$mode = self::READ_BINARY;
|
|
|
|
$this->exists($filePath, $mode);
|
|
$handle = @fopen($filePath, $mode);
|
|
|
|
if ($handle === false) {
|
|
throw new FileException("Unable to open $filePath.");
|
|
}
|
|
|
|
$position = fseek($handle, $offset, SEEK_SET);
|
|
if ($position === -1) {
|
|
throw new FileException('Cannot move pointer to desired position.');
|
|
}
|
|
|
|
$data = fread($handle, $bytes);
|
|
if ($data === false) {
|
|
throw new FileException('Cannot read file.');
|
|
}
|
|
|
|
fclose($handle);
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Upload file.
|
|
*
|
|
* @param int $bytes Bytes to upload
|
|
*
|
|
* @throws TusException
|
|
* @throws GuzzleException
|
|
* @throws ConnectionException
|
|
*
|
|
* @return int
|
|
*/
|
|
public function upload(int $bytes = -1): int {
|
|
$bytes = $bytes < 0 ? $this->getFileSize() : $bytes;
|
|
$offset = $this->partialOffset < 0 ? 0 : $this->partialOffset;
|
|
|
|
try {
|
|
// Check if this upload exists with HEAD request.
|
|
$offset = $this->sendHeadRequest();
|
|
} catch (FileException | ClientException $e) {
|
|
// Create a new upload.
|
|
$this->url = $this->create();
|
|
} catch (ConnectException $e) {
|
|
throw new ConnectionException("Couldn't connect to server.");
|
|
}
|
|
|
|
// Now, resume upload with PATCH request.
|
|
return $this->sendPatchRequest($bytes, $offset);
|
|
}
|
|
|
|
/**
|
|
* Create resource with POST request.
|
|
*
|
|
* @throws FileException
|
|
* @throws GuzzleException
|
|
*
|
|
* @return string
|
|
*/
|
|
public function create(): string {
|
|
return $this->createWithUpload(0)['location'];
|
|
}
|
|
|
|
/**
|
|
* Create resource with POST request and upload data using the creation-with-upload extension.
|
|
*
|
|
* @see https://tus.io/protocols/resumable-upload.html#creation-with-upload
|
|
*
|
|
* @param int $bytes -1 => all data; 0 => no data
|
|
*
|
|
* @throws GuzzleException
|
|
*
|
|
* @return array [
|
|
* 'location' => string,
|
|
* 'offset' => int
|
|
* ]
|
|
*/
|
|
public function createWithUpload(int $bytes = -1): array {
|
|
$bytes = $bytes < 0 ? $this->fileSize : $bytes;
|
|
|
|
$headers = $this->headers + [
|
|
'Upload-Length' => (string)$this->fileSize,
|
|
'Upload-Checksum' => $this->getUploadChecksumHeader(),
|
|
'Upload-Metadata' => $this->getUploadMetadataHeader(),
|
|
];
|
|
|
|
$data = '';
|
|
if ($bytes > 0) {
|
|
$data = $this->getData(0, $bytes);
|
|
|
|
$headers += [
|
|
'Content-Type' => self::HEADER_CONTENT_TYPE,
|
|
'Content-Length' => (string)\strlen($data),
|
|
];
|
|
}
|
|
|
|
try {
|
|
$response = $this->getClient()->post(
|
|
$this->apiPath,
|
|
[
|
|
'body' => $data,
|
|
'headers' => $headers,
|
|
]
|
|
);
|
|
} catch (ClientException $e) {
|
|
$response = $e->getResponse();
|
|
}
|
|
|
|
$statusCode = $response->getStatusCode();
|
|
|
|
if ($statusCode !== HttpResponse::HTTP_CREATED) {
|
|
throw new FileException('Unable to create resource.');
|
|
}
|
|
|
|
$uploadOffset = $bytes > 0 ? current($response->getHeader('upload-offset')) : 0;
|
|
$uploadLocation = current($response->getHeader('location'));
|
|
|
|
return [
|
|
'location' => $uploadLocation,
|
|
'offset' => $uploadOffset,
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* --------------------------------
|
|
* Exceptions
|
|
* --------------------------------
|
|
*/
|
|
|
|
/**
|
|
* File exception class.
|
|
*/
|
|
class FileException extends \RuntimeException {
|
|
}
|
|
|
|
/**
|
|
* Connection exception class.
|
|
*/
|
|
class ConnectionException extends \Exception {
|
|
}
|
|
|
|
/**
|
|
* Tus exception class.
|
|
*/
|
|
class TusException extends \Exception {
|
|
}
|