0
0
Fork 0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-04-25 04:51:35 +00:00

Merge pull request from BookStackApp/data_streaming

Add data streaming where beneficial to reduce memory usage
This commit is contained in:
Dan Brown 2022-04-24 13:59:47 +01:00 committed by GitHub
commit 829f808800
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 96 additions and 20 deletions

View file

@ -87,14 +87,32 @@ class AttachmentApiController extends ApiController
'markdown' => $attachment->markdownLink(), 'markdown' => $attachment->markdownLink(),
]); ]);
if (!$attachment->external) { // Simply return a JSON response of the attachment for link-based attachments
$attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment); if ($attachment->external) {
$attachment->setAttribute('content', base64_encode($attachmentContents));
} else {
$attachment->setAttribute('content', $attachment->path); $attachment->setAttribute('content', $attachment->path);
return response()->json($attachment);
} }
return response()->json($attachment); // Build and split our core JSON, at point of content.
$splitter = 'CONTENT_SPLIT_LOCATION_' . time() . '_' . rand(1, 40000);
$attachment->setAttribute('content', $splitter);
$json = $attachment->toJson();
$jsonParts = explode($splitter, $json);
// Get a stream for the file data from storage
$stream = $this->attachmentService->streamAttachmentFromStorage($attachment);
return response()->stream(function () use ($jsonParts, $stream) {
// Output the pre-content JSON data
echo $jsonParts[0];
// Stream out our attachment data as base64 content
stream_filter_append($stream, 'convert.base64-encode', STREAM_FILTER_READ);
fpassthru($stream);
fclose($stream);
// Output our post-content JSON data
echo $jsonParts[1];
}, 200, ['Content-Type' => 'application/json']);
} }
/** /**

View file

@ -15,8 +15,8 @@ use Illuminate\Validation\ValidationException;
class AttachmentController extends Controller class AttachmentController extends Controller
{ {
protected $attachmentService; protected AttachmentService $attachmentService;
protected $pageRepo; protected PageRepo $pageRepo;
/** /**
* AttachmentController constructor. * AttachmentController constructor.
@ -230,13 +230,13 @@ class AttachmentController extends Controller
} }
$fileName = $attachment->getFileName(); $fileName = $attachment->getFileName();
$attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment); $attachmentStream = $this->attachmentService->streamAttachmentFromStorage($attachment);
if ($request->get('open') === 'true') { 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);
} }
/** /**

View file

@ -12,6 +12,7 @@ use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Routing\Controller as BaseController; use Illuminate\Routing\Controller as BaseController;
use Symfony\Component\HttpFoundation\StreamedResponse;
abstract class Controller extends BaseController abstract class Controller extends BaseController
{ {
@ -115,7 +116,28 @@ abstract class Controller extends BaseController
{ {
return response()->make($content, 200, [ return response()->make($content, 200, [
'Content-Type' => 'application/octet-stream', 'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . $fileName . '"', 'Content-Disposition' => 'attachment; filename="' . str_replace('"', '', $fileName) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
/**
* 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) {
// End & flush the output buffer otherwise we still seem to use memory.
// Ignore in testing since output buffers are used to gather a response.
if (!app()->runningUnitTests()) {
ob_end_clean();
}
fpassthru($stream);
fclose($stream);
}, 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . str_replace('"', '', $fileName) . '"',
'X-Content-Type-Options' => 'nosniff', 'X-Content-Type-Options' => 'nosniff',
]); ]);
} }
@ -130,7 +152,28 @@ abstract class Controller extends BaseController
return response()->make($content, 200, [ return response()->make($content, 200, [
'Content-Type' => $mime, 'Content-Type' => $mime,
'Content-Disposition' => 'inline; filename="' . $fileName . '"', 'Content-Disposition' => 'inline; filename="' . str_replace('"', '', $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,
* 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="' . str_replace('"', '', $fileName) . '"',
'X-Content-Type-Options' => 'nosniff', 'X-Content-Type-Options' => 'nosniff',
]); ]);
} }

View file

@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
class AttachmentService class AttachmentService
{ {
protected $fileSystem; protected FilesystemManager $fileSystem;
/** /**
* AttachmentService constructor. * AttachmentService constructor.
@ -73,6 +73,18 @@ class AttachmentService
return $this->getStorageDisk()->get($this->adjustPathForStorageDisk($attachment->path)); 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. * Store a new attachment upon user upload.
* *
@ -211,8 +223,6 @@ class AttachmentService
*/ */
protected function putFileInStorage(UploadedFile $uploadedFile): string protected function putFileInStorage(UploadedFile $uploadedFile): string
{ {
$attachmentData = file_get_contents($uploadedFile->getRealPath());
$storage = $this->getStorageDisk(); $storage = $this->getStorageDisk();
$basePath = 'uploads/files/' . date('Y-m-M') . '/'; $basePath = 'uploads/files/' . date('Y-m-M') . '/';
@ -221,10 +231,11 @@ class AttachmentService
$uploadFileName = Str::random(3) . $uploadFileName; $uploadFileName = Str::random(3) . $uploadFileName;
} }
$attachmentStream = fopen($uploadedFile->getRealPath(), 'r');
$attachmentPath = $basePath . $uploadFileName; $attachmentPath = $basePath . $uploadFileName;
try { try {
$storage->put($this->adjustPathForStorageDisk($attachmentPath), $attachmentData); $storage->writeStream($this->adjustPathForStorageDisk($attachmentPath), $attachmentStream);
} catch (Exception $e) { } catch (Exception $e) {
Log::error('Error when attempting file upload:' . $e->getMessage()); Log::error('Error when attempting file upload:' . $e->getMessage());

View file

@ -1,5 +1,5 @@
<style> <style>
@if (!app()->environment('testing')) @if (!app()->runningUnitTests())
{!! file_get_contents(public_path('/dist/export-styles.css')) !!} {!! file_get_contents(public_path('/dist/export-styles.css')) !!}
@endif @endif
</style> </style>

View file

@ -5,6 +5,7 @@ namespace Tests\Api;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Uploads\Attachment; use BookStack\Uploads\Attachment;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Testing\AssertableJsonString;
use Tests\TestCase; use Tests\TestCase;
class AttachmentsApiTest extends TestCase class AttachmentsApiTest extends TestCase
@ -228,9 +229,11 @@ class AttachmentsApiTest extends TestCase
$attachment = Attachment::query()->orderByDesc('id')->where('name', '=', $details['name'])->firstOrFail(); $attachment = Attachment::query()->orderByDesc('id')->where('name', '=', $details['name'])->firstOrFail();
$resp = $this->getJson("{$this->baseEndpoint}/{$attachment->id}"); $resp = $this->getJson("{$this->baseEndpoint}/{$attachment->id}");
$resp->assertStatus(200); $resp->assertStatus(200);
$resp->assertJson([ $resp->assertHeader('Content-Type', 'application/json');
$json = new AssertableJsonString($resp->streamedContent());
$json->assertSubset([
'id' => $attachment->id, 'id' => $attachment->id,
'content' => base64_encode(file_get_contents(storage_path($attachment->path))), 'content' => base64_encode(file_get_contents(storage_path($attachment->path))),
'external' => false, 'external' => false,

View file

@ -128,7 +128,8 @@ class AttachmentTest extends TestCase
$pageGet->assertSee($attachment->getUrl()); $pageGet->assertSee($attachment->getUrl());
$attachmentGet = $this->get($attachment->getUrl()); $attachmentGet = $this->get($attachment->getUrl());
$attachmentGet->assertSee('Hi, This is a test file for testing the upload process.'); $content = $attachmentGet->streamedContent();
$this->assertStringContainsString('Hi, This is a test file for testing the upload process.', $content);
$this->deleteUploads(); $this->deleteUploads();
} }