create-nevela 0.8.0 → 0.9.2

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 (62) hide show
  1. package/index.mjs +24 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +41 -0
  5. package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
  6. package/template/laravel/resources/disposable-domains.txt +9880 -0
  7. package/template/laravel/src/Access/ClosedAccounts.php +2 -1
  8. package/template/laravel/src/Concerns/Confirmable.php +81 -0
  9. package/template/laravel/src/Console/BlogCommand.php +254 -0
  10. package/template/laravel/src/Console/DevCommand.php +9 -0
  11. package/template/laravel/src/Console/DigestCommand.php +28 -0
  12. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  13. package/template/laravel/src/Console/GenerateCommand.php +25 -0
  14. package/template/laravel/src/Console/SetupCommand.php +15 -2
  15. package/template/laravel/src/Console/StatusCommand.php +14 -0
  16. package/template/laravel/src/Console/TrashCommand.php +9 -0
  17. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  18. package/template/laravel/src/Forms/Confirmation.php +88 -0
  19. package/template/laravel/src/Forms/Disposable.php +74 -0
  20. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  21. package/template/laravel/src/Forms/FormGuard.php +359 -0
  22. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  23. package/template/laravel/src/Forms/PublicMail.php +217 -0
  24. package/template/laravel/src/Forms/Refusal.php +50 -0
  25. package/template/laravel/src/Generator/BlogPages.php +409 -0
  26. package/template/laravel/src/Generator/ResourceGenerator.php +133 -7
  27. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  28. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  29. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  30. package/template/laravel/src/Http/ActionController.php +2 -1
  31. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  32. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  33. package/template/laravel/src/Http/FormsController.php +45 -0
  34. package/template/laravel/src/Http/PublicController.php +89 -4
  35. package/template/laravel/src/Http/ResourceRequest.php +43 -3
  36. package/template/laravel/src/Http/TrashController.php +2 -1
  37. package/template/laravel/src/Http/UploadController.php +2 -2
  38. package/template/laravel/src/Nevela.php +30 -1
  39. package/template/laravel/src/NevelaServiceProvider.php +11 -2
  40. package/template/laravel/src/Support/Descriptor.php +24 -0
  41. package/template/laravel/src/Support/Field.php +17 -0
  42. package/template/laravel/src/Support/Key.php +57 -0
  43. package/template/laravel/src/Support/ListQuery.php +29 -1
  44. package/template/laravel/src/Support/PublicAccess.php +75 -0
  45. package/template/laravel/src/Support/Slug.php +43 -0
  46. package/template/laravel/src/Trash/Trash.php +3 -1
  47. package/template/web/.env.example +4 -0
  48. package/template/web/app/api/[...path]/route.ts +6 -3
  49. package/template/web/app/confirm/page.tsx +51 -0
  50. package/template/web/app/dashboard/actions.ts +15 -1
  51. package/template/web/components/dashboard/form-activity.tsx +53 -0
  52. package/template/web/components/dashboard/record-actions.tsx +29 -5
  53. package/template/web/components/dashboard/record-detail.tsx +2 -1
  54. package/template/web/components/dashboard/resource-form.tsx +19 -2
  55. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  56. package/template/web/components/dashboard/resource-table.tsx +32 -3
  57. package/template/web/components/dashboard/row-actions.tsx +33 -3
  58. package/template/web/components/marketing/site-header.tsx +9 -2
  59. package/template/web/components/public-form-guard.tsx +125 -0
  60. package/template/web/hooks/use-public-form.ts +263 -0
  61. package/template/web/lib/extras.ts +39 -0
  62. package/template/web/package.json +1 -1
@@ -6,6 +6,7 @@ use Nevela\Laravel\Support\Action;
6
6
  use Nevela\Laravel\Support\Descriptor;
7
7
  use Nevela\Laravel\Support\Field;
8
8
  use Nevela\Laravel\Support\Naming;
9
+ use Nevela\Laravel\Support\PublicAccess;
9
10
 
