create-nevela 0.8.0 → 0.9.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 (61) 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 +131 -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 +88 -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/confirm/page.tsx +51 -0
  49. package/template/web/app/dashboard/actions.ts +15 -1
  50. package/template/web/components/dashboard/form-activity.tsx +53 -0
  51. package/template/web/components/dashboard/record-actions.tsx +29 -5
  52. package/template/web/components/dashboard/record-detail.tsx +2 -1
  53. package/template/web/components/dashboard/resource-form.tsx +19 -2
  54. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  55. package/template/web/components/dashboard/resource-table.tsx +32 -3
  56. package/template/web/components/dashboard/row-actions.tsx +33 -3
  57. package/template/web/components/marketing/site-header.tsx +9 -2
  58. package/template/web/components/public-form-guard.tsx +125 -0
  59. package/template/web/hooks/use-public-form.ts +175 -0
  60. package/template/web/lib/extras.ts +39 -0
  61. 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;
@@ -515,6 +529,14 @@ final class ResourceGenerator
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}');";
516
530
  }
517
531
  }
532
+ // What a protected form asks for before it is shown, and after each attempt.
533
+ if ($public->protected()) {
534
+ $lines[] = "Route::get('_nevela/form/{$d->slug}', [{$controller}, 'form']){$for}->middleware([{$throttle(PublicAccess::READ_THROTTLE, 'form')}])->name('{$d->slug}.form');";
535
+ }
536
+ // The link that proves an address.
537
+ if ($public->confirm !== null) {
538
+ $lines[] = "Route::post('_nevela/confirm/{$d->slug}/{id}/{key}', [{$controller}, 'confirm']){$for}->middleware([{$throttle(PublicAccess::READ_THROTTLE, 'confirm')}])->name('{$d->slug}.confirm');";
539
+ }
518
540
  }
519
541
  }
520
542
  $body = $lines === [] ? '// No resource has a "public" section, so nothing is open.' : implode("\n", $lines);
@@ -578,7 +600,25 @@ final class ResourceGenerator
578
600
  }
579
601
  $castBlock = $casts ? implode("\n", $casts) : '';
580
602
  // 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
