create-nevela 0.8.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/index.mjs +24 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +41 -0
  5. package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
  6. package/template/laravel/resources/disposable-domains.txt +9880 -0
  7. package/template/laravel/src/Access/ClosedAccounts.php +2 -1
  8. package/template/laravel/src/Concerns/Confirmable.php +81 -0
  9. package/template/laravel/src/Console/BlogCommand.php +254 -0
  10. package/template/laravel/src/Console/DevCommand.php +9 -0
  11. package/template/laravel/src/Console/DigestCommand.php +28 -0
  12. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  13. package/template/laravel/src/Console/GenerateCommand.php +25 -0
  14. package/template/laravel/src/Console/SetupCommand.php +15 -2
  15. package/template/laravel/src/Console/StatusCommand.php +14 -0
  16. package/template/laravel/src/Console/TrashCommand.php +9 -0
  17. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  18. package/template/laravel/src/Forms/Confirmation.php +88 -0
  19. package/template/laravel/src/Forms/Disposable.php +74 -0
  20. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  21. package/template/laravel/src/Forms/FormGuard.php +359 -0
  22. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  23. package/template/laravel/src/Forms/PublicMail.php +217 -0
  24. package/template/laravel/src/Forms/Refusal.php +50 -0
  25. package/template/laravel/src/Generator/BlogPages.php +409 -0
  26. package/template/laravel/src/Generator/ResourceGenerator.php +133 -7
  27. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  28. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  29. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  30. package/template/laravel/src/Http/ActionController.php +2 -1
  31. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  32. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  33. package/template/laravel/src/Http/FormsController.php +45 -0
  34. package/template/laravel/src/Http/PublicController.php +89 -4
  35. package/template/laravel/src/Http/ResourceRequest.php +43 -3
  36. package/template/laravel/src/Http/TrashController.php +2 -1
  37. package/template/laravel/src/Http/UploadController.php +2 -2
  38. package/template/laravel/src/Nevela.php +30 -1
  39. package/template/laravel/src/NevelaServiceProvider.php +11 -2
  40. package/template/laravel/src/Support/Descriptor.php +24 -0
  41. package/template/laravel/src/Support/Field.php +17 -0
  42. package/template/laravel/src/Support/Key.php +57 -0
  43. package/template/laravel/src/Support/ListQuery.php +29 -1
  44. package/template/laravel/src/Support/PublicAccess.php +75 -0
  45. package/template/laravel/src/Support/Slug.php +43 -0
  46. package/template/laravel/src/Trash/Trash.php +3 -1
  47. package/template/web/.env.example +4 -0
  48. package/template/web/app/api/[...path]/route.ts +6 -3
  49. package/template/web/app/confirm/page.tsx +51 -0
  50. package/template/web/app/dashboard/actions.ts +15 -1
  51. package/template/web/components/dashboard/form-activity.tsx +53 -0
  52. package/template/web/components/dashboard/record-actions.tsx +29 -5
  53. package/template/web/components/dashboard/record-detail.tsx +2 -1
  54. package/template/web/components/dashboard/resource-form.tsx +19 -2
  55. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  56. package/template/web/components/dashboard/resource-table.tsx +32 -3
  57. package/template/web/components/dashboard/row-actions.tsx +33 -3
  58. package/template/web/components/marketing/site-header.tsx +9 -2
  59. package/template/web/components/public-form-guard.tsx +125 -0
  60. package/template/web/hooks/use-public-form.ts +263 -0
  61. package/template/web/lib/extras.ts +39 -0
  62. package/template/web/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,359 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Forms;