10
11
  /**
11
12
  * Turns descriptors into files. Pure: no Laravel, no filesystem — Writer does the I/O.
@@ -289,7 +290,7 @@ final class ResourceGenerator
289
290
 
290
291
  // What you type => the artisan command it runs.
291
292
  // "update" is what upgrade was called before 0.3.0; it still works.
292
- \$nevela = ['resource', 'adopt', 'generate', 'seed', 'user', 'upgrade', 'update', 'dev', 'status', 'version', 'trash'];
293
+ \$nevela = ['resource', 'adopt', 'blog', 'generate', 'seed', 'user', 'upgrade', 'update', 'dev', 'status', 'version', 'trash', 'digest', 'disposable'];
293
294
  \$artisan = ['migrate', 'tinker', 'test', 'serve'];
294
295
  if (in_array(\$name, ['--version', '-v', '-V'], true)) {
295
296
  \$name = 'version';
@@ -316,10 +317,12 @@ final class ResourceGenerator
316
317
  php nevela resource Product --fields="name:string, price:money" --seed
317
318
  add a resource, create its table, fill it
318
319
  php nevela adopt posts make a resource of a table you already have
320
+ php nevela blog give the site a blog: posts in the dashboard, /blog to read them
319
321
  php nevela generate regenerate after editing a descriptor
320
322
  php nevela seed Product fill a resource with records
321
323
  php nevela user create someone who can sign in
322
324
  php nevela trash remove deleted records whose time in the trash is up
325
+ php nevela disposable fetch the current list of throwaway email domains
323
326
  php nevela upgrade bring this app to the latest Nevela
324
327
  php nevela version which Nevela this is
325
328
 
@@ -373,6 +376,17 @@ final class ResourceGenerator
373
376
  if ($d->actions !== []) {
374
377
  $extras[$d->name]['actions'] = array_map(fn (Action $a) => ['permission' => "{$d->table}.{$a->key()}"] + $a->toArray(), $d->actions);
375
378
  }
379
+ // The field its records are published by, and where on the site they are a blog.
380
+ if ($d->publish !== null) {
381
+ $extras[$d->name]['publish'] = $d->publish;
382
+ }
383
+ if ($d->blog !== null) {
384
+ $extras[$d->name]['blog'] = $d->blog;
385
+ }
386
+ // What its public form checks, and the address it has proved.
387
+ if ($d->public?->protect !== null || $d->public?->confirm !== null) {
388
+ $extras[$d->name]['form'] = array_filter(['protect' => $d->public->toArray()['create']['protect'] ?? null, 'confirm' => $d->public->confirm], fn ($v) => $v !== null);
389
+ }
376
390
  }
377
391
  $body .= "\n\n/** What a resource has beyond its fields. Read through lib/extras.ts. */\nexport const extras: Record<string, unknown> = ".($extras === [] ? '{}' : json_encode($extras, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)).';';
378
392
  $m = self::M;
@@ -513,8 +527,18 @@ final class ResourceGenerator
513
527
  foreach ($public->create as $field) {
514
528
  if ($d->fields[$field]->kind === 'file') {
515
529
  $lines[] = "Route::put('_nevela/uploads/{$d->name}/{$field}', [{$controller}, 'upload']){$for}->defaults('field', '{$field}')->middleware([{$throttle($public->createThrottle, 'upload')}{$more}])->name('{$d->slug}.upload.{$field}');";
530
+ // The same, beside the form's own address: a page knows the form by its slug, not by the resource's name.
531
+ $lines[] = "Route::put('{$d->slug}/_uploads/{$field}', [{$controller}, 'upload']){$for}->defaults('field', '{$field}')->middleware([{$throttle($public->createThrottle, 'upload')}{$more}])->name('{$d->slug}.upload.{$field}.here');";
516
532
  }
517
533
  }
534
+ // What a protected form asks for before it is shown, and after each attempt.
535
+ if ($public->protected()) {
536
+ $lines[] = "Route::get('_nevela/form/{$d->slug}', [{$controller}, 'form']){$for}->middleware([{$throttle(PublicAccess::READ_THROTTLE, 'form')}])->name('{$d->slug}.form');";
537
+ }
538
+ // The link that proves an address.
539
+ if ($public->confirm !== null) {
540
+ $lines[] = "Route::post('_nevela/confirm/{$d->slug}/{id}/{key}', [{$controller}, 'confirm']){$for}->middleware([{$throttle(PublicAccess::READ_THROTTLE, 'confirm')}])->name('{$d->slug}.confirm');";
541
+ }
518
542
  }
519
543
  }
520
544
  $body = $lines === [] ? '// No resource has a "public" section, so nothing is open.' : implode("\n", $lines);
@@ -578,7 +602,25 @@ final class ResourceGenerator
578
602
  }
579
603
  $castBlock = $casts ? implode("\n", $casts) : '';
580
604
  // Written with the model, outside its generated block: which records are public is the app's to say.
