create-nevela 0.7.1 → 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 (87) hide show
  1. package/index.mjs +25 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +66 -3
  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/Access/Permissions.php +36 -5
  9. package/template/laravel/src/Concerns/Confirmable.php +81 -0
  10. package/template/laravel/src/Concerns/Trashable.php +23 -0
  11. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  12. package/template/laravel/src/Console/BlogCommand.php +254 -0
  13. package/template/laravel/src/Console/DevCommand.php +9 -0
  14. package/template/laravel/src/Console/DigestCommand.php +28 -0
  15. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  16. package/template/laravel/src/Console/GenerateCommand.php +38 -3
  17. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  18. package/template/laravel/src/Console/SeedCommand.php +3 -1
  19. package/template/laravel/src/Console/SetupCommand.php +15 -2
  20. package/template/laravel/src/Console/StatusCommand.php +14 -0
  21. package/template/laravel/src/Console/TrashCommand.php +17 -0
  22. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  23. package/template/laravel/src/Forms/Confirmation.php +88 -0
  24. package/template/laravel/src/Forms/Disposable.php +74 -0
  25. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  26. package/template/laravel/src/Forms/FormGuard.php +359 -0
  27. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  28. package/template/laravel/src/Forms/PublicMail.php +217 -0
  29. package/template/laravel/src/Forms/Refusal.php +50 -0
  30. package/template/laravel/src/Generator/BlogPages.php +409 -0
  31. package/template/laravel/src/Generator/ResourceGenerator.php +356 -32
  32. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  33. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  34. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  35. package/template/laravel/src/Http/ActionController.php +223 -0
  36. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  37. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  38. package/template/laravel/src/Http/FormsController.php +45 -0
  39. package/template/laravel/src/Http/PublicController.php +294 -0
  40. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  41. package/template/laravel/src/Http/ResourceRequest.php +193 -4
  42. package/template/laravel/src/Http/TrashController.php +19 -2
  43. package/template/laravel/src/Http/UploadController.php +42 -1
  44. package/template/laravel/src/Media/Uploads.php +111 -0
  45. package/template/laravel/src/Nevela.php +83 -2
  46. package/template/laravel/src/NevelaServiceProvider.php +27 -2
  47. package/template/laravel/src/Support/Action.php +168 -0
  48. package/template/laravel/src/Support/Adoption.php +352 -0
  49. package/template/laravel/src/Support/Descriptor.php +83 -4
  50. package/template/laravel/src/Support/Fake.php +15 -1
  51. package/template/laravel/src/Support/Field.php +91 -3
  52. package/template/laravel/src/Support/Html.php +185 -0
  53. package/template/laravel/src/Support/Key.php +57 -0
  54. package/template/laravel/src/Support/ListQuery.php +29 -1
  55. package/template/laravel/src/Support/PublicAccess.php +208 -0
  56. package/template/laravel/src/Support/Slug.php +43 -0
  57. package/template/laravel/src/Trash/Trash.php +3 -1
  58. package/template/web/.env.example +4 -0
  59. package/template/web/app/api/[...path]/route.ts +42 -9
  60. package/template/web/app/confirm/page.tsx +51 -0
  61. package/template/web/app/dashboard/actions.ts +48 -0
  62. package/template/web/app/globals.css +46 -0
  63. package/template/web/components/access/role-form.tsx +40 -5
  64. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  65. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  66. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  67. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  68. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  69. package/template/web/components/dashboard/form-activity.tsx +53 -0
  70. package/template/web/components/dashboard/record-actions.tsx +33 -4
  71. package/template/web/components/dashboard/record-detail.tsx +10 -2
  72. package/template/web/components/dashboard/resource-action.tsx +281 -0
  73. package/template/web/components/dashboard/resource-form.tsx +19 -2
  74. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  75. package/template/web/components/dashboard/resource-table.tsx +46 -4
  76. package/template/web/components/dashboard/row-actions.tsx +56 -2
  77. package/template/web/components/dashboard/table-selection.tsx +19 -1
  78. package/template/web/components/marketing/site-header.tsx +9 -2
  79. package/template/web/components/public-form-guard.tsx +125 -0
  80. package/template/web/hooks/use-public-form.ts +175 -0
  81. package/template/web/lib/access.ts +3 -1
  82. package/template/web/lib/dashboard.ts +10 -0
  83. package/template/web/lib/extras.ts +105 -0
  84. package/template/web/lib/files.ts +14 -0
  85. package/template/web/lib/laravel.ts +60 -0
  86. package/template/web/lib/rich.ts +79 -0
  87. package/template/web/package.json +1 -1