4
+
5
+ use Illuminate\Http\Request;
6
+ use Illuminate\Support\Facades\Cache;
7
+ use Illuminate\Support\Facades\Http;
8
+ use Illuminate\Support\Facades\RateLimiter;
9
+ use Illuminate\Support\Facades\Validator;
10
+ use Nevela\Laravel\Auth\Web;
11
+ use Nevela\Laravel\Support\Descriptor;
12
+ use Throwable;
13
+
14
+ /**
15
+ * What stands between a public form and whoever sends it: the descriptor's "protect".
16
+ *
17
+ * "protect": { "turnstile": true, "honeypot": true, "minSeconds": 3, "email": "email" }
18
+ *
19
+ * The checks run in this order, cheapest first, before anything is validated or stored:
20
+ *
21
+ * 1. the honeypot: a field no person sees has a value;
22
+ * 2. the time taken: the form came back sooner than a person could fill it in, or its
23
+ * token is missing, forged or too old;
24
+ * 3. Turnstile: Cloudflare, asked by this server, doesn't vouch for the visitor;
25
+ * 4. the address: its domain takes no mail, or is a throwaway one;
26
+ * 5. the count for that address: one person, however many networks they send from.
27
+ *
28
+ * A resource with no "protect" passes straight through, as before there was any.
29
+ */
30
+ final class FormGuard
31
+ {
32
+ /** The form's own token, and Turnstile's, as a form sends them. Neither is a field of the resource. */
33
+ public const TOKEN = '_form';
34
+
35
+ public const TURNSTILE = '_turnstile';
36
+
37
+ /** What a person is told when it can't be shown they are one. A person can get this, so it says what to do. */
38
+ public const NOT_A_PERSON = "We couldn't confirm you're not a robot. Please try again.";
39
+
40
+ /**
41
+ * Names a bot fills in because they look like something a form wants. The first that
42
+ * isn't a field of the form is used. None is something a browser keeps for a person
43
+ * (a company, a second address line): filled in for them, it would turn them away.
44
+ */
45
+ private const BAIT = ['website', 'homepage', 'url', 'site', 'web'];
46
+
47
+ /**
48
+ * How long a token that was taken is remembered, in seconds, so it can't be taken
49
+ * again. Cloudflare's are good for 300; past that it refuses them itself.
50
+ */
51
+ private const REMEMBERED = 600;
52
+
53
+ /** Where a form is built, and never where it is live. */
54
+ private const LOCAL = ['localhost', '127.0.0.1'];
55
+
56
+ /** Asks whether a domain takes mail. Replaced in tests, which have no DNS to ask. @var (callable(string): bool)|null */
57
+ public static $takesMail = null;
58
+
59
+ /**
60
+ * What a form needs before it is shown: the token to send back, the name of the field
61
+ * to leave empty, and whether the robot check is on.
62
+ *
63
+ * @return array{token: string|null, honeypot: string|null, minSeconds: int, turnstile: bool, action: string|null}
64
+ */
65
+ public static function issue(Descriptor $resource): array
66
+ {
67
+ $protect = $resource->public?->protect ?? [];
68
+ $timed = ($protect['honeypot'] ?? false) || ($protect['minSeconds'] ?? 0) > 0;
69
+
70
+ return [
71
+ 'token' => $timed ? self::sign($resource->name, now()->getTimestamp()) : null,
72
+ 'honeypot' => ($protect['honeypot'] ?? false) ? self::honeypot($resource) : null,
73
+ 'minSeconds' => (int) ($protect['minSeconds'] ?? 0),
74
+ // On only where the answer can be checked: see robot().
75
+ 'turnstile' => $robot = ($protect['turnstile'] ?? false) && self::robotCheck() === 'on',
76
+ // What the widget is rendered with, and Cloudflare hands back: which form the check was for.
77
+ 'action' => $robot ? self::action($resource) : null,
78
+ ];
79
+ }
80
+
81
+ /**
82
+ * The name a form's robot check is made under. The widget is given it, Cloudflare
83
+ * returns it with its answer, and the two must agree: a token solved for one form
84
+ * can't be spent on another. (Cloudflare allows letters, digits, _ and -, up to 32.)
85
+ *
86
+ * A slug longer than that is cut, and ends in a few characters made from the whole of
87
+ * it: two long slugs that begin alike are still two names.
88
+ */
89
+ public static function action(Descriptor $resource): string
90
+ {
91
+ $name = (string) preg_replace('/[^A-Za-z0-9_-]/', '-', $resource->slug);
92
+
93
+ return strlen($name) <= 32 ? $name : substr($name, 0, 23).'-'.substr(hash('sha256', $resource->slug), 0, 8);
94
+ }
95
+
96
+ /**
97
+ * The sites a robot check may have been solved on: TURNSTILE_HOSTNAMES, or failing that
98
+ * the web app's own. A name stands for its subdomains too, as it does on the widget.
99
+ *
100
+ * A widget usually lists localhost beside the real site, so the form can be built. This
101
+ * is what stops a token solved on someone's own machine being spent on the live form:
102
+ * in production localhost is never accepted, whatever the setting says.
103
+ *
104
+ * @return list<string>
105
+ */
106
+ public static function hostnames(?Request $request = null): array
107
+ {
108
+ $set = config('nevela.public.turnstile.hostnames');
109
+ $names = array_values(array_filter(array_map(
110
+ fn ($name) => strtolower(trim((string) $name)),
111
+ is_array($set) ? $set : explode(',', (string) $set),
112
+ )));
113
+ if ($names === []) {
114
+ $names = [strtolower(Web::host($request))];
115
+ }
116
+
117
+ return app()->environment('production')
118
+ ? array_values(array_diff($names, self::LOCAL))
119
+ : array_values(array_unique([...$names, ...self::LOCAL]));
120
+ }
121
+
122
+ /**
123
+ * Whether the robot check can be made here: "on"; "skipped", in development with no
124
+ * secret key; or "refusing", in production with something it needs missing. A form that
125
+ * asks for the check never goes without it quietly.
126
+ */
127
+ public static function robotCheck(): string
128
+ {
129
+ $production = app()->environment('production');
130
+ if (self::secret() === '') {
131
+ return $production ? 'refusing' : 'skipped';
132
+ }
133
+
134
+ return $production && self::hostnames() === [] ? 'refusing' : 'on';
135
+ }
136
+
137
+ /** The field a person never sees, for this form. */
138
+ public static function honeypot(Descriptor $resource): string
139
+ {
140
+ foreach (self::BAIT as $name) {
141
+ if (! isset($resource->fields[$name])) {
142
+ return $name;
143
+ }
144
+ }
145
+
146
+ return '_'.self::BAIT[0];
147
+ }
148
+
149
+ /**
150
+ * The input without what the guard added to the form: what is left is the resource's own fields.
151
+ *
152
+ * @param array<string, mixed> $input
153
+ * @return array<string, mixed>
154
+ */
155
+ public static function strip(Descriptor $resource, array $input): array
156
+ {
157
+ if (! ($resource->public?->protected() ?? false)) {
158
+ return $input;
159
+ }
160
+ unset($input[self::TOKEN], $input[self::TURNSTILE], $input['cf-turnstile-response'], $input[self::honeypot($resource)]);
161
+
162
+ return $input;
163
+ }
164
+
165
+ /**
166
+ * Whether to turn this one away, and why. Null lets it through to validation.
167
+ *
168
+ * @param array<string, mixed> $input
169
+ */
170
+ public static function check(Descriptor $resource, Request $request, array $input): ?Refusal
171
+ {
172
+ $public = $resource->public;
173
+ if ($public === null || ! $public->protected()) {
174
+ return null;
175
+ }
176
+ $protect = $public->protect;
177
+
178
+ if ($protect['honeypot'] && self::filled($input[self::honeypot($resource)] ?? null)) {
179
+ return new Refusal('honeypot');
180
+ }
181
+
182
+ if ($protect['honeypot'] || $protect['minSeconds'] > 0) {
183
+ $issued = self::issuedAt($resource->name, $input[self::TOKEN] ?? null);
184
+ if ($issued === null) {
185
+ return new Refusal('forged');
186
+ }
187
+ $age = now()->getTimestamp() - $issued;
188
+ if ($age < $protect['minSeconds']) {
189
+ return new Refusal('too_fast');
190
+ }
191
+ // Left open for hours, by a person. The form asks for a new token after every
192
+ // attempt, so trying again is all they need to do.
193
+ if ($age > (int) config('nevela.public.form_lifetime', 7200)) {
194
+ return new Refusal('expired', 422, self::NOT_A_PERSON);
195
+ }
196
+ }
197
+
198
+ if ($protect['turnstile'] && ($refusal = self::robot($resource, $request, $input)) !== null) {
199
+ return $refusal;
200
+ }
201
+
202
+ $field = $protect['email'];
203
+ $address = $field !== null && is_string($input[$field] ?? null) ? strtolower(trim($input[$field])) : null;
204
+ // An address that isn't one at all is validation's to say, in its own words.
205
+ if ($address !== null && filter_var($address, FILTER_VALIDATE_EMAIL)) {
206
+ $domain = substr($address, (int) strrpos($address, '@') + 1);
207
+ if (Disposable::is($domain)) {
208
+ return new Refusal('disposable', 422, 'Use an address you keep. This one is from a throwaway service.', $field);
209
+ }
210
+ if (! self::takesMail($address, $domain)) {
211
+ return new Refusal('undeliverable', 422, "This address's domain doesn't receive email. Check the spelling.", $field);
212
+ }
213
+
214
+ // The route's throttle counts a visitor. This counts an address, wherever it is sent from.
215
+ [$most, $minutes] = array_map('intval', explode(',', $public->createThrottle));
216
+ $key = "nevela-form:{$resource->slug}:".sha1($address);
217
+ if (RateLimiter::tooManyAttempts($key, $most)) {
218
+ return new Refusal('throttled', 429, 'Too many requests. Try again shortly.');
219
+ }
220
+ RateLimiter::hit($key, $minutes * 60);
221
+ }
222
+
223
+ return null;
224
+ }
225
+
226
+ /**
227
+ * Whether Cloudflare vouches for the visitor. It is asked from here, with the secret
228
+ * only this server has: what the browser says about its own check is not evidence.
229
+ *
230
+ * With no secret set the check can't be made. In development it is skipped, so a form
231
+ * can be built before the keys exist. In production the form is refused outright: a
232
+ * form that asked for the check and quietly went without would be open, and nobody
233
+ * would know.
234
+ *
235
+ * A yes from Cloudflare says a person solved a check for this widget. It is taken only
236
+ * if it was solved on this site and for this form: see hostnames() and action().
237
+ *
238
+ * @param array<string, mixed> $input
239
+ */
240
+ private static function robot(Descriptor $resource, Request $request, array $input): ?Refusal
241
+ {
242
+ $secret = self::secret();
243
+ $unconfigured = new Refusal('unconfigured', 503, "This form can't be sent at the moment. Please try again later.");
244
+ $state = self::robotCheck();
245
+ if ($state !== 'on') {
246
+ return $state === 'refusing' ? $unconfigured : null;
247
+ }
248
+ $token = $input[self::TURNSTILE] ?? $input['cf-turnstile-response'] ?? null;
249
+ $failed = new Refusal('robot', 422, self::NOT_A_PERSON);
250
+ // (Cloudflare's tokens are at most 2048 characters. Anything longer is not one.)
251
+ if (! is_string($token) || $token === '' || strlen($token) > 2048) {
252
+ return $failed;
253
+ }
254
+ // One this app has already taken. Cloudflare says a token is good once, and has been
255
+ // seen to say yes to the same one again, so that is kept count of here as well.
256
+ $taken = 'nevela-turnstile:'.hash('sha256', $token);
257
+ if (Cache::has($taken)) {
258
+ return $failed;
259
+ }
260
+
261
+ try {
262
+ $response = Http::asForm()->timeout(8)->post((string) config('nevela.public.turnstile.verify_url', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'), [
263
+ 'secret' => $secret,
264
+ 'response' => $token,
265
+ // The visitor's, where the web app passed it on: see Http\PublicThrottle.
266
+ 'remoteip' => $request->ip(),
267
+ ]);
268
+ } catch (Throwable) {
269
+ $response = null;
270
+ }
271
+ // Cloudflare not answering is not a yes. The person is asked to try again; nothing is let through.
272
+ if ($response === null || $response->serverError()) {
273
+ return new Refusal('unreachable', 503, "We couldn't check the form just now. Please try again in a moment.");
274
+ }
275
+
276
+ // A token is good once: a second use is answered with "timeout-or-duplicate", which is a no.
277
+ if ($response->json('success') !== true) {
278
+ return $failed;
279
+ }
280
+ // The keys Cloudflare publishes for testing say yes to everything, name no form, and
281
+ // call every site example.com. Good for building a form. On a live one they are no check at all.
282
+ if ($response->json('metadata.result_with_testing_key') === true) {
283
+ return app()->environment('production') ? $unconfigured : null;
284
+ }
285
+
286
+ $solvedOn = strtolower((string) $response->json('hostname'));
287
+ $expected = self::hostnames($request);
288
+ $here = false;
289
+ foreach ($expected as $name) {
290
+ $here = $here || $solvedOn === $name || str_ends_with($solvedOn, '.'.$name);
291
+ }
292
+ if (! $here) {
293
+ // Most often a setting, not an attack: the site is served from a name this app wasn't told of.
294
+ logger()->warning("Nevela refused a robot check solved on \"{$solvedOn}\". This app accepts: ".implode(', ', $expected).'. If that is your site, add it to TURNSTILE_HOSTNAMES or correct NEVELA_WEB_URL.');
295
+
296
+ return new Refusal('hostname', 422, self::NOT_A_PERSON);
297
+ }
298
+
299
+ if ((string) $response->json('action') !== self::action($resource)) {
300
+ return $failed;
301
+ }
302
+
303
+ // Taken, once: of two requests that arrive together with the same token, the second
304
+ // finds it there. (The test keys' one token is everyone's, and is not kept.)
305
+ return Cache::add($taken, true, self::REMEMBERED) ? null : $failed;
306
+ }
307
+
308
+ public static function secret(): string
309
+ {
310
+ return (string) config('nevela.public.turnstile.secret', '');
311
+ }
312
+
313
+ private static function takesMail(string $address, string $domain): bool
314
+ {
315
+ if (self::$takesMail !== null) {
316
+ return (bool) (self::$takesMail)($domain);
317
+ }
318
+ // Laravel's own check, which needs PHP's intl extension to read a domain in another script.
319
+ if (function_exists('idn_to_ascii')) {
320
+ return Validator::make(['address' => $address], ['address' => 'email:rfc,dns'])->passes();
321
+ }
322
+
323
+ // A domain with no MX record still takes mail at its A record, so either will do.
324
+ return checkdnsrr($domain.'.', 'MX') || checkdnsrr($domain.'.', 'A') || checkdnsrr($domain.'.', 'AAAA');
325
+ }
326
+
327
+ private static function filled(mixed $value): bool
328
+ {
329
+ return is_scalar($value) ? trim((string) $value) !== '' : $value !== null && $value !== [];
330
+ }
331
+
332
+ /** A token that says when this form was handed out, in a way only this app could have written. */
333
+ private static function sign(string $resource, int $time): string
334
+ {
335
+ $payload = rtrim(strtr(base64_encode($resource.'|'.$time), '+/', '-_'), '=');
336
+
337
+ return $payload.'.'.hash_hmac('sha256', $payload, self::key());
338
+ }
339
+
340
+ /** When a token was issued, or null when it isn't one of this app's for this form. */
341
+ private static function issuedAt(string $resource, mixed $token): ?int
342
+ {
343
+ if (! is_string($token) || substr_count($token, '.') !== 1) {
344
+ return null;
345
+ }
346
+ [$payload, $signature] = explode('.', $token);
347
+ if (! hash_equals(hash_hmac('sha256', $payload, self::key()), $signature)) {
348
+ return null;
349
+ }
350
+ [$for, $time] = array_pad(explode('|', (string) base64_decode(strtr($payload, '-_', '+/'), true), 2), 2, '');
351
+
352
+ return $for === $resource && ctype_digit($time) ? (int) $time : null;
353
+ }
354
+
355
+ private static function key(): string
356
+ {
357
+ return 'nevela-form|'.config('app.key');
358
+ }
359
+ }
@@ -0,0 +1,63 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Forms;
4
+
5
+ use Illuminate\Bus\Queueable;
6
+ use Illuminate\Contracts\Queue\ShouldQueue;
7
+ use Illuminate\Notifications\Messages\MailMessage;
8
+ use Illuminate\Notifications\Notification;
9
+
10
+ /**
11
+ * One email for the notices that were held back: "14 more enquiries since 14:05".
12
+ *
13
+ * Each is named by its subject and first line, with a link to where it is in the
14
+ * dashboard. See PublicMail::toOwner().
15
+ */
16
+ final class OwnerDigest extends Notification implements ShouldQueue
17
+ {
18
+ use Queueable;
19
+
20
+ /** The most a digest lists by name. Beyond it the email says how many more there are. */
21
+ public const LISTED = 50;
22
+
23
+ /**
24
+ * @param list<array{subject: string, line: string|null, url: string|null}> $held
25
+ * @param string $since The time the first of them arrived: "14:05"
26
+ */
27
+ public function __construct(public array $held, public string $since) {}
28
+
29
+ /** @return list<string> */
30
+ public function via(object $notifiable): array
31
+ {
32
+ return ['mail'];
33
+ }
34
+
35
+ public function subject(): string
36
+ {
37
+ $count = count($this->held);
38
+
39
+ return $count === 1 ? "1 more enquiry since {$this->since}" : number_format($count)." more enquiries since {$this->since}";
40
+ }
41
+
42
+ public function toMail(object $notifiable): MailMessage
43
+ {
44
+ $message = (new MailMessage)
45
+ ->subject($this->subject())
46
+ ->line('These arrived after the hour\'s notices had gone out. Every one of them is in the dashboard.');
47
+ foreach (array_slice($this->held, 0, self::LISTED) as $row) {
48
+ $text = '**'.self::plain($row['subject']).'**'.($row['line'] !== null && $row['line'] !== '' ? ': '.self::plain($row['line']) : '');
49
+ $message->line($row['url'] !== null && $row['url'] !== '' ? "{$text} ([open]({$row['url']}))" : $text);
50
+ }
51
+ if (($more = count($this->held) - self::LISTED) > 0) {
52
+ $message->line("And {$more} more.");
53
+ }
54
+
55
+ return $message;
56
+ }
57
+
58
+ /** What a visitor typed, as words: the email is written in Markdown, and theirs must not be read as any. */
59
+ private static function plain(string $text): string
60
+ {
61
+ return (string) preg_replace('/([\\\\`*_{}\[\]()#+\-.!|<>])/', '\\\\$1', $text);
62
+ }
63
+ }