create-nevela 0.1.5 → 0.2.1

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 (31) hide show
  1. package/package.json +1 -1
  2. package/project.mjs +11 -0
  3. package/template/laravel/composer.json +61 -59
  4. package/template/laravel/config/nevela.php +70 -0
  5. package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php +38 -0
  6. package/template/laravel/src/Console/GenerateCommand.php +25 -2
  7. package/template/laravel/src/Console/MakeResourceCommand.php +8 -0
  8. package/template/laravel/src/Console/SeedCommand.php +186 -124
  9. package/template/laravel/src/Console/UpdateCommand.php +64 -3
  10. package/template/laravel/src/Generator/ResourceGenerator.php +151 -19
  11. package/template/laravel/src/Http/ResourceRequest.php +4 -1
  12. package/template/laravel/src/Http/UploadController.php +147 -0
  13. package/template/laravel/src/Media/FileTypes.php +97 -0
  14. package/template/laravel/src/Media/ImageOptimizer.php +252 -0
  15. package/template/laravel/src/Media/Profile.php +94 -0
  16. package/template/laravel/src/Media/UploadRejected.php +8 -0
  17. package/template/laravel/src/Media/Uploads.php +325 -0
  18. package/template/laravel/src/Models/Upload.php +46 -0
  19. package/template/laravel/src/Nevela.php +20 -1
  20. package/template/laravel/src/NevelaServiceProvider.php +24 -0
  21. package/template/laravel/src/Rules/UploadKey.php +27 -0
  22. package/template/laravel/src/Support/Fake.php +194 -171
  23. package/template/laravel/src/Support/Field.php +79 -10
  24. package/template/laravel/src/Support/TemplateSource.php +1 -1
  25. package/template/web/app/api/[...path]/route.ts +87 -0
  26. package/template/web/app/dashboard/actions.ts +26 -9
  27. package/template/web/components/dashboard/fields/file-cell.tsx +10 -47
  28. package/template/web/components/dashboard/fields/file-field.tsx +25 -31
  29. package/template/web/lib/files.ts +21 -0
  30. package/template/web/package.json +1 -1
  31. package/template.mjs +1 -1
@@ -45,7 +45,48 @@ final class UpdateCommand extends Command
45
45
  $this->modernizeDevScript();
46
46
  }
47
47
 
48
- return $this->updateDashboard($check);
48
+ $status = $this->updateDashboard($check);
49
+
50
+ // A new version may bring a table of its own (uploads did). Say so, don't run it:
51
+ // changing a database is the developer's call.
52
+ if (! $check && ($pending = $this->pendingMigrations()) > 0) {
53
+ $this->newLine();
54
+ $this->components->warn("{$pending} migration(s) have not been run. Run them before using the app: php nevela migrate");
55
+ }
56
+
57
+ return $status;
58
+ }
59
+
60
+ /** What the app's composer.json asks for, or an empty string when it can't be read. */
61
+ public static function rootConstraint(string $composerJson): string
62
+ {
63
+ $manifest = json_decode($composerJson, true);
64
+
65
+ return is_array($manifest) && is_string($manifest['require']['nevela/laravel'] ?? null) ? trim($manifest['require']['nevela/laravel']) : '';
66
+ }
67
+
68
+ /**
69
+ * Whether a constraint is the kind the installer writes ("^0.1", "^0.1.3") and nothing
70
+ * more: the only kind an update moves on its own.
71
+ */
72
+ public static function isPlainCaret(string $constraint): bool
73
+ {
74
+ return preg_match('/^\^\d+\.\d+(\.\d+)?$/', $constraint) === 1;
75
+ }
76
+
77
+ private function pendingMigrations(): int
78
+ {
79
+ try {
80
+ $migrator = app('migrator');
81
+ if (! $migrator->repositoryExists()) {
82
+ return 0;
83
+ }
84
+ $files = $migrator->getMigrationFiles([...$migrator->paths(), database_path('migrations')]);
85
+
86
+ return count(array_diff(array_keys($files), $migrator->getRepository()->getRan()));
87
+ } catch (\Throwable) {
88
+ return 0;
89
+ }
49
90
  }
50
91
 