603
+ $published = $d->publish === null ? null : $d->fields[$d->publish]->column();
604
+ $scope = match (true) {
605
+ $d->public?->read === null => '',
606
+ // A resource with a publish date has said it already: the ones whose time has come.
607
+ $published !== null => <<<PHP
608
+
609
+
610
+ /**
611
+ * Which {$this->lower($d->pluralLabel)} anyone may read, signed in or not: the published ones. The
612
+ * public list and the public page of one both go through here. A draft has no
613
+ * {$published}, and a scheduled one has a time still to come, which this lets
614
+ * through when it does, with nobody saving anything.
615
+ */
616
+ public function scopePublicQuery(\Illuminate\Database\Eloquent\Builder \$query): void
617
+ {
618
+ \$query->whereNotNull('{$published}')->where('{$published}', '<=', now());
619
+ }
620
+ PHP,
621
+ default => <<<PHP
582
622
 
583
623
 
584
624
  /**
@@ -592,9 +632,15 @@ final class ResourceGenerator
592
632
  {
593
633
  \$query->whereRaw('1 = 0');
594
634
  }
595
- PHP;
635
+ PHP,
636
+ };
596
637
  // An integer id is Eloquent's own default: the database numbers the records.
597
638
  [$import, $trait] = $d->idType === 'int' ? ['', ''] : ["use Illuminate\\Database\\Eloquent\\Concerns\\HasUuids;\n", " use HasUuids;\n\n"];
639
+ $confirmable = $d->public?->confirm === null ? '' : <<<'PHP'
640
+
641
+ // Its public form asks for an address to be proved: Subscriber::confirmed() is who has.
642
+ use \Nevela\Laravel\Concerns\Confirmable;
643
+ PHP;
598
644
  $m = self::M;
599
645
  $e = self::E;
600
646
 
@@ -611,7 +657,7 @@ final class ResourceGenerator
611
657
 
612
658
  {$m}
613
659
  // Deleted records go to the trash, where they can be restored for a while.
614
- use \Nevela\Laravel\Concerns\Trashable;
660
+ use \Nevela\Laravel\Concerns\Trashable;{$confirmable}
615
661
 
616
662
  protected \$fillable = [{$fillable}];
617
663
 
@@ -675,6 +721,10 @@ final class ResourceGenerator
675
721
  }
676
722
  $columns[] = " {$line};";
677
723
  }
724
+ if ($d->public?->confirm !== null) {
725
+ $columns[] = ' // When the address was proved, by the link emailed to it. Empty until then.';
726
+ $columns[] = " \$table->timestamp('confirmed_at')->nullable()->index();";
727
+ }
678
728
  $cols = implode("\n", $columns);
679
729
  $id = $d->idType === 'int' ? '$table->id();' : "\$table->uuid('id')->primary();";
680
730
 
@@ -749,6 +799,58 @@ final class ResourceGenerator
749
799
  PHP;
750
800
  }
751
801
 
802
+ /**
803
+ * For a table made before its form asked for addresses to be proved: the column that
804
+ * records it. The records already there are taken as proved. They were on the list
805
+ * before anyone was asked, and asking now would empty it.
806
+ */
807
+ public function confirmMigration(Descriptor $d): string
808
+ {
809
+ return <<<PHP
810
+ <?php
811
+
812
+ use Illuminate\Database\Migrations\Migration;
813
+ use Illuminate\Database\Schema\Blueprint;
814
+ use Illuminate\Support\Facades\DB;
815
+ use Illuminate\Support\Facades\Schema;
816
+
817
+ // {$d->pluralLabel} now prove their address: a record made through the public form is
818
+ // unconfirmed until the link emailed to it is followed. Generated once by Nevela.
819
+ return new class extends Migration
820
+ {
821
+ public function up(): void
822
+ {
823
+ if (Schema::hasTable('{$d->table}') && ! Schema::hasColumn('{$d->table}', 'confirmed_at')) {
824
+ Schema::table('{$d->table}', function (Blueprint \$table) {
825
+ \$table->timestamp('confirmed_at')->nullable()->index();
826
+ });
827
+ // Whoever is on the list already stays on it.
828
+ DB::table('{$d->table}')->update(['confirmed_at' => now()]);
829
+ }
830
+ }
831
+
832
+ public function down(): void
833
+ {
834
+ if (Schema::hasColumn('{$d->table}', 'confirmed_at')) {
835
+ Schema::table('{$d->table}', function (Blueprint \$table) {
836
+ \$table->dropIndex(['confirmed_at']);
837
+ \$table->dropColumn('confirmed_at');
838
+ });
839
+ }
840
+ }
841
+ };
842
+
843
+ PHP;
844
+ }
845
+
846
+ /** The file `confirmMigration()` goes in. Written once for a table, whatever its date. */
847
+ public function confirmMigrationFile(Descriptor $d, ?string $timestamp = null): GeneratedFile
848
+ {
849
+ $timestamp ??= date('Y_m_d_His');
850
+
851
+ 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");
852
+ }
853
+
752
854
  /** The file `trashMigration()` goes in. Not made again once one exists for the table, whatever its date. */
753
855
  public function trashMigrationFile(Descriptor $d, ?string $timestamp = null): GeneratedFile
754
856
  {
@@ -780,6 +882,19 @@ final class ResourceGenerator
780
882
  return [{$defaults}];
781
883
  }
782
884
  PHP;
885
+ $made = implode(', ', array_map(
886
+ fn (Field $f) => "'{$f->name}' => ['from' => '{$f->from}', 'table' => '{$d->table}', 'column' => '{$f->column()}']",
887
+ array_filter($d->fields, fn (Field $f) => $f->from !== null),
888
+ ));
889
+ $madeBlock = $made === '' ? '' : <<<PHP
890
+
891
+
892
+ /** Fields made from another when a create leaves them empty. They are never remade afterwards. */
893
+ protected function made(): array
894
+ {
895
+ return [{$made}];
896
+ }
897
+ PHP;
783
898
  // Only where there is one: a resource without rich text has the request it always had.
784
899
  $rich = implode(', ', array_map(fn (Field $f) => "'{$f->name}'", array_filter($d->fields, fn (Field $f) => $f->isRich())));
785
900
  $richBlock = $rich === '' ? '' : <<<PHP
@@ -832,7 +947,7 @@ final class ResourceGenerator
832
947
  protected function columns(): array
833
948
  {
834
949
  return [{$columnMap}];
835
- }{$richBlock}{$defaultsBlock}
950
+ }{$richBlock}{$defaultsBlock}{$madeBlock}
836
951
  {$e}
837
952
  }
838
953
 
@@ -845,7 +960,8 @@ final class ResourceGenerator
845
960
  */
