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