51
92
  /**
@@ -115,9 +156,29 @@ final class UpdateCommand extends Command
115
156
  $source = (string) @file_get_contents(base_path('vendor/nevela/laravel/src/Nevela.php'));
116
157
  $now = preg_match("/const VERSION = '([^']+)'/", $source, $m) ? $m[1] : $installed;
117
158
  if ($now === $installed) {
118
- $this->components->warn("Composer kept {$installed}: the constraint in composer.json doesn't allow {$latest}. Set \"nevela/laravel\" to \"^".implode('.', array_slice(explode('.', $latest), 0, 2)).'" there and run this again.');
159
+ // composer.json's range doesn't reach the new version: below 1.0, "^0.1" stops
160
+ // at 0.1.x. Updating is asking for the newest, so ask Composer for the range
161
+ // that has it. `require` rewrites composer.json and installs in one step.
162
+ $range = '^'.implode('.', array_slice(explode('.', $latest), 0, 2));
163
+ $current = self::rootConstraint((string) @file_get_contents(base_path('composer.json')));
164
+ if (! self::isPlainCaret($current)) {
165
+ // A pin, a branch or a hand-written range is somebody's decision. It is left
166
+ // as it is, and the update stops here.
167
+ $this->components->warn("Composer kept {$installed}: composer.json asks for \"{$current}\", which doesn't allow {$latest}. That looks deliberate, so it was left alone. To move, set \"nevela/laravel\" to \"{$range}\" there and run this again.");
168
+
169
+ return null;
170
+ }
171
+ $this->components->info("composer.json asks for \"{$current}\", which doesn't reach {$latest}. Moving \"nevela/laravel\" to \"{$range}\".");
172
+ $require = new Process(['composer', 'require', "nevela/laravel:{$range}", '--no-interaction', '--no-progress', '--update-with-dependencies'], base_path(), null, null, null);
173
+ $require->run(fn ($type, $buffer) => $this->output->write($buffer));
119
174
 
120
- return null;
175
+ $source = (string) @file_get_contents(base_path('vendor/nevela/laravel/src/Nevela.php'));
176
+ $now = preg_match("/const VERSION = '([^']+)'/", $source, $m) ? $m[1] : $installed;
177
+ if (! $require->isSuccessful() || $now === $installed) {
178
+ $this->components->warn("Composer kept {$installed}. Set \"nevela/laravel\" to \"{$range}\" in composer.json, see what Composer says about it, and run this again.");
179
+
180
+ return null;
181
+ }
121
182
  }
122
183
 
123
184
  // The package's own files have just been replaced. Finish in a new process, so the
@@ -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
@@ -288,8 +341,31 @@ final class ResourceGenerator
288
341
  PHP);
289
342
  }
290
343
 
291
- public function model(Descriptor $d): string
344
+ /** @param list<Descriptor> $all */
345
+ public function model(Descriptor $d, array $all = []): string
292
346
  {
347
+ $relations = [];
348
+ foreach ($d->fields as $f) {
349
+ if ($f->kind === 'belongsTo') {
350
+ $relations[] = <<<PHP
351
+
352
+ public function {$f->relation()}(): \Illuminate\Database\Eloquent\Relations\BelongsTo
353
+ {
354
+ return \$this->belongsTo({$f->target}::class, '{$f->column()}');
355
+ }
356
+ PHP;
357
+ }
358
+ }
359
+ foreach ($this->children($d, $all) as $child) {
360
+ $relations[] = <<<PHP
361
+
362
+ public function {$child['name']}(): \Illuminate\Database\Eloquent\Relations\HasMany
363
+ {
364
+ return \$this->hasMany({$child['resource']->name}::class, '{$child['field']->column()}');
365
+ }
366
+ PHP;
367
+ }
368
+ $relationBlock = $relations ? "\n".implode("\n", array_map(fn (string $r) => rtrim($r), $relations)) : '';
293
369
  $fillable = implode(', ', array_map(fn (Field $f) => "'{$f->column()}'", $d->fields));
294
370
  $casts = [];
295
371
  foreach ($d->fields as $f) {
@@ -331,21 +407,33 @@ final class ResourceGenerator
331
407
  return [
332
408
  {$castBlock}
333
409
  ];
334
- }
410
+ }{$relationBlock}
335
411
  {$e}
336
412
 
337
- // Relationships, scopes and accessors go here — outside the generated block.
413
+ // Your own relationships, scopes and accessors go here — outside the generated block.
338
414
  }
