first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
<?php
namespace Common\Files\Tus;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
class DeleteExpiredTusUploads extends Command
{
protected $signature = 'tus:abort_expired';
protected $description = 'Abort and delete expired TUS uploads.';
public function handle(): int
{
$cache = app(TusCache::class);
$directory = storage_path('tus');
if (File::exists($directory)) {
foreach (File::allFiles(storage_path('tus')) as $file) {
$uploadKey = $file->getFilename();
$tusData = $cache->get($uploadKey);
if (
!Arr::get($tusData, 'expires_at') ||
Carbon::parse($tusData['expires_at'])->lt(Carbon::now())
) {
$cache->delete($uploadKey);
File::delete($file);
}
}
}
$this->info('Expired TUS uploads deleted.');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Common\Files\Tus\Exceptions;
class ConnectionException extends \Exception
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Common\Files\Tus\Exceptions;
class FileException extends \RuntimeException
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace Common\Files\Tus\Exceptions;
class OutOfRangeException extends \OutOfRangeException
{
}

47
common/Files/Tus/TusCache.php Executable file
View File

@@ -0,0 +1,47 @@
<?php
namespace Common\Files\Tus;
use Carbon\Carbon;
use Carbon\CarbonInterface;
class TusCache
{
public function __construct()
{
if (
config('cache.default') === 'array' ||
config('cache.default') === 'null'
) {
config()->set('cache.default', 'file');
}
}
public function get(string $uploadKey)
{
return cache()->get("tus:$uploadKey");
}
public function set(
string $uploadKey,
array $value,
CarbonInterface $expiresAt,
): bool {
return cache()->set("tus:$uploadKey", $value, $expiresAt);
}
public function merge(string $uploadKey, array $partialValue): bool
{
$value = $this->get($uploadKey);
return cache()->set(
"tus:$uploadKey",
array_merge($value, $partialValue),
Carbon::parse($value['expires_at']),
);
}
public function delete(string $uploadKey): bool
{
return cache()->delete("tus:$uploadKey");
}
}

158
common/Files/Tus/TusFile.php Executable file
View File

@@ -0,0 +1,158 @@
<?php
namespace Common\Files\Tus;
use Common\Files\Tus\Exceptions\ConnectionException;
use Common\Files\Tus\Exceptions\FileException;
use Common\Files\Tus\Exceptions\OutOfRangeException;
use Illuminate\Support\Facades\File;
class TusFile
{
protected const INPUT_STREAM = 'php://input';
protected const READ_BINARY = 'rb';
protected const APPEND_BINARY = 'ab';
protected int $offset = 0;
protected int $totalBytes = 0;
protected string $uploadKey;
protected string $filePath;
protected TusCache $cache;
public function __construct(array $params)
{
$this->cache = app(TusCache::class);
$this->uploadKey = $params['upload_key'];
$this->totalBytes = $params['total_bytes'];
$this->filePath = $params['file_path'];
$this->offset = $params['offset'];
}
public function upload(): int
{
if ($this->offset === $this->totalBytes) {
return $this->offset;
}
$method = config('common.site.uploads_tus_method') ?: 'wait';
$input = $this->open(self::INPUT_STREAM, self::READ_BINARY);
$output = $this->open($this->filePath, self::APPEND_BINARY);
try {
if ($method === 'loop') {
$this->uploadUsingLoop($input, $output);
} else {
$this->uploadUsingCopyToStream($input, $output);
}
} finally {
$this->cache->merge($this->uploadKey, [
'offset' => $this->offset,
]);
fclose($input);
fclose($output);
}
return $this->offset;
}
/**
* @param resource $input
* @param resource $output
*/
protected function uploadUsingLoop($input, $output): void
{
$chunkSize = 2097152; // 2MB
$this->seek($output, $this->offset);
while (!feof($input)) {
if (CONNECTION_NORMAL !== connection_status()) {
throw new ConnectionException('Connection aborted by user.');
}
// read
$data = fread($input, $chunkSize);
if ($data === false) {
throw new FileException('Cannot read file.');
}
// write
$bytesWritten = fwrite($output, $data, $chunkSize);
if ($bytesWritten === false) {
throw new FileException('Cannot write to a file.');
}
$this->offset += $bytesWritten;
if ($this->offset > $this->totalBytes) {
throw new OutOfRangeException('The uploaded file is corrupt.');
}
if ($this->offset === $this->totalBytes) {
break;
}
}
}
/**
* @param resource $input
* @param resource $output
*/
protected function uploadUsingCopyToStream($input, $output): void
{
$this->seek($output, $this->offset);
$bytesWritten = stream_copy_to_stream($input, $output, null);
if ($bytesWritten === false) {
throw new FileException('Cannot write to a file.');
}
$this->offset += $bytesWritten;
if ($this->offset > $this->totalBytes) {
throw new OutOfRangeException('The uploaded file is corrupt.');
}
}
protected function open(string $filePath, string $mode)
{
$this->exists($filePath, $mode);
$resource = @fopen($filePath, $mode);
if ($resource === false) {
throw new FileException("Unable to open $filePath.");
}
return $resource;
}
public function exists(
string $filePath,
string $mode = self::READ_BINARY,
): bool {
if (self::INPUT_STREAM === $filePath) {
return true;
}
File::ensureDirectoryExists(storage_path('tus'));
if (self::READ_BINARY === $mode && !file_exists($filePath)) {
throw new FileException('File not found.');
}
return true;
}
public function seek($handle, int $offset): int
{
$position = fseek($handle, $offset);
if (-1 === $position) {
throw new FileException('Cannot move pointer to desired position.');
}
return $position;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Common\Files\Tus;
use Common\Core\BaseController;
use Common\Files\Actions\CreateFileEntry;
use Common\Files\Actions\StoreFile;
use Common\Files\Events\FileUploaded;
use Common\Files\FileEntry;
use Common\Files\FileEntryPayload;
use Illuminate\Support\Facades\File;
class TusFileEntryController extends BaseController
{
public function __construct()
{
$this->middleware('auth');
}
public function store()
{
$data = $this->validate(request(), [
'uploadKey' => 'required|string',
]);
$tusData = app(TusCache::class)->get($data['uploadKey']);
if (!$tusData) {
return $this->error();
}
$metadata = $tusData['metadata'];
$tusFilePath = $tusData['file_path'];
$metadata['size'] = $tusData['size'];
// tus temp file fingerprint, not needed anymore
unset($metadata['name']);
$payload = new FileEntryPayload($metadata);
$this->authorize('store', [FileEntry::class, $payload->parentId]);
app(StoreFile::class)->execute($payload, [
'path' => $tusFilePath,
'moveFile' => true,
]);
$fileEntry = app(CreateFileEntry::class)->execute($payload);
event(new FileUploaded($fileEntry));
File::delete($tusFilePath);
return $this->success(['fileEntry' => $fileEntry]);
}
}

377
common/Files/Tus/TusServer.php Executable file
View File

@@ -0,0 +1,377 @@
<?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);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Common\Files\Tus;
use Common\Core\AppUrl;
use Illuminate\Support\ServiceProvider;
use TusPhp\Tus\Server as TusServer;
class TusServiceProvider extends ServiceProvider
{
static function uploadDir(): string
{
return storage_path('tus');
}
public function register()
{
$this->app->singleton('tus-server', function () {
$server = new TusServer(config('cache.default'));
$baseUri = app(AppUrl::class)->htmlBaseUri;
$server
->setApiPath("{$baseUri}api/v1/tus/upload")
->setUploadDir(TusServiceProvider::uploadDir());
return $server;
});
}
}