Changeset View
Changeset View
Standalone View
Standalone View
src/app/Backends/Storage.php
- This file was added.
<?php | |||||
namespace App\Backends; | |||||
use App\Fs\Chunk; | |||||
use App\Fs\Item; | |||||
use Illuminate\Http\Request; | |||||
use Illuminate\Support\Facades\Cache; | |||||
use Illuminate\Support\Facades\Storage as LaravelStorage; | |||||
use Illuminate\Support\Str; | |||||
use Symfony\Component\HttpFoundation\StreamedResponse; | |||||
class Storage | |||||
{ | |||||
/** @const How long the resumable upload "token" is valid (in seconds) */ | |||||
public const UPLOAD_TTL = 60 * 60 * 6; | |||||
/** | |||||
* Delete a file. | |||||
* | |||||
* @param \App\Fs\Item $file File object | |||||
* | |||||
* @throws \Exception | |||||
*/ | |||||
public static function fileDelete(Item $file): void | |||||
{ | |||||
$disk = LaravelStorage::disk('files'); | |||||
$path = $file->path . '/' . $file->id; | |||||
// TODO: Deleting files might be slow, consider marking as deleted and async job | |||||
$disk->deleteDirectory($path); | |||||
$file->chunks()->delete(); | |||||
} | |||||
/** | |||||
* File download handler. | |||||
* | |||||
* @param \App\Fs\Item $file File object | |||||
* | |||||
* @throws \Exception | |||||
*/ | |||||
public static function fileDownload(Item $file): StreamedResponse | |||||
{ | |||||
$response = new StreamedResponse(); | |||||
$props = $file->getProperties(['name', 'size', 'mimetype']); | |||||
// Prepare the file name for the Content-Disposition header | |||||
$extension = pathinfo($props['name'], \PATHINFO_EXTENSION) ?: 'file'; | |||||
$fallbackName = str_replace('%', '', Str::ascii($props['name'])) ?: "file.{$extension}"; | |||||
$disposition = $response->headers->makeDisposition('attachment', $props['name'], $fallbackName); | |||||
$response->headers->replace([ | |||||
'Content-Length' => $props['size'] ?: 0, | |||||
'Content-Type' => $props['mimetype'], | |||||
'Content-Disposition' => $disposition, | |||||
]); | |||||
$response->setCallback(function () use ($file) { | |||||
$file->chunks()->orderBy('sequence')->get()->each(function ($chunk) use ($file) { | |||||
$disk = LaravelStorage::disk('files'); | |||||
$path = Storage::chunkLocation($chunk->chunk_id, $file); | |||||
$stream = $disk->readStream($path); | |||||
fpassthru($stream); | |||||
fclose($stream); | |||||
}); | |||||
}); | |||||
return $response; | |||||
} | |||||
/** | |||||
* File upload handler | |||||
* | |||||
* @param resource $stream File input stream | |||||
* @param array $params Request parameters | |||||
* @param ?\App\Fs\Item $file The file object | |||||
* | |||||
* @return array File/Response attributes | |||||
* @throws \Exception | |||||
*/ | |||||
public static function fileInput($stream, array $params, Item $file = null): array | |||||
{ | |||||
if (!empty($params['uploadId'])) { | |||||
return self::fileInputResumable($stream, $params, $file); | |||||
} | |||||
$disk = LaravelStorage::disk('files'); | |||||
$chunkId = \App\Utils::uuidStr(); | |||||
$path = self::chunkLocation($chunkId, $file); | |||||
$disk->writeStream($path, $stream); | |||||
$fileSize = $disk->fileSize($path); | |||||
// Update the file type and size information | |||||
$file->setProperties([ | |||||
'size' => $fileSize, | |||||
'mimetype' => self::mimetype($path), | |||||
]); | |||||
// Assign the node to the file, "unlink" any old nodes of this file | |||||
$file->chunks()->delete(); | |||||
$file->chunks()->create([ | |||||
'chunk_id' => $chunkId, | |||||
'sequence' => 0, | |||||
'size' => $fileSize, | |||||
]); | |||||
return ['id' => $file->id]; | |||||
} | |||||
/** | |||||
* Resumable file upload handler | |||||
* | |||||
* @param resource $stream File input stream | |||||
* @param array $params Request parameters | |||||
* @param ?\App\Fs\Item $file The file object | |||||
* | |||||
* @return array File/Response attributes | |||||
* @throws \Exception | |||||
*/ | |||||
protected static function fileInputResumable($stream, array $params, Item $file = null): array | |||||
{ | |||||
// Initial request, save file metadata, return uploadId | |||||
if ($params['uploadId'] == 'resumable') { | |||||
if (empty($params['size']) || empty($file)) { | |||||
throw new \Exception("Missing parameters of resumable file upload."); | |||||
} | |||||
$params['uploadId'] = \App\Utils::uuidStr(); | |||||
$upload = [ | |||||
'fileId' => $file->id, | |||||
'size' => $params['size'], | |||||
'uploaded' => 0, | |||||
]; | |||||
if (!Cache::add('upload:' . $params['uploadId'], $upload, self::UPLOAD_TTL)) { | |||||
throw new \Exception("Failed to create cache entry for resumable file upload."); | |||||
} | |||||
return [ | |||||
'uploadId' => $params['uploadId'], | |||||
'uploaded' => 0, | |||||
'maxChunkSize' => (\config('octane.swoole.options.package_max_length') ?: 10 * 1024 * 1024) - 8192, | |||||
]; | |||||
} | |||||
$upload = Cache::get('upload:' . $params['uploadId']); | |||||
if (empty($upload)) { | |||||
throw new \Exception("Cache entry for resumable file upload does not exist."); | |||||
} | |||||
$file = Item::find($upload['fileId']); | |||||
if (!$file) { | |||||
throw new \Exception("Invalid fileId for resumable file upload."); | |||||
} | |||||
$from = $params['from'] ?? 0; | |||||
// Sanity checks on the input parameters | |||||
// TODO: Support uploading again a chunk that already has been uploaded? | |||||
if ($from < $upload['uploaded'] || $from > $upload['uploaded'] || $from > $upload['size']) { | |||||
throw new \Exception("Invalid 'from' parameter for resumable file upload."); | |||||
} | |||||
$disk = LaravelStorage::disk('files'); | |||||
$chunkId = \App\Utils::uuidStr(); | |||||
$path = self::chunkLocation($chunkId, $file); | |||||
// Save the file chunk | |||||
$disk->writeStream($path, $stream); | |||||
// Detect file type using the first chunk | |||||
if ($from == 0) { | |||||
$upload['mimetype'] = self::mimetype($path); | |||||
$upload['chunks'] = []; | |||||
} | |||||
$chunkSize = $disk->fileSize($path); | |||||
// Create the chunk record | |||||
$file->chunks()->create([ | |||||
'chunk_id' => $chunkId, | |||||
'sequence' => count($upload['chunks']), | |||||
'size' => $chunkSize, | |||||
'deleted_at' => \now(), // not yet active chunk | |||||
]); | |||||
$upload['chunks'][] = $chunkId; | |||||
$upload['uploaded'] += $chunkSize; | |||||
// Update the file metadata after the upload of all chunks is completed | |||||
if ($upload['uploaded'] >= $upload['size']) { | |||||
// Update file metadata | |||||
$file->setProperties([ | |||||
'size' => $upload['uploaded'], | |||||
'mimetype' => $upload['mimetype'] ?: 'application/octet-stream', | |||||
]); | |||||
// Assign uploaded chunks to the file, "unlink" any old chunks of this file | |||||
$file->chunks()->delete(); | |||||
$file->chunks()->whereIn('chunk_id', $upload['chunks'])->restore(); | |||||
// TODO: Create a "cron" job to remove orphaned nodes from DB and the storage. | |||||
// I.e. all with deleted_at set and older than UPLOAD_TTL | |||||
// Delete the upload cache record | |||||
Cache::forget('upload:' . $params['uploadId']); | |||||
return ['id' => $file->id]; | |||||
} | |||||
// Update the upload metadata | |||||
Cache::put('upload:' . $params['uploadId'], $upload, self::UPLOAD_TTL); | |||||
return ['uploadId' => $params['uploadId'], 'uploaded' => $upload['uploaded']]; | |||||
} | |||||
/** | |||||
* Get the file mime type. | |||||
* | |||||
* @param string $path File location | |||||
* | |||||
* @return string File mime type | |||||
*/ | |||||
protected static function mimetype(string $path): string | |||||
{ | |||||
$disk = LaravelStorage::disk('files'); | |||||
// TODO: If file is empty, detect the mimetype based on the extension? | |||||
try { | |||||
return $disk->mimeType($path); | |||||
} catch (\Exception $e) { | |||||
// do nothing | |||||
} | |||||
// TODO: If it fails detect the mimetype based on extension? | |||||
return 'application/octet-stream'; | |||||
} | |||||
/** | |||||
* Node location in the storage | |||||
* | |||||
* @param string $chunkId Chunk identifier | |||||
* @param \App\Fs\Item $file File the chunk belongs to | |||||
* | |||||
* @return string Chunk location | |||||
*/ | |||||
public static function chunkLocation(string $chunkId, Item $file): string | |||||
{ | |||||
return $file->path . '/' . $file->id . '/' . $chunkId; | |||||
} | |||||
} |