Viewing File: /home/markqprx/iniasli.pro/Tus/TusServer.php
<?php
namespace Common\Files\Tus;
use Carbon\Carbon;
use Common\Files\Actions\ValidateFileUpload;
use Common\Settings\Settings;
use Ramsey\Uuid\Uuid;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use TusPhp\Exception\ConnectionException;
use TusPhp\Exception\FileException;
use TusPhp\Exception\OutOfRangeException;
class TusServer
{
protected TusCache $cache;
protected array $allowedHttpVerbs = [
Request::METHOD_POST,
Request::METHOD_PATCH,
Request::METHOD_DELETE,
Request::METHOD_HEAD,
Request::METHOD_OPTIONS,
];
protected const TUS_EXTENSIONS = [
'creation',
'termination',
'checksum',
'expiration',
];
protected const TUS_PROTOCOL_VERSION = '1.0.0';
protected const HEADER_CONTENT_TYPE = 'application/offset+octet-stream';
protected const DEFAULT_CHECKSUM_ALGORITHM = 'sha256';
protected const HTTP_CHECKSUM_MISMATCH = 460;
public function __construct()
{
$this->cache = app(TusCache::class);
}
public function serve(): Response
{
$requestMethod = request()->method();
if (!in_array($requestMethod, $this->allowedHttpVerbs, true)) {
return $this->response(null, Response::HTTP_METHOD_NOT_ALLOWED);
}
$clientVersion = request()->header('Tus-Resumable');
if (
Request::METHOD_OPTIONS !== $requestMethod &&
$clientVersion &&
self::TUS_PROTOCOL_VERSION !== $clientVersion
) {
return $this->response(null, Response::HTTP_PRECONDITION_FAILED, [
'Tus-Version' => self::TUS_PROTOCOL_VERSION,
]);
}
return match (strtoupper($requestMethod)) {
'OPTIONS' => $this->handleOptions(),
'HEAD' => $this->handleHead(),
'POST' => $this->handlePost(),
'PATCH' => $this->handlePatch(),
'DELETE' => $this->handleDelete(),
};
}
protected function handleOptions(): Response
{
$supportedAlgorithms = hash_algos();
$algorithms = [];
foreach ($supportedAlgorithms as $hashAlgo) {
if (str_contains($hashAlgo, ',')) {
$algorithms[] = "'{$hashAlgo}'";
} else {
$algorithms[] = $hashAlgo;
}
}
$headers = [
'Allow' => implode(',', $this->allowedHttpVerbs),
'Tus-Version' => self::TUS_PROTOCOL_VERSION,
'Tus-Extension' => implode(',', self::TUS_EXTENSIONS),
'Tus-Checksum-Algorithm' => implode(',', $algorithms),
];
$maxUploadSize = app(Settings::class)->get('uploads.max_size');
if ($maxUploadSize > 0) {
$headers['Tus-Max-Size'] = $maxUploadSize;
}
return $this->response(null, Response::HTTP_OK, $headers);
}
protected function handleHead(): Response
{
$uploadKey = $this->getUploadKeyFromUrl();
if (!($tusData = $this->cache->get($uploadKey))) {
return $this->response(null, Response::HTTP_NOT_FOUND);
}
$offset = $tusData['offset'] ?? false;
if ($offset === false) {
return $this->response(null, Response::HTTP_GONE);
}
$headers = [
'Upload-Length' => (int) $tusData['size'],
'Upload-Offset' => (int) $tusData['offset'],
'Cache-Control' => 'no-store',
];
return $this->response(null, Response::HTTP_OK, $headers);
}
protected function handlePost(): Response
{
$meta = $this->extractMeta();
$errors = app(ValidateFileUpload::class)->execute([
'size' => $meta['clientSize'],
'extension' => $meta['clientExtension'],
]);
if ($errors) {
return $this->response(
json_encode(['message' => $errors->first()]),
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
$uploadKey = $this->getOrCreateUploadKey();
$filePath = storage_path("tus/$uploadKey");
$checksum = $this->getClientChecksum();
$location = url("api/v1/tus/upload/$uploadKey");
$expiresAt = now()->addDay();
$formattedExpiresAt = $expiresAt->format('D, d M Y H:i:s \G\M\T');
$this->cache->set(
$uploadKey,
[
'size' => (int) request()->header('Upload-Length'),
'offset' => 0,
'checksum' => $checksum,
'location' => $location,
'file_path' => $filePath,
'metadata' => $this->extractMeta(),
'created_at' => Carbon::now()->timestamp,
'expires_at' => $formattedExpiresAt,
],
$expiresAt,
);
return $this->response(null, Response::HTTP_CREATED, [
'Location' => $location,
'Upload-Expires' => $formattedExpiresAt,
]);
}
protected function handlePatch(): Response
{
$uploadKey = $this->getUploadKeyFromUrl();
if (!($tusData = $this->cache->get($uploadKey))) {
return $this->response(null, Response::HTTP_GONE);
}
$status = $this->verifyPatchRequest($tusData);
if (Response::HTTP_OK !== $status) {
return $this->response(null, $status);
}
$checksum = $tusData['checksum'];
$fileSize = $tusData['size'];
try {
$newOffset = (new TusFile([
'upload_key' => $this->getUploadKeyFromUrl(),
'total_bytes' => $tusData['size'],
'file_path' => $tusData['file_path'],
'offset' => $tusData['offset'],
]))->upload();
if (
$newOffset === $fileSize &&
!$this->verifyChecksum($checksum, $tusData['file_path'])
) {
return $this->response(null, self::HTTP_CHECKSUM_MISMATCH);
}
} catch (FileException $e) {
return $this->response(
$e->getMessage(),
Response::HTTP_UNPROCESSABLE_ENTITY,
);
} catch (OutOfRangeException) {
return $this->response(
null,
Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE,
);
} catch (ConnectionException) {
return $this->response(null, Response::HTTP_CONTINUE);
}
if (!($tusData = $this->cache->get($uploadKey))) {
return $this->response(null, Response::HTTP_GONE);
}
return $this->response(null, Response::HTTP_NO_CONTENT, [
'Content-Type' => self::HEADER_CONTENT_TYPE,
'Upload-Expires' => $tusData['expires_at'],
'Upload-Offset' => $newOffset,
]);
}
protected function verifyPatchRequest(array $meta): int
{
$uploadOffset = request()->header('upload-offset');
if ($uploadOffset && $uploadOffset !== (string) $meta['offset']) {
return Response::HTTP_CONFLICT;
}
$contentType = request()->header('Content-Type');
if ($contentType !== self::HEADER_CONTENT_TYPE) {
return Response::HTTP_UNSUPPORTED_MEDIA_TYPE;
}
return Response::HTTP_OK;
}
protected function handleDelete(): Response
{
$uploadKey = $this->getUploadKeyFromUrl();
$tusData = $this->cache->get($uploadKey);
$resource = $tusData['file_path'] ?? null;
if (!$resource) {
return $this->response(null, Response::HTTP_NOT_FOUND);
}
$isDeleted = $this->cache->delete($uploadKey);
if (!$isDeleted || !file_exists($resource)) {
return $this->response(null, Response::HTTP_GONE);
}
unlink($resource);
return $this->response(null, Response::HTTP_NO_CONTENT, [
'Tus-Extension' => 'termination',
]);
}
protected function getClientChecksum(): string
{
$checksumHeader = request()->header('Upload-Checksum');
if (empty($checksumHeader)) {
return '';
}
[$checksumAlgorithm, $checksum] = explode(' ', $checksumHeader);
$checksum = base64_decode($checksum);
if (
$checksum === false ||
!in_array($checksumAlgorithm, hash_algos(), true)
) {
abort(Response::HTTP_BAD_REQUEST);
}
return $checksum;
}
protected function verifyChecksum(string $checksum, string $filePath): bool
{
if (empty($checksum)) {
return true;
}
return $checksum ===
hash_file($this->getChecksumAlgorithm(), $filePath);
}
protected function getChecksumAlgorithm(): ?string
{
$checksumHeader = request()->header('Upload-Checksum');
if (empty($checksumHeader)) {
return self::DEFAULT_CHECKSUM_ALGORITHM;
}
[$checksumAlgorithm] = explode(' ', $checksumHeader);
return $checksumAlgorithm;
}
protected function getOrCreateUploadKey(): string
{
if (!empty($this->uploadKey)) {
return $this->uploadKey;
}
$key = request()->header('Upload-Key') ?? Uuid::uuid4()->toString();
if (empty($key)) {
abort(Response::HTTP_BAD_REQUEST);
}
$this->uploadKey = $key;
return $this->uploadKey;
}
protected function extractMeta(): array
{
$uploadMetaData = request()->header('Upload-Metadata');
if (empty($uploadMetaData)) {
return [];
}
$uploadMetaDataChunks = explode(',', $uploadMetaData);
$result = [];
foreach ($uploadMetaDataChunks as $chunk) {
$pieces = explode(' ', trim($chunk));
$key = $pieces[0];
$value = $pieces[1] ?? '';
$result[$key] = base64_decode($value);
}
return $result;
}
function getUploadKeyFromUrl(): string
{
return basename(request()->getPathInfo());
}
protected function response(
string $content = null,
int $status = 200,
array $headers = [],
): Response {
$mergedHeaders = array_merge(
[
'X-Content-Type-Options' => 'nosniff',
'Tus-Resumable' => self::TUS_PROTOCOL_VERSION,
'Access-Control-Allow-Origin' => request()->header('Origin'),
'Access-Control-Allow-Methods' => implode(
',',
$this->allowedHttpVerbs,
),
'Access-Control-Allow-Headers' =>
'Origin, X-Requested-With, Content-Type, Content-Length, Upload-Key, Upload-Checksum, Upload-Length, Upload-Offset, Tus-Version, Tus-Resumable, Upload-Metadata',
'Access-Control-Expose-Headers' =>
'Upload-Key, Upload-Checksum, Upload-Length, Upload-Offset, Upload-Metadata, Tus-Version, Tus-Resumable, Tus-Extension, Location',
'Access-Control-Max-Age' => 86400, // 24 hours
],
$headers,
);
return response($content, $status, $mergedHeaders);
}
}
Back to Directory
File Manager