581
- $scope = $d->public?->read === null ? '' : <<<PHP
605
+ $published = $d->publish === null ? null : $d->fields[$d->publish]->column();
606
+ $scope = match (true) {
607
+ $d->public?->read === null => '',
608
+ // A resource with a publish date has said it already: the ones whose time has come.
609
+ $published !== null => <<<PHP
610
+
611
+
612
+ /**
613
+ * Which {$this->lower($d->pluralLabel)} anyone may read, signed in or not: the published ones. The
614
+ * public list and the public page of one both go through here. A draft has no
615
+ * {$published}, and a scheduled one has a time still to come, which this lets
616
+ * through when it does, with nobody saving anything.
617
+ */
618
+ public function scopePublicQuery(\Illuminate\Database\Eloquent\Builder \$query): void
619
+ {
620
+ \$query->whereNotNull('{$published}')->where('{$published}', '<=', now());
621
+ }
622
+ PHP,
623
+ default => <<<PHP
582
624
 
583
625
 
584
626
  /**
@@ -592,9 +634,15 @@ final class ResourceGenerator
592
634
  {
593
635
  \$query->whereRaw('1 = 0');
594
636
  }
595
- PHP;
637
+ PHP,
638
+ };
596
639
  // An integer id is Eloquent's own default: the database numbers the records.
597
640
  [$import, $trait] = $d->idType === 'int' ? ['', ''] : ["use Illuminate\\Database\\Eloquent\\Concerns\\HasUuids;\n", " use HasUuids;\n\n"];
641
+ $confirmable = $d->public?->confirm === null ? '' : <<<'PHP'
642
+
643
+ // Its public form asks for an address to be proved: Subscriber::confirmed() is who has.
644
+ use \Nevela\Laravel\Concerns\Confirmable;
645
+ PHP;
598
646
  $m = self::M;
599
647
  $e = self::E;
600
648
 
@@ -611,7 +659,7 @@ final class ResourceGenerator
611
659
 
612
660
  {$m}
613
661
  // Deleted records go to the trash, where they can be restored for a while.
614
- use \Nevela\Laravel\Concerns\Trashable;
662
+ use \Nevela\Laravel\Concerns\Trashable;{$confirmable}
615
663
 
616
664
  protected \$fillable = [{$fillable}];
617
665
 
@@ -675,6 +723,10 @@ final class ResourceGenerator
675
723
  }
676
724
  $columns[] = " {$line};";
677
725
  }
726
+ if ($d->public?->confirm !== null) {
727
+ $columns[] = ' // When the address was proved, by the link emailed to it. Empty until then.';
728
+ $columns[] = " \$table->timestamp('confirmed_at')->nullable()->index();";
729
+ }
678
730
  $cols = implode("\n", $columns);
679
731
  $id = $d->idType === 'int' ? '$table->id();' : "\$table->uuid('id')->primary();";
680
732
 
@@ -749,6 +801,58 @@ final class ResourceGenerator
749
801
  PHP;
750
802
  }
751
803
 
804
+ /**
805
+ * For a table made before its form asked for addresses to be proved: the column that
806
+ * records it. The records already there are taken as proved. They were on the list
807
+ * before anyone was asked, and asking now would empty it.
808
+ */
809
+ public function confirmMigration(Descriptor $d): string
810
+ {
811
+ return <<<PHP
812
+ <?php
813
+
814
+ use Illuminate\Database\Migrations\Migration;
815
+ use Illuminate\Database\Schema\Blueprint;
816
+ use Illuminate\Support\Facades\DB;
817
+ use Illuminate\Support\Facades\Schema;
818
+
819
+ // {$d->pluralLabel} now prove their address: a record made through the public form is
820
+ // unconfirmed until the link emailed to it is followed. Generated once by Nevela.
821
+ return new class extends Migration
822
+ {
823
+ public function up(): void
824
+ {
825
+ if (Schema::hasTable('{$d->table}') && ! Schema::hasColumn('{$d->table}', 'confirmed_at')) {
826
+ Schema::table('{$d->table}', function (Blueprint \$table) {
827
+ \$table->timestamp('confirmed_at')->nullable()->index();
828
+ });
829
+ // Whoever is on the list already stays on it.
830
+ DB::table('{$d->table}')->update(['confirmed_at' => now()]);
831
+ }
832
+ }
833
+
834
+ public function down(): void
835
+ {
836
+ if (Schema::hasColumn('{$d->table}', 'confirmed_at')) {
837
+ Schema::table('{$d->table}', function (Blueprint \$table) {
838
+ \$table->dropIndex(['confirmed_at']);
839
+ \$table->dropColumn('confirmed_at');
840
+ });
841
+ }
842
+ }
843
+ };
844
+
845
+ PHP;
846
+ }
847
+
848
+ /** The file `confirmMigration()` goes in. Written once for a table, whatever its date. */
849
+ public function confirmMigrationFile(Descriptor $d, ?string $timestamp = null): GeneratedFile
850
+ {
851
+ $timestamp ??= date('Y_m_d_His');
852
+
853
+ return new GeneratedFile(GeneratedFile::TARGET_API, "database/migrations/{$timestamp}_add_confirmation_to_{$d->table}_table.php", $this->confirmMigration($d), GeneratedFile::MODE_ONCE, "database/migrations/*_add_confirmation_to_{$d->table}_table.php");
854
+ }
855
+
752
856
  /** The file `trashMigration()` goes in. Not made again once one exists for the table, whatever its date. */
753
857
  public function trashMigrationFile(Descriptor $d, ?string $timestamp = null): GeneratedFile
754
858
  {
@@ -780,6 +884,19 @@ final class ResourceGenerator
780
884
  return [{$defaults}];
781
885
  }
782
886
  PHP;
887
+ $made = implode(', ', array_map(
888
+ fn (Field $f) => "'{$f->name}' => ['from' => '{$f->from}', 'table' => '{$d->table}', 'column' => '{$f->column()}']",
889
+ array_filter($d->fields, fn (Field $f) => $f->from !== null),
890
+ ));
891
+ $madeBlock = $made === '' ? '' : <<<PHP
892
+
893
+
894
+ /** Fields made from another when a create leaves them empty. They are never remade afterwards. */
895
+ protected function made(): array
896
+ {
897
+ return [{$made}];
898
+ }
899
+ PHP;
783
900
  // Only where there is one: a resource without rich text has the request it always had.
784
901
  $rich = implode(', ', array_map(fn (Field $f) => "'{$f->name}'", array_filter($d->fields, fn (Field $f) => $f->isRich())));
785
902
  $richBlock = $rich === '' ? '' : <<<PHP
@@ -832,7 +949,7 @@ final class ResourceGenerator
832
949
  protected function columns(): array
833
950
  {
834
951
  return [{$columnMap}];
835
- }{$richBlock}{$defaultsBlock}
952
+ }{$richBlock}{$defaultsBlock}{$madeBlock}
836
953
  {$e}