846
961
  private function rules(Descriptor $d, Field $f, array $all = []): array
847
962
  {
848
- $rules = [$f->required ? "'required'" : "'nullable'"];
963
+ // A slug that is made from another field may be left empty: the request fills it in.
964
+ $rules = [$f->required && $f->from === null ? "'required'" : "'nullable'"];
849
965
  // With a default, leaving it out is saying "the usual". Sent, it is checked like any other.
850
966
  if ($f->default !== null) {
851
967
  array_unshift($rules, "'sometimes'");
@@ -903,6 +1019,10 @@ final class ResourceGenerator
903
1019
  : " '{$f->name}File' => \\Nevela\\Laravel\\Nevela::file(\$this->{$f->column()}),";
904
1020
  }
905
1021
  }
1022
+ if ($d->public?->confirm !== null) {
1023
+ // Not a field: when the address was proved, for the dashboard to say "Not confirmed" until it is.
1024
+ $lines[] = " 'confirmedAt' => \$this->confirmed_at?->toJSON(),";
1025
+ }
906
1026
  $body = implode("\n", $lines);
907
1027
  $id = $d->idType === 'int' ? '(string) $this->id' : '$this->id';
908
1028
  $m = self::M;
@@ -1139,7 +1259,8 @@ final class ResourceGenerator
1139
1259
  $fields = [];
