diff --git a/app/App/Providers/AppServiceProvider.php b/app/App/Providers/AppServiceProvider.php index 9012a07ebf4..56e120200a3 100644 --- a/app/App/Providers/AppServiceProvider.php +++ b/app/App/Providers/AppServiceProvider.php @@ -12,6 +12,7 @@ use BookStack\Http\HttpRequestService; use BookStack\Permissions\PermissionApplicator; use BookStack\Settings\SettingService; +use BookStack\Uploads\Attachment; use BookStack\Util\CspService; use Illuminate\Contracts\Foundation\ExceptionRenderer; use Illuminate\Database\Eloquent\Relations\Relation; @@ -73,6 +74,7 @@ public function boot(): void 'book' => Book::class, 'chapter' => Chapter::class, 'page' => Page::class, + 'attachment' => Attachment::class, ]); } } diff --git a/app/Entities/EntityProvider.php b/app/Entities/EntityProvider.php index 3276a6c7a91..3dab49d8a15 100644 --- a/app/Entities/EntityProvider.php +++ b/app/Entities/EntityProvider.php @@ -8,6 +8,7 @@ use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Entities\Models\PageRevision; +use BookStack\Uploads\Attachment; /** * Class EntityProvider. @@ -23,6 +24,7 @@ class EntityProvider public Chapter $chapter; public Page $page; public PageRevision $pageRevision; + public Attachment $attachment; public function __construct() { @@ -31,6 +33,7 @@ public function __construct() $this->chapter = new Chapter(); $this->page = new Page(); $this->pageRevision = new PageRevision(); + $this->attachment = new Attachment(); } /** @@ -46,6 +49,7 @@ public function all(): array 'book' => $this->book, 'chapter' => $this->chapter, 'page' => $this->page, + 'attachment' => $this->attachment, ]; } diff --git a/app/Entities/Queries/AttachmentQueries.php b/app/Entities/Queries/AttachmentQueries.php new file mode 100644 index 00000000000..402f39b2e22 --- /dev/null +++ b/app/Entities/Queries/AttachmentQueries.php @@ -0,0 +1,37 @@ +start()->scopes('visible')->find($id); + } + + public function visibleForList(): Builder + { + return $this->start() + ->select(array_merge(static::$listAttributes, ['page_slug' => function ($builder) { + $builder->select('slug') + ->from('pages') + ->whereColumn('pages.id', '=', 'attachments.uploaded_to'); + }])); + } +} diff --git a/app/Entities/Queries/EntityQueries.php b/app/Entities/Queries/EntityQueries.php index 36dc6c0bc8a..6ef81ba8cce 100644 --- a/app/Entities/Queries/EntityQueries.php +++ b/app/Entities/Queries/EntityQueries.php @@ -14,6 +14,7 @@ public function __construct( public ChapterQueries $chapters, public PageQueries $pages, public PageRevisionQueries $revisions, + public AttachmentQueries $attachment, ) { } @@ -50,6 +51,7 @@ protected function getQueriesForType(string $type): ProvidesEntityQueries 'chapter' => $this->chapters, 'book' => $this->books, 'bookshelf' => $this->shelves, + 'attachment' => $this->attachment, default => null, }; diff --git a/app/Permissions/PermissionApplicator.php b/app/Permissions/PermissionApplicator.php index ce4a543fd83..ee4b7885b5b 100644 --- a/app/Permissions/PermissionApplicator.php +++ b/app/Permissions/PermissionApplicator.php @@ -100,7 +100,7 @@ public function checkUserHasEntityPermissionOnAny(string $action, string $entity public function restrictEntityQuery(Builder $query): Builder { return $query->where(function (Builder $parentQuery) { - $parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) { + $parentQuery->whereHas($parentQuery->getModel()->getTable() === 'attachments' ? 'attachmentJointPermissions' : 'jointPermissions', function (Builder $permissionQuery) { $permissionQuery->select(['entity_id', 'entity_type']) ->selectRaw('max(owner_id) as owner_id') ->selectRaw('max(status) as status') @@ -161,6 +161,9 @@ public function filterDeletedFromEntityRelationQuery(Builder $query, string $tab $joinQuery = function ($query) use ($entityProvider) { $first = true; foreach ($entityProvider->all() as $entity) { + if ($entity->getModel()->getTable() === 'attachments') { + continue; + } /** @var Builder $query */ $entityQuery = function ($query) use ($entity) { $query->select(['id', 'deleted_at']) diff --git a/app/Search/SearchIndex.php b/app/Search/SearchIndex.php index d9fc4e7aadc..72533689eff 100644 --- a/app/Search/SearchIndex.php +++ b/app/Search/SearchIndex.php @@ -6,6 +6,7 @@ use BookStack\Entities\EntityProvider; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; +use BookStack\Uploads\Attachment; use BookStack\Util\HtmlDocument; use DOMNode; use Illuminate\Database\Eloquent\Builder; @@ -68,6 +69,7 @@ public function indexAllEntities(?callable $progressCallback = null): void foreach ($this->entityProvider->all() as $entityModel) { $indexContentField = $entityModel instanceof Page ? 'html' : 'description'; + $indexContentField = $entityModel instanceof Attachment ? 'name' : ($entityModel instanceof Page ? 'html' : 'description'); $selectFields = ['id', 'name', $indexContentField]; /** @var Builder $query */ $query = $entityModel->newQuery(); diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index 9716f8053be..ed1fcb39e85 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -135,6 +135,12 @@ protected function getPageOfDataFromQuery(EloquentBuilder $query, string $entity }; } + if ($entityType === 'attachment') { + $relations['page'] = function (BelongsTo $query) { + $query->scopes('visible'); + }; + } + return $query->clone() ->with(array_filter($relations)) ->skip(($page - 1) * $count) diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php index 57d7cb3346c..fcffa7f7b32 100644 --- a/app/Uploads/Attachment.php +++ b/app/Uploads/Attachment.php @@ -2,12 +2,12 @@ namespace BookStack\Uploads; -use BookStack\App\Model; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\PermissionApplicator; use BookStack\Users\Models\HasCreatorAndUpdater; +use BookStack\Users\Models\HasOwner; use BookStack\Users\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -27,10 +27,15 @@ * * @method static Entity|Builder visible() */ -class Attachment extends Model +class Attachment extends Entity { use HasCreatorAndUpdater; use HasFactory; + use HasOwner; + + public string $textField = 'name'; + + public string $htmlField = 'name'; protected $fillable = ['name', 'order']; protected $hidden = ['path', 'page']; @@ -38,6 +43,11 @@ class Attachment extends Model 'external' => 'bool', ]; + public static function bootSoftDeletes() + { + // No operation: override with an empty method + } + /** * Get the downloadable file name for this upload. */ @@ -55,13 +65,13 @@ public function getFileName(): string */ public function page(): BelongsTo { - return $this->belongsTo(Page::class, 'uploaded_to'); + return $this->belongsTo(Page::class, 'uploaded_to')->with(['chapter','book']); } - public function jointPermissions(): HasMany + public function attachmentJointPermissions(): HasMany { return $this->hasMany(JointPermission::class, 'entity_id', 'uploaded_to') - ->where('joint_permissions.entity_type', '=', 'page'); + ->where('joint_permissions.entity_type', '=', 'page'); } /** @@ -110,14 +120,38 @@ public function markdownLink(): string /** * Scope the query to those attachments that are visible based upon related page permissions. */ - public function scopeVisible(): Builder + public function scopeVisible(Builder $query): Builder { $permissions = app()->make(PermissionApplicator::class); return $permissions->restrictPageRelationQuery( - self::query(), + $query, 'attachments', 'uploaded_to' ); } + + protected function performDeleteOnModel() + { + // Perform a direct delete without relying on soft deletes + return $this->newQueryWithoutScopes()->where($this->getKeyName(), $this->getKey())->delete(); + } + + // Prevent soft deletes by setting the deleted column to null + public function getDeletedAtColumn() + { + return null; + } + + public function scopeWithTrashed($query) + { + // No conditions added, so it includes both active and inactive records + return $query; + } + + public function scopeOnlyTrashed($query) + { + // No conditions added, so it includes both active and inactive records + return $query; + } } diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index bd319fbd795..28f96282aad 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -85,7 +85,7 @@ public function getAttachmentFileSize(Attachment $attachment): int * * @throws FileUploadException */ - public function saveNewUpload(UploadedFile $uploadedFile, int $pageId): Attachment + public function saveNewUpload(UploadedFile $uploadedFile, int $pageId, int $owned_by): Attachment { $attachmentName = $uploadedFile->getClientOriginalName(); $attachmentPath = $this->putFileInStorage($uploadedFile); @@ -99,6 +99,7 @@ public function saveNewUpload(UploadedFile $uploadedFile, int $pageId): Attachme 'uploaded_to' => $pageId, 'created_by' => user()->id, 'updated_by' => user()->id, + 'owned_by' => $owned_by, 'order' => $largestExistingOrder + 1, ]); @@ -132,7 +133,7 @@ public function saveUpdatedUpload(UploadedFile $uploadedFile, Attachment $attach /** * Save a new File attachment from a given link and name. */ - public function saveNewFromLink(string $name, string $link, int $page_id): Attachment + public function saveNewFromLink(string $name, string $link, int $page_id, int $owned_by): Attachment { $largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order'); @@ -144,6 +145,7 @@ public function saveNewFromLink(string $name, string $link, int $page_id): Attac 'uploaded_to' => $page_id, 'created_by' => user()->id, 'updated_by' => user()->id, + 'owned_by' => $owned_by, 'order' => $largestExistingOrder + 1, ]); } diff --git a/app/Uploads/Controllers/AttachmentApiController.php b/app/Uploads/Controllers/AttachmentApiController.php index 87e00257cb4..2c9beaf443d 100644 --- a/app/Uploads/Controllers/AttachmentApiController.php +++ b/app/Uploads/Controllers/AttachmentApiController.php @@ -28,7 +28,7 @@ public function __construct( public function list() { return $this->apiListingResponse(Attachment::visible(), [ - 'id', 'name', 'extension', 'uploaded_to', 'external', 'order', 'created_at', 'updated_at', 'created_by', 'updated_by', + 'id', 'name', 'extension', 'uploaded_to', 'external', 'order', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by' ]); } @@ -54,12 +54,13 @@ public function create(Request $request) if ($request->hasFile('file')) { $uploadedFile = $request->file('file'); - $attachment = $this->attachmentService->saveNewUpload($uploadedFile, $page->id); + $attachment = $this->attachmentService->saveNewUpload($uploadedFile, $page->id, $page->owned_by); } else { $attachment = $this->attachmentService->saveNewFromLink( $requestData['name'], $requestData['link'], - $page->id + $page->id, + $page->owned_by, ); } diff --git a/app/Uploads/Controllers/AttachmentController.php b/app/Uploads/Controllers/AttachmentController.php index 809cdfa581f..6ae8295b8ff 100644 --- a/app/Uploads/Controllers/AttachmentController.php +++ b/app/Uploads/Controllers/AttachmentController.php @@ -46,7 +46,8 @@ public function upload(Request $request) $uploadedFile = $request->file('file'); try { - $attachment = $this->attachmentService->saveNewUpload($uploadedFile, $pageId); + $attachment = $this->attachmentService->saveNewUpload($uploadedFile, $pageId, $page->owned_by); + $attachment->indexForSearch(); } catch (FileUploadException $e) { return response($e->getMessage(), 500); } @@ -161,7 +162,7 @@ public function attachLink(Request $request) $attachmentName = $request->get('attachment_link_name'); $link = $request->get('attachment_link_url'); - $this->attachmentService->saveNewFromLink($attachmentName, $link, intval($pageId)); + $this->attachmentService->saveNewFromLink($attachmentName, $link, intval($pageId), $page->owned_by); return view('attachments.manager-link-form', [ 'pageId' => $pageId, diff --git a/database/migrations/2024_11_07_072714_add_column_owned_by_in_attachments.php b/database/migrations/2024_11_07_072714_add_column_owned_by_in_attachments.php new file mode 100755 index 00000000000..b3787e1c3e4 --- /dev/null +++ b/database/migrations/2024_11_07_072714_add_column_owned_by_in_attachments.php @@ -0,0 +1,28 @@ +integer('owned_by')->after('updated_by'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('attachments', function (Blueprint $table) { + // + }); + } +}; diff --git a/resources/views/entities/list-item-basic.blade.php b/resources/views/entities/list-item-basic.blade.php index 398c33b93f6..53344a4ec0b 100644 --- a/resources/views/entities/list-item-basic.blade.php +++ b/resources/views/entities/list-item-basic.blade.php @@ -1,5 +1,5 @@ getType(); ?> - + @icon($type)

{{ $entity->preview_name ?? $entity->name }}

diff --git a/resources/views/entities/list-item.blade.php b/resources/views/entities/list-item.blade.php index 2fadef19193..a1374da2445 100644 --- a/resources/views/entities/list-item.blade.php +++ b/resources/views/entities/list-item.blade.php @@ -15,6 +15,15 @@ @icon('chevron-right') {{ $entity->chapter->getShortName(42) }} @endif @endif + @if($entity->relationLoaded('page') && $entity->page) + {{ $entity->page->getShortName(42) }} + @if($entity->page->chapter) + @icon('chevron-right') {{ $entity->page->chapter->getShortName(42) }} + @if($entity->page->book) + @icon('chevron-right') {{ $entity->page->book->getShortName(42) }} + @endif + @endif + @endif @endif

{{ $entity->preview_content ?? $entity->getExcerpt() }}

diff --git a/resources/views/search/all.blade.php b/resources/views/search/all.blade.php index ad437604b1c..a864bf1c287 100644 --- a/resources/views/search/all.blade.php +++ b/resources/views/search/all.blade.php @@ -27,6 +27,8 @@
@include('search.parts.type-filter', ['checked' => !$hasTypes || in_array('book', $types), 'entity' => 'book', 'transKey' => 'book']) @include('search.parts.type-filter', ['checked' => !$hasTypes || in_array('bookshelf', $types), 'entity' => 'bookshelf', 'transKey' => 'shelf']) +
+ @include('search.parts.type-filter', ['checked' => !$hasTypes || in_array('attachment', $types), 'entity' => 'attachment', 'transKey' => 'attachments'])
{{ trans('entities.search_exact_matches') }}
diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index 57b7c3f6b88..af8df612d70 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -77,6 +77,27 @@ public function test_chapter_search() $pageTestResp->assertSee($page->name); } + public function test_attachment_search() + { + $page = $this->entities->page(); + + $admin = $this->users->admin(); + /** @var Attachment $attachment */ + $attachment = $page->attachments()->forceCreate([ + 'uploaded_to' => $page->id, + 'name' => 'My test attachment', + 'external' => true, + 'order' => 1, + 'created_by' => $admin->id, + 'updated_by' => $admin->id, + 'path' => 'https://attachment.example.com', + ]); + + $search = $this->asEditor()->get('/search?term=' . urlencode($attachment->name)); + $search->assertSee('Search Results'); + $search->assertSeeText($attachment->name, true); + } + public function test_tag_search() { $newTags = [ diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index 2e1a7b3395a..480f2f22452 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -24,6 +24,7 @@ public function test_file_upload() 'order' => 1, 'created_by' => $admin->id, 'updated_by' => $admin->id, + 'owned_by' => $page->owned_by, ]; $upload = $this->files->uploadAttachmentFile($this, $fileName, $page->id);