├── .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 | [](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 | [](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 | [](https://github.styleci.io/repos/85587885)
6 | [](https://packagist.org/packages/laravel-enso/datatable)
7 | [](https://packagist.org/packages/laravel-enso/documents)
8 | [](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 | [](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 |
--------------------------------------------------------------------------------