From 32f6ea946f00d25b3e70166d4e1bd3ef27d64a33 Mon Sep 17 00:00:00 2001
From: Dan Brown
Date: Mon, 18 Oct 2021 17:46:55 +0100
Subject: [PATCH 1/4] Build out core attachments API controller
Related to #2942
---
.../Api/AttachmentApiController.php | 155 ++++++++++++++++++
app/Http/Controllers/AttachmentController.php | 4 +-
app/Uploads/Attachment.php | 32 +++-
app/Uploads/AttachmentService.php | 16 +-
routes/api.php | 6 +
5 files changed, 196 insertions(+), 17 deletions(-)
create mode 100644 app/Http/Controllers/Api/AttachmentApiController.php
diff --git a/app/Http/Controllers/Api/AttachmentApiController.php b/app/Http/Controllers/Api/AttachmentApiController.php
new file mode 100644
index 000000000..2ee1c98a6
--- /dev/null
+++ b/app/Http/Controllers/Api/AttachmentApiController.php
@@ -0,0 +1,155 @@
+ [
+ 'name' => 'required|min:1|max:255|string',
+ 'uploaded_to' => 'required|integer|exists:pages,id',
+ 'file' => 'required_without:link|file',
+ 'link' => 'required_without:file|min:1|max:255|safe_url'
+ ],
+ 'update' => [
+ 'name' => 'min:1|max:255|string',
+ 'uploaded_to' => 'integer|exists:pages,id',
+ 'file' => 'link|file',
+ 'link' => 'file|min:1|max:255|safe_url'
+ ],
+ ];
+
+ public function __construct(AttachmentService $attachmentService)
+ {
+ $this->attachmentService = $attachmentService;
+ }
+
+ /**
+ * Get a listing of attachments visible to the user.
+ * The external property indicates whether the attachment is simple a link.
+ * A false value for the external property would indicate a file upload.
+ */
+ public function list()
+ {
+ return $this->apiListingResponse(Attachment::visible(), [
+ 'id', 'name', 'extension', 'uploaded_to', 'external', 'order', 'created_at', 'updated_at', 'created_by', 'updated_by',
+ ]);
+ }
+
+ /**
+ * Create a new attachment in the system.
+ * An uploaded_to value must be provided containing an ID of the page
+ * that this upload will be related to.
+ *
+ * @throws ValidationException
+ * @throws FileUploadException
+ */
+ public function create(Request $request)
+ {
+ $this->checkPermission('attachment-create-all');
+ $requestData = $this->validate($request, $this->rules['create']);
+
+ $pageId = $request->get('uploaded_to');
+ $page = Page::visible()->findOrFail($pageId);
+ $this->checkOwnablePermission('page-update', $page);
+
+ if ($request->hasFile('file')) {
+ $uploadedFile = $request->file('file');
+ $attachment = $this->attachmentService->saveNewUpload($uploadedFile, $page->id);
+ } else {
+ $attachment = $this->attachmentService->saveNewFromLink(
+ $requestData['name'], $requestData['link'], $page->id
+ );
+ }
+
+ $this->attachmentService->updateFile($attachment, $requestData);
+ return response()->json($attachment);
+ }
+
+ /**
+ * Get the details & content of a single attachment of the given ID.
+ * The attachment link or file content is provided via a 'content' property.
+ * For files the content will be base64 encoded.
+ *
+ * @throws FileNotFoundException
+ */
+ public function read(string $id)
+ {
+ /** @var Attachment $attachment */
+ $attachment = Attachment::visible()->findOrFail($id);
+
+ $attachment->setAttribute('links', [
+ 'html' => $attachment->htmlLink(),
+ 'markdown' => $attachment->markdownLink(),
+ ]);
+
+ if (!$attachment->external) {
+ $attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
+ $attachment->setAttribute('content', base64_encode($attachmentContents));
+ } else {
+ $attachment->setAttribute('content', $attachment->path);
+ }
+
+ return response()->json($attachment);
+ }
+
+ /**
+ * Update the details of a single attachment.
+ *
+ * @throws ValidationException
+ * @throws FileUploadException
+ */
+ public function update(Request $request, string $id)
+ {
+ $requestData = $this->validate($request, $this->rules['update']);
+ /** @var Attachment $attachment */
+ $attachment = Attachment::visible()->findOrFail($id);
+
+ $page = $attachment->page;
+ if ($requestData['uploaded_to'] ?? false) {
+ $pageId = $request->get('uploaded_to');
+ $page = Page::visible()->findOrFail($pageId);
+ $attachment->uploaded_to = $requestData['uploaded_to'];
+ }
+
+ $this->checkOwnablePermission('page-view', $page);
+ $this->checkOwnablePermission('page-update', $page);
+ $this->checkOwnablePermission('attachment-update', $attachment);
+
+ if ($request->hasFile('file')) {
+ $uploadedFile = $request->file('file');
+ $attachment = $this->attachmentService->saveUpdatedUpload($uploadedFile, $page->id);
+ }
+
+ $this->attachmentService->updateFile($attachment, $requestData);
+ return response()->json($attachment);
+ }
+
+ /**
+ * Delete an attachment of the given ID.
+ *
+ * @throws Exception
+ */
+ public function delete(string $id)
+ {
+ /** @var Attachment $attachment */
+ $attachment = Attachment::visible()->findOrFail($id);
+ $this->checkOwnablePermission('attachment-delete', $attachment);
+
+ $this->attachmentService->deleteFile($attachment);
+
+ return response('', 204);
+ }
+
+}
diff --git a/app/Http/Controllers/AttachmentController.php b/app/Http/Controllers/AttachmentController.php
index 046b8c19d..56503a694 100644
--- a/app/Http/Controllers/AttachmentController.php
+++ b/app/Http/Controllers/AttachmentController.php
@@ -121,9 +121,9 @@ class AttachmentController extends Controller
]), 422);
}
- $this->checkOwnablePermission('view', $attachment->page);
+ $this->checkOwnablePermission('page-view', $attachment->page);
$this->checkOwnablePermission('page-update', $attachment->page);
- $this->checkOwnablePermission('attachment-create', $attachment);
+ $this->checkOwnablePermission('attachment-update', $attachment);
$attachment = $this->attachmentService->updateFile($attachment, [
'name' => $request->get('attachment_edit_name'),
diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php
index 5acd4f141..dfd7d980a 100644
--- a/app/Uploads/Attachment.php
+++ b/app/Uploads/Attachment.php
@@ -2,18 +2,24 @@
namespace BookStack\Uploads;
+use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
- * @property int id
- * @property string name
- * @property string path
- * @property string extension
- * @property ?Page page
- * @property bool external
+ * @property int $id
+ * @property string $name
+ * @property string $path
+ * @property string $extension
+ * @property ?Page $page
+ * @property bool $external
+ * @property int $uploaded_to
+ *
+ * @method static Entity|Builder visible()
*/
class Attachment extends Model
{
@@ -70,4 +76,18 @@ class Attachment extends Model
{
return '[' . $this->name . '](' . $this->getUrl() . ')';
}
+
+ /**
+ * Scope the query to those attachments that are visible based upon related page permissions.
+ */
+ public function scopeVisible(): string
+ {
+ $permissionService = app()->make(PermissionService::class);
+ return $permissionService->filterRelatedEntity(
+ Page::class,
+ Attachment::query(),
+ 'attachments',
+ 'uploaded_to'
+ );
+ }
}
diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php
index 3de0a0dae..d530d8fbe 100644
--- a/app/Uploads/AttachmentService.php
+++ b/app/Uploads/AttachmentService.php
@@ -78,18 +78,18 @@ class AttachmentService
*
* @throws FileUploadException
*/
- public function saveNewUpload(UploadedFile $uploadedFile, int $page_id): Attachment
+ public function saveNewUpload(UploadedFile $uploadedFile, int $pageId): Attachment
{
$attachmentName = $uploadedFile->getClientOriginalName();
$attachmentPath = $this->putFileInStorage($uploadedFile);
- $largestExistingOrder = Attachment::query()->where('uploaded_to', '=', $page_id)->max('order');
+ $largestExistingOrder = Attachment::query()->where('uploaded_to', '=', $pageId)->max('order');
/** @var Attachment $attachment */
$attachment = Attachment::query()->forceCreate([
'name' => $attachmentName,
'path' => $attachmentPath,
'extension' => $uploadedFile->getClientOriginalExtension(),
- 'uploaded_to' => $page_id,
+ 'uploaded_to' => $pageId,
'created_by' => user()->id,
'updated_by' => user()->id,
'order' => $largestExistingOrder + 1,
@@ -159,8 +159,9 @@ class AttachmentService
public function updateFile(Attachment $attachment, array $requestData): Attachment
{
$attachment->name = $requestData['name'];
+ $link = trim($requestData['link'] ?? '');
- if (isset($requestData['link']) && trim($requestData['link']) !== '') {
+ if (!empty($link)) {
$attachment->path = $requestData['link'];
if (!$attachment->external) {
$this->deleteFileInStorage($attachment);
@@ -180,13 +181,10 @@ class AttachmentService
*/
public function deleteFile(Attachment $attachment)
{
- if ($attachment->external) {
- $attachment->delete();
-
- return;
+ if (!$attachment->external) {
+ $this->deleteFileInStorage($attachment);
}
- $this->deleteFileInStorage($attachment);
$attachment->delete();
}
diff --git a/routes/api.php b/routes/api.php
index 83a411219..49521bb89 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -7,6 +7,12 @@
*/
Route::get('docs.json', 'ApiDocsController@json');
+Route::get('attachments', 'AttachmentApiController@list');
+Route::post('attachments', 'AttachmentApiController@create');
+Route::get('attachments/{id}', 'AttachmentApiController@read');
+Route::put('attachments/{id}', 'AttachmentApiController@update');
+Route::delete('attachments/{id}', 'AttachmentApiController@delete');
+
Route::get('books', 'BookApiController@list');
Route::post('books', 'BookApiController@create');
Route::get('books/{id}', 'BookApiController@read');
From 2409d1850feeae10b5f0ef6a3f67bc9739881f44 Mon Sep 17 00:00:00 2001
From: Dan Brown
Date: Wed, 20 Oct 2021 00:58:56 +0100
Subject: [PATCH 2/4] Added TestCase for attachments API methods
---
.../Api/AttachmentApiController.php | 10 +-
app/Uploads/Attachment.php | 9 +-
app/Uploads/AttachmentService.php | 6 +-
tests/Api/AttachmentsApiTest.php | 330 ++++++++++++++++++
tests/Api/TestsApi.php | 10 +
tests/Uploads/AttachmentTest.php | 4 +-
6 files changed, 359 insertions(+), 10 deletions(-)
create mode 100644 tests/Api/AttachmentsApiTest.php
diff --git a/app/Http/Controllers/Api/AttachmentApiController.php b/app/Http/Controllers/Api/AttachmentApiController.php
index 2ee1c98a6..7aa4ee493 100644
--- a/app/Http/Controllers/Api/AttachmentApiController.php
+++ b/app/Http/Controllers/Api/AttachmentApiController.php
@@ -25,8 +25,8 @@ class AttachmentApiController extends ApiController
'update' => [
'name' => 'min:1|max:255|string',
'uploaded_to' => 'integer|exists:pages,id',
- 'file' => 'link|file',
- 'link' => 'file|min:1|max:255|safe_url'
+ 'file' => 'file',
+ 'link' => 'min:1|max:255|safe_url'
],
];
@@ -87,7 +87,9 @@ class AttachmentApiController extends ApiController
public function read(string $id)
{
/** @var Attachment $attachment */
- $attachment = Attachment::visible()->findOrFail($id);
+ $attachment = Attachment::visible()
+ ->with(['createdBy', 'updatedBy'])
+ ->findOrFail($id);
$attachment->setAttribute('links', [
'html' => $attachment->htmlLink(),
@@ -129,7 +131,7 @@ class AttachmentApiController extends ApiController
if ($request->hasFile('file')) {
$uploadedFile = $request->file('file');
- $attachment = $this->attachmentService->saveUpdatedUpload($uploadedFile, $page->id);
+ $attachment = $this->attachmentService->saveUpdatedUpload($uploadedFile, $attachment);
}
$this->attachmentService->updateFile($attachment, $requestData);
diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php
index dfd7d980a..8ae53199e 100644
--- a/app/Uploads/Attachment.php
+++ b/app/Uploads/Attachment.php
@@ -3,6 +3,7 @@
namespace BookStack\Uploads;
use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Model;
@@ -18,6 +19,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property ?Page $page
* @property bool $external
* @property int $uploaded_to
+ * @property User $updatedBy
+ * @property User $createdBy
*
* @method static Entity|Builder visible()
*/
@@ -26,6 +29,10 @@ class Attachment extends Model
use HasCreatorAndUpdater;
protected $fillable = ['name', 'order'];
+ protected $hidden = ['path'];
+ protected $casts = [
+ 'external' => 'bool',
+ ];
/**
* Get the downloadable file name for this upload.
@@ -80,7 +87,7 @@ class Attachment extends Model
/**
* Scope the query to those attachments that are visible based upon related page permissions.
*/
- public function scopeVisible(): string
+ public function scopeVisible(): Builder
{
$permissionService = app()->make(PermissionService::class);
return $permissionService->filterRelatedEntity(
diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php
index d530d8fbe..2ad1663ff 100644
--- a/app/Uploads/AttachmentService.php
+++ b/app/Uploads/AttachmentService.php
@@ -162,16 +162,16 @@ class AttachmentService
$link = trim($requestData['link'] ?? '');
if (!empty($link)) {
- $attachment->path = $requestData['link'];
if (!$attachment->external) {
$this->deleteFileInStorage($attachment);
$attachment->external = true;
+ $attachment->extension = '';
}
+ $attachment->path = $requestData['link'];
}
$attachment->save();
-
- return $attachment;
+ return $attachment->refresh();
}
/**
diff --git a/tests/Api/AttachmentsApiTest.php b/tests/Api/AttachmentsApiTest.php
new file mode 100644
index 000000000..88b5b9ddd
--- /dev/null
+++ b/tests/Api/AttachmentsApiTest.php
@@ -0,0 +1,330 @@
+actingAsApiEditor();
+ $page = Page::query()->first();
+ $attachment = $this->createAttachmentForPage($page, [
+ 'name' => 'My test attachment',
+ 'external' => true,
+ ]);
+
+ $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
+ $resp->assertJson(['data' => [
+ [
+ 'id' => $attachment->id,
+ 'name' => 'My test attachment',
+ 'uploaded_to' => $page->id,
+ 'external' => true,
+ ],
+ ]]);
+ }
+
+ public function test_attachments_listing_based_upon_page_visibility()
+ {
+ $this->actingAsApiEditor();
+ /** @var Page $page */
+ $page = Page::query()->first();
+ $attachment = $this->createAttachmentForPage($page, [
+ 'name' => 'My test attachment',
+ 'external' => true,
+ ]);
+
+ $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
+ $resp->assertJson(['data' => [
+ [
+ 'id' => $attachment->id,
+ ],
+ ]]);
+
+ $page->restricted = true;
+ $page->save();
+ $this->regenEntityPermissions($page);
+
+ $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
+ $resp->assertJsonMissing(['data' => [
+ [
+ 'id' => $attachment->id,
+ ],
+ ]]);
+ }
+
+ public function test_create_endpoint_for_link_attachment()
+ {
+ $this->actingAsApiAdmin();
+ /** @var Page $page */
+ $page = Page::query()->first();
+
+ $details = [
+ 'name' => 'My attachment',
+ 'uploaded_to' => $page->id,
+ 'link' => 'https://cats.example.com',
+ ];
+
+ $resp = $this->postJson($this->baseEndpoint, $details);
+ $resp->assertStatus(200);
+ /** @var Attachment $newItem */
+ $newItem = Attachment::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
+ $resp->assertJson(['id' => $newItem->id, 'external' => true, 'name' => $details['name'], 'uploaded_to' => $page->id]);
+ }
+
+ public function test_create_endpoint_for_upload_attachment()
+ {
+ $this->actingAsApiAdmin();
+ /** @var Page $page */
+ $page = Page::query()->first();
+ $file = $this->getTestFile('textfile.txt');
+
+ $details = [
+ 'name' => 'My attachment',
+ 'uploaded_to' => $page->id,
+ ];
+
+ $resp = $this->call('POST', $this->baseEndpoint, $details, [], ['file' => $file]);
+ $resp->assertStatus(200);
+ /** @var Attachment $newItem */
+ $newItem = Attachment::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
+ $resp->assertJson(['id' => $newItem->id, 'external' => false, 'extension' => 'txt', 'name' => $details['name'], 'uploaded_to' => $page->id]);
+ $this->assertTrue(file_exists(storage_path($newItem->path)));
+ unlink(storage_path($newItem->path));
+ }
+
+ public function test_name_needed_to_create()
+ {
+ $this->actingAsApiAdmin();
+ /** @var Page $page */
+ $page = Page::query()->first();
+
+ $details = [
+ 'uploaded_to' => $page->id,
+ 'link' => 'https://example.com',
+ ];
+
+ $resp = $this->postJson($this->baseEndpoint, $details);
+ $resp->assertStatus(422);
+ $resp->assertJson([
+ 'error' => [
+ 'message' => 'The given data was invalid.',
+ 'validation' => [
+ 'name' => ['The name field is required.'],
+ ],
+ 'code' => 422,
+ ],
+ ]);
+ }
+
+ public function test_link_or_file_needed_to_create()
+ {
+ $this->actingAsApiAdmin();
+ /** @var Page $page */
+ $page = Page::query()->first();
+
+ $details = [
+ 'name' => 'my attachment',
+ 'uploaded_to' => $page->id,
+ ];
+
+ $resp = $this->postJson($this->baseEndpoint, $details);
+ $resp->assertStatus(422);
+ $resp->assertJson([
+ 'error' => [
+ 'message' => 'The given data was invalid.',
+ 'validation' => [
+ "file" => ["The file field is required when link is not present."],
+ "link" => ["The link field is required when file is not present."],
+ ],
+ 'code' => 422,
+ ],
+ ]);
+ }
+
+ public function test_read_endpoint_for_link_attachment()
+ {
+ $this->actingAsApiAdmin();
+ /** @var Page $page */
+ $page = Page::query()->first();
+
+ $attachment = $this->createAttachmentForPage($page, [
+ 'name' => 'my attachment',
+ 'path' => 'https://example.com',
+ 'order' => 1,
+ ]);
+
+ $resp = $this->getJson("{$this->baseEndpoint}/{$attachment->id}");
+
+ $resp->assertStatus(200);
+ $resp->assertJson([
+ 'id' => $attachment->id,
+ 'content' => 'https://example.com',
+ 'external' => true,
+ 'uploaded_to' => $page->id,
+ 'order' => 1,
+ 'created_by' => [
+ 'name' => $attachment->createdBy->name,
+ ],
+ 'updated_by' => [
+ 'name' => $attachment->createdBy->name,
+ ],
+ 'links' => [
+ "html" => "id}\">my attachment",
+ "markdown" => "[my attachment](http://localhost/attachments/{$attachment->id})"
+ ],
+ ]);
+ }
+
+ public function test_read_endpoint_for_file_attachment()
+ {
+ $this->actingAsApiAdmin();
+ /** @var Page $page */
+ $page = Page::query()->first();
+ $file = $this->getTestFile('textfile.txt');
+
+ $details = [
+ 'name' => 'My file attachment',
+ 'uploaded_to' => $page->id,
+ ];
+ $this->call('POST', $this->baseEndpoint, $details, [], ['file' => $file]);
+ /** @var Attachment $attachment */
+ $attachment = Attachment::query()->orderByDesc('id')->where('name', '=', $details['name'])->firstOrFail();
+
+ $resp = $this->getJson("{$this->baseEndpoint}/{$attachment->id}");
+
+ $resp->assertStatus(200);
+ $resp->assertJson([
+ 'id' => $attachment->id,
+ 'content' => base64_encode(file_get_contents(storage_path($attachment->path))),
+ 'external' => false,
+ 'uploaded_to' => $page->id,
+ 'order' => 1,
+ 'created_by' => [
+ 'name' => $attachment->createdBy->name,
+ ],
+ 'updated_by' => [
+ 'name' => $attachment->updatedBy->name,
+ ],
+ 'links' => [
+ "html" => "id}\">My file attachment",
+ "markdown" => "[My file attachment](http://localhost/attachments/{$attachment->id})"
+ ],
+ ]);
+
+ unlink(storage_path($attachment->path));
+ }
+
+ public function test_update_endpoint()
+ {
+ $this->actingAsApiAdmin();
+ /** @var Page $page */
+ $page = Page::query()->first();
+ $attachment = $this->createAttachmentForPage($page);
+
+ $details = [
+ 'name' => 'My updated API attachment',
+ ];
+
+ $resp = $this->putJson("{$this->baseEndpoint}/{$attachment->id}", $details);
+ $attachment->refresh();
+
+ $resp->assertStatus(200);
+ $resp->assertJson(['id' => $attachment->id, 'name' => 'My updated API attachment']);
+ }
+
+ public function test_update_link_attachment_to_file()
+ {
+ $this->actingAsApiAdmin();
+ /** @var Page $page */
+ $page = Page::query()->first();
+ $attachment = $this->createAttachmentForPage($page);
+ $file = $this->getTestFile('textfile.txt');
+
+
+ $resp = $this->call('PUT', "{$this->baseEndpoint}/{$attachment->id}", ['name' => 'My updated file'], [], ['file' => $file]);
+ $resp->assertStatus(200);
+
+ $attachment->refresh();
+ $this->assertFalse($attachment->external);
+ $this->assertEquals('txt', $attachment->extension);
+ $this->assertStringStartsWith('uploads/files/', $attachment->path);
+ $this->assertFileExists(storage_path($attachment->path));
+
+ unlink(storage_path($attachment->path));
+ }
+
+ public function test_update_file_attachment_to_link()
+ {
+ $this->actingAsApiAdmin();
+ /** @var Page $page */
+ $page = Page::query()->first();
+ $file = $this->getTestFile('textfile.txt');
+ $this->call('POST', $this->baseEndpoint, ['name' => 'My file attachment', 'uploaded_to' => $page->id], [], ['file' => $file]);
+ /** @var Attachment $attachment */
+ $attachment = Attachment::query()->where('name', '=', 'My file attachment')->firstOrFail();
+
+ $filePath = storage_path($attachment->path);
+ $this->assertFileExists($filePath);
+
+ $details = [
+ 'name' => 'My updated API attachment',
+ 'link' => 'https://cats.example.com'
+ ];
+
+ $resp = $this->putJson("{$this->baseEndpoint}/{$attachment->id}", $details);
+ $resp->assertStatus(200);
+ $attachment->refresh();
+
+ $this->assertFileDoesNotExist($filePath);
+ $this->assertTrue($attachment->external);
+ $this->assertEquals('https://cats.example.com', $attachment->path);
+ $this->assertEquals('', $attachment->extension);
+ }
+
+ public function test_delete_endpoint()
+ {
+ $this->actingAsApiAdmin();
+ /** @var Page $page */
+ $page = Page::query()->first();
+ $attachment = $this->createAttachmentForPage($page);
+
+ $resp = $this->deleteJson("{$this->baseEndpoint}/{$attachment->id}");
+
+ $resp->assertStatus(204);
+ $this->assertDatabaseMissing('attachments', ['id' => $attachment->id]);
+ }
+
+ protected function createAttachmentForPage(Page $page, $attributes = []): Attachment
+ {
+ $admin = $this->getAdmin();
+ /** @var Attachment $attachment */
+ $attachment = $page->attachments()->forceCreate(array_merge([
+ 'uploaded_to' => $page->id,
+ 'name' => 'test attachment',
+ 'external' => true,
+ 'order' => 1,
+ 'created_by' => $admin->id,
+ 'updated_by' => $admin->id,
+ 'path' => 'https://attachment.example.com'
+ ], $attributes));
+ return $attachment;
+ }
+
+ /**
+ * Get a test file that can be uploaded.
+ */
+ protected function getTestFile(string $fileName): UploadedFile
+ {
+ return new UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', 55, null, true);
+ }
+}
diff --git a/tests/Api/TestsApi.php b/tests/Api/TestsApi.php
index 683ca0c74..97ca82ea7 100644
--- a/tests/Api/TestsApi.php
+++ b/tests/Api/TestsApi.php
@@ -17,6 +17,16 @@ trait TestsApi
return $this;
}
+ /**
+ * Set the API admin role as the current user via the API driver.
+ */
+ protected function actingAsApiAdmin()
+ {
+ $this->actingAs($this->getAdmin(), 'api');
+
+ return $this;
+ }
+
/**
* Format the given items into a standardised error format.
*/
diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php
index 2248bc2c5..60fd370b6 100644
--- a/tests/Uploads/AttachmentTest.php
+++ b/tests/Uploads/AttachmentTest.php
@@ -76,9 +76,9 @@ class AttachmentTest extends TestCase
$upload->assertStatus(200);
$attachment = Attachment::query()->orderBy('id', 'desc')->first();
- $expectedResp['path'] = $attachment->path;
-
$upload->assertJson($expectedResp);
+
+ $expectedResp['path'] = $attachment->path;
$this->assertDatabaseHas('attachments', $expectedResp);
$this->deleteUploads();
From 60d4c5902b1a3d03ae493fdeabfa09bbf80d844d Mon Sep 17 00:00:00 2001
From: Dan Brown
Date: Wed, 20 Oct 2021 10:43:03 +0100
Subject: [PATCH 3/4] Added attachment API examples during manual testing
---
.../Api/AttachmentApiController.php | 5 ++++
app/Uploads/Attachment.php | 2 +-
dev/api/requests/attachments-create.json | 5 ++++
dev/api/requests/attachments-update.json | 5 ++++
dev/api/responses/attachments-create.json | 12 ++++++++
dev/api/responses/attachments-list.json | 29 +++++++++++++++++++
dev/api/responses/attachments-read.json | 25 ++++++++++++++++
dev/api/responses/attachments-update.json | 12 ++++++++
8 files changed, 94 insertions(+), 1 deletion(-)
create mode 100644 dev/api/requests/attachments-create.json
create mode 100644 dev/api/requests/attachments-update.json
create mode 100644 dev/api/responses/attachments-create.json
create mode 100644 dev/api/responses/attachments-list.json
create mode 100644 dev/api/responses/attachments-read.json
create mode 100644 dev/api/responses/attachments-update.json
diff --git a/app/Http/Controllers/Api/AttachmentApiController.php b/app/Http/Controllers/Api/AttachmentApiController.php
index 7aa4ee493..353cb058d 100644
--- a/app/Http/Controllers/Api/AttachmentApiController.php
+++ b/app/Http/Controllers/Api/AttachmentApiController.php
@@ -52,6 +52,9 @@ class AttachmentApiController extends ApiController
* An uploaded_to value must be provided containing an ID of the page
* that this upload will be related to.
*
+ * If you're uploading a file the POST data should be provided via
+ * a multipart/form-data type request instead of JSON.
+ *
* @throws ValidationException
* @throws FileUploadException
*/
@@ -108,6 +111,8 @@ class AttachmentApiController extends ApiController
/**
* Update the details of a single attachment.
+ * As per the create endpoint, if a file is being provided as the attachment content
+ * the request should be formatted as a multipart/form-data request instead of JSON.
*
* @throws ValidationException
* @throws FileUploadException
diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php
index 8ae53199e..410a7d4dd 100644
--- a/app/Uploads/Attachment.php
+++ b/app/Uploads/Attachment.php
@@ -29,7 +29,7 @@ class Attachment extends Model
use HasCreatorAndUpdater;
protected $fillable = ['name', 'order'];
- protected $hidden = ['path'];
+ protected $hidden = ['path', 'page'];
protected $casts = [
'external' => 'bool',
];
diff --git a/dev/api/requests/attachments-create.json b/dev/api/requests/attachments-create.json
new file mode 100644
index 000000000..8ed34b24e
--- /dev/null
+++ b/dev/api/requests/attachments-create.json
@@ -0,0 +1,5 @@
+{
+ "name": "My uploaded attachment",
+ "uploaded_to": 8,
+ "link": "https://link.example.com"
+}
\ No newline at end of file
diff --git a/dev/api/requests/attachments-update.json b/dev/api/requests/attachments-update.json
new file mode 100644
index 000000000..062050b3a
--- /dev/null
+++ b/dev/api/requests/attachments-update.json
@@ -0,0 +1,5 @@
+{
+ "name": "My updated attachment",
+ "uploaded_to": 4,
+ "link": "https://link.example.com/updated"
+}
\ No newline at end of file
diff --git a/dev/api/responses/attachments-create.json b/dev/api/responses/attachments-create.json
new file mode 100644
index 000000000..5af524e1a
--- /dev/null
+++ b/dev/api/responses/attachments-create.json
@@ -0,0 +1,12 @@
+{
+ "id": 5,
+ "name": "My uploaded attachment",
+ "extension": "",
+ "uploaded_to": 8,
+ "external": true,
+ "order": 2,
+ "created_by": 1,
+ "updated_by": 1,
+ "created_at": "2021-10-20 06:35:46",
+ "updated_at": "2021-10-20 06:35:46"
+}
\ No newline at end of file
diff --git a/dev/api/responses/attachments-list.json b/dev/api/responses/attachments-list.json
new file mode 100644
index 000000000..946dd542a
--- /dev/null
+++ b/dev/api/responses/attachments-list.json
@@ -0,0 +1,29 @@
+{
+ "data": [
+ {
+ "id": 3,
+ "name": "datasheet.pdf",
+ "extension": "pdf",
+ "uploaded_to": 8,
+ "external": false,
+ "order": 1,
+ "created_at": "2021-10-11 06:18:49",
+ "updated_at": "2021-10-20 06:31:10",
+ "created_by": 1,
+ "updated_by": 1
+ },
+ {
+ "id": 4,
+ "name": "Cat reference",
+ "extension": "",
+ "uploaded_to": 9,
+ "external": true,
+ "order": 1,
+ "created_at": "2021-10-20 06:30:11",
+ "updated_at": "2021-10-20 06:30:11",
+ "created_by": 1,
+ "updated_by": 1
+ }
+ ],
+ "total": 2
+}
\ No newline at end of file
diff --git a/dev/api/responses/attachments-read.json b/dev/api/responses/attachments-read.json
new file mode 100644
index 000000000..e22f4e5fe
--- /dev/null
+++ b/dev/api/responses/attachments-read.json
@@ -0,0 +1,25 @@
+{
+ "id": 5,
+ "name": "My link attachment",
+ "extension": "",
+ "uploaded_to": 4,
+ "external": true,
+ "order": 2,
+ "created_by": {
+ "id": 1,
+ "name": "Admin",
+ "slug": "admin"
+ },
+ "updated_by": {
+ "id": 1,
+ "name": "Admin",
+ "slug": "admin"
+ },
+ "created_at": "2021-10-20 06:35:46",
+ "updated_at": "2021-10-20 06:37:11",
+ "links": {
+ "html": "My updated attachment",
+ "markdown": "[My updated attachment](https://bookstack.local/attachments/5)"
+ },
+ "content": "https://link.example.com/updated"
+}
\ No newline at end of file
diff --git a/dev/api/responses/attachments-update.json b/dev/api/responses/attachments-update.json
new file mode 100644
index 000000000..8054b0e48
--- /dev/null
+++ b/dev/api/responses/attachments-update.json
@@ -0,0 +1,12 @@
+{
+ "id": 5,
+ "name": "My updated attachment",
+ "extension": "",
+ "uploaded_to": 4,
+ "external": true,
+ "order": 2,
+ "created_by": 1,
+ "updated_by": 1,
+ "created_at": "2021-10-20 06:35:46",
+ "updated_at": "2021-10-20 06:37:11"
+}
\ No newline at end of file
From 7e28c76e6fef824d9efcce3d75f5050a389b4c00 Mon Sep 17 00:00:00 2001
From: Dan Brown
Date: Wed, 20 Oct 2021 10:46:06 +0100
Subject: [PATCH 4/4] Adjusted API docs table
---
resources/views/api-docs/parts/getting-started.blade.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/resources/views/api-docs/parts/getting-started.blade.php b/resources/views/api-docs/parts/getting-started.blade.php
index ba0f85fc7..ca28a7d90 100644
--- a/resources/views/api-docs/parts/getting-started.blade.php
+++ b/resources/views/api-docs/parts/getting-started.blade.php
@@ -44,7 +44,7 @@
- Parameter |
+ Parameter |
Details |
Examples |