837
954
  }
838
955
 
@@ -845,7 +962,8 @@ final class ResourceGenerator
845
962
  */
846
963
  private function rules(Descriptor $d, Field $f, array $all = []): array
847
964
  {
848
- $rules = [$f->required ? "'required'" : "'nullable'"];
965
+ // A slug that is made from another field may be left empty: the request fills it in.
966
+ $rules = [$f->required && $f->from === null ? "'required'" : "'nullable'"];
849
967
  // With a default, leaving it out is saying "the usual". Sent, it is checked like any other.
850
968
  if ($f->default !== null) {
851
969
  array_unshift($rules, "'sometimes'");
@@ -903,6 +1021,10 @@ final class ResourceGenerator
903
1021
  : " '{$f->name}File' => \\Nevela\\Laravel\\Nevela::file(\$this->{$f->column()}),";
904
1022
  }
905
1023
  }
1024
+ if ($d->public?->confirm !== null) {
1025
+ // Not a field: when the address was proved, for the dashboard to say "Not confirmed" until it is.
1026
+ $lines[] = " 'confirmedAt' => \$this->confirmed_at?->toJSON(),";
1027
+ }
906
1028
  $body = implode("\n", $lines);
907
1029
  $id = $d->idType === 'int' ? '(string) $this->id' : '$this->id';
908
1030
  $m = self::M;
@@ -1139,7 +1261,8 @@ final class ResourceGenerator
1139
1261
  $fields = [];