1140
1260
  foreach ($d->fields as $f) {
1141
1261
  $options = array_filter([
1142
- 'required' => $f->required ? null : 'false',
1262
+ // (A slug made from another field is optional in the form: left empty, Laravel makes it.)
1263
+ 'required' => $f->required && $f->from === null ? null : 'false',
1143
1264
  'unique' => $f->unique ? 'true' : null,
1144
1265
  'label' => $f->label ? $j($f->label) : null,
1145
1266
  'format' => in_array($f->format, ['money', 'percent', 'rating'], true) ? $j($f->format) : null,
@@ -1154,6 +1275,9 @@ final class ResourceGenerator
1154
1275
  if ($f->private) {
1155
1276
  $parts[] = '...({ private: true } as {})';
1156
1277
  }
1278
+ if ($f->from !== null) {
1279
+ $parts[] = '...({ from: '.$j($f->from).' } as {})';
1280
+ }
1157
1281
  if ($f->isRich()) {
1158
1282
  // Flare has no word for it, so it is said in passing: the dashboard reads `rich` off the field.
1159
1283
  $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,8 @@ 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
+ * GET {prefix}/public/_nevela/form/{slug} what a protected form sends back with it
29
+ * POST {prefix}/public/_nevela/confirm/{slug}/{id}/{key} the link that proves an address
25
30
  *
26
31
  * The routes are generated (routes/nevela-public.php), one set per resource that has a
27
32
  * section and only for what it opens, so a resource without one has nothing here to call.
@@ -75,8 +80,39 @@ final class PublicController
75
80
  $requestClass = "App\\Http\\Requests\\Nevela\\{$descriptor->name}Request";
76
81
  $model = $this->model($descriptor);
77
82
  $input = $request->json()->all() ?: $request->request->all();
83
+ $input = is_array($input) ? $input : [];
78
84
 
79
- $checked = (new $requestClass)->forFields($public->create, is_array($input) ? $input : []);
85
+ // Before anything is validated or stored: what "protect" asks for. What is turned
86
+ // away is counted, and a program is told it worked.
87
+ if (($refusal = FormGuard::check($descriptor, $request, $input)) !== null) {
88
+ FormBlocks::record($descriptor->name, $refusal->reason);
89
+
90
+ return $refusal->response();
91
+ }
92
+ $input = FormGuard::strip($descriptor, $input);
93
+
94
+ $address = null;
95
+ if ($public->confirm !== null) {
96
+ $column = $descriptor->fields[$public->confirm]->column();
97
+ $typed = $input[$public->confirm] ?? null;
98
+ if (is_string($typed) && filter_var(trim($typed), FILTER_VALIDATE_EMAIL)) {
99
+ // One spelling of an address, so the same one can't join twice.
100
+ $address = $input[$public->confirm] = strtolower(trim($typed));
101
+ // Already on the list, or once was: answered exactly as the first time, so the
102
+ // form can't be used to ask who is on it. Someone still waiting gets the link
103
+ // again, as often as an address may be written to.
104
+ $existing = DB::table($descriptor->table)->whereRaw("lower({$column}) = ?", [$address])->first();
105
+ if ($existing !== null) {
106
+ if (property_exists($existing, 'confirmed_at') && $existing->confirmed_at === null && ($existing->deleted_at ?? null) === null) {
107
+ Confirmation::send($descriptor, $existing->id, $address, $request);
108
+ }
109
+
110
+ return response()->noContent(201);
111
+ }
112
+ }
113
+ }
114
+
115
+ $checked = (new $requestClass)->forFields($public->create, $input);
80
116
  // A file has to be one a visitor uploaded for this, a moment ago, and not yet used.
81
117
  foreach ($public->create as $field) {
82
118
  $key = $checked['values'][$descriptor->fields[$field]->column()] ?? null;
@@ -89,7 +125,12 @@ final class PublicController
89
125
  }
90
126
 
91
127
  $record = DB::transaction(function () use ($model, $checked, $descriptor, $public) {
92
- $record = $model::create($checked['values']);
128
+ $record = new $model($checked['values']);
129
+ if ($public->confirm !== null) {
130
+ // Not yet: see Concerns\Confirmable.
131
+ $record->setAttribute('confirmed_at', null);
132
+ }
133
+ $record->save();
93
134
  // Claimed: the nightly clear-out of uploads nobody used leaves it alone from here on.
94
135
  foreach ($public->create as $field) {
95
136
  $key = $descriptor->fields[$field]->kind === 'file' ? $record->getAttribute($descriptor->fields[$field]->column()) : null;
@@ -101,8 +142,14 @@ final class PublicController
101
142
  return $record;
102
143
  });
103
144
 
104
- // What they may read of what they made, or nothing where nothing may be read.
105
- if ($public->read === null) {
145
+ if ($address !== null) {
146
+ Confirmation::send($descriptor, $record->getKey(), $address, $request);
147
+ }
148
+
149
+ // What they may read of what they made, or nothing where nothing may be read. A
150
+ // form with checks, or an address to prove, says nothing either way: the answer to
151
+ // one that was sent has to be the answer to one that was quietly turned away.
152
+ if ($public->read === null || $public->protected() || $public->confirm !== null) {
106
153
  return response()->noContent(201);
107
154
  }
108
155
  $resourceClass = $this->resourceClass($descriptor);
@@ -110,6 +157,43 @@ final class PublicController
110
157
  return response()->json($this->visible($descriptor, $public, (new $resourceClass($record->refresh()))->resolve($request)), 201);
111
158
  }
112
159
 
160
+ /**
161
+ * What a protected form needs before it is shown: a token that says when it was handed
162
+ * out, the name of the field a person never sees, and whether the robot check is on.
163
+ */
164
+ public function form(Request $request): JsonResponse
165
+ {
166
+ [$descriptor] = $this->open($request, 'create');
167
+
168
+ return response()->json(FormGuard::issue($descriptor))->header('Cache-Control', 'no-store');
169
+ }
170
+
171
+ /**
172
+ * The link in the email: the record it names is confirmed. The key is what only this
173
+ * app could have written for that record and that address, so a link can't be made up
174
+ * and stops working if the address is changed. Following it twice does no harm.
175
+ */
176
+ public function confirm(Request $request): JsonResponse
177
+ {
178
+ [$descriptor, $public] = $this->open($request, 'create');
179
+ $id = (string) $request->route('id');
180
+ $model = $this->model($descriptor);
181
+ $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);
182
+ if ($public->confirm === null || ($descriptor->idType === 'int' ? ! preg_match('/^[0-9]{1,18}$/', $id) : ! \Illuminate\Support\Str::isUuid($id))) {
183
+ return $gone();
184
+ }
185
+ $record = $model::query()->whereKey($id)->first();
186
+ $address = $record?->getAttribute($descriptor->fields[$public->confirm]->column());
187
+ if ($record === null || ! is_string($address) || ! hash_equals(Confirmation::hash($descriptor, $id, $address), (string) $request->route('key'))) {
188
+ return $gone();
189
+ }
190
+ if ($record->getAttribute('confirmed_at') === null) {
191
+ $record->forceFill(['confirmed_at' => now()])->save();
192
+ }
193
+
194
+ return response()->json(['confirmed' => true]);
195
+ }
196
+
113
197
  /** A file for a public create: the field's own types and size limit, and no sign-in. */
114
198
  public function upload(Request $request): JsonResponse
115
199
  {
@@ -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
  }