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,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
|
+
}
|