├── .github └── issue_template.md ├── .styleci.yml ├── LICENSE ├── README.md ├── codesize.xml ├── composer.json ├── config └── documents.php ├── database └── migrations │ ├── 2017_01_01_140000_create_documents_table.php │ └── 2017_01_01_141000_create_structure_for_documents.php ├── routes └── api.php ├── src ├── AppServiceProvider.php ├── AuthServiceProvider.php ├── Contracts │ └── Ocrable.php ├── Exceptions │ └── DocumentConflict.php ├── Http │ ├── Controllers │ │ ├── Destroy.php │ │ ├── Index.php │ │ └── Store.php │ ├── Requests │ │ └── ValidateDocument.php │ └── Resources │ │ └── Document.php ├── Jobs │ └── Ocr.php ├── Models │ └── Document.php ├── Policies │ └── Document.php └── Traits │ └── Documentable.php └── tests └── features └── DocumentTest.php /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | 2 | This is a **bug | feature request**. 3 | 4 | 5 | ### Prerequisites 6 | * [ ] Are you running the latest version? 7 | * [ ] Are you reporting to the correct repository? 8 | * [ ] Did you check the documentation? 9 | * [ ] Did you perform a cursory search? 10 | 11 | ### Description 12 | 13 | 14 | ### Steps to Reproduce 15 | 20 | 21 | ### Expected behavior 22 | 23 | 24 | ### Actual behavior 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | risky: true 2 | 3 | preset: laravel 4 | 5 | enabled: 6 | - strict 7 | - unalign_double_arrow 8 | 9 | disabled: 10 | - short_array_syntax 11 | 12 | finder: 13 | exclude: 14 | - "public" 15 | - "resources" 16 | - "tests" 17 | name: 18 | - "*.php" 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 laravel-enso 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Documents 2 | 3 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/135a2cb8336d410490cf79a270852d43)](https://app.codacy.com/gh/laravel-enso/documents?utm_source=github.com&utm_medium=referral&utm_content=laravel-enso/documents&utm_campaign=Badge_Grade_Settings) 4 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/12db635b31fd45feb6b24fde2506e58d)](https://www.codacy.com/gh/laravel-enso/documents?utm_source=github.com&utm_medium=referral&utm_content=laravel-enso/documents&utm_campaign=Badge_Grade) 5 | [![StyleCI](https://github.styleci.io/repos/85587885/shield?branch=master)](https://github.styleci.io/repos/85587885) 6 | [![License](https://poser.pugx.org/laravel-enso/datatable/license)](https://packagist.org/packages/laravel-enso/datatable) 7 | [![Total Downloads](https://poser.pugx.org/laravel-enso/documents/downloads)](https://packagist.org/packages/laravel-enso/documents) 8 | [![Latest Stable Version](https://poser.pugx.org/laravel-enso/documents/version)](https://packagist.org/packages/laravel-enso/documents) 9 | 10 | Documents Manager for [Laravel Enso](https://github.com/laravel-enso/Enso). 11 | 12 | This package works exclusively within the [Enso](https://github.com/laravel-enso/Enso) ecosystem. 13 | 14 | There is a front end implementation for this this api in the [accessories](https://github.com/enso-ui/accessories) package. 15 | 16 | For live examples and demos, you may visit [laravel-enso.com](https://www.laravel-enso.com) 17 | 18 | [![Watch the demo](https://laravel-enso.github.io/documents/screenshots/bulma_019_thumb.png)](https://laravel-enso.github.io/documents/videos/bulma_demo_01.webm) 19 | 20 | click on the photo to view a short demo in compatible browsers 21 | 22 | ### Installation, Configuration & Usage 23 | 24 | Be sure to check out the full documentation for this package available at [docs.laravel-enso.com](https://docs.laravel-enso.com/backend/documents.html) 25 | 26 | ### Contributions 27 | 28 | are welcome. Pull requests are great, but issues are good too. 29 | 30 | ### License 31 | 32 | This package is released under the MIT license. 33 | -------------------------------------------------------------------------------- /codesize.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | custom rules 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-enso/documents", 3 | "description": "Documents Manager for Laravel Enso", 4 | "keywords": [ 5 | "laravel-enso", 6 | "documents", 7 | "documents-manager", 8 | "file-uploader" 9 | ], 10 | "homepage": "https://github.com/laravel-enso/documents", 11 | "type": "plugin", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Adrian Ocneanu", 16 | "email": "aocneanu@gmail.com", 17 | "homepage": "https://laravel-enso.com", 18 | "role": "Developer" 19 | } 20 | ], 21 | "require": { 22 | "laravel-enso/core": "^10.0", 23 | "laravel-enso/helpers": "^3.0", 24 | "laravel-enso/files": "^5.0", 25 | "laravel-enso/ocr": "1.0.*", 26 | "laravel-enso/image-transformer": "^2.0", 27 | "laravel-enso/migrator": "^2.0", 28 | "laravel-enso/track-who": "^2.0" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "LaravelEnso\\Documents\\": "src/" 33 | } 34 | }, 35 | "extra": { 36 | "laravel": { 37 | "providers": [ 38 | "LaravelEnso\\Documents\\AppServiceProvider", 39 | "LaravelEnso\\Documents\\AuthServiceProvider" 40 | ], 41 | "aliases": [] 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /config/documents.php: -------------------------------------------------------------------------------- 1 | 60 * 60, 5 | 'imageWidth' => 2048, 6 | 'imageHeight' => 2048, 7 | 'onDelete' => 'restrict', 8 | 'loggableMorph' => [ 9 | 'documentable' => [], 10 | ], 11 | 'queues' => [ 12 | 'ocr' => 'heavy', 13 | ], 14 | ]; 15 | -------------------------------------------------------------------------------- /database/migrations/2017_01_01_140000_create_documents_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 14 | 15 | $table->morphs('documentable'); 16 | 17 | $table->bigInteger('file_id')->unsigned()->nullable()->unique(); 18 | $table->foreign('file_id')->references('id')->on('files') 19 | ->onUpdate('restrict')->onDelete('restrict'); 20 | 21 | $table->longText('text')->nullable(); 22 | 23 | $table->timestamps(); 24 | }); 25 | 26 | if (DB::getDriverName() === 'mysql') { 27 | DB::statement('ALTER TABLE `documents` ADD FULLTEXT(`text`)'); 28 | } 29 | } 30 | 31 | public function down() 32 | { 33 | Schema::dropIfExists('documents'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /database/migrations/2017_01_01_141000_create_structure_for_documents.php: -------------------------------------------------------------------------------- 1 | 'core.documents.store', 'description' => 'Upload documents', 'is_default' => false], 9 | ['name' => 'core.documents.index', 'description' => 'List documents for documentable', 'is_default' => false], 10 | ['name' => 'core.documents.destroy', 'description' => 'Delete document', 'is_default' => false], 11 | ]; 12 | }; 13 | -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | prefix('api/core/documents') 10 | ->as('core.documents.') 11 | ->group(function () { 12 | Route::get('', Index::class)->name('index'); 13 | Route::post('', Store::class)->name('store'); 14 | Route::delete('{document}', Destroy::class)->name('destroy'); 15 | }); 16 | -------------------------------------------------------------------------------- /src/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | load() 12 | ->publish(); 13 | } 14 | 15 | private function load() 16 | { 17 | $this->mergeConfigFrom(__DIR__.'/../config/documents.php', 'enso.documents'); 18 | 19 | $this->loadRoutesFrom(__DIR__.'/../routes/api.php'); 20 | 21 | $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); 22 | 23 | return $this; 24 | } 25 | 26 | private function publish() 27 | { 28 | $this->publishes([ 29 | __DIR__.'/../config' => config_path('enso'), 30 | ], ['documents-config', 'enso-config']); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/AuthServiceProvider.php: -------------------------------------------------------------------------------- 1 | Policy::class, 13 | ]; 14 | 15 | public function boot() 16 | { 17 | $this->registerPolicies(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Contracts/Ocrable.php: -------------------------------------------------------------------------------- 1 | authorize('destroy', $document); 16 | 17 | $document->delete(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Http/Controllers/Index.php: -------------------------------------------------------------------------------- 1 | with(['file.createdBy.avatar', 'file.type']) 17 | ->for($request->validated()) 18 | ->filter($request->get('query')) 19 | ->get() 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Http/Controllers/Store.php: -------------------------------------------------------------------------------- 1 | fill($request->validated()); 18 | 19 | $this->authorize('store', $document); 20 | 21 | $documents = $document->store( 22 | $request->validated(), 23 | $request->allFiles() 24 | ); 25 | 26 | $documents->each->load('file'); 27 | 28 | return Resource::collection($documents); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Http/Requests/ValidateDocument.php: -------------------------------------------------------------------------------- 1 | 'required', 26 | 'documentable_type' => 'required', 27 | ]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Http/Resources/Document.php: -------------------------------------------------------------------------------- 1 | $this->id, 14 | 'file' => new File($this->whenLoaded('file')), 15 | 'createdAt' => $this->created_at, 16 | ]; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Jobs/Ocr.php: -------------------------------------------------------------------------------- 1 | queue = config('enso.documents.queues.ocr'); 21 | } 22 | 23 | public function handle() 24 | { 25 | $path = Storage::path($this->document->file->path); 26 | $text = (new Service($path))->text(); 27 | 28 | $this->document->update([ 29 | 'text' => preg_replace('/\s+/', ' ', $text), 30 | ]); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Models/Document.php: -------------------------------------------------------------------------------- 1 | belongsTo(File::class); 36 | } 37 | 38 | public function documentable() 39 | { 40 | return $this->morphTo(); 41 | } 42 | 43 | public function imageWidth(): ?int 44 | { 45 | return Config::get('enso.documents.imageWidth'); 46 | } 47 | 48 | public function imageHeight(): ?int 49 | { 50 | return Config::get('enso.documents.imageHeight'); 51 | } 52 | 53 | public static function cascadeFileDeletion(File $file): void 54 | { 55 | self::whereFileId($file->id)->first()->delete(); 56 | } 57 | 58 | public function store(array $request, array $files) 59 | { 60 | $class = Relation::getMorphedModel($request['documentable_type']) 61 | ?? $request['documentable_type']; 62 | 63 | $documentable = $class::query()->find($request['documentable_id']); 64 | 65 | return Collection::wrap($files) 66 | ->map(fn ($file) => $this->attemptStore($documentable, $file)) 67 | ->filter() 68 | ->values(); 69 | } 70 | 71 | public function scopeFor(Builder $query, array $params): Builder 72 | { 73 | return $query->whereDocumentableId($params['documentable_id']) 74 | ->whereDocumentableType($params['documentable_type']); 75 | } 76 | 77 | public function scopeFilter(Builder $query, ?string $search): Builder 78 | { 79 | return $query->when($search, fn ($query) => $query 80 | ->where(fn ($query) => $query 81 | ->whereHas('file', fn ($file) => $file 82 | ->where('original_name', 'LIKE', '%'.$search.'%')) 83 | ->orWhere('text', 'LIKE', '%'.$search.'%'))); 84 | } 85 | 86 | private function ocr($document) 87 | { 88 | if ($this->ocrable($document)) { 89 | Job::dispatch($document); 90 | } 91 | 92 | return $this; 93 | } 94 | 95 | private function ocrable($document) 96 | { 97 | return $document->documentable instanceof Ocrable 98 | && $document->file->mime_type === 'application/pdf'; 99 | } 100 | 101 | private function attemptStore($documentable, UploadedFile $file): ?self 102 | { 103 | try { 104 | return DB::transaction(fn () => $this 105 | ->storeFile($documentable, $file)); 106 | } catch (\Throwable) { 107 | return null; 108 | } 109 | } 110 | 111 | private function storeFile($documentable, UploadedFile $file): self 112 | { 113 | $document = $documentable->documents()->create(); 114 | $file = File::upload($document, $file); 115 | $document->file()->associate($file)->save(); 116 | 117 | $this->ocr($document); 118 | 119 | return $document; 120 | } 121 | 122 | public function delete() 123 | { 124 | parent::delete(); 125 | $this->file->delete(); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Policies/Document.php: -------------------------------------------------------------------------------- 1 | isSuperior()) { 18 | return true; 19 | } 20 | } 21 | 22 | public function store(User $user, Model $document) 23 | { 24 | return true; 25 | } 26 | 27 | public function view(User $user, Model $document) 28 | { 29 | return $this->ownsDocument($user, $document); 30 | } 31 | 32 | public function share(User $user, Model $document) 33 | { 34 | return $this->ownsDocument($user, $document); 35 | } 36 | 37 | public function destroy(User $user, Model $document) 38 | { 39 | return $this->ownsDocument($user, $document) 40 | && $this->isRecent($document); 41 | } 42 | 43 | protected function ownsDocument(User $user, Model $document) 44 | { 45 | return $user->id === (int) $document->file->created_by; 46 | } 47 | 48 | private function isRecent(Model $document) 49 | { 50 | return (int) $document->created_at->diffInSeconds(Carbon::now(), true) 51 | <= (int) Config::get('enso.documents.deletableTimeLimit'); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Traits/Documentable.php: -------------------------------------------------------------------------------- 1 | $model->attemptDocumentableDeletion()); 14 | 15 | self::deleted(fn ($model) => $model->cascadeDocumentDeletion()); 16 | } 17 | 18 | public function document() 19 | { 20 | return $this->morphOne(Document::class, 'documentable'); 21 | } 22 | 23 | public function documents() 24 | { 25 | return $this->morphMany(Document::class, 'documentable'); 26 | } 27 | 28 | private function attemptDocumentableDeletion() 29 | { 30 | $shouldRestrict = Config::get('enso.documents.onDelete') === 'restrict' 31 | && $this->documents()->exists(); 32 | 33 | if ($shouldRestrict) { 34 | throw DocumentConflict::delete(); 35 | } 36 | } 37 | 38 | private function cascadeDocumentDeletion() 39 | { 40 | if (Config::get('enso.documents.onDelete') === 'cascade') { 41 | $this->documents()->delete(); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/features/DocumentTest.php: -------------------------------------------------------------------------------- 1 | seed() 28 | ->actingAs(User::first()); 29 | 30 | $this->testModel = $this->model(); 31 | $this->testFolder = Config::get('enso.files.testingFolder'); 32 | } 33 | 34 | public function tearDown(): void 35 | { 36 | $this->cleanUp(); 37 | parent::tearDown(); 38 | } 39 | 40 | /** @test */ 41 | public function can_get_documents_index() 42 | { 43 | $this->get(route('core.documents.index', [ 44 | 'documentable_type' => $this->testModel::class, 45 | 'documentable_id' => $this->testModel->id, 46 | ], false))->assertStatus(200); 47 | } 48 | 49 | /** @test */ 50 | public function can_upload_document() 51 | { 52 | $this->post(route('core.documents.store'), [ 53 | 'documentable_type' => $this->testModel::class, 54 | 'documentable_id' => $this->testModel->id, 55 | 'file' => UploadedFile::fake()->create('document.doc'), 56 | ]); 57 | 58 | $document = $this->testModel->documents() 59 | ->with('file') 60 | ->first(); 61 | 62 | Storage::assertExists($document->file->path()); 63 | } 64 | 65 | /** @test */ 66 | public function can_display_document() 67 | { 68 | $document = $this->testModel->documents()->create(); 69 | $uploadedFile = UploadedFile::fake()->create('document.doc'); 70 | 71 | $file = File::upload($document, $uploadedFile); 72 | $document->file()->associate($file)->save(); 73 | 74 | $this->get(route('core.files.show', $file->id, false)) 75 | ->assertStatus(200); 76 | } 77 | 78 | /** @test */ 79 | public function can_download_document() 80 | { 81 | $document = $this->testModel->documents()->create(); 82 | $uploadedFile = UploadedFile::fake()->create('document.doc'); 83 | 84 | $file = File::upload($document, $uploadedFile); 85 | $document->file()->associate($file)->save(); 86 | 87 | $this->get(route('core.files.download', $file->id, false)) 88 | ->assertStatus(200); 89 | } 90 | 91 | /** @test */ 92 | public function can_destroy_document() 93 | { 94 | $document = $this->testModel->documents()->create(); 95 | $uploadedFile = UploadedFile::fake()->create('document.doc'); 96 | 97 | $file = File::upload($document, $uploadedFile); 98 | $document->file()->associate($file)->save(); 99 | 100 | $document = $this->testModel->documents() 101 | ->with('file') 102 | ->first(); 103 | 104 | $this->delete(route('core.documents.destroy', [$this->testModel->id], false)) 105 | ->assertStatus(200); 106 | 107 | Storage::assertMissing($file->path()); 108 | $this->assertNull($document->fresh()); 109 | } 110 | 111 | private function cleanUp() 112 | { 113 | Storage::deleteDirectory($this->testFolder); 114 | } 115 | 116 | private function model() 117 | { 118 | $this->createTestTable(); 119 | 120 | return DocumentTestModel::create(['name' => 'documentable']); 121 | } 122 | 123 | private function createTestTable(): self 124 | { 125 | Schema::create('document_test_models', function ($table) { 126 | $table->increments('id'); 127 | $table->string('name'); 128 | $table->timestamps(); 129 | }); 130 | 131 | return $this; 132 | } 133 | } 134 | 135 | class DocumentTestModel extends Model implements Attachable 136 | { 137 | use Documentable; 138 | 139 | protected $fillable = ['name']; 140 | 141 | public function file(): Relation 142 | { 143 | return $this->belongsTo(File::class); 144 | } 145 | } 146 | --------------------------------------------------------------------------------