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.
- package/index.mjs +25 -7
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +66 -3
- 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/Access/Permissions.php +36 -5
- package/template/laravel/src/Concerns/Confirmable.php +81 -0
- package/template/laravel/src/Concerns/Trashable.php +23 -0
- package/template/laravel/src/Console/AdoptCommand.php +139 -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 +38 -3
- package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
- package/template/laravel/src/Console/SeedCommand.php +3 -1
- 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 +17 -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 +356 -32
- 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 +223 -0
- 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 +294 -0
- package/template/laravel/src/Http/PublicThrottle.php +48 -0
- package/template/laravel/src/Http/ResourceRequest.php +193 -4
- package/template/laravel/src/Http/TrashController.php +19 -2
- package/template/laravel/src/Http/UploadController.php +42 -1
- package/template/laravel/src/Media/Uploads.php +111 -0
- package/template/laravel/src/Nevela.php +83 -2
- package/template/laravel/src/NevelaServiceProvider.php +27 -2
- package/template/laravel/src/Support/Action.php +168 -0
- package/template/laravel/src/Support/Adoption.php +352 -0
- package/template/laravel/src/Support/Descriptor.php +83 -4
- package/template/laravel/src/Support/Fake.php +15 -1
- package/template/laravel/src/Support/Field.php +91 -3
- package/template/laravel/src/Support/Html.php +185 -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 +208 -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 +42 -9
- package/template/web/app/confirm/page.tsx +51 -0
- package/template/web/app/dashboard/actions.ts +48 -0
- package/template/web/app/globals.css +46 -0
- package/template/web/components/access/role-form.tsx +40 -5
- package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
- package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
- package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
- package/template/web/components/dashboard/fields/file-field.tsx +9 -5
- package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
- package/template/web/components/dashboard/form-activity.tsx +53 -0
- package/template/web/components/dashboard/record-actions.tsx +33 -4
- package/template/web/components/dashboard/record-detail.tsx +10 -2
- package/template/web/components/dashboard/resource-action.tsx +281 -0
- 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 +46 -4
- package/template/web/components/dashboard/row-actions.tsx +56 -2
- package/template/web/components/dashboard/table-selection.tsx +19 -1
- 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 +175 -0
- package/template/web/lib/access.ts +3 -1
- package/template/web/lib/dashboard.ts +10 -0
- package/template/web/lib/extras.ts +105 -0
- package/template/web/lib/files.ts +14 -0
- package/template/web/lib/laravel.ts +60 -0
- package/template/web/lib/rich.ts +79 -0
- 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
|
-
|
|
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
|
+
}
|