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.
- package/index.mjs +24 -7
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +41 -0
- package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
- package/template/laravel/resources/disposable-domains.txt +9880 -0
- package/template/laravel/src/Access/ClosedAccounts.php +2 -1
- package/template/laravel/src/Concerns/Confirmable.php +81 -0
- package/template/laravel/src/Console/BlogCommand.php +254 -0
- package/template/laravel/src/Console/DevCommand.php +9 -0
- package/template/laravel/src/Console/DigestCommand.php +28 -0
- package/template/laravel/src/Console/DisposableCommand.php +49 -0
- package/template/laravel/src/Console/GenerateCommand.php +25 -0
- package/template/laravel/src/Console/SetupCommand.php +15 -2
- package/template/laravel/src/Console/StatusCommand.php +14 -0
- package/template/laravel/src/Console/TrashCommand.php +9 -0
- package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
- package/template/laravel/src/Forms/Confirmation.php +88 -0
- package/template/laravel/src/Forms/Disposable.php +74 -0
- package/template/laravel/src/Forms/FormBlocks.php +76 -0
- package/template/laravel/src/Forms/FormGuard.php +359 -0
- package/template/laravel/src/Forms/OwnerDigest.php +63 -0
- package/template/laravel/src/Forms/PublicMail.php +217 -0
- package/template/laravel/src/Forms/Refusal.php +50 -0
- package/template/laravel/src/Generator/BlogPages.php +409 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +133 -7
- package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
- package/template/laravel/src/Http/Access/RolesController.php +2 -1
- package/template/laravel/src/Http/Access/UsersController.php +2 -1
- package/template/laravel/src/Http/ActionController.php +2 -1
- package/template/laravel/src/Http/Auth/AccountController.php +6 -3
- package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
- package/template/laravel/src/Http/FormsController.php +45 -0
- package/template/laravel/src/Http/PublicController.php +89 -4
- package/template/laravel/src/Http/ResourceRequest.php +43 -3
- package/template/laravel/src/Http/TrashController.php +2 -1
- package/template/laravel/src/Http/UploadController.php +2 -2
- package/template/laravel/src/Nevela.php +30 -1
- package/template/laravel/src/NevelaServiceProvider.php +11 -2
- package/template/laravel/src/Support/Descriptor.php +24 -0
- package/template/laravel/src/Support/Field.php +17 -0
- package/template/laravel/src/Support/Key.php +57 -0
- package/template/laravel/src/Support/ListQuery.php +29 -1
- package/template/laravel/src/Support/PublicAccess.php +75 -0
- package/template/laravel/src/Support/Slug.php +43 -0
- package/template/laravel/src/Trash/Trash.php +3 -1
- package/template/web/.env.example +4 -0
- package/template/web/app/api/[...path]/route.ts +6 -3
- package/template/web/app/confirm/page.tsx +51 -0
- package/template/web/app/dashboard/actions.ts +15 -1
- package/template/web/components/dashboard/form-activity.tsx +53 -0
- package/template/web/components/dashboard/record-actions.tsx +29 -5
- package/template/web/components/dashboard/record-detail.tsx +2 -1
- package/template/web/components/dashboard/resource-form.tsx +19 -2
- package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
- package/template/web/components/dashboard/resource-table.tsx +32 -3
- package/template/web/components/dashboard/row-actions.tsx +33 -3
- package/template/web/components/marketing/site-header.tsx +9 -2
- package/template/web/components/public-form-guard.tsx +125 -0
- package/template/web/hooks/use-public-form.ts +263 -0
- package/template/web/lib/extras.ts +39 -0
- 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
|
-
$
|
|
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
|
-
|
|
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
|
-
|
|
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) ||
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
}
|