diff --git a/src/app/Backends/Storage.php b/src/app/Backends/Storage.php index 21d50caa..47f57e1a 100644 --- a/src/app/Backends/Storage.php +++ b/src/app/Backends/Storage.php @@ -1,298 +1,294 @@ path . '/' . $file->id; // TODO: Deleting files might be slow, consider marking as deleted and async job $disk->deleteDirectory($path); $file->forceDelete(); } /** * Delete a file chunk. * * @param \App\Fs\Chunk $chunk File chunk object * * @throws \Exception */ public static function fileChunkDelete(Chunk $chunk): void { $disk = LaravelStorage::disk('files'); $path = self::chunkLocation($chunk->chunk_id, $chunk->item); $disk->delete($path); $chunk->forceDelete(); } /** * 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); + $fileSize = $disk->size($path); if ($file->type & Item::TYPE_INCOMPLETE) { $file->type -= Item::TYPE_INCOMPLETE; $file->save(); } // 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); + $chunkSize = $disk->size($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']) { if ($file->type & Item::TYPE_INCOMPLETE) { $file->type -= Item::TYPE_INCOMPLETE; $file->save(); } // 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 { - $mimetype = $disk->mimeType($path); - // The mimetype may contain e.g. "; charset=UTF-8", remove this - return preg_replace('/;.*$/', '', $mimetype); - } catch (\Exception $e) { - // do nothing - } + $mimetype = $disk->mimeType($path); - // TODO: If it fails detect the mimetype based on extension? + // The mimetype may contain e.g. "; charset=UTF-8", remove this + if ($mimetype) { + return explode(';', $mimetype)[0]; + } 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; } }