1140
1262
  foreach ($d->fields as $f) {
1141
1263
  $options = array_filter([
1142
- 'required' => $f->required ? null : 'false',
1264
+ // (A slug made from another field is optional in the form: left empty, Laravel makes it.)
1265
+ 'required' => $f->required && $f->from === null ? null : 'false',
1143
1266
  'unique' => $f->unique ? 'true' : null,
1144
1267
  'label' => $f->label ? $j($f->label) : null,
1145
1268
  'format' => in_array($f->format, ['money', 'percent', 'rating'], true) ? $j($f->format) : null,
@@ -1154,6 +1277,9 @@ final class ResourceGenerator
1154
1277
  if ($f->private) {
1155
1278
  $parts[] = '...({ private: true } as {})';
1156
1279
  }
1280
+ if ($f->from !== null) {
1281
+ $parts[] = '...({ from: '.$j($f->from).' } as {})';
1282
+ }
1157
1283
  if ($f->isRich()) {
1158
1284
  // Flare has no word for it, so it is said in passing: the dashboard reads `rich` off the field.
1159
1285
  $parts[] = '...({ rich: true } as {})';
@@ -11,6 +11,7 @@ use Nevela\Laravel\Access\ClosedAccounts;
11
11
  use Nevela\Laravel\Auth\Account;
12
12
  use Nevela\Laravel\Auth\AuthMail;
13
13
  use Nevela\Laravel\Http\Auth\Answers;
14
+ use Nevela\Laravel\Support\Key;
14
15
  use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
15
16
 
16
17
  /**
@@ -144,7 +145,7 @@ final class DeletedAccountsController
144
145
 
145
146
  private function find(string $id): mixed
146
147
  {
147
- return ClosedAccounts::closed()->find($id) ?? throw new NotFoundHttpException('No such deleted account.');
148
+ return (Key::fits(Account::model(), $id) ? ClosedAccounts::closed()->find($id) : null) ?? throw new NotFoundHttpException('No such deleted account.');
148
149
  }
149
150
 
150
151
  /** An upgraded app keeps no deleted accounts until it has run its migrations. */
@@ -13,6 +13,7 @@ use Nevela\Laravel\Access\ClosedAccounts;
13
13
  use Nevela\Laravel\Access\Permissions;
14
14
  use Nevela\Laravel\Http\Auth\Answers;
15
15
  use Nevela\Laravel\Models\Role;
16
+ use Nevela\Laravel\Support\Key;
16
17
  use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
17
18
  use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
18
19
 
@@ -124,7 +125,7 @@ final class RolesController
124
125
 
125
126
  private function find(string $id): Role
126
127
  {
127
- return Role::query()->find($id) ?? throw new NotFoundHttpException('No such role.');
128
+ return (Key::fits(Role::class, $id) ? Role::query()->find($id) : null) ?? throw new NotFoundHttpException('No such role.');
128
129
  }
129
130
 
130
131
  private function users(Role $role): int
@@ -19,6 +19,7 @@ use Nevela\Laravel\Auth\AuthMail;
19
19
  use Nevela\Laravel\Http\Auth\Answers;
20
20
  use Nevela\Laravel\Media\Uploads;
21
21
  use Nevela\Laravel\Models\Role;
22
+ use Nevela\Laravel\Support\Key;
22
23
  use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
23
24
 
24
25
  /**
@@ -248,7 +249,7 @@ final class UsersController
248
249
 
249
250
  private function find(string $id): mixed
250
251
  {
251
- return ClosedAccounts::open()->find($id) ?? throw new NotFoundHttpException('No such user.');
252
+ return (Key::fits(Account::model(), $id) ? ClosedAccounts::open()->find($id) : null) ?? throw new NotFoundHttpException('No such user.');
252
253
  }
253
254
 
254
255
  /**
@@ -12,6 +12,7 @@ use Illuminate\Validation\ValidationException;
12
12
  use Nevela\Laravel\Nevela;
13
13
  use Nevela\Laravel\Support\Action;
14
14
  use Nevela\Laravel\Support\Descriptor;
15
+ use Nevela\Laravel\Support\Key;
15
16
  use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
16
17
  use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
17
18
  use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -128,7 +129,7 @@ final class ActionController
128
129
  private function one(Descriptor $descriptor, string $id): ?Model
129
130
  {
130
131
  $model = "App\\Models\\{$descriptor->name}";
131
- if (! class_exists($model) || ($descriptor->idType === 'int' && ! preg_match('/^[0-9]{1,18}$/', $id))) {
132
+ if (! class_exists($model) || ! Key::fits($model, $id)) {
132
133
  return null;
133
134
  }
134
135
  $record = $model::query()->find($id);
@@ -21,6 +21,7 @@ use Nevela\Laravel\Models\Upload;
21
21
  use Nevela\Laravel\Rules\UploadKey;
22
22
  use Nevela\Laravel\Support\Descriptor;
23
23
  use Nevela\Laravel\Support\Field;
24
+ use Nevela\Laravel\Support\Key;
24
25
 
25
26
  /**
26
27
  * An account and what its owner can do with it: create it, prove the email address,
@@ -281,9 +282,11 @@ final class AccountController
281
282
  public function revokeSession(Request $request, string $id): Response
282
283
  {
283
284
  $user = $request->user();
284
- DB::table('personal_access_tokens')
285
- ->where('tokenable_type', $user->getMorphClass())->where('tokenable_id', $user->getAuthIdentifier())
286
- ->where('id', $id)->delete();
285
+ if (Key::isInt($id)) {
286
+ DB::table('personal_access_tokens')
287
+ ->where('tokenable_type', $user->getMorphClass())->where('tokenable_id', $user->getAuthIdentifier())
288
+ ->where('id', $id)->delete();
289
+ }
287
290
 
288
291
  return response()->noContent();
289
292
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  namespace Nevela\Laravel\Http\Auth;
4
4
 
5
+ use Illuminate\Database\Eloquent\ModelNotFoundException;
5
6
  use Illuminate\Http\JsonResponse;
6
7
  use Illuminate\Http\Request;
7
8
  use Illuminate\Http\Response;
@@ -13,6 +14,7 @@ use Nevela\Laravel\Auth\Passkeys;
13
14
  use Nevela\Laravel\Auth\Totp;
14
15
  use Nevela\Laravel\Models\Passkey;
15
16
  use Nevela\Laravel\Models\TwoFactor;
17
+ use Nevela\Laravel\Support\Key;
16
18
  use RuntimeException;
17
19
 
18
20
  /**
@@ -161,6 +163,9 @@ final class SecurityController
161
163
  public function renamePasskey(Request $request, string $id): JsonResponse
162
164
  {
163
165
  $input = $request->validate(['name' => ['required', 'string', 'max:80']]);
166
+ if (! Key::fits(Passkey::class, $id)) {
167
+ throw (new ModelNotFoundException)->setModel(Passkey::class, [$id]);
168
+ }
164
169
  $passkey = Passkey::query()->where('user_id', (string) $request->user()->getAuthIdentifier())->findOrFail($id);
165
170
  $passkey->fill(['name' => trim($input['name'])])->save();
166
171
 
@@ -170,7 +175,9 @@ final class SecurityController
170
175
  /** DELETE auth/passkeys/{id} */
171
176
  public function removePasskey(Request $request, string $id): Response
172
177
  {
173
- Passkey::query()->where('user_id', (string) $request->user()->getAuthIdentifier())->where('id', $id)->delete();
178
+ if (Key::fits(Passkey::class, $id)) {
179
+ Passkey::query()->where('user_id', (string) $request->user()->getAuthIdentifier())->where('id', $id)->delete();
180
+ }
174
181
 
175
182
  return response()->noContent();
176
183
  }
@@ -0,0 +1,45 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http;
4
+
5
+ use Illuminate\Http\JsonResponse;
6
+ use Illuminate\Support\Facades\Gate;
7
+ use Nevela\Laravel\Forms\FormBlocks;
8
+ use Nevela\Laravel\Forms\FormGuard;
9
+ use Nevela\Laravel\Nevela;
10
+ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
11
+
12
+ /**
13
+ * What a resource's public form has been doing, for whoever may see the resource:
14
+ *
15
+ * GET {prefix}/_nevela/forms/{slug} → { protect, confirm, robotCheck, blocked }
16
+ *
17
+ * `blocked` is what was turned away in the last week, in all and by reason. It is how a
18
+ * form that is well protected is told from one nobody uses.
19
+ */
20
+ final class FormsController
21
+ {
22
+ public function show(string $slug): JsonResponse
23
+ {
24
+ $descriptor = null;
25
+ foreach (Nevela::all() as $candidate) {
26
+ if ($candidate->slug === $slug) {
27
+ $descriptor = $candidate;
28
+ }
29
+ }
30
+ $model = $descriptor === null ? null : "App\\Models\\{$descriptor->name}";
31
+ if ($descriptor === null || $descriptor->public?->create === null || ! class_exists((string) $model)) {
32
+ throw new NotFoundHttpException;
33
+ }
34
+ Gate::authorize('viewAny', $model);
35
+ $protect = $descriptor->public->protect;
36
+
37
+ return response()->json([
38
+ 'protect' => $protect,
39
+ 'confirm' => $descriptor->public->confirm,
40
+ // Asked for and not possible: the form is refusing everyone (production) or checking nobody (development).
41
+ 'robotCheck' => ($protect['turnstile'] ?? false) ? FormGuard::robotCheck() : 'off',
42
+ 'blocked' => FormBlocks::summary($descriptor->name),
43
+ ]);
44
+ }
45
+ }
@@ -8,6 +8,9 @@ use Illuminate\Http\JsonResponse;
8
8
  use Illuminate\Http\Request;
9
9
  use Illuminate\Http\Response;
10
10
  use Illuminate\Support\Facades\DB;
11
+ use Nevela\Laravel\Forms\Confirmation;
12
+ use Nevela\Laravel\Forms\FormBlocks;
13
+ use Nevela\Laravel\Forms\FormGuard;
11
14
  use Nevela\Laravel\Media\Uploads;
12
15
  use Nevela\Laravel\Models\Upload;
13
16
  use Nevela\Laravel\Nevela;
@@ -22,6 +25,9 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
22
25
  * GET {prefix}/public/{slug}/{id} one of them, by id or by the field the section names
23
26
  * POST {prefix}/public/{slug} create one, from the fields the section lists
24
27
  * PUT {prefix}/public/_nevela/uploads/{Resource}/{field} a file for such a create
28
+ * PUT {prefix}/public/{slug}/_uploads/{field} the same, by the form's own address
29
+ * GET {prefix}/public/_nevela/form/{slug} what a protected form sends back with it
30
+ * POST {prefix}/public/_nevela/confirm/{slug}/{id}/{key} the link that proves an address
25
31
  *
26
32
  * The routes are generated (routes/nevela-public.php), one set per resource that has a
27
33
  * section and only for what it opens, so a resource without one has nothing here to call.
@@ -75,8 +81,39 @@ final class PublicController
75
81
  $requestClass = "App\\Http\\Requests\\Nevela\\{$descriptor->name}Request";
76
82
  $model = $this->model($descriptor);
77
83
  $input = $request->json()->all() ?: $request->request->all();
84
+ $input = is_array($input) ? $input : [];
78
85
 
79
- $checked = (new $requestClass)->forFields($public->create, is_array($input) ? $input : []);
86
+ // Before anything is validated or stored: what "protect" asks for. What is turned
87
+ // away is counted, and a program is told it worked.
88
+ if (($refusal = FormGuard::check($descriptor, $request, $input)) !== null) {
89
+ FormBlocks::record($descriptor->name, $refusal->reason);
90
+
91
+ return $refusal->response();
92
+ }
93
+ $input = FormGuard::strip($descriptor, $input);
94
+
95
+ $address = null;
96
+ if ($public->confirm !== null) {
97
+ $column = $descriptor->fields[$public->confirm]->column();
98
+ $typed = $input[$public->confirm] ?? null;
99
+ if (is_string($typed) && filter_var(trim($typed), FILTER_VALIDATE_EMAIL)) {
100
+ // One spelling of an address, so the same one can't join twice.
101
+ $address = $input[$public->confirm] = strtolower(trim($typed));
102
+ // Already on the list, or once was: answered exactly as the first time, so the
103
+ // form can't be used to ask who is on it. Someone still waiting gets the link
104
+ // again, as often as an address may be written to.
105
+ $existing = DB::table($descriptor->table)->whereRaw("lower({$column}) = ?", [$address])->first();
106
+ if ($existing !== null) {
107
+ if (property_exists($existing, 'confirmed_at') && $existing->confirmed_at === null && ($existing->deleted_at ?? null) === null) {
108
+ Confirmation::send($descriptor, $existing->id, $address, $request);
109
+ }
110
+
111
+ return response()->noContent(201);
112
+ }
113
+ }
114
+ }
115
+
116
+ $checked = (new $requestClass)->forFields($public->create, $input);
80
117
  // A file has to be one a visitor uploaded for this, a moment ago, and not yet used.
81
118
  foreach ($public->create as $field) {
82
119
  $key = $checked['values'][$descriptor->fields[$field]->column()] ?? null;
@@ -89,7 +126,12 @@ final class PublicController
89
126
  }
90
127
 
91
128
  $record = DB::transaction(function () use ($model, $checked, $descriptor, $public) {
92
- $record = $model::create($checked['values']);
129
+ $record = new $model($checked['values']);
130
+ if ($public->confirm !== null) {
131
+ // Not yet: see Concerns\Confirmable.
132
+ $record->setAttribute('confirmed_at', null);
133
+ }
134
+ $record->save();
93
135
  // Claimed: the nightly clear-out of uploads nobody used leaves it alone from here on.
94
136
  foreach ($public->create as $field) {
95
137
  $key = $descriptor->fields[$field]->kind === 'file' ? $record->getAttribute($descriptor->fields[$field]->column()) : null;
@@ -101,8 +143,14 @@ final class PublicController
101
143
  return $record;
102
144
  });
103
145
 
104
- // What they may read of what they made, or nothing where nothing may be read.
105
- if ($public->read === null) {
146
+ if ($address !== null) {
147
+ Confirmation::send($descriptor, $record->getKey(), $address, $request);
148
+ }
149
+
150
+ // What they may read of what they made, or nothing where nothing may be read. A
151
+ // form with checks, or an address to prove, says nothing either way: the answer to
152
+ // one that was sent has to be the answer to one that was quietly turned away.
153
+ if ($public->read === null || $public->protected() || $public->confirm !== null) {
106
154
  return response()->noContent(201);
107
155
  }
108
156
  $resourceClass = $this->resourceClass($descriptor);
@@ -110,6 +158,43 @@ final class PublicController
110
158
  return response()->json($this->visible($descriptor, $public, (new $resourceClass($record->refresh()))->resolve($request)), 201);
111
159
  }
112
160
 
161
+ /**
162
+ * What a protected form needs before it is shown: a token that says when it was handed
163
+ * out, the name of the field a person never sees, and whether the robot check is on.
164
+ */
165
+ public function form(Request $request): JsonResponse
166
+ {
167
+ [$descriptor] = $this->open($request, 'create');
168
+
169
+ return response()->json(FormGuard::issue($descriptor))->header('Cache-Control', 'no-store');
170
+ }
171
+
172
+ /**
173
+ * The link in the email: the record it names is confirmed. The key is what only this
174
+ * app could have written for that record and that address, so a link can't be made up
175
+ * and stops working if the address is changed. Following it twice does no harm.
176
+ */
177
+ public function confirm(Request $request): JsonResponse
178
+ {
179
+ [$descriptor, $public] = $this->open($request, 'create');
180
+ $id = (string) $request->route('id');
181
+ $model = $this->model($descriptor);
182
+ $gone = fn () => response()->json(['error' => 'This link is no longer good. It may have been used too late: sign up again for a new one.'], 404);
183
+ if ($public->confirm === null || ($descriptor->idType === 'int' ? ! preg_match('/^[0-9]{1,18}$/', $id) : ! \Illuminate\Support\Str::isUuid($id))) {
184
+ return $gone();
185
+ }
186
+ $record = $model::query()->whereKey($id)->first();
187
+ $address = $record?->getAttribute($descriptor->fields[$public->confirm]->column());
188
+ if ($record === null || ! is_string($address) || ! hash_equals(Confirmation::hash($descriptor, $id, $address), (string) $request->route('key'))) {
189
+ return $gone();
190
+ }
191
+ if ($record->getAttribute('confirmed_at') === null) {
192
+ $record->forceFill(['confirmed_at' => now()])->save();
193
+ }
194
+
195
+ return response()->json(['confirmed' => true]);
196
+ }
197
+
113
198
  /** A file for a public create: the field's own types and size limit, and no sign-in. */
114
199
  public function upload(Request $request): JsonResponse
115
200
  {
@@ -10,6 +10,7 @@ use Illuminate\Validation\Rules\Exists;
10
10
  use Illuminate\Validation\Rules\Unique;
11
11
  use Illuminate\Validation\Validator;
12
12
  use Nevela\Laravel\Support\Html;
13
+ use Nevela\Laravel\Support\Slug;
13
14
 
14
15
  /**
15
16
  * Base for generated resource requests.
@@ -53,6 +54,43 @@ abstract class ResourceRequest extends FormRequest
53
54
  return [];
54
55
  }
55
56
 
57
+ /**
58
+ * Fields made from another when a create leaves them empty: a slug from a title. A
59
+ * generated request with any says which, and where to look for the ones taken.
60
+ *
61
+ * @return array<string, array{from: string, table: string, column: string}>
62
+ */
63
+ protected function made(): array
64
+ {
65
+ return [];
66
+ }
67
+
68
+ /**
69
+ * Fill what a create left empty and can be made, and keep a later save from emptying
70
+ * it: once made, a slug is an address, and an address doesn't move.
71
+ *
72
+ * @param array<string, mixed> $values by column
73
+ * @param array<string, true> $taken Values made for earlier rows of the same batch
74
+ * @return array<string, mixed>
75
+ */
76
+ private function make(array $values, bool $creating, array &$taken = []): array
77
+ {
78
+ $columns = $this->columns();
79
+ foreach ($this->made() as $definition) {
80
+ $column = $definition['column'];
81
+ $empty = ! isset($values[$column]) || $values[$column] === '';
82
+ if (! $empty) {
83
+ $taken[(string) $values[$column]] = true;
84
+ } elseif ($creating) {
85
+ $values[$column] = Slug::free($definition['table'], $column, (string) ($values[$columns[$definition['from']] ?? ''] ?? ''), $taken);
86
+ } else {
87
+ unset($values[$column]);
88
+ }
89
+ }
90
+
91
+ return $values;
92
+ }
93
+
56
94
  /**
57
95
  * A create from some of the fields only: a public form's, where nobody is asked who
58
96
  * they are and so only what the descriptor lists is taken.
@@ -93,7 +131,7 @@ abstract class ResourceRequest extends FormRequest
93
131
  }
94
132
  }
95
133
 
96
- return ['values' => $values, 'issues' => []];
134
+ return ['values' => $this->make($values, true), 'issues' => []];
97
135
  }
98
136
 
99
137
  /**
@@ -252,6 +290,7 @@ abstract class ResourceRequest extends FormRequest
252
290
  $values = [];
253
291
  $problems = [];
254
292
  $taken = [];
293
+ $made = [];
255
294
  foreach (array_values($rows) as $index => $row) {
256
295
  $number = $index + 1;
257
296
  if (! is_array($row) || array_is_list($row)) {
@@ -299,7 +338,8 @@ abstract class ResourceRequest extends FormRequest
299
338
  $mapped[$column] = $defaults[$field];
300
339
  }
301
340
  }
302
- $values[] = $mapped;
341
+ // Two rows with one title get two slugs: what was made for an earlier row is remembered.
342
+ $values[] = $this->make($mapped, true, $made);
303
343
  }
304
344
 
305
345
  return ['values' => $values, 'problems' => $problems];
@@ -322,6 +362,6 @@ abstract class ResourceRequest extends FormRequest
322
362
  }
323
363
  }
324
364
 
325
- return $values;
365
+ return $this->make($values, $this->isMethod('POST'));
326
366
  }
327
367
  }