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,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
+ }
@@ -0,0 +1,217 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Forms;
4
+
5
+ use Illuminate\Contracts\Mail\Mailable;
6
+ use Illuminate\Support\Carbon;
7
+ use Illuminate\Support\Facades\Cache;
8
+ use Illuminate\Support\Facades\DB;
9
+ use Illuminate\Support\Facades\Mail;
10
+ use Illuminate\Support\Facades\Notification;
11
+ use Illuminate\Support\Str;
12
+ use Throwable;
13
+
14
+ /**
15
+ * The mail a public form causes, with the limits that make it safe to send.
16
+ *
17
+ * PublicMail::toVisitor($message->email, new MessageReceived($message));
18
+ * PublicMail::toOwner(new NewMessage($message), line: $message->subject, url: $link);
19
+ *
20
+ * A form that writes back to whatever address was typed into it will write to anyone:
21
+ * type a stranger's address a thousand times and the site has sent them a thousand
22
+ * emails. So an address is written to at most once an hour and three times a day,
23
+ * counted across every form, and past that the email is dropped. The record is saved
24
+ * either way.
25
+ *
26
+ * The owner is the other inbox. A burst of submissions would bury the real ones, so only
27
+ * so many notices go out in an hour. The rest are held, and one email at the top of the
28
+ * hour names them. Nothing is lost: every record is in the dashboard.
29
+ *
30
+ * Both are queued, so a slow mail server doesn't keep a visitor waiting.
31
+ *
32
+ * Called while a record is being saved (a model's `created`, inside a transaction), the
33
+ * counting and the sending wait until the save is committed. A record that is rolled
34
+ * back sends nothing, and nothing here can spoil the transaction it was called in: on
35
+ * Postgres one failed statement, such as a lock someone else holds, would abort it all.
36
+ */
37
+ final class PublicMail
38
+ {
39
+ private const SENT = 'nevela_public_mail';
40
+
41
+ private const HELD = 'nevela_owner_digest';
42
+
43
+ /**
44
+ * Write to an address a form was given. False when it has been written to too often
45
+ * and this one was dropped. (Inside a transaction that isn't known yet: the answer is
46
+ * true, and the limit is applied when the transaction commits.)
47
+ */
48
+ public static function toVisitor(string $address, Mailable $mail): bool
49
+ {
50
+ $address = strtolower(trim($address));
51
+ if (! filter_var($address, FILTER_VALIDATE_EMAIL)) {
52
+ return false;
53
+ }
54
+ $who = self::fingerprint($address);
55
+
56
+ return self::whenSaved(fn () => self::counted("visitor:{$who}", function () use ($address, $mail, $who) {
57
+ $hour = self::sentSince('visitor', $who, now()->subHour());
58
+ $day = self::sentSince('visitor', $who, now()->subDay());
59
+ if ($hour >= (int) config('nevela.public.mail.visitor_per_hour', 1) || $day >= (int) config('nevela.public.mail.visitor_per_day', 3)) {
60
+ return false;
61
+ }
62
+ self::record('visitor', $who);
63
+ Mail::to($address)->queue($mail);
64
+
65
+ return true;
66
+ }));
67
+ }
68
+
69
+ /**
70
+ * Tell the owner a form was sent. False when the hour's notices have gone, and this
71
+ * one waits for the digest.
72
+ *
73
+ * @param string|null $line What the digest says of it: a subject, or its first line
74
+ * @param string|null $url Where in the dashboard it is
75
+ */
76
+ public static function toOwner(Mailable $mail, ?string $line = null, ?string $url = null): bool
77
+ {
78
+ $to = self::owner();
79
+ if ($to === null) {
80
+ return false;
81
+ }
82
+
83
+ return self::whenSaved(fn () => self::counted('owner', function () use ($mail, $line, $url, $to) {
84
+ if (self::sentSince('owner', 'owner', now()->subHour()) >= (int) config('nevela.public.notify.max_per_hour', 10)) {
85
+ DB::table(self::HELD)->insert([
86
+ 'id' => (string) Str::uuid(),
87
+ 'subject' => Str::limit(self::subjectOf($mail), 250, ''),
88
+ 'line' => $line === null ? null : Str::limit(trim(preg_replace('/\s+/', ' ', $line) ?? ''), 490),
89
+ 'url' => $url === null ? null : Str::limit($url, 2040, ''),
90
+ 'held_at' => now(),
91
+ ]);
92
+
93
+ return false;
94
+ }
95
+ self::record('owner', 'owner');
96
+ Mail::to($to)->queue($mail);
97
+
98
+ return true;
99
+ }));
100
+ }
101
+
102
+ /**
103
+ * Send what was held, as one email, and forget it. Run at the top of each hour by the
104
+ * scheduler (`nevela:digest`). Returns how many notices it named.
105
+ */
106
+ public static function digest(): int
107
+ {
108
+ $to = self::owner();
109
+ $held = DB::table(self::HELD)->orderBy('held_at')->get();
110
+ if ($held->isEmpty()) {
111
+ return 0;
112
+ }
113
+ if ($to !== null) {
114
+ Notification::route('mail', $to)->notify(new OwnerDigest(
115
+ $held->map(fn ($row) => ['subject' => (string) $row->subject, 'line' => $row->line, 'url' => $row->url])->all(),
116
+ Carbon::parse($held->first()->held_at)->format('H:i'),
117
+ ));
118
+ }
119
+ DB::table(self::HELD)->whereIn('id', $held->pluck('id')->all())->delete();
120
+
121
+ return $held->count();
122
+ }
123
+
124
+ /** Forget who was written to more than a few days ago, and old counts of what was refused. */
125
+ public static function prune(): void
126
+ {
127
+ try {
128
+ DB::table(self::SENT)->where('sent_at', '<', now()->subDays(2))->delete();
129
+ DB::table('nevela_form_blocks')->where('day', '<', now()->subDays(90)->toDateString())->delete();
130
+ } catch (Throwable) {
131
+ // Not migrated yet.
132
+ }
133
+ }
134
+
135
+ /** Who a form's notices go to: nevela.public.notify.to, or the address the app sends from. */
136
+ public static function owner(): ?string
137
+ {
138
+ $to = config('nevela.public.notify.to') ?: config('mail.from.address');
139
+
140
+ return is_string($to) && filter_var($to, FILTER_VALIDATE_EMAIL) ? $to : null;
141
+ }
142
+
143
+ /** An address as it is counted by: enough to tell it from another, not to read it back. */
144
+ public static function fingerprint(string $address): string
145
+ {
146
+ return hash_hmac('sha256', strtolower(trim($address)), 'nevela-mail|'.config('app.key'));
147
+ }
148
+
149
+ private static function sentSince(string $kind, string $recipient, Carbon $since): int
150
+ {
151
+ return DB::table(self::SENT)->where('kind', $kind)->where('recipient', $recipient)->where('sent_at', '>', $since)->count();
152
+ }
153
+
154
+ private static function record(string $kind, string $recipient): void
155
+ {
156
+ DB::table(self::SENT)->insert(['id' => (string) Str::uuid(), 'kind' => $kind, 'recipient' => $recipient, 'sent_at' => now()]);
157
+ }
158
+
159
+ /** What an email is headed, for the digest to name it by: its subject, or failing that what it is called. */
160
+ private static function subjectOf(Mailable $mail): string
161
+ {
162
+ try {
163
+ // Asked of a copy: a mailable that sets its subject in build() must not be built twice.
164
+ $probe = clone $mail;
165
+ if (method_exists($probe, 'envelope')) {
166
+ $subject = $probe->envelope()->subject;
167
+ } else {
168
+ if (method_exists($probe, 'build')) {
169
+ app()->call([$probe, 'build']);
170
+ }
171
+ $subject = $probe->subject ?? null;
172
+ }
173
+ } catch (Throwable) {
174
+ $subject = null;
175
+ }
176
+
177
+ return is_string($subject) && $subject !== '' ? $subject : Str::headline(class_basename($mail));
178
+ }
179
+
180
+ /**
181
+ * Now, or once the transaction this was called in has committed. Never inside it.
182
+ *
183
+ * @param callable(): bool $send
184
+ */
185
+ private static function whenSaved(callable $send): bool
186
+ {
187
+ $sent = null;
188
+ // Runs at once where there is no transaction to wait for.
189
+ DB::afterCommit(function () use (&$sent, $send) {
190
+ $sent = $send();
191
+ });
192
+
193
+ return $sent ?? true;
194
+ }
195
+
196
+ /**
197
+ * Count and send as one step, so two requests at the same moment can't both be the
198
+ * last one allowed. Mail is never the reason a form fails: a problem here is reported
199
+ * and the answer is "not sent".
200
+ *
201
+ * @param callable(): bool $send
202
+ */
203
+ private static function counted(string $name, callable $send): bool
204
+ {
205
+ try {
206
+ try {
207
+ return (bool) Cache::lock('nevela-mail:'.$name, 10)->block(5, $send);
208
+ } catch (\Illuminate\Contracts\Cache\LockTimeoutException) {
209
+ return (bool) $send();
210
+ }
211
+ } catch (Throwable $e) {
212
+ report($e);
213
+
214
+ return false;
215
+ }
216
+ }
217
+ }
@@ -0,0 +1,50 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Forms;
4
+
5
+ use Illuminate\Http\JsonResponse;
6
+ use Illuminate\Http\Response;
7
+
8
+ /**
9
+ * A public form that was turned away, and what its sender is told.
10
+ *
11
+ * Which of three it is depends on who could be on the other end. A filled honeypot or a
12
+ * forged token is a program, and is told it worked: it learns nothing to adjust to. A
13
+ * failed robot check can happen to a person, so they are told, in a sentence. A problem
14
+ * with the address they typed is a validation message on that field, like any other.
15
+ */
16
+ final class Refusal
17
+ {
18
+ /** Refusals nobody is told about. The answer is the one a success gets, and nothing is stored. */
19
+ public const SILENT = ['honeypot', 'forged', 'too_fast'];
20
+
21
+ public function __construct(
22
+ public readonly string $reason,
23
+ public readonly int $status = 422,
24
+ public readonly string $message = '',
25
+ public readonly ?string $field = null,
26
+ ) {}
27
+
28
+ public function silent(): bool
29
+ {
30
+ return in_array($this->reason, self::SILENT, true);
31
+ }
32
+
33
+ public function response(): JsonResponse|Response
34
+ {
35
+ if ($this->silent()) {
36
+ $response = response()->noContent(201);
37
+ // Whoever is building the form sees why nothing arrived. A bot in production doesn't.
38
+ if (config('app.debug')) {
39
+ $response->header('X-Nevela-Blocked', $this->reason);
40
+ }
41
+
42
+ return $response;
43
+ }
44
+ if ($this->field !== null) {
45
+ return response()->json(['error' => 'Validation failed.', 'issues' => [['path' => $this->field, 'message' => $this->message]]], $this->status);
46
+ }
47
+
48
+ return response()->json(['error' => $this->message, 'code' => strtoupper($this->reason)], $this->status);
49
+ }
50
+ }