@@ -0,0 +1,28 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Console;
4
+
5
+ use Illuminate\Console\Command;
6
+ use Nevela\Laravel\Forms\PublicMail;
7
+
8
+ final class DigestCommand extends Command
9
+ {
10
+ protected $signature = 'nevela:digest';
11
+
12
+ protected $description = 'Send the owner one email for the form notices that were held back in a busy hour';
13
+
14
+ public function handle(): int
15
+ {
16
+ if (PublicMail::owner() === null) {
17
+ $this->components->warn('There is nobody to send it to. Set NEVELA_NOTIFY_TO, or the address the app sends mail from.');
18
+
19
+ return self::SUCCESS;
20
+ }
21
+ $count = PublicMail::digest();
22
+ $this->components->info($count === 0
23
+ ? 'No notices are waiting.'
24
+ : 'Sent one email naming '.number_format($count).' notice(s) to '.PublicMail::owner().'.');
25
+
26
+ return self::SUCCESS;
27
+ }
28
+ }
@@ -0,0 +1,49 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Console;
4
+
5
+ use Illuminate\Console\Command;
6
+ use Illuminate\Support\Facades\Http;
7
+ use Nevela\Laravel\Forms\Disposable;
8
+ use Throwable;
9
+
10
+ final class DisposableCommand extends Command
11
+ {
12
+ protected $signature = 'nevela:disposable {--url= : Fetch the list from somewhere else}';
13
+
14
+ protected $description = 'Fetch the current list of throwaway email domains a protected form refuses';
15
+
16
+ public function handle(): int
17
+ {
18
+ $url = (string) ($this->option('url') ?: Disposable::SOURCE);
19
+ try {
20
+ $response = Http::timeout(30)->get($url);
21
+ } catch (Throwable $e) {
22
+ $this->components->error("Couldn't fetch the list: ".$e->getMessage());
23
+
24
+ return self::FAILURE;
25
+ }
26
+ $domains = [];
27
+ foreach (preg_split('/\R/', $response->successful() ? $response->body() : '') ?: [] as $line) {
28
+ $line = strtolower(trim($line));
29
+ // One domain a line. Anything else is not the list: an error page, a redirect to a sign-in.
30
+ if ($line !== '' && $line[0] !== '#' && preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/', $line)) {
31
+ $domains[$line] = true;
32
+ }
33
+ }
34
+ // The list has thousands. A few lines is something else, and would let every throwaway address through.
35
+ if (count($domains) < 1000) {
36
+ $this->components->error("What came back from {$url} isn't the list (".count($domains).' domains). The one in use is unchanged.');
37
+
38
+ return self::FAILURE;
39
+ }
40
+ ksort($domains);
41
+ $path = Disposable::refreshedPath();
42
+ is_dir(dirname($path)) || mkdir(dirname($path), 0775, true);
43
+ file_put_contents($path, implode("\n", array_keys($domains))."\n", LOCK_EX);
44
+ Disposable::forget();
45
+ $this->components->info(number_format(count($domains)).' throwaway domains, kept in '.str_replace(base_path().DIRECTORY_SEPARATOR, '', $path).'.');
46
+
47
+ return self::SUCCESS;
48
+ }
49
+ }
@@ -28,10 +28,11 @@ final class GenerateCommand extends Command
28
28
  * @param list<string> $createMigrations The contents of the table's own migration(s); none for a resource that is new
29
29
  * @param bool $written Whether an add_trash_to_… migration exists for the table already
30
30
  * @param bool $columnExists Whether the table has deleted_at in the database now
31
+ * @param bool $adopted Whether the table was there before Nevela: then there may be no migration of that name to read
31
32
  */
