]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #2791 from BookStackApp/attachments_open_in_browser
authorDan Brown <redacted>
Sun, 13 Jun 2021 13:03:08 +0000 (14:03 +0100)
committerGitHub <redacted>
Sun, 13 Jun 2021 13:03:08 +0000 (14:03 +0100)
Attachment serving without forced download

app/Http/Controllers/AttachmentController.php
app/Http/Controllers/Controller.php
app/Uploads/Attachment.php
app/Uploads/AttachmentService.php
composer.json
resources/js/components/attachments-list.js [new file with mode: 0644]
resources/js/components/index.js
resources/views/attachments/list.blade.php
tests/Uploads/AttachmentTest.php

index 04e89ac5d1a0db18407398aabd3689951822eee8..74eae641b8e85785b69849d3c7663e2fc892b434 100644 (file)
@@ -14,16 +14,14 @@ use Illuminate\Validation\ValidationException;
 class AttachmentController extends Controller
 {
     protected $attachmentService;
-    protected $attachment;
     protected $pageRepo;
 
     /**
      * AttachmentController constructor.
      */
-    public function __construct(AttachmentService $attachmentService, Attachment $attachment, PageRepo $pageRepo)
+    public function __construct(AttachmentService $attachmentService, PageRepo $pageRepo)
     {
         $this->attachmentService = $attachmentService;
-        $this->attachment = $attachment;
         $this->pageRepo = $pageRepo;
     }
 
@@ -67,7 +65,7 @@ class AttachmentController extends Controller
             'file' => 'required|file'
         ]);
 
-        $attachment = $this->attachment->newQuery()->findOrFail($attachmentId);
+        $attachment = Attachment::query()->findOrFail($attachmentId);
         $this->checkOwnablePermission('view', $attachment->page);
         $this->checkOwnablePermission('page-update', $attachment->page);
         $this->checkOwnablePermission('attachment-create', $attachment);
@@ -89,7 +87,7 @@ class AttachmentController extends Controller
      */
     public function getUpdateForm(string $attachmentId)
     {
-        $attachment = $this->attachment->findOrFail($attachmentId);
+        $attachment = Attachment::query()->findOrFail($attachmentId);
 
         $this->checkOwnablePermission('page-update', $attachment->page);
         $this->checkOwnablePermission('attachment-create', $attachment);
@@ -202,9 +200,10 @@ class AttachmentController extends Controller
      * @throws FileNotFoundException
      * @throws NotFoundException
      */
-    public function get(string $attachmentId)
+    public function get(Request $request, string $attachmentId)
     {
-        $attachment = $this->attachment->findOrFail($attachmentId);
+        /** @var Attachment $attachment */
+        $attachment = Attachment::query()->findOrFail($attachmentId);
         try {
             $page = $this->pageRepo->getById($attachment->uploaded_to);
         } catch (NotFoundException $exception) {
@@ -217,8 +216,13 @@ class AttachmentController extends Controller
             return redirect($attachment->path);
         }
 
+        $fileName = $attachment->getFileName();
         $attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
-        return $this->downloadResponse($attachmentContents, $attachment->getFileName());
+
+        if ($request->get('open') === 'true') {
+            return $this->inlineDownloadResponse($attachmentContents, $fileName);
+        }
+        return $this->downloadResponse($attachmentContents, $fileName);
     }
 
     /**
@@ -227,7 +231,7 @@ class AttachmentController extends Controller
      */
     public function delete(string $attachmentId)
     {
-        $attachment = $this->attachment->findOrFail($attachmentId);
+        $attachment = Attachment::query()->findOrFail($attachmentId);
         $this->checkOwnablePermission('attachment-delete', $attachment);
         $this->attachmentService->deleteFile($attachment);
         return response()->json(['message' => trans('entities.attachments_deleted')]);
index 034dfa524192013b4766880ebb878176ef95d496..47b03b28d48aaa29610ef562635670af6adbb630 100644 (file)
@@ -6,6 +6,7 @@ use BookStack\Facades\Activity;
 use BookStack\Interfaces\Loggable;
 use BookStack\HasCreatorAndUpdater;
 use BookStack\Model;
+use finfo;
 use Illuminate\Foundation\Bus\DispatchesJobs;
 use Illuminate\Foundation\Validation\ValidatesRequests;
 use Illuminate\Http\Exceptions\HttpResponseException;
@@ -121,6 +122,20 @@ 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.
+     */
+    protected function inlineDownloadResponse(string $content, string $fileName): Response
+    {
+        $finfo = new finfo(FILEINFO_MIME_TYPE);
+        $mime = $finfo->buffer($content) ?: 'application/octet-stream';
+        return response()->make($content, 200, [
+            'Content-Type'        => $mime,
+            'Content-Disposition' => 'inline; filename="' . $fileName . '"'
+        ]);
+    }
+
     /**
      * Show a positive, successful notification to the user on next view load.
      */
index d1060477d085d3cda5c23b7363c5d54067de7b3d..474d68998f4f48305ef8d103251490e9f3b67730 100644 (file)
@@ -41,12 +41,12 @@ class Attachment extends Model
     /**
      * Get the url of this file.
      */
-    public function getUrl(): string
+    public function getUrl($openInline = false): string
     {
         if ($this->external && strpos($this->path, 'http') !== 0) {
             return $this->path;
         }
-        return url('/attachments/' . $this->id);
+        return url('/attachments/' . $this->id . ($openInline ? '?open=true' : ''));
     }
 
     /**
index 4437897c711e49527035d78478e0a95625b2bcbb..37adb4f8368f0c355fcad8ae3bfd37037876c402 100644 (file)
@@ -3,8 +3,10 @@
 use BookStack\Exceptions\FileUploadException;
 use Exception;
 use Illuminate\Contracts\Filesystem\Factory as FileSystem;
+use Illuminate\Contracts\Filesystem\FileNotFoundException;
 use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
 use Illuminate\Support\Str;
+use Log;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 
 class AttachmentService
@@ -38,11 +40,9 @@ class AttachmentService
 
     /**
      * Get an attachment from storage.
-     * @param Attachment $attachment
-     * @return string
-     * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
+     * @throws FileNotFoundException
      */
-    public function getAttachmentFromStorage(Attachment $attachment)
+    public function getAttachmentFromStorage(Attachment $attachment): string
     {
         return $this->getStorage()->get($attachment->path);
     }
@@ -202,7 +202,7 @@ class AttachmentService
         try {
             $storage->put($attachmentPath, $attachmentData);
         } catch (Exception $e) {
-            \Log::error('Error when attempting file upload:' . $e->getMessage());
+            Log::error('Error when attempting file upload:' . $e->getMessage());
             throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $attachmentPath]));
         }
 
