Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add data streaming where beneficial to reduce memory usage #3365

Merged
merged 5 commits into from Apr 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
28 changes: 23 additions & 5 deletions app/Http/Controllers/Api/AttachmentApiController.php
Expand Up @@ -87,14 +87,32 @@ public function read(string $id)
'markdown' => $attachment->markdownLink(),
]);

if (!$attachment->external) {
$attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
$attachment->setAttribute('content', base64_encode($attachmentContents));
} else {
// Simply return a JSON response of the attachment for link-based attachments
if ($attachment->external) {
$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']);
}

/**
Expand Down
10 changes: 5 additions & 5 deletions app/Http/Controllers/AttachmentController.php
Expand Up @@ -15,8 +15,8 @@

class AttachmentController extends Controller
{
protected $attachmentService;
protected $pageRepo;
protected AttachmentService $attachmentService;
protected PageRepo $pageRepo;

/**
* AttachmentController constructor.
Expand Down Expand Up @@ -230,13 +230,13 @@ public function get(Request $request, string $attachmentId)
}

$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);
}

/**
Expand Down
47 changes: 45 additions & 2 deletions app/Http/Controllers/Controller.php
Expand Up @@ -12,6 +12,7 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller as BaseController;
use Symfony\Component\HttpFoundation\StreamedResponse;

abstract class Controller extends BaseController
{
Expand Down Expand Up @@ -115,7 +116,28 @@ protected function downloadResponse(string $content, string $fileName): Response
{
return response()->make($content, 200, [
'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',
]);
}
Expand All @@ -130,7 +152,28 @@ protected function inlineDownloadResponse(string $content, string $fileName): Re

return response()->make($content, 200, [
'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',
]);
}
Expand Down
19 changes: 15 additions & 4 deletions app/Uploads/AttachmentService.php
Expand Up @@ -14,7 +14,7 @@

class AttachmentService
{
protected $fileSystem;
protected FilesystemManager $fileSystem;

/**
* AttachmentService constructor.
Expand Down Expand Up @@ -73,6 +73,18 @@ public function getAttachmentFromStorage(Attachment $attachment): string
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.
*
Expand Down Expand Up @@ -211,8 +223,6 @@ protected function deleteFileInStorage(Attachment $attachment)
*/
protected function putFileInStorage(UploadedFile $uploadedFile): string
{
$attachmentData = file_get_contents($uploadedFile->getRealPath());

$storage = $this->getStorageDisk();
$basePath = 'uploads/files/' . date('Y-m-M') . '/';

Expand All @@ -221,10 +231,11 @@ protected function putFileInStorage(UploadedFile $uploadedFile): string
$uploadFileName = Str::random(3) . $uploadFileName;
}

$attachmentStream = fopen($uploadedFile->getRealPath(), 'r');
$attachmentPath = $basePath . $uploadFileName;

try {
$storage->put($this->adjustPathForStorageDisk($attachmentPath), $attachmentData);
$storage->writeStream($this->adjustPathForStorageDisk($attachmentPath), $attachmentStream);
} catch (Exception $e) {
Log::error('Error when attempting file upload:' . $e->getMessage());

Expand Down
2 changes: 1 addition & 1 deletion resources/views/common/export-styles.blade.php
@@ -1,5 +1,5 @@
<style>
@if (!app()->environment('testing'))
@if (!app()->runningUnitTests())
{!! file_get_contents(public_path('/dist/export-styles.css')) !!}
@endif
</style>
Expand Down
7 changes: 5 additions & 2 deletions tests/Api/AttachmentsApiTest.php
Expand Up @@ -5,6 +5,7 @@
use BookStack\Entities\Models\Page;
use BookStack\Uploads\Attachment;
use Illuminate\Http\UploadedFile;
use Illuminate\Testing\AssertableJsonString;
use Tests\TestCase;

class AttachmentsApiTest extends TestCase
Expand Down Expand Up @@ -228,9 +229,11 @@ public function test_read_endpoint_for_file_attachment()
$attachment = Attachment::query()->orderByDesc('id')->where('name', '=', $details['name'])->firstOrFail();

$resp = $this->getJson("{$this->baseEndpoint}/{$attachment->id}");

$resp->assertStatus(200);
$resp->assertJson([
$resp->assertHeader('Content-Type', 'application/json');

$json = new AssertableJsonString($resp->streamedContent());
$json->assertSubset([
'id' => $attachment->id,
'content' => base64_encode(file_get_contents(storage_path($attachment->path))),
'external' => false,
Expand Down
3 changes: 2 additions & 1 deletion tests/Uploads/AttachmentTest.php
Expand Up @@ -128,7 +128,8 @@ public function test_file_display_and_access()
$pageGet->assertSee($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();
}
Expand Down