From 82e8b1577ec0c7b136da5eed9c89d4790714814c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 2 Apr 2022 18:07:43 +0100 Subject: [PATCH] Updated attachment download responses to stream from filesystem This allows download of attachments that are larger than current memory limits, since we're not loading the entire file into memory any more. For inline file responses, we take a 1kb portion of the file to sniff before to check mime before we proceed. --- app/Http/Controllers/AttachmentController.php | 11 +++--- app/Http/Controllers/Controller.php | 37 +++++++++++++++++++ app/Uploads/AttachmentService.php | 14 ++++++- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/AttachmentController.php b/app/Http/Controllers/AttachmentController.php index 084f6f96a..7f5ffc8cb 100644 --- a/app/Http/Controllers/AttachmentController.php +++ b/app/Http/Controllers/AttachmentController.php @@ -10,13 +10,14 @@ use BookStack\Uploads\AttachmentService; use Exception; use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\MessageBag; use Illuminate\Validation\ValidationException; class AttachmentController extends Controller { - protected $attachmentService; - protected $pageRepo; + protected AttachmentService $attachmentService; + protected PageRepo $pageRepo; /** * AttachmentController constructor. @@ -230,13 +231,13 @@ class AttachmentController extends Controller } $fileName = $attachment->getFileName(); - $attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment); + $attachmentStream = $this->attachmentService->streamAttachmentFromStorage($attachment); if ($request->get('open') === 'true') { - return $this->inlineDownloadResponse($attachmentContents, $fileName); + return $this->streamedInlineDownloadResponse($attachmentStream, $fileName); } - return $this->downloadResponse($attachmentContents, $fileName); + return $this->streamedDownloadResponse($attachmentStream, $fileName); } /** diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index d616974c6..ae1f4e4ba 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -12,6 +12,7 @@ use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; use Illuminate\Routing\Controller as BaseController; +use Symfony\Component\HttpFoundation\StreamedResponse; abstract class Controller extends BaseController { @@ -120,6 +121,21 @@ abstract class Controller extends BaseController ]); } + /** + * Create a response that forces a download, from a given stream of content. + */ + protected function streamedDownloadResponse($stream, string $fileName): StreamedResponse + { + return response()->stream(function() use ($stream) { + fpassthru($stream); + fclose($stream); + }, 200, [ + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename="' . $fileName . '"', + 'X-Content-Type-Options' => 'nosniff', + ]); + } + /** * Create a file download response that provides the file with a content-type * correct for the file, in a way so the browser can show the content in browser. @@ -135,6 +151,27 @@ abstract class Controller extends BaseController ]); } + /** + * Create a file download response that provides the file with a content-type + * correct for the file, in a way so the browser can show the content in browser, + * for a given content stream. + */ + protected function streamedInlineDownloadResponse($stream, string $fileName): StreamedResponse + { + $sniffContent = fread($stream, 1000); + $mime = (new WebSafeMimeSniffer())->sniff($sniffContent); + + return response()->stream(function() use ($sniffContent, $stream) { + echo $sniffContent; + fpassthru($stream); + fclose($stream); + }, 200, [ + 'Content-Type' => $mime, + 'Content-Disposition' => 'inline; filename="' . $fileName . '"', + 'X-Content-Type-Options' => 'nosniff', + ]); + } + /** * Show a positive, successful notification to the user on next view load. */ diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index 7974d7ae9..05e70a502 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\File\UploadedFile; class AttachmentService { - protected $fileSystem; + protected FilesystemManager $fileSystem; /** * AttachmentService constructor. @@ -73,6 +73,18 @@ class AttachmentService return $this->getStorageDisk()->get($this->adjustPathForStorageDisk($attachment->path)); } + /** + * Stream an attachment from storage. + * + * @return resource|null + * @throws FileNotFoundException + */ + public function streamAttachmentFromStorage(Attachment $attachment) + { + + return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path)); + } + /** * Store a new attachment upon user upload. *