index 3e604b8fdfb29dd13cef6be681971ecdad0a9a9b..8450a2f9250205109ecaeebdca2ac3538c86fc69 100644 (file)
@@ -8,6 +8,7 @@
         "php": "^7.3|^8.0",
         "ext-curl": "*",
         "ext-dom": "*",
+        "ext-fileinfo": "*",
         "ext-gd": "*",
         "ext-json": "*",
         "ext-mbstring": "*",
diff --git a/resources/js/components/attachments-list.js b/resources/js/components/attachments-list.js
new file mode 100644 (file)
index 0000000..34979c2
--- /dev/null
@@ -0,0 +1,47 @@
+/**
+ * Attachments List
+ * Adds '?open=true' query to file attachment links
+ * when ctrl/cmd is pressed down.
+ * @extends {Component}
+ */
+class AttachmentsList {
+
+    setup() {
+        this.container = this.$el;
+        this.setupListeners();
+    }
+
+    setupListeners() {
+        const isExpectedKey = (event) => event.key === 'Control' || event.key === 'Meta';
+        window.addEventListener('keydown', event => {
+             if (isExpectedKey(event)) {
+                this.addOpenQueryToLinks();
+             }
+        }, {passive: true});
+        window.addEventListener('keyup', event => {
+            if (isExpectedKey(event)) {
+                this.removeOpenQueryFromLinks();
+            }
+        }, {passive: true});
+    }
+
+    addOpenQueryToLinks() {
+        const links = this.container.querySelectorAll('a.attachment-file');
+        for (const link of links) {
+            if (link.href.split('?')[1] !== 'open=true') {
+                link.href = link.href + '?open=true';
+                link.setAttribute('target', '_blank');
+            }
+        }
+    }
+
+    removeOpenQueryFromLinks() {
+        const links = this.container.querySelectorAll('a.attachment-file');
+        for (const link of links) {
+            link.href = link.href.split('?')[0];
+            link.removeAttribute('target');
+        }
+    }
+}
+
+export default AttachmentsList;
\ No newline at end of file
index 91ccdaf3aa6f23fca3a923283634c75eb42a0265..010ee04bae01f349a95ccf9d0eb8dac3f86ce051 100644 (file)
@@ -2,6 +2,7 @@ import addRemoveRows from "./add-remove-rows.js"
 import ajaxDeleteRow from "./ajax-delete-row.js"
 import ajaxForm from "./ajax-form.js"
 import attachments from "./attachments.js"
+import attachmentsList from "./attachments-list.js"
 import autoSuggest from "./auto-suggest.js"
 import backToTop from "./back-to-top.js"
 import bookSort from "./book-sort.js"
