'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']);
}
/**
class AttachmentController extends Controller
{
- protected $attachmentService;
- protected $pageRepo;
+ protected AttachmentService $attachmentService;
+ protected PageRepo $pageRepo;
/**
* AttachmentController constructor.
}
$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);
}
/**
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller as BaseController;
+use Symfony\Component\HttpFoundation\StreamedResponse;
abstract class Controller extends BaseController
{
{
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',
]);
}
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',
]);
}
class AttachmentService
{
- protected $fileSystem;
+ protected FilesystemManager $fileSystem;
/**
* AttachmentService constructor.
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.
*
*/
protected function putFileInStorage(UploadedFile $uploadedFile): string
{
- $attachmentData = file_get_contents($uploadedFile->getRealPath());
-
$storage = $this->getStorageDisk();
$basePath = 'uploads/files/' . date('Y-m-M') . '/';
$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());
<style>
- @if (!app()->environment('testing'))
+ @if (!app()->runningUnitTests())
{!! file_get_contents(public_path('/dist/export-styles.css')) !!}
@endif
</style>
use BookStack\Entities\Models\Page;
use BookStack\Uploads\Attachment;
use Illuminate\Http\UploadedFile;
+use Illuminate\Testing\AssertableJsonString;
use Tests\TestCase;
class AttachmentsApiTest extends TestCase
$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,
$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();
}