339
415
 
340
416
  PHP;
341
417
  }
342
418
 
343
- public function migration(Descriptor $d): string
419
+ /** @param list<Descriptor> $all */
420
+ public function migration(Descriptor $d, array $all = []): string
344
421
  {
345
422
  $columns = [];
346
423
  foreach ($d->fields as $f) {
347
424
  $c = $f->column();
425
+ if ($f->kind === 'belongsTo') {
426
+ // A required parent can't be deleted while it has children; an optional one lets go of them.
427
+ $columns[] = " \$table->foreignUuid('{$c}')"
428
+ .($f->required ? '' : '->nullable()')
429
+ .($f->unique ? '->unique()' : '->index()')
430
+ ."->constrained('{$this->tableOf((string) $f->target, $d, $all)}')"
431
+ .($f->required ? '' : '->nullOnDelete()').';';
432
+
433
+ continue;
434
+ }
348
435
  $line = match (true) {
436
+ $f->kind === 'file' => "\$table->string('{$c}', 512)",
349
437
  $f->kind === 'text' => "\$table->text('{$c}')",
350
438
  $f->kind === 'int' => "\$table->integer('{$c}')",
351
439
  $f->kind === 'float' && $f->format === 'money' => "\$table->decimal('{$c}', 12, 2)",
@@ -396,14 +484,15 @@ final class ResourceGenerator
396
484
  PHP;
397
485
  }
398
486
 
399
- public function request(Descriptor $d): string
487
+ /** @param list<Descriptor> $all */
488
+ public function request(Descriptor $d, array $all = []): string
400
489
  {
401
490
  $var = $d->variable();
402
491
  $param = $d->routeParameter();
403
492
  $rules = [];
404
493
  $columns = [];
405
494
  foreach ($d->fields as $f) {
406
- $rules[] = " '{$f->name}' => [".implode(', ', $this->rules($d, $f)).'],';
495
+ $rules[] = " '{$f->name}' => [".implode(', ', $this->rules($d, $f, $all)).'],';
407
496
  $columns[] = "'{$f->name}' => '{$f->column()}'";
408
497
  }
409
498
  $ruleLines = implode("\n", $rules);
@@ -456,11 +545,16 @@ final class ResourceGenerator
456
545
  PHP;
457
546
  }
458
547
 
459
- /** @return list<string> PHP expressions */
460
- private function rules(Descriptor $d, Field $f): array
548
+ /**
549
+ * @param list<Descriptor> $all
550
+ * @return list<string> PHP expressions
551
+ */
552
+ private function rules(Descriptor $d, Field $f, array $all = []): array
461
553
  {
462
554
  $rules = [$f->required ? "'required'" : "'nullable'"];
463
555
  $type = match (true) {
556
+ $f->kind === 'belongsTo' => ["'uuid'", "Rule::exists('{$this->tableOf((string) $f->target, $d, $all)}', 'id')"],
557
+ $f->kind === 'file' => ["'string'", "'max:512'", "new \\Nevela\\Laravel\\Rules\\UploadKey('{$d->name}', '{$f->name}')"],
464
558
  $f->format === 'email' => ["'email'", "'max:255'"],
465
559
  $f->format === 'url' => ["'url'", "'max:2048'"],
466
560
  $f->format === 'tel' => ["'string'", "'max:32'"],
@@ -495,6 +589,10 @@ final class ResourceGenerator
495
589
  default => "\$this->{$f->column()}",
496
590
  };
497
591
  $lines[] = " '{$f->name}' => {$value},";
592
+ if ($f->kind === 'file') {
593
+ // The key is what a record stores; this is what a page needs to show it.
594
+ $lines[] = " '{$f->name}File' => \\Nevela\\Laravel\\Nevela::file(\$this->{$f->column()}),";
595
+ }
498
596
  }
499
597
  $body = implode("\n", $lines);
500
598
  $m = self::M;
@@ -529,9 +627,28 @@ final class ResourceGenerator
529
627
  PHP;
530
628
  }
531
629
 
532
- public function controller(Descriptor $d): string
630
+ /** @param list<Descriptor> $all */
631
+ public function controller(Descriptor $d, array $all = []): string
533
632
  {
534
633
  $var = $d->variable();
634
+ $guards = [];
635
+ foreach ($this->children($d, $all) as $child) {
636
+ if (! $child['field']->required) {
637
+ continue; // the database clears an optional link itself
638
+ }
639
+ $one = strtolower($child['resource']->label);
640
+ $many = strtolower($child['resource']->pluralLabel);
641
+ $parent = $this->lower($d->label);
642
+ $guards[] = <<<PHP
643
+ if ((\$count = \\App\\Models\\{$child['resource']->name}::query()->where('{$child['field']->column()}', \${$var}->getKey())->count()) > 0) {
644
+ return response()->json(['error' => \$count === 1
645
+ ? '1 {$one} belongs to this {$parent}. Move or delete it first.'
646
+ : "{\$count} {$many} belong to this {$parent}. Move or delete them first."], 409);
647
+ }
648
+ PHP;
649
+ }
650
+ $guardBlock = $guards ? "\n".implode("\n", array_map(fn (string $g) => rtrim($g), $guards)) : '';
651
+ $destroyReturn = $guards ? 'Response|JsonResponse' : 'Response';
535
652
  $m = self::M;
536
653
  $e = self::E;
537
654
 
@@ -595,9 +712,9 @@ final class ResourceGenerator
595
712
  return new {$d->name}Resource(\${$var}->refresh());
596
713
  }
597
714
 
598
- public function destroy({$d->name} \${$var}): Response
715
+ public function destroy({$d->name} \${$var}): {$destroyReturn}
599
716
  {
600
- Gate::authorize('delete', \${$var});
717
+ Gate::authorize('delete', \${$var});{$guardBlock}
601
718
  \${$var}->delete();
602
719
 
603
720
  return response()->noContent();
@@ -608,6 +725,11 @@ final class ResourceGenerator
608
725
  PHP;
609
726
  }
610
727
 
728
+ private function lower(string $label): string
729
+ {
730
+ return strtolower($label);
731
+ }
732
+
611
733
  public function policy(Descriptor $d): string
612
734
  {
613
735
  $var = $d->variable();
@@ -657,7 +779,8 @@ final class ResourceGenerator
657
779
  PHP;
658
780
  }
659
781
 
660
- public function typescript(Descriptor $d): string
782
+ /** @param list<Descriptor> $all */
783
+ public function typescript(Descriptor $d, array $all = []): string
661
784
  {
662
785
  $j = fn (mixed $v) => json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
663
786
  $config = [
@@ -683,15 +806,24 @@ final class ResourceGenerator
683
806
  'unique' => $f->unique ? 'true' : null,
684
807
  'label' => $f->label ? $j($f->label) : null,
685
808
  'format' => in_array($f->format, ['money', 'percent', 'rating'], true) ? $j($f->format) : null,
809
+ // What the migration does when the parent goes: an optional link is cleared.
810
+ 'onDelete' => $f->kind === 'belongsTo' && ! $f->required ? '"set null"' : null,
811
+ // A picture is worth a column in the table; other files are not.
812
+ 'list' => $f->isImage() ? 'true' : null,
686
813
  ]);
687
814
  $opts = $options ? '{ '.implode(', ', array_map(fn ($k, $v) => "{$k}: {$v}", array_keys($options), $options)).' }' : '';
688
815
  $call = match (true) {
816
+ $f->kind === 'belongsTo' => "field.belongsTo({$j($f->target)}".($opts ? ", {$opts}" : '').')',
817
+ $f->kind === 'file' => "field.file({$j($f->accept)}".($opts ? ", {$opts}" : '').')',
689
818
  $f->kind === 'enum' => "field.enum({$j($f->options)}".($opts ? ", {$opts}" : '').')',
690
819
  $f->kind === 'string' && $f->format !== null => "field.{$f->format}({$opts})",
691
820
  default => "field.{$f->kind}({$opts})",
692
821
  };
693
822
  $fields[] = " {$f->name}: {$call},";
694
823
  }
824
+ foreach ($this->children($d, $all) as $child) {
825
+ $fields[] = " {$child['name']}: field.hasMany({$j($child['resource']->name)}, { foreignKey: {$j($child['field']->name)} }),";
826
+ }
695
827
  $configLines = implode("\n", $config);
696
828
  $fieldLines = implode("\n", $fields);
697
829
  $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
+ }