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.
Files changed (38) hide show
  1. package/README.md +17 -6
  2. package/index.mjs +77 -16
  3. package/package.json +2 -1
  4. package/project.mjs +20 -4
  5. package/selfupdate.mjs +13 -3
  6. package/template/laravel/README.md +1 -1
  7. package/template/laravel/composer.json +61 -59
  8. package/template/laravel/config/nevela.php +70 -0
  9. package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php +38 -0
  10. package/template/laravel/src/Console/GenerateCommand.php +25 -2
  11. package/template/laravel/src/Console/MakeResourceCommand.php +8 -0
  12. package/template/laravel/src/Console/SeedCommand.php +186 -124
  13. package/template/laravel/src/Console/StatusCommand.php +2 -2
  14. package/template/laravel/src/Console/UpdateCommand.php +76 -9
  15. package/template/laravel/src/Generator/ResourceGenerator.php +154 -21
  16. package/template/laravel/src/Http/ResourceRequest.php +4 -1
  17. package/template/laravel/src/Http/UploadController.php +147 -0
  18. package/template/laravel/src/Media/FileTypes.php +97 -0
  19. package/template/laravel/src/Media/ImageOptimizer.php +252 -0
  20. package/template/laravel/src/Media/Profile.php +94 -0
  21. package/template/laravel/src/Media/UploadRejected.php +8 -0
  22. package/template/laravel/src/Media/Uploads.php +325 -0
  23. package/template/laravel/src/Models/Upload.php +46 -0
  24. package/template/laravel/src/Nevela.php +20 -1
  25. package/template/laravel/src/NevelaServiceProvider.php +24 -0
  26. package/template/laravel/src/Rules/UploadKey.php +27 -0
  27. package/template/laravel/src/Support/Descriptor.php +3 -3
  28. package/template/laravel/src/Support/Fake.php +194 -171
  29. package/template/laravel/src/Support/Field.php +108 -12
  30. package/template/laravel/src/Support/TemplateSource.php +1 -1
  31. package/template/web/app/api/[...path]/route.ts +87 -0
  32. package/template/web/app/dashboard/actions.ts +26 -9
  33. package/template/web/components/dashboard/fields/file-cell.tsx +10 -47
  34. package/template/web/components/dashboard/fields/file-field.tsx +25 -31
  35. package/template/web/lib/files.ts +21 -0
  36. package/template/web/package.json +1 -1
  37. package/template.mjs +2 -2
  38. package/tool.mjs +58 -0
@@ -19,24 +19,77 @@ final class ResourceGenerator
19
19
 
20
20
  private const E = '// '.Writer::END;
21
21
 
22
- /** @return list<GeneratedFile> */
23
- public function forResource(Descriptor $d, ?string $timestamp = null): array
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
- \$nevela = ['resource', 'generate', 'seed', 'user', 'update', 'dev', 'status', 'version'];
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 update update Nevela and the dashboard
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
- public function model(Descriptor $d): string
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
- // Relationships, scopes and accessors go here — outside the generated block.
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
- public function migration(Descriptor $d): string
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
- public function request(Descriptor $d): string
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
- /** @return list<string> PHP expressions */
460
- private function rules(Descriptor $d, Field $f): array
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
- public function controller(Descriptor $d): string
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}): Response
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
- public function typescript(Descriptor $d): string
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
+ }