32
- public static function needsTrashMigration(array $createMigrations, bool $written, bool $columnExists): bool
33
+ public static function needsTrashMigration(array $createMigrations, bool $written, bool $columnExists, bool $adopted = false): bool
33
34
  {
34
- if ($createMigrations === []) {
35
+ if ($createMigrations === [] && ! $adopted) {
35
36
  return false;
36
37
  }
37
38
  foreach ($createMigrations as $contents) {
@@ -43,6 +44,27 @@ final class GenerateCommand extends Command
43
44
  return $written || ! $columnExists;
44
45
  }
45
46
 
47
+ /**
48
+ * Whether a resource whose form proves an address needs the migration that adds the
49
+ * column for it: when its table was made without one. A new resource's own migration
50
+ * has it.
51
+ *
52
+ * @param list<string> $createMigrations The contents of the table's own migration(s); none for a resource that is new
53
+ */
54
+ public static function needsConfirmMigration(array $createMigrations, bool $adopted = false): bool
55
+ {
56
+ if ($createMigrations === []) {
57
+ return $adopted;
58
+ }
59
+ foreach ($createMigrations as $contents) {
60
+ if (str_contains($contents, 'confirmed_at')) {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ return true;
66
+ }
67
+
46
68
  /** Whether the table has the column now. No, where there is no database to ask. */
47
69
  private static function hasTrashColumn(string $table): bool
48
70
  {
@@ -127,11 +149,16 @@ final class GenerateCommand extends Command
127
149
  // resource's own migration, written by the line above, already has the column.
128
150
  $made = array_map(fn (string $file) => (string) file_get_contents($file), glob(database_path("migrations/*_create_{$descriptor->table}_table.php")) ?: []);
129
151
  $written = (glob(database_path("migrations/*_add_trash_to_{$descriptor->table}_table.php")) ?: []) !== [];
130
- if (self::needsTrashMigration($made, $written, self::hasTrashColumn($descriptor->table))) {
152
+ if (self::needsTrashMigration($made, $written, self::hasTrashColumn($descriptor->table), $descriptor->adopted)) {
131
153
  $files[] = $generator->trashMigrationFile($descriptor);
132
154
  }
155
+ // The same for "confirm" added to a resource whose table is already made.
156
+ if ($descriptor->public?->confirm !== null && self::needsConfirmMigration($made, $descriptor->adopted)) {
157
+ $files[] = $generator->confirmMigrationFile($descriptor);
158
+ }
133
159
  }
134
160
  $files[] = $generator->routes($all);
161
+ $files[] = $generator->publicRoutes($all);
135
162
  $files[] = $generator->registry($all);
136
163
  $files[] = $generator->authConfig((array) config('nevela.auth', []));
137
164
 
@@ -168,6 +195,14 @@ final class GenerateCommand extends Command
168
195
  if ($edited) {
169
196
  $command->components->warn("{$edited} file(s) skipped because you edited their generated block. Move your changes outside it, or rerun with --force.");
170
197
  }
198
+ // A model written before its resource was made public has no scope saying which
199
+ // records are, and without one none are. Said, so an empty public list isn't a puzzle.
200
+ foreach ($queue as $descriptor) {
201
+ $model = base_path("app/Models/{$descriptor->name}.php");
202
+ if ($descriptor->public?->read !== null && is_file($model) && ! str_contains((string) file_get_contents($model), 'scopePublicQuery')) {
203
+ $command->components->warn("{$descriptor->name} can be read publicly, but its model doesn't say which records may be, so none are. Add to app/Models/{$descriptor->name}.php: public function scopePublicQuery(\\Illuminate\\Database\\Eloquent\\Builder \$query): void { … }");
204
+ }
205
+ }
171
206
 
172
207
  return self::SUCCESS;
173
208
  }
@@ -15,6 +15,7 @@ final class MakeResourceCommand extends Command
15
15
  {--fields= : "name:string, sku:string!, price:money, status:enum(draft|live), notes:text?"}
16
16
  {--icon= : Lucide icon name for the dashboard, e.g. package}
17
17
  {--group= : Sidebar group in the dashboard}
18
+ {--id=uuid : What a record\'s id is: uuid, or int for the numbered ids of $table->id()}
18
19
  {--force : Overwrite an existing descriptor}
19
20
  {--migrate : Run the new migration straight away}
20
21
  {--seed= : Fill the new resource with records too (25, or the number you give). Runs the migration first}';
@@ -29,6 +30,7 @@ final class MakeResourceCommand extends Command
29
30
  (string) ($this->option('fields') ?? $this->ask('Fields (e.g. name:string, price:money, active:boolean)')),
30
31
  $this->option('icon'),
31
32
  $this->option('group'),
33
+ (string) $this->option('id'),
32
34
  );
33
35
  } catch (InvalidArgumentException $e) {
34
36
  $this->components->error($e->getMessage());
@@ -175,7 +175,9 @@ final class SeedCommand extends Command
175
175
  for ($i = $done; $i < min($count, $done + $chunk); $i++) {
176
176
  // Spread over the last 60 days, so the dashboard's "this week" and trend have something to show.
177
177
  $created = date('Y-m-d H:i:s', $now - mt_rand(0, 60 * 86400));
178
- $rows[] = ['id' => (string) Str::uuid7()] + Fake::row($resource, $offset + $i + 1, $pools) + ['created_at' => $created, 'updated_at' => $created];
178
+ // An integer id is the database's to give.
179
+ $id = $resource->idType === 'int' ? [] : ['id' => (string) Str::uuid7()];
180
+ $rows[] = $id + Fake::row($resource, $offset + $i + 1, $pools) + ['created_at' => $created, 'updated_at' => $created];
179
181
  }
180
182
  DB::table($resource->table)->insert($rows);
181
183
  $bar->advance(count($rows));
@@ -16,9 +16,10 @@ final class SetupCommand extends Command
16
16
  {--name= : Name for the starter account}
17
17
  {--email= : Email for the starter account. Leave out to create no account}
18
18
  {--password= : Password for the starter account}
19
- {--no-sample-users : Create the starter account only, without the ten sample users}';
19
+ {--no-sample-users : Create the starter account only, without the ten sample users}
20
+ {--no-blog : Leave out the blog: the Post resource, its three sample posts and the /blog pages}';
20
21
 
21
- protected $description = 'Finish setting up a new app: key, Sanctum\'s migration, generated files, migrations, a starter account and sample users';
22
+ protected $description = 'Finish setting up a new app: key, Sanctum\'s migration, generated files, migrations, the blog, a starter account and sample users';
22
23
 
23
24
  protected $hidden = true;
24
25
 
@@ -39,6 +40,18 @@ final class SetupCommand extends Command
39
40
  return self::FAILURE;
40
41
  }
41
42
 
43
+ // A blog, as a first resource and a first public page: a Post to write in the
44
+ // dashboard, /blog to read it at, and three posts so neither is empty. Not having
45
+ // one is no reason to call the setup failed.
46
+ if (! $this->option('no-blog')) {
47
+ // The sample posts are for development: asked for in production they are refused,
48
+ // which would read as the blog having failed.
49
+ $blog = $this->call('nevela:blog', app()->environment('production') ? [] : ['--sample' => true]);
50
+ if ($blog !== self::SUCCESS) {
51
+ $this->components->warn('The blog could not be set up, so this app has none yet. Everything else is in place. Add it later: php nevela blog');
52
+ }
53
+ }
54
+
42
55
  if ($this->option('email')) {
43
56
  // The first account in an app is its administrator.
44
57
  $status = $this->call('nevela:user', [
@@ -7,6 +7,7 @@ use Illuminate\Support\Facades\DB;
7
7
  use Illuminate\Support\Facades\Http;
8
8
  use Illuminate\Support\Facades\Schema;
9
9
  use Nevela\Laravel\Access\ClosedAccounts;
10
+ use Nevela\Laravel\Forms\FormGuard;
10
11
  use Nevela\Laravel\Nevela;
11
12
  use Nevela\Laravel\Support\DashboardState;
12
13
  use Nevela\Laravel\Support\Releases;
@@ -95,6 +96,19 @@ final class StatusCommand extends Command
95
96
  }
96
97
  }
97
98
 
99
+ // --- public forms ---
100
+ // A form that asks for the robot check can only make it with the secret key.
101
+ $checked = array_map(fn ($r) => $r->name, array_values(array_filter($resources, fn ($r) => $r->public?->protect['turnstile'] ?? false)));
102
+ if ($checked !== []) {
103
+ $forms = implode(', ', $checked);
104
+ $row('Robot check (Turnstile)', match (true) {
105
+ FormGuard::robotCheck() === 'on' => "on for {$forms}, solved on ".implode(', ', FormGuard::hostnames()),
106
+ FormGuard::secret() === '' && app()->environment('production') => $bad("TURNSTILE_SECRET_KEY is not set, so the form for {$forms} answers 503 to everyone"),
107
+ FormGuard::secret() === '' => "<fg=yellow>skipped: TURNSTILE_SECRET_KEY is not set, so nothing checks {$forms}. Set it before going live.</>",
108
+ default => $bad("no site to accept a check from, so the form for {$forms} answers 503 to everyone: set NEVELA_WEB_URL to the site's address, or TURNSTILE_HOSTNAMES"),
109
+ });
110
+ }
111
+
98
112
  // --- dashboard ---
99
113
  $web = config('nevela.web_path');
100
114
  if (! $web || ! is_dir($web)) {
@@ -3,6 +3,9 @@
3
3
  namespace Nevela\Laravel\Console;
4
4
 
5
5
  use Illuminate\Console\Command;
6
+ use Nevela\Laravel\Forms\Confirmation;
7
+ use Nevela\Laravel\Forms\PublicMail;
8
+ use Nevela\Laravel\Media\Uploads;
6
9
  use Nevela\Laravel\Trash\Trash;
7
10
 
8
11
  final class TrashCommand extends Command
@@ -13,6 +16,20 @@ final class TrashCommand extends Command
13
16
 
14
17
  public function handle(): int
15
18
  {
19
+ // Files visitors uploaded for a form they never sent. Nothing to do with the trash,
20
+ // but the same kind of job, on the same night.
21
+ $unclaimed = Uploads::purgeUnclaimed();
22
+ if ($unclaimed > 0) {
23
+ $this->components->info('Removed '.number_format($unclaimed).' file(s) uploaded through a public form and never used.');
24
+ }
25
+
26
+ // People who joined a list and never followed the link that proves their address.
27
+ $hours = Confirmation::hours();
28
+ foreach (Confirmation::purge() as $resource => $count) {
29
+ $this->components->info('Removed '.number_format($count)." {$resource} record(s) whose address wasn't confirmed within {$hours} hours.");
30
+ }
31
+ PublicMail::prune();
32
+
16
33
  $days = Trash::days();
17
34
  if ($days === null) {
18
35
  $this->components->info('Deleted records are kept until someone removes them (nevela.trash.days is off), so there is nothing to do.');
@@ -0,0 +1,33 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Forms;
4
+
5
+ use Illuminate\Bus\Queueable;
6
+ use Illuminate\Mail\Mailable;
7
+ use Illuminate\Notifications\Messages\MailMessage;
8
+ use Illuminate\Queue\SerializesModels;
9
+
10
+ /**
11
+ * The email with the link that proves an address: see Confirmation.
12
+ *
13
+ * Written like the emails signing in sends, so the two look alike. To say it in your own
14
+ * words, send your own Mailable with Confirmation::link() in it, through
15
+ * PublicMail::toVisitor().
16
+ */
17
+ final class ConfirmAddress extends Mailable
18
+ {
19
+ use Queueable, SerializesModels;
20
+
21
+ public function __construct(public string $app, public string $url, public int $hours) {}
22
+
23
+ public function build(): self
24
+ {
25
+ $message = (new MailMessage)
26
+ ->subject("Confirm your email address for {$this->app}")
27
+ ->line("Someone, we hope you, gave this address to {$this->app}. Confirm it and you're on the list.")
28
+ ->action('Confirm my address', $this->url)
29
+ ->line("The link works for {$this->hours} hours. If this wasn't you, do nothing: the address is removed and you won't hear from us again.");
30
+
31
+ return $this->subject("Confirm your email address for {$this->app}")->html((string) $message->render());
32
+ }
33
+ }
@@ -0,0 +1,88 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Forms;
4
+
5
+ use Illuminate\Database\Eloquent\Model;
6
+ use Illuminate\Http\Request;
7
+ use Nevela\Laravel\Auth\Web;
8
+ use Nevela\Laravel\Nevela;
9
+ use Nevela\Laravel\Support\Descriptor;
10
+ use Nevela\Laravel\Trash\Trash;
11
+
12
+ /**
13
+ * Proving an address belongs to whoever typed it: the descriptor's "confirm".
14
+ *
15
+ * "create": { "fields": ["email"], "confirm": "email" }
16
+ *
17
+ * The record is saved unconfirmed and one link is emailed to the address. Following it
18
+ * confirms the record. This is for a list people join, where the other checks aren't
19
+ * enough: an address can be real, take mail and belong to someone who never asked.
20
+ *
21
+ * The link is sent through PublicMail::toVisitor(), so its limits apply: signing a
22
+ * stranger up a hundred times sends them one email.
23
+ */
24
+ final class Confirmation
25
+ {
26
+ /** How long an address has to follow its link before the record is removed, in hours. */
27
+ public static function hours(): int
28
+ {
29
+ return max(1, (int) config('nevela.public.confirm.hours', 48));
30
+ }
31
+
32
+ /** What the link carries: only this app could have written it, for this record and this address. */
33
+ public static function hash(Descriptor $resource, mixed $id, string $address): string
34
+ {
35
+ return hash_hmac('sha256', $resource->slug.'|'.$id.'|'.strtolower(trim($address)), 'nevela-confirm|'.config('app.key'));
36
+ }
37
+
38
+ /** The page of the web app that confirms a record when it is opened. */
39
+ public static function link(Descriptor $resource, mixed $id, string $address, ?Request $request = null): string
40
+ {
41
+ return Web::url((string) config('nevela.public.confirm.path', '/confirm'), [
42
+ 'list' => $resource->slug,
43
+ 'id' => (string) $id,
44
+ 'key' => self::hash($resource, $id, $address),
45
+ ], $request);
46
+ }
47
+
48
+ /** Email the link. False when the address has been written to too often lately, and nothing went. */
49
+ public static function send(Descriptor $resource, mixed $id, string $address, ?Request $request = null): bool
50
+ {
51
+ return PublicMail::toVisitor($address, new ConfirmAddress(
52
+ (string) config('app.name', 'Nevela'),
53
+ self::link($resource, $id, $address, $request),
54
+ self::hours(),
55
+ ));
56
+ }
57
+
58
+ /**
59
+ * Remove the records whose link was never followed in time. Run each night with the trash.
60
+ *
61
+ * @return array<string, int> How many were removed, by resource name
62
+ */
63
+ public static function purge(): array
64
+ {
65
+ $removed = [];
66
+ $cutoff = now()->subHours(self::hours());
67
+ foreach (Nevela::all() as $resource) {
68
+ $model = "App\\Models\\{$resource->name}";
69
+ if ($resource->public?->confirm === null || ! class_exists($model) || ! method_exists($model, 'confirms') || ! $model::confirms()) {
70
+ continue;
71
+ }
72
+ $count = 0;
73
+ // Whether or not someone put it in the trash meanwhile: it was never anyone's record.
74
+ $model::query()->withoutGlobalScopes()->whereNull('confirmed_at')->where('created_at', '<', $cutoff)->orderBy((new $model)->getKeyName())
75
+ ->chunkById(200, function ($records) use (&$count) {
76
+ foreach ($records as $record) {
77
+ /** @var Model $record */
78
+ $count += Trash::purge($record) ? 1 : 0;
79
+ }
80
+ });
81
+ if ($count > 0) {
82
+ $removed[$resource->name] = $count;
83
+ }
84
+ }
85
+
86
+ return $removed;
87
+ }
88
+ }
@@ -0,0 +1,74 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Forms;
4
+
5
+ /**
6
+ * Domains that hand out addresses to be thrown away: mailinator.com and its many kin.
7
+ *
8
+ * An address there is real for ten minutes, which is long enough to pass every other
9
+ * check and no use for writing back. The list ships with the package (the public domain
10
+ * list kept at github.com/disposable-email-domains), and `php artisan nevela:disposable`
11
+ * fetches the current one into the app's storage, where it is read from in preference.
12
+ */
13
+ final class Disposable
14
+ {
15
+ public const SOURCE = 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
16
+
17
+ /** @var array<string, true>|null */
18
+ private static ?array $domains = null;
19
+
20
+ /** Whether a domain, or one it is a subdomain of, is on the list. */
21
+ public static function is(string $domain): bool
22
+ {
23
+ $domain = strtolower(trim($domain, " .\t\n\r"));
24
+ $list = self::domains();
25
+ // mail.mailinator.com is mailinator.com's too.
26
+ while ($domain !== '' && str_contains($domain, '.')) {
27
+ if (isset($list[$domain])) {
28
+ return true;
29
+ }
30
+ $domain = substr($domain, (int) strpos($domain, '.') + 1);
31
+ }
32
+
33
+ return false;
34
+ }
35
+
36
+ /** Where a refreshed list is kept, in the app's own storage. */
37
+ public static function refreshedPath(): string
38
+ {
39
+ return storage_path('app/nevela/disposable-domains.txt');
40
+ }
41
+
42
+ public static function count(): int
43
+ {
44
+ return count(self::domains());
45
+ }
46
+
47
+ public static function forget(): void
48
+ {
49
+ self::$domains = null;
50
+ }
51
+
52
+ /** @return array<string, true> */
53
+ private static function domains(): array
54
+ {
55
+ if (self::$domains !== null) {
56
+ return self::$domains;
57
+ }
58
+ try {
59
+ $refreshed = self::refreshedPath();
60
+ } catch (\Throwable) {
61
+ $refreshed = null; // no app to have a storage folder: the list is read on its own in a test
62
+ }
63
+ $file = $refreshed !== null && is_file($refreshed) ? $refreshed : dirname(__DIR__, 2).'/resources/disposable-domains.txt';
64
+ $domains = [];
65
+ foreach (@file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
66
+ $line = strtolower(trim($line));
67
+ if ($line !== '' && $line[0] !== '#') {
68
+ $domains[$line] = true;
69
+ }
70
+ }
71
+
72
+ return self::$domains = $domains;
73
+ }
74
+ }
@@ -0,0 +1,76 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Forms;
4
+
5
+ use Illuminate\Support\Facades\DB;
6
+ use Illuminate\Support\Facades\Schema;
7
+ use Illuminate\Support\Str;
8
+ use Throwable;
9
+
10
+ /**
11
+ * What a public form turned away, counted by resource, reason and day.
12
+ *
13
+ * The dashboard shows it on the resource's page ("Blocked this week: 132"). Without it
14
+ * a form that is well protected and a form nobody uses look the same from the inside.
15
+ */
16
+ final class FormBlocks
17
+ {
18
+ private const TABLE = 'nevela_form_blocks';
19
+
20
+ /** What each reason is called where a person reads it. */
21
+ public const LABELS = [
22
+ 'honeypot' => 'filled the hidden field',
23
+ 'forged' => 'no form token',
24
+ 'too_fast' => 'sent too fast',
25
+ 'expired' => 'form left open too long',
26
+ 'robot' => 'robot check',
27
+ 'hostname' => 'robot check solved on another site',
28
+ 'disposable' => 'throwaway address',
29
+ 'undeliverable' => 'address that takes no mail',
30
+ 'throttled' => 'too many from one address',
31
+ 'unconfigured' => 'robot check not set up',
32
+ 'unreachable' => 'robot check unreachable',
33
+ ];
34
+
35
+ /** One more of this reason, today. Never the reason a request fails: counting is not what the form is for. */
36
+ public static function record(string $resource, string $reason): void
37
+ {
38
+ try {
39
+ $day = now()->toDateString();
40
+ $where = ['resource' => $resource, 'reason' => $reason, 'day' => $day];
41
+ if (DB::table(self::TABLE)->where($where)->increment('count') === 0) {
42
+ DB::table(self::TABLE)->insert($where + ['id' => (string) Str::uuid(), 'count' => 1]);
43
+ }
44
+ } catch (Throwable) {
45
+ // The table isn't there yet (not migrated), or two requests made today's row at once.
46
+ }
47
+ }
48
+
49
+ /**
50
+ * How many were turned away in the last `$days` days, in all and by reason, most first.
51
+ *
52
+ * @return array{total: int, days: int, reasons: list<array{reason: string, label: string, count: int}>}
53
+ */
54
+ public static function summary(string $resource, int $days = 7): array
55
+ {
56
+ $reasons = [];
57
+ try {
58
+ if (Schema::hasTable(self::TABLE)) {
59
+ $rows = DB::table(self::TABLE)
60
+ ->where('resource', $resource)
61
+ ->where('day', '>', now()->subDays($days)->toDateString())
62
+ ->selectRaw('reason, sum(count) as total')
63
+ ->groupBy('reason')
64
+ ->get();
65
+ foreach ($rows as $row) {
66
+ $reasons[] = ['reason' => (string) $row->reason, 'label' => self::LABELS[$row->reason] ?? (string) $row->reason, 'count' => (int) $row->total];
67
+ }
68
+ }
69
+ } catch (Throwable) {
70
+ $reasons = [];
71
+ }
72
+ usort($reasons, fn (array $a, array $b) => $b['count'] <=> $a['count']);
73
+
74
+ return ['total' => array_sum(array_column($reasons, 'count')), 'days' => $days, 'reasons' => $reasons];
75
+ }
76
+ }