@@ -56,6 +57,7 @@ const componentMapping = {
     "ajax-delete-row": ajaxDeleteRow,
     "ajax-form": ajaxForm,
     "attachments": attachments,
+    "attachments-list": attachmentsList,
     "auto-suggest": autoSuggest,
     "back-to-top": backToTop,
     "book-sort": bookSort,
index 8c9be8290025932277c6f1c1436e67faa7479381..f0a1354ea1e513adda8ad44b1c8e03ed63c0a2a2 100644 (file)
@@ -1,8 +1,10 @@
-@foreach($attachments as $attachment)
-    <div class="attachment icon-list">
-        <a class="icon-list-item py-xs" href="{{ $attachment->getUrl() }}" @if($attachment->external) target="_blank" @endif>
-            <span class="icon">@icon($attachment->external ? 'export' : 'file')</span>
-            <span>{{ $attachment->name }}</span>
-        </a>
-    </div>
-@endforeach
\ No newline at end of file
+<div component="attachments-list">
+    @foreach($attachments as $attachment)
+        <div class="attachment icon-list">
+            <a class="icon-list-item py-xs attachment-{{ $attachment->external ? 'link' : 'file' }}" href="{{ $attachment->getUrl() }}" @if($attachment->external) target="_blank" @endif>
+                <span class="icon">@icon($attachment->external ? 'export' : 'file')</span>
+                <span>{{ $attachment->name }}</span>
+            </a>
+        </div>
+    @endforeach
+</div>
\ No newline at end of file
index 1ca9ea23b17d5d04101c2203173d394b3455b379..55a5aa84fed023e82f2c384d8b13327231dae8c2 100644 (file)
@@ -4,11 +4,9 @@ use BookStack\Entities\Tools\TrashCan;
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Uploads\Attachment;
 use BookStack\Entities\Models\Page;
-use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Uploads\AttachmentService;
 use Illuminate\Http\UploadedFile;
 use Tests\TestCase;
-use Tests\TestResponse;
 
 class AttachmentTest extends TestCase
 {
@@ -57,7 +55,7 @@ class AttachmentTest extends TestCase
 
     public function test_file_upload()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
         $admin = $this->getAdmin();
         $fileName = 'upload_test_file.txt';
@@ -85,7 +83,7 @@ class AttachmentTest extends TestCase
 
     public function test_file_upload_does_not_use_filename()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $fileName = 'upload_test_file.txt';
 
 
@@ -99,7 +97,7 @@ class AttachmentTest extends TestCase
 
     public function test_file_display_and_access()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
         $fileName = 'upload_test_file.txt';
 
@@ -119,7 +117,7 @@ class AttachmentTest extends TestCase
 
     public function test_attaching_link_to_page()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $admin = $this->getAdmin();
         $this->asAdmin();
 
@@ -156,7 +154,7 @@ class AttachmentTest extends TestCase
 
     public function test_attachment_updating()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
 
         $attachment = $this->createAttachment($page);
@@ -180,7 +178,7 @@ class AttachmentTest extends TestCase
 
     public function test_file_deletion()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
         $fileName = 'deletion_test.txt';
         $this->uploadFile($fileName, $page->id);
@@ -202,7 +200,7 @@ class AttachmentTest extends TestCase
 
     public function test_attachment_deletion_on_page_deletion()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
         $fileName = 'deletion_test.txt';
         $this->uploadFile($fileName, $page->id);
@@ -230,7 +228,7 @@ class AttachmentTest extends TestCase
     {
         $admin = $this->getAdmin();
         $viewer = $this->getViewer();
-        $page = Page::first(); /** @var Page $page */
+        $page = Page::query()->first(); /** @var Page $page */
 
         $this->actingAs($admin);
         $fileName = 'permission_test.txt';
@@ -253,7 +251,7 @@ class AttachmentTest extends TestCase
 
     public function test_data_and_js_links_cannot_be_attached_to_a_page()
     {
-        $page = Page::first();
+        $page = Page::query()->first();
         $this->asAdmin();
 
         $badLinks = [
@@ -291,4 +289,22 @@ class AttachmentTest extends TestCase
             ]);
         }
     }
+
+    public function test_file_access_with_open_query_param_provides_inline_response_with_correct_content_type()
+    {
+        $page = Page::query()->first();
+        $this->asAdmin();
+        $fileName = 'upload_test_file.txt';
+
+        $upload = $this->uploadFile($fileName, $page->id);
+        $upload->assertStatus(200);
+        $attachment = Attachment::query()->orderBy('id', 'desc')->take(1)->first();
+
+        $attachmentGet = $this->get($attachment->getUrl(true));
+        // http-foundation/Response does some 'fixing' of responses to add charsets to text responses.
+        $attachmentGet->assertHeader('Content-Type', 'text/plain; charset=UTF-8');
+        $attachmentGet->assertHeader('Content-Disposition', "inline; filename=\"upload_test_file.txt\"");
+
+        $this->deleteUploads();
+    }
 }