create-nevela 0.1.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -6
- package/index.mjs +77 -16
- package/package.json +2 -1
- package/project.mjs +20 -4
- package/selfupdate.mjs +13 -3
- package/template/laravel/README.md +1 -1
- package/template/laravel/composer.json +61 -59
- package/template/laravel/config/nevela.php +70 -0
- package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php +38 -0
- package/template/laravel/src/Console/GenerateCommand.php +25 -2
- package/template/laravel/src/Console/MakeResourceCommand.php +8 -0
- package/template/laravel/src/Console/SeedCommand.php +186 -124
- package/template/laravel/src/Console/StatusCommand.php +2 -2
- package/template/laravel/src/Console/UpdateCommand.php +76 -9
- package/template/laravel/src/Generator/ResourceGenerator.php +154 -21
- package/template/laravel/src/Http/ResourceRequest.php +4 -1
- package/template/laravel/src/Http/UploadController.php +147 -0
- package/template/laravel/src/Media/FileTypes.php +97 -0
- package/template/laravel/src/Media/ImageOptimizer.php +252 -0
- package/template/laravel/src/Media/Profile.php +94 -0
- package/template/laravel/src/Media/UploadRejected.php +8 -0
- package/template/laravel/src/Media/Uploads.php +325 -0
- package/template/laravel/src/Models/Upload.php +46 -0
- package/template/laravel/src/Nevela.php +20 -1
- package/template/laravel/src/NevelaServiceProvider.php +24 -0
- package/template/laravel/src/Rules/UploadKey.php +27 -0
- package/template/laravel/src/Support/Descriptor.php +3 -3
- package/template/laravel/src/Support/Fake.php +194 -171
- package/template/laravel/src/Support/Field.php +108 -12
- package/template/laravel/src/Support/TemplateSource.php +1 -1
- package/template/web/app/api/[...path]/route.ts +87 -0
- package/template/web/app/dashboard/actions.ts +26 -9
- package/template/web/components/dashboard/fields/file-cell.tsx +10 -47
- package/template/web/components/dashboard/fields/file-field.tsx +25 -31
- package/template/web/lib/files.ts +21 -0
- package/template/web/package.json +1 -1
- package/template.mjs +2 -2
- package/tool.mjs +58 -0
|
@@ -19,24 +19,77 @@ final class ResourceGenerator
|
|
|
19
19
|
|
|
20
20
|
private const E = '// '.Writer::END;
|
|
21
21
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* @param list<Descriptor> $all Every resource in the app: a relation is written on both ends
|
|
24
|
+
* @return list<GeneratedFile>
|
|
25
|
+
*/
|
|
26
|
+
public function forResource(Descriptor $d, ?string $timestamp = null, array $all = []): array
|
|
24
27
|
{
|
|
25
28
|
$timestamp ??= date('Y_m_d_His');
|
|
26
29
|
$api = GeneratedFile::TARGET_API;
|
|
27
30
|
|
|
28
31
|
return [
|
|
29
|
-
new GeneratedFile($api, "app/Models/{$d->name}.php", $this->model($d)),
|
|
30
|
-
new GeneratedFile($api, "database/migrations/{$timestamp}_create_{$d->table}_table.php", $this->migration($d), GeneratedFile::MODE_ONCE, "database/migrations/*_create_{$d->table}_table.php"),
|
|
31
|
-
new GeneratedFile($api, "app/Http/Requests/Nevela/{$d->name}Request.php", $this->request($d)),
|
|
32
|
+
new GeneratedFile($api, "app/Models/{$d->name}.php", $this->model($d, $all)),
|
|
33
|
+
new GeneratedFile($api, "database/migrations/{$timestamp}_create_{$d->table}_table.php", $this->migration($d, $all), GeneratedFile::MODE_ONCE, "database/migrations/*_create_{$d->table}_table.php"),
|
|
34
|
+
new GeneratedFile($api, "app/Http/Requests/Nevela/{$d->name}Request.php", $this->request($d, $all)),
|
|
32
35
|
new GeneratedFile($api, "app/Http/Resources/Nevela/{$d->name}Resource.php", $this->resource($d)),
|
|
33
|
-
new GeneratedFile($api, "app/Http/Controllers/Api/{$d->name}Controller.php", $this->controller($d)),
|
|
36
|
+
new GeneratedFile($api, "app/Http/Controllers/Api/{$d->name}Controller.php", $this->controller($d, $all)),
|
|
34
37
|
new GeneratedFile($api, "app/Policies/{$d->name}Policy.php", $this->policy($d), GeneratedFile::MODE_ONCE),
|
|
35
|
-
new GeneratedFile(GeneratedFile::TARGET_WEB, 'resources/'.Naming::kebab($d->name).'.resource.ts', $this->typescript($d)),
|
|
38
|
+
new GeneratedFile(GeneratedFile::TARGET_WEB, 'resources/'.Naming::kebab($d->name).'.resource.ts', $this->typescript($d, $all)),
|
|
36
39
|
...$this->pages($d),
|
|
37
40
|
];
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The resources that point at this one, each with the field that does it and the name
|
|
45
|
+
* the relation gets here: a Category's Products are `products`. A second field from
|
|
46
|
+
* the same resource is named after itself (`productsAsFeaturedCategory`), and a name
|
|
47
|
+
* already taken by one of this resource's own fields is left out.
|
|
48
|
+
*
|
|
49
|
+
* @param list<Descriptor> $all
|
|
50
|
+
* @return list<array{resource: Descriptor, field: Field, name: string}>
|
|
51
|
+
*/
|
|
52
|
+
public function children(Descriptor $d, array $all): array
|
|
53
|
+
{
|
|
54
|
+
$taken = [];
|
|
55
|
+
foreach ($d->fields as $field) {
|
|
56
|
+
$taken[$field->name] = true;
|
|
57
|
+
if ($field->kind === 'belongsTo') {
|
|
58
|
+
$taken[$field->relation()] = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
$children = [];
|
|
62
|
+
usort($all, fn (Descriptor $a, Descriptor $b) => strcmp($a->name, $b->name));
|
|
63
|
+
foreach ($all as $other) {
|
|
64
|
+
$plural = Naming::camel(Naming::plural($other->name));
|
|
65
|
+
foreach ($other->fields as $field) {
|
|
66
|
+
if ($field->kind !== 'belongsTo' || $field->target !== $d->name) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
$name = isset($taken[$plural]) ? $plural.'As'.ucfirst($field->relation()) : $plural;
|
|
70
|
+
if (isset($taken[$name])) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
$taken[$name] = true;
|
|
74
|
+
$children[] = ['resource' => $other, 'field' => $field, 'name' => $name];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return $children;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @param list<Descriptor> $all */
|
|
82
|
+
private function tableOf(string $name, Descriptor $d, array $all): string
|
|
83
|
+
{
|
|
84
|
+
foreach ([$d, ...$all] as $candidate) {
|
|
85
|
+
if ($candidate->name === $name) {
|
|
86
|
+
return $candidate->table;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return Naming::snake(Naming::plural($name));
|
|
91
|
+
}
|
|
92
|
+
|
|
40
93
|
/**
|
|
41
94
|
* The dashboard pages for a resource, in Flare's layout: list, new, detail and edit,
|
|
42
95
|
* each with its loading skeleton. They only name the descriptor — Flare's components
|
|
@@ -172,7 +225,8 @@ final class ResourceGenerator
|
|
|
172
225
|
\$name = array_shift(\$args) ?? 'help';
|
|
173
226
|
|
|
174
227
|
// What you type => the artisan command it runs.
|
|
175
|
-
|
|
228
|
+
// "update" is what upgrade was called before 0.3.0; it still works.
|
|
229
|
+
\$nevela = ['resource', 'generate', 'seed', 'user', 'upgrade', 'update', 'dev', 'status', 'version'];
|
|
176
230
|
\$artisan = ['migrate', 'tinker', 'test', 'serve'];
|
|
177
231
|
if (in_array(\$name, ['--version', '-v', '-V'], true)) {
|
|
178
232
|
\$name = 'version';
|
|
@@ -201,7 +255,7 @@ final class ResourceGenerator
|
|
|
201
255
|
php nevela generate regenerate after editing a descriptor
|
|
202
256
|
php nevela seed Product fill a resource with records
|
|
203
257
|
php nevela user create someone who can sign in
|
|
204
|
-
php nevela
|
|
258
|
+
php nevela upgrade bring this app to the latest Nevela
|
|
205
259
|
php nevela version which Nevela this is
|
|
206
260
|
|
|
207
261
|
php nevela migrate php artisan migrate
|
|
@@ -288,8 +342,31 @@ final class ResourceGenerator
|
|
|
288
342
|
PHP);
|
|
289
343
|
}
|
|
290
344
|
|
|
291
|
-
|
|
345
|
+
/** @param list<Descriptor> $all */
|
|
346
|
+
public function model(Descriptor $d, array $all = []): string
|
|
292
347
|
{
|
|
348
|
+
$relations = [];
|
|
349
|
+
foreach ($d->fields as $f) {
|
|
350
|
+
if ($f->kind === 'belongsTo') {
|
|
351
|
+
$relations[] = <<<PHP
|
|
352
|
+
|
|
353
|
+
public function {$f->relation()}(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
|
354
|
+
{
|
|
355
|
+
return \$this->belongsTo({$f->target}::class, '{$f->column()}');
|
|
356
|
+
}
|
|
357
|
+
PHP;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
foreach ($this->children($d, $all) as $child) {
|
|
361
|
+
$relations[] = <<<PHP
|
|
362
|
+
|
|
363
|
+
public function {$child['name']}(): \Illuminate\Database\Eloquent\Relations\HasMany
|
|
364
|
+
{
|
|
365
|
+
return \$this->hasMany({$child['resource']->name}::class, '{$child['field']->column()}');
|
|
366
|
+
}
|
|
367
|
+
PHP;
|
|
368
|
+
}
|
|
369
|
+
$relationBlock = $relations ? "\n".implode("\n", array_map(fn (string $r) => rtrim($r), $relations)) : '';
|
|
293
370
|
$fillable = implode(', ', array_map(fn (Field $f) => "'{$f->column()}'", $d->fields));
|
|
294
371
|
$casts = [];
|
|
295
372
|
foreach ($d->fields as $f) {
|
|
@@ -331,21 +408,33 @@ final class ResourceGenerator
|
|
|
331
408
|
return [
|
|
332
409
|
{$castBlock}
|
|
333
410
|
];
|
|
334
|
-
}
|
|
411
|
+
}{$relationBlock}
|
|
335
412
|
{$e}
|
|
336
413
|
|
|
337
|
-
//
|
|
414
|
+
// Your own relationships, scopes and accessors go here — outside the generated block.
|
|
338
415
|
}
|
|
339
416
|
|
|
340
417
|
PHP;
|
|
341
418
|
}
|
|
342
419
|
|
|
343
|
-
|
|
420
|
+
/** @param list<Descriptor> $all */
|
|
421
|
+
public function migration(Descriptor $d, array $all = []): string
|
|
344
422
|
{
|
|
345
423
|
$columns = [];
|
|
346
424
|
foreach ($d->fields as $f) {
|
|
347
425
|
$c = $f->column();
|
|
426
|
+
if ($f->kind === 'belongsTo') {
|
|
427
|
+
// A required parent can't be deleted while it has children; an optional one lets go of them.
|
|
428
|
+
$columns[] = " \$table->foreignUuid('{$c}')"
|
|
429
|
+
.($f->required ? '' : '->nullable()')
|
|
430
|
+
.($f->unique ? '->unique()' : '->index()')
|
|
431
|
+
."->constrained('{$this->tableOf((string) $f->target, $d, $all)}')"
|
|
432
|
+
.($f->required ? '' : '->nullOnDelete()').';';
|
|
433
|
+
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
348
436
|
$line = match (true) {
|
|
437
|
+
$f->kind === 'file' => "\$table->string('{$c}', 512)",
|
|
349
438
|
$f->kind === 'text' => "\$table->text('{$c}')",
|
|
350
439
|
$f->kind === 'int' => "\$table->integer('{$c}')",
|
|
351
440
|
$f->kind === 'float' && $f->format === 'money' => "\$table->decimal('{$c}', 12, 2)",
|
|
@@ -396,14 +485,15 @@ final class ResourceGenerator
|
|
|
396
485
|
PHP;
|
|
397
486
|
}
|
|
398
487
|
|
|
399
|
-
|
|
488
|
+
/** @param list<Descriptor> $all */
|
|
489
|
+
public function request(Descriptor $d, array $all = []): string
|
|
400
490
|
{
|
|
401
491
|
$var = $d->variable();
|
|
402
492
|
$param = $d->routeParameter();
|
|
403
493
|
$rules = [];
|
|
404
494
|
$columns = [];
|
|
405
495
|
foreach ($d->fields as $f) {
|
|
406
|
-
$rules[] = " '{$f->name}' => [".implode(', ', $this->rules($d, $f)).'],';
|
|
496
|
+
$rules[] = " '{$f->name}' => [".implode(', ', $this->rules($d, $f, $all)).'],';
|
|
407
497
|
$columns[] = "'{$f->name}' => '{$f->column()}'";
|
|
408
498
|
}
|
|
409
499
|
$ruleLines = implode("\n", $rules);
|
|
@@ -456,11 +546,16 @@ final class ResourceGenerator
|
|
|
456
546
|
PHP;
|
|
457
547
|
}
|
|
458
548
|
|
|
459
|
-
/**
|
|
460
|
-
|
|
549
|
+
/**
|
|
550
|
+
* @param list<Descriptor> $all
|
|
551
|
+
* @return list<string> PHP expressions
|
|
552
|
+
*/
|
|
553
|
+
private function rules(Descriptor $d, Field $f, array $all = []): array
|
|
461
554
|
{
|
|
462
555
|
$rules = [$f->required ? "'required'" : "'nullable'"];
|
|
463
556
|
$type = match (true) {
|
|
557
|
+
$f->kind === 'belongsTo' => ["'uuid'", "Rule::exists('{$this->tableOf((string) $f->target, $d, $all)}', 'id')"],
|
|
558
|
+
$f->kind === 'file' => ["'string'", "'max:512'", "new \\Nevela\\Laravel\\Rules\\UploadKey('{$d->name}', '{$f->name}')"],
|
|
464
559
|
$f->format === 'email' => ["'email'", "'max:255'"],
|
|
465
560
|
$f->format === 'url' => ["'url'", "'max:2048'"],
|
|
466
561
|
$f->format === 'tel' => ["'string'", "'max:32'"],
|
|
@@ -495,6 +590,10 @@ final class ResourceGenerator
|
|
|
495
590
|
default => "\$this->{$f->column()}",
|
|
496
591
|
};
|
|
497
592
|
$lines[] = " '{$f->name}' => {$value},";
|
|
593
|
+
if ($f->kind === 'file') {
|
|
594
|
+
// The key is what a record stores; this is what a page needs to show it.
|
|
595
|
+
$lines[] = " '{$f->name}File' => \\Nevela\\Laravel\\Nevela::file(\$this->{$f->column()}),";
|
|
596
|
+
}
|
|
498
597
|
}
|
|
499
598
|
$body = implode("\n", $lines);
|
|
500
599
|
$m = self::M;
|
|
@@ -529,9 +628,28 @@ final class ResourceGenerator
|
|
|
529
628
|
PHP;
|
|
530
629
|
}
|
|
531
630
|
|
|
532
|
-
|
|
631
|
+
/** @param list<Descriptor> $all */
|
|
632
|
+
public function controller(Descriptor $d, array $all = []): string
|
|
533
633
|
{
|
|
534
634
|
$var = $d->variable();
|
|
635
|
+
$guards = [];
|
|
636
|
+
foreach ($this->children($d, $all) as $child) {
|
|
637
|
+
if (! $child['field']->required) {
|
|
638
|
+
continue; // the database clears an optional link itself
|
|
639
|
+
}
|
|
640
|
+
$one = strtolower($child['resource']->label);
|
|
641
|
+
$many = strtolower($child['resource']->pluralLabel);
|
|
642
|
+
$parent = $this->lower($d->label);
|
|
643
|
+
$guards[] = <<<PHP
|
|
644
|
+
if ((\$count = \\App\\Models\\{$child['resource']->name}::query()->where('{$child['field']->column()}', \${$var}->getKey())->count()) > 0) {
|
|
645
|
+
return response()->json(['error' => \$count === 1
|
|
646
|
+
? '1 {$one} belongs to this {$parent}. Move or delete it first.'
|
|
647
|
+
: "{\$count} {$many} belong to this {$parent}. Move or delete them first."], 409);
|
|
648
|
+
}
|
|
649
|
+
PHP;
|
|
650
|
+
}
|
|
651
|
+
$guardBlock = $guards ? "\n".implode("\n", array_map(fn (string $g) => rtrim($g), $guards)) : '';
|
|
652
|
+
$destroyReturn = $guards ? 'Response|JsonResponse' : 'Response';
|
|
535
653
|
$m = self::M;
|
|
536
654
|
$e = self::E;
|
|
537
655
|
|
|
@@ -595,9 +713,9 @@ final class ResourceGenerator
|
|
|
595
713
|
return new {$d->name}Resource(\${$var}->refresh());
|
|
596
714
|
}
|
|
597
715
|
|
|
598
|
-
public function destroy({$d->name} \${$var}):
|
|
716
|
+
public function destroy({$d->name} \${$var}): {$destroyReturn}
|
|
599
717
|
{
|
|
600
|
-
Gate::authorize('delete', \${$var});
|
|
718
|
+
Gate::authorize('delete', \${$var});{$guardBlock}
|
|
601
719
|
\${$var}->delete();
|
|
602
720
|
|
|
603
721
|
return response()->noContent();
|
|
@@ -608,6 +726,11 @@ final class ResourceGenerator
|
|
|
608
726
|
PHP;
|
|
609
727
|
}
|
|
610
728
|
|
|
729
|
+
private function lower(string $label): string
|
|
730
|
+
{
|
|
731
|
+
return strtolower($label);
|
|
732
|
+
}
|
|
733
|
+
|
|
611
734
|
public function policy(Descriptor $d): string
|
|
612
735
|
{
|
|
613
736
|
$var = $d->variable();
|
|
@@ -657,7 +780,8 @@ final class ResourceGenerator
|
|
|
657
780
|
PHP;
|
|
658
781
|
}
|
|
659
782
|
|
|
660
|
-
|
|
783
|
+
/** @param list<Descriptor> $all */
|
|
784
|
+
public function typescript(Descriptor $d, array $all = []): string
|
|
661
785
|
{
|
|
662
786
|
$j = fn (mixed $v) => json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
663
787
|
$config = [
|
|
@@ -683,15 +807,24 @@ final class ResourceGenerator
|
|
|
683
807
|
'unique' => $f->unique ? 'true' : null,
|
|
684
808
|
'label' => $f->label ? $j($f->label) : null,
|
|
685
809
|
'format' => in_array($f->format, ['money', 'percent', 'rating'], true) ? $j($f->format) : null,
|
|
810
|
+
// What the migration does when the parent goes: an optional link is cleared.
|
|
811
|
+
'onDelete' => $f->kind === 'belongsTo' && ! $f->required ? '"set null"' : null,
|
|
812
|
+
// A picture is worth a column in the table; other files are not.
|
|
813
|
+
'list' => $f->isImage() ? 'true' : null,
|
|
686
814
|
]);
|
|
687
815
|
$opts = $options ? '{ '.implode(', ', array_map(fn ($k, $v) => "{$k}: {$v}", array_keys($options), $options)).' }' : '';
|
|
688
816
|
$call = match (true) {
|
|
817
|
+
$f->kind === 'belongsTo' => "field.belongsTo({$j($f->target)}".($opts ? ", {$opts}" : '').')',
|
|
818
|
+
$f->kind === 'file' => "field.file({$j($f->accept)}".($opts ? ", {$opts}" : '').')',
|
|
689
819
|
$f->kind === 'enum' => "field.enum({$j($f->options)}".($opts ? ", {$opts}" : '').')',
|
|
690
820
|
$f->kind === 'string' && $f->format !== null => "field.{$f->format}({$opts})",
|
|
691
821
|
default => "field.{$f->kind}({$opts})",
|
|
692
822
|
};
|
|
693
823
|
$fields[] = " {$f->name}: {$call},";
|
|
694
824
|
}
|
|
825
|
+
foreach ($this->children($d, $all) as $child) {
|
|
826
|
+
$fields[] = " {$child['name']}: field.hasMany({$j($child['resource']->name)}, { foreignKey: {$j($child['field']->name)} }),";
|
|
827
|
+
}
|
|
695
828
|
$configLines = implode("\n", $config);
|
|
696
829
|
$fieldLines = implode("\n", $fields);
|
|
697
830
|
$file = Naming::kebab($d->name);
|
|
@@ -41,8 +41,11 @@ abstract class ResourceRequest extends FormRequest
|
|
|
41
41
|
{
|
|
42
42
|
$validator->after(function (Validator $validator) {
|
|
43
43
|
$known = array_keys($this->columns());
|
|
44
|
+
// A file field is sent back with its details beside it (imageFile for image);
|
|
45
|
+
// a client that echoes a record shouldn't be refused for it.
|
|
46
|
+
$computed = array_map(fn (string $field) => $field.'File', $known);
|
|
44
47
|
foreach (array_keys($this->json()->all() ?: $this->request->all()) as $key) {
|
|
45
|
-
if (! in_array($key, $known, true) && ! in_array($key, self::READ_ONLY, true)) {
|
|
48
|
+
if (! in_array($key, $known, true) && ! in_array($key, self::READ_ONLY, true) && ! in_array($key, $computed, true)) {
|
|
46
49
|
$validator->errors()->add((string) $key, 'Unknown field.');
|
|
47
50
|
}
|
|
48
51
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Http;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Http\JsonResponse;
|
|
6
|
+
use Illuminate\Http\Request;
|
|
7
|
+
use Illuminate\Support\Facades\Gate;
|
|
8
|
+
use Illuminate\Support\Facades\Storage;
|
|
9
|
+
use InvalidArgumentException;
|
|
10
|
+
use Nevela\Laravel\Media\FileTypes;
|
|
11
|
+
use Nevela\Laravel\Media\UploadRejected;
|
|
12
|
+
use Nevela\Laravel\Media\Uploads;
|
|
13
|
+
use Nevela\Laravel\Models\Upload;
|
|
14
|
+
use Nevela\Laravel\Nevela;
|
|
15
|
+
use Symfony\Component\HttpFoundation\Response;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Receiving files and handing them back.
|
|
19
|
+
*
|
|
20
|
+
* PUT {prefix}/_nevela/uploads/{Resource}/{field}?name=kettle.jpg the file is the request body
|
|
21
|
+
* GET {prefix}/_nevela/files/{key} the stored file
|
|
22
|
+
*
|
|
23
|
+
* The body is the file itself, not a form: that is what a browser's upload progress
|
|
24
|
+
* works with, and it isn't held to PHP's 2 MB default for form uploads.
|
|
25
|
+
*/
|
|
26
|
+
final class UploadController
|
|
27
|
+
{
|
|
28
|
+
public function store(Request $request, string $resource, string $field): JsonResponse
|
|
29
|
+
{
|
|
30
|
+
try {
|
|
31
|
+
$descriptor = Nevela::resource($resource);
|
|
32
|
+
} catch (InvalidArgumentException) {
|
|
33
|
+
return $this->refuse(404, "There is no resource called {$resource}.");
|
|
34
|
+
}
|
|
35
|
+
$definition = $descriptor->fields[$field] ?? null;
|
|
36
|
+
if ($definition === null || $definition->kind !== 'file') {
|
|
37
|
+
return $this->refuse(404, "{$resource} has no file field called {$field}.");
|
|
38
|
+
}
|
|
39
|
+
$model = 'App\\Models\\'.$descriptor->name;
|
|
40
|
+
// Storing a file is a write, so it takes the permission to create a record. Being
|
|
41
|
+
// allowed to look at the resource is not enough.
|
|
42
|
+
if (class_exists($model) && Gate::getPolicyFor($model) !== null && ! Gate::allows('create', $model)) {
|
|
43
|
+
return $this->refuse(403, "You can't upload files to {$descriptor->pluralLabel}.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
$name = basename(str_replace('\\', '/', (string) $request->query('name', 'file')));
|
|
47
|
+
$limit = Uploads::maxBytes();
|
|
48
|
+
$megabytes = round($limit / 1024 / 1024, 1);
|
|
49
|
+
if ((int) $request->header('Content-Length', '0') > $limit) {
|
|
50
|
+
return $this->refuse(413, "{$name} is larger than the {$megabytes} MB this field takes.");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Copied to a temporary file with the limit enforced while it arrives: the length
|
|
54
|
+
// a client declares is not something to rely on.
|
|
55
|
+
$path = Uploads::scratchFile();
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
$out = fopen($path, 'wb');
|
|
59
|
+
try {
|
|
60
|
+
$written = stream_copy_to_stream($request->getContent(true), $out, $limit + 1);
|
|
61
|
+
} finally {
|
|
62
|
+
fclose($out);
|
|
63
|
+
}
|
|
64
|
+
if ($written === false || $written === 0) {
|
|
65
|
+
return $this->refuse(422, 'The upload arrived empty. Try again.');
|
|
66
|
+
}
|
|
67
|
+
if ($written > $limit) {
|
|
68
|
+
return $this->refuse(413, "{$name} is larger than the {$megabytes} MB this field takes.");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
$mime = FileTypes::sniff($path);
|
|
72
|
+
$declared = (string) $request->header('Content-Type', 'application/octet-stream');
|
|
73
|
+
// The contents decide. What was declared only has to agree with them.
|
|
74
|
+
$effective = FileTypes::accepts($definition->accept, $mime) ? $mime : $declared;
|
|
75
|
+
if (FileTypes::forbidden($mime) || ! FileTypes::accepts($definition->accept, $effective) || ! FileTypes::consistent($declared, $mime)) {
|
|
76
|
+
$wanted = in_array('any', $definition->accept, true) ? 'a file this field can store' : implode(' or ', $definition->accept).' data';
|
|
77
|
+
|
|
78
|
+
return $this->refuse(422, "{$name} doesn't contain {$wanted}. It may have been renamed; choose the original file.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
$upload = Uploads::store($descriptor, $definition, $name, $path, strtolower(trim(explode(';', $effective)[0])), (string) ($request->user()?->getAuthIdentifier() ?? '') ?: null);
|
|
83
|
+
} catch (UploadRejected $e) {
|
|
84
|
+
return $this->refuse(422, $e->getMessage());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return response()->json(Uploads::ref($upload->key), 201);
|
|
88
|
+
} finally {
|
|
89
|
+
@unlink($path);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A stored file. Keys are unguessable and never reused, so the response can be cached
|
|
95
|
+
* for good. Asking for a rendition that was never made (the image wasn't one that could
|
|
96
|
+
* be optimised) gets the file itself, so a client can always ask for a thumbnail.
|
|
97
|
+
*/
|
|
98
|
+
public function show(string $path): Response
|
|
99
|
+
{
|
|
100
|
+
// A key is letters, digits, dashes, dots and slashes. Anything else was never one.
|
|
101
|
+
if ($path === '' || str_contains($path, '..') || ! preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $path)) {
|
|
102
|
+
abort(404);
|
|
103
|
+
}
|
|
104
|
+
$cache ='public, max-age=31536000, immutable';
|
|
105
|
+
|
|
106
|
+
// Only what was uploaded through Nevela is served, found by its record. The disk may
|
|
107
|
+
// hold other things, and being on the disk is not a reason to hand a file out.
|
|
108
|
+
$upload = Upload::query()->where('key', $path)->first();
|
|
109
|
+
if ($upload === null) {
|
|
110
|
+
// Not a stored image: a rendition, then. "kettle.thumb.webp" belongs to the upload
|
|
111
|
+
// whose key is "kettle" and one extension.
|
|
112
|
+
$base = preg_replace('/\.[a-z][a-z0-9-]*\.[A-Za-z0-9]+$/', '', $path);
|
|
113
|
+
if ($base === null || $base === $path) {
|
|
114
|
+
abort(404);
|
|
115
|
+
}
|
|
116
|
+
// LIKE narrows it down; the exact comparison after it is what decides. ("_" in a
|
|
117
|
+
// key is a wildcard to LIKE, and how to escape it differs between databases.)
|
|
118
|
+
$upload = Upload::query()->where('key', 'like', $base.'.%')->limit(20)->get()
|
|
119
|
+
->first(fn (Upload $candidate) => str_starts_with($candidate->key, $base.'.')
|
|
120
|
+
&& preg_match('/^\.[A-Za-z0-9]+$/', substr($candidate->key, strlen($base))) === 1);
|
|
121
|
+
if ($upload === null) {
|
|
122
|
+
abort(404);
|
|
123
|
+
}
|
|
124
|
+
if (! in_array($path, array_column($upload->renditions ?? [], 'key'), true)) {
|
|
125
|
+
// A rendition that was never made: the image itself, so a client can always
|
|
126
|
+
// ask for a thumbnail. Cached briefly, in case one is made later.
|
|
127
|
+
$path = $upload->key;
|
|
128
|
+
$cache = 'public, max-age=300';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
$disk = Storage::disk($upload->disk);
|
|
133
|
+
if (! $disk->exists($path)) {
|
|
134
|
+
abort(404);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return $disk->response($path, null, [
|
|
138
|
+
'Cache-Control' => $cache,
|
|
139
|
+
'X-Content-Type-Options' => 'nosniff',
|
|
140
|
+
]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private function refuse(int $status, string $message): JsonResponse
|
|
144
|
+
{
|
|
145
|
+
return response()->json(['error' => $message], $status);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Media;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What each of Flare's file categories lets through. The same lists the dashboard's
|
|
7
|
+
* upload widget checks in the browser; this is the check that counts.
|
|
8
|
+
*/
|
|
9
|
+
final class FileTypes
|
|
10
|
+
{
|
|
11
|
+
public const CATEGORIES = [
|
|
12
|
+
'any' => ['*/*'],
|
|
13
|
+
'image' => ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/avif'],
|
|
14
|
+
'pdf' => ['application/pdf'],
|
|
15
|
+
'video' => ['video/*'],
|
|
16
|
+
'audio' => ['audio/*'],
|
|
17
|
+
'text' => ['text/plain'],
|
|
18
|
+
'csv' => ['text/csv'],
|
|
19
|
+
'document' => ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.oasis.opendocument.text'],
|
|
20
|
+
'spreadsheet' => ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.oasis.opendocument.spreadsheet'],
|
|
21
|
+
'archive' => ['application/zip', 'application/x-zip-compressed', 'application/x-zip', 'application/gzip', 'application/x-gzip', 'application/x-7z-compressed', 'application/vnd.rar', 'application/x-rar-compressed'],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/** Types a browser would run or render as a page. Never stored, whatever a field accepts. */
|
|
25
|
+
private const NEVER = ['text/html', 'application/xhtml+xml', 'image/svg+xml', 'application/javascript', 'text/javascript', 'application/x-httpd-php', 'text/x-php', 'application/x-msdownload', 'application/x-dosexec'];
|
|
26
|
+
|
|
27
|
+
/** Extensions a web server might run or a browser might render as a page. */
|
|
28
|
+
private const NEVER_EXTENSIONS = ['html', 'htm', 'xhtml', 'shtml', 'svg', 'svgz', 'js', 'mjs', 'php', 'phtml', 'phar', 'exe', 'dll', 'bat', 'cmd', 'com', 'msi', 'sh', 'cgi', 'pl', 'py', 'jsp', 'asp', 'aspx', 'htaccess'];
|
|
29
|
+
|
|
30
|
+
public static function forbidden(string $mime): bool
|
|
31
|
+
{
|
|
32
|
+
return in_array(strtolower(trim(explode(';', $mime)[0])), self::NEVER, true);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The extension a file is stored with: its own, unless that one could be run or rendered. */
|
|
36
|
+
public static function safeExtension(string $name): string
|
|
37
|
+
{
|
|
38
|
+
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
|
39
|
+
|
|
40
|
+
return $extension !== '' && preg_match('/^[a-z0-9]{1,10}$/', $extension) && ! in_array($extension, self::NEVER_EXTENSIONS, true) ? $extension : 'bin';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param list<string> $accept Categories, e.g. ["image", "pdf"]
|
|
45
|
+
*/
|
|
46
|
+
public static function accepts(array $accept, string $mime): bool
|
|
47
|
+
{
|
|
48
|
+
$mime = strtolower(trim(explode(';', $mime)[0]));
|
|
49
|
+
if ($mime === '' || self::forbidden($mime)) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
foreach ($accept as $category) {
|
|
53
|
+
foreach (self::CATEGORIES[$category] ?? [] as $pattern) {
|
|
54
|
+
if ($pattern === '*/*' || $pattern === $mime || (str_ends_with($pattern, '/*') && str_starts_with($mime, substr($pattern, 0, -1)))) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What a file is, from its contents. The type a browser sends is only a label, and a
|
|
65
|
+
* renamed file carries the wrong one.
|
|
66
|
+
*/
|
|
67
|
+
public static function sniff(string $path): string
|
|
68
|
+
{
|
|
69
|
+
$mime = function_exists('finfo_open') ? (string) @finfo_file(finfo_open(FILEINFO_MIME_TYPE), $path) : '';
|
|
70
|
+
|
|
71
|
+
return $mime !== '' ? strtolower($mime) : 'application/octet-stream';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether what a file contains fits what it was sent as. Office documents and CSVs are
|
|
76
|
+
* containers and plain text underneath, so those are compared by family, not exactly.
|
|
77
|
+
*/
|
|
78
|
+
public static function consistent(string $declared, string $sniffed): bool
|
|
79
|
+
{
|
|
80
|
+
$declared = strtolower(trim(explode(';', $declared)[0]));
|
|
81
|
+
if ($declared === $sniffed) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
$family = fn (string $mime) => explode('/', $mime)[0];
|
|
85
|
+
if (in_array($family($declared), ['image', 'video', 'audio'], true)) {
|
|
86
|
+
return $family($declared) === $family($sniffed);
|
|
87
|
+
}
|
|
88
|
+
if ($declared === 'application/pdf') {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Zip-based documents, text formats and anything finfo has no opinion on.
|
|
93
|
+
return in_array($sniffed, ['application/zip', 'application/octet-stream', 'text/plain', 'application/csv', 'text/csv', 'application/x-ole-storage', 'application/cdfv2'], true)
|
|
94
|
+
|| str_starts_with($sniffed, 'application/vnd.')
|
|
95
|
+
|| str_starts_with($sniffed, 'application/x-');
|
|
96
|
+
}
|
|
97
|
+
}
|