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.
- package/package.json +1 -1
- package/project.mjs +11 -0
- 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/UpdateCommand.php +64 -3
- package/template/laravel/src/Generator/ResourceGenerator.php +151 -19
- 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/Fake.php +194 -171
- package/template/laravel/src/Support/Field.php +79 -10
- 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 +1 -1
|
@@ -45,7 +45,48 @@ final class UpdateCommand extends Command
|
|
|
45
45
|
$this->modernizeDevScript();
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
@@ -288,8 +341,31 @@ final class ResourceGenerator
|
|
|
288
341
|
PHP);
|
|
289
342
|
}
|
|
290
343
|
|
|
291
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
460
|
-
|
|
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
|
-
|
|
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}):
|
|
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
|
-
|
|
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
|
+
}
|