create-nevela 0.7.0 → 0.8.0

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 (53) hide show
  1. package/index.mjs +1 -0
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +25 -3
  5. package/template/laravel/src/Access/Permissions.php +36 -5
  6. package/template/laravel/src/Access/SampleUsers.php +1 -3
  7. package/template/laravel/src/Concerns/Trashable.php +23 -0
  8. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  9. package/template/laravel/src/Console/GenerateCommand.php +13 -3
  10. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  11. package/template/laravel/src/Console/SeedCommand.php +3 -1
  12. package/template/laravel/src/Console/TrashCommand.php +8 -0
  13. package/template/laravel/src/Console/UserCommand.php +5 -1
  14. package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
  15. package/template/laravel/src/Http/Access/UsersController.php +2 -4
  16. package/template/laravel/src/Http/ActionController.php +222 -0
  17. package/template/laravel/src/Http/PublicController.php +210 -0
  18. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  19. package/template/laravel/src/Http/ResourceRequest.php +151 -2
  20. package/template/laravel/src/Http/TrashController.php +18 -2
  21. package/template/laravel/src/Http/UploadController.php +42 -1
  22. package/template/laravel/src/Media/Uploads.php +111 -0
  23. package/template/laravel/src/Nevela.php +54 -2
  24. package/template/laravel/src/NevelaServiceProvider.php +17 -1
  25. package/template/laravel/src/Support/Action.php +168 -0
  26. package/template/laravel/src/Support/Adoption.php +352 -0
  27. package/template/laravel/src/Support/Descriptor.php +59 -4
  28. package/template/laravel/src/Support/Fake.php +15 -1
  29. package/template/laravel/src/Support/Field.php +74 -3
  30. package/template/laravel/src/Support/Html.php +185 -0
  31. package/template/laravel/src/Support/PublicAccess.php +133 -0
  32. package/template/web/app/api/[...path]/route.ts +42 -9
  33. package/template/web/app/dashboard/actions.ts +34 -0
  34. package/template/web/app/globals.css +46 -0
  35. package/template/web/components/access/role-form.tsx +40 -5
  36. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  37. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  38. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  39. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  40. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  41. package/template/web/components/dashboard/record-actions.tsx +7 -2
  42. package/template/web/components/dashboard/record-detail.tsx +9 -2
  43. package/template/web/components/dashboard/resource-action.tsx +281 -0
  44. package/template/web/components/dashboard/resource-table.tsx +15 -2
  45. package/template/web/components/dashboard/row-actions.tsx +24 -0
  46. package/template/web/components/dashboard/table-selection.tsx +19 -1
  47. package/template/web/lib/access.ts +3 -1
  48. package/template/web/lib/dashboard.ts +10 -0
  49. package/template/web/lib/extras.ts +66 -0
  50. package/template/web/lib/files.ts +14 -0
  51. package/template/web/lib/laravel.ts +60 -0
  52. package/template/web/lib/rich.ts +79 -0
  53. package/template/web/package.json +1 -1
@@ -0,0 +1,222 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http;
4
+
5
+ use Illuminate\Database\Eloquent\Model;
6
+ use Illuminate\Http\JsonResponse;
7
+ use Illuminate\Http\Request;
8
+ use Illuminate\Support\Facades\DB;
9
+ use Illuminate\Support\Facades\Gate;
10
+ use Illuminate\Support\Facades\Validator;
11
+ use Illuminate\Validation\ValidationException;
12
+ use Nevela\Laravel\Nevela;
13
+ use Nevela\Laravel\Support\Action;
14
+ use Nevela\Laravel\Support\Descriptor;
15
+ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
16
+ use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
17
+ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
18
+ use Throwable;
19
+
20
+ /**
21
+ * A resource's own actions: what its descriptor's "actions" section names.
22
+ *
23
+ * POST {slug}/{id}/_actions/{action} { input } on one record
24
+ * POST {slug}/_actions/{action} { input } on the resource as a whole
25
+ * POST {slug}/_actions/{action} { ids, input } a record action, on a selection
26
+ *
27
+ * One controller for every resource, like the trash: every action is reached the same
28
+ * way and differs only in the class that does it, App\Nevela\Actions\<Resource>\<Action>.
29
+ * Here is everything around that class: who may, whether it applies, whether what was
30
+ * asked for is valid, and what the person is told when it fails.
31
+ */
32
+ final class ActionController
33
+ {
34
+ /** The most records a selection can be: what the dashboard's table can select at once, with room. */
35
+ private const MOST = 500;
36
+
37
+ public function record(Request $request, string $slug, string $id, string $action): JsonResponse
38
+ {
39
+ [$descriptor, $definition] = $this->find($slug, $action, 'record');
40
+ $this->authorize($request, $descriptor, $definition);
41
+ $record = $this->one($descriptor, $id) ?? throw new NotFoundHttpException;
42
+ if (! $this->applies($descriptor, $definition, $record)) {
43
+ return response()->json(['error' => "{$definition->label} can't be done to this ".strtolower($descriptor->label).' as it is now.', 'code' => 'NOT_APPLICABLE'], 409);
44
+ }
45
+ $input = $this->input($request, $definition);
46
+
47
+ return $this->run($definition, function () use ($descriptor, $definition, $record, $input, $request) {
48
+ $message = $this->handler($descriptor, $definition)->handle($record, $input);
49
+
50
+ return ['message' => is_string($message) && $message !== '' ? $message : "{$definition->label}: done.", 'record' => $this->present($descriptor, $record, $request)];
51
+ });
52
+ }
53
+
54
+ public function collection(Request $request, string $slug, string $action): JsonResponse
55
+ {
56
+ [$descriptor, $definition] = $this->find($slug, $action, null);
57
+ $this->authorize($request, $descriptor, $definition);
58
+ $input = $this->input($request, $definition);
59
+
60
+ if ($definition->on === 'collection') {
61
+ return $this->run($definition, function () use ($descriptor, $definition, $input) {
62
+ $message = $this->handler($descriptor, $definition)->handle($input);
63
+
64
+ return ['message' => is_string($message) && $message !== '' ? $message : "{$definition->label}: done."];
65
+ });
66
+ }
67
+
68
+ // A record action, on several records at once.
69
+ if (! $definition->many) {
70
+ throw new NotFoundHttpException;
71
+ }
72
+ $ids = $request->input('ids');
73
+ if (! is_array($ids) || ! array_is_list($ids) || $ids === [] || count($ids) > self::MOST) {
74
+ return response()->json(['error' => 'Send the records as "ids": a list of 1 to '.self::MOST.'.'], 422);
75
+ }
76
+
77
+ return $this->run($definition, function () use ($descriptor, $definition, $input, $ids) {
78
+ $handler = $this->handler($descriptor, $definition);
79
+ $done = 0;
80
+ $skipped = 0;
81
+ foreach (array_unique(array_map('strval', array_filter($ids, 'is_scalar'))) as $id) {
82
+ $record = $this->one($descriptor, $id);
83
+ // One it no longer applies to, or that has gone, is passed over: the rest still want doing.
84
+ if ($record === null || ! $this->applies($descriptor, $definition, $record)) {
85
+ $skipped++;
86
+
87
+ continue;
88
+ }
89
+ $handler->handle($record, $input);
90
+ $done++;
91
+ }
92
+ $what = strtolower($done === 1 ? $descriptor->label : $descriptor->pluralLabel);
93
+
94
+ return [
95
+ 'message' => "{$definition->label}: {$done} {$what}".($skipped > 0 ? ", and {$skipped} left as ".($skipped === 1 ? 'it was' : 'they were').'.' : '.'),
96
+ 'done' => $done,
97
+ 'skipped' => $skipped,
98
+ ];
99
+ });
100
+ }
101
+
102
+ /**
103
+ * The resource and the action an address names.
104
+ *
105
+ * @param 'record'|null $on Where it must be, or either
106
+ * @return array{Descriptor, Action}
107
+ */
108
+ private function find(string $slug, string $action, ?string $on): array
109
+ {
110
+ foreach (Nevela::all() as $descriptor) {
111
+ $definition = $descriptor->slug === $slug ? ($descriptor->actions[$action] ?? null) : null;
112
+ if ($definition !== null && ($on === null || $definition->on === $on)) {
113
+ return [$descriptor, $definition];
114
+ }
115
+ }
116
+ throw new NotFoundHttpException;
117
+ }
118
+
119
+ /** Its own permission, by name. Being able to edit the records is not being able to do this to them. */
120
+ private function authorize(Request $request, Descriptor $descriptor, Action $action): void
121
+ {
122
+ if (! $request->user()?->can("{$descriptor->table}.{$action->key()}")) {
123
+ throw new AccessDeniedHttpException("You don't have access to this.");
124
+ }
125
+ }
126
+
127
+ /** The record with this id, if there is one and the person may see it. */
128
+ private function one(Descriptor $descriptor, string $id): ?Model
129
+ {
130
+ $model = "App\\Models\\{$descriptor->name}";
131
+ if (! class_exists($model) || ($descriptor->idType === 'int' && ! preg_match('/^[0-9]{1,18}$/', $id))) {
132
+ return null;
133
+ }
134
+ $record = $model::query()->find($id);
135
+
136
+ return $record !== null && Gate::allows('view', $record) ? $record : null;
137
+ }
138
+
139
+ private function applies(Descriptor $descriptor, Action $action, Model $record): bool
140
+ {
141
+ $values = [];
142
+ foreach (array_keys($action->when) as $field) {
143
+ $values[$field] = $record->getAttribute($descriptor->fields[$field]->column());
144
+ }
145
+
146
+ return $action->applies($values);
147
+ }
148
+
149
+ /**
150
+ * What the action asked for, checked. A problem is reported against its field, the way
151
+ * a form's is, so the sheet can show it there.
152
+ *
153
+ * @return array<string, mixed>
154
+ */
155
+ private function input(Request $request, Action $action): array
156
+ {
157
+ $sent = $request->input('input', []);
158
+ $sent = is_array($sent) ? $sent : [];
159
+ $validator = Validator::make($sent, $action->rules());
160
+ $validator->after(function ($validator) use ($sent, $action) {
161
+ foreach (array_keys($sent) as $key) {
162
+ if (! isset($action->fields[$key])) {
163
+ $validator->errors()->add((string) $key, 'Unknown field.');
164
+ }
165
+ }
166
+ });
167
+ if ($validator->fails()) {
168
+ throw new ValidationException($validator);
169
+ }
170
+
171
+ return $validator->validated();
172
+ }
173
+
174
+ private function handler(Descriptor $descriptor, Action $action): object
175
+ {
176
+ $class = "App\\Nevela\\Actions\\{$descriptor->name}\\{$action->className()}";
177
+ if (! class_exists($class) || ! method_exists($class, 'handle')) {
178
+ throw new \LogicException("{$action->label} has no class to do it. Run: php artisan nevela:generate {$descriptor->name}");
179
+ }
180
+
181
+ return app($class);
182
+ }
183
+
184
+ /**
185
+ * Do it, all of it or none: what the class changed in the database is undone when it
186
+ * throws, and the person is told that it didn't work and that nothing was changed.
187
+ *
188
+ * @param callable(): array<string, mixed> $do
189
+ */
190
+ private function run(Action $action, callable $do): JsonResponse
191
+ {
192
+ try {
193
+ return response()->json(DB::transaction($do));
194
+ } catch (HttpExceptionInterface|ValidationException $e) {
195
+ throw $e; // the class said why itself: abort(409, …), or a validation error
196
+ } catch (Throwable $e) {
197
+ report($e);
198
+
199
+ return response()->json(array_filter([
200
+ 'error' => "{$action->label} didn't work, and nothing was changed.",
201
+ // Said to whoever is building the app, not to whoever is using it.
202
+ 'detail' => config('app.debug') ? $e->getMessage() : null,
203
+ ]), 500);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * The record as the API shows it, after the action: what the page it was pressed on now needs.
209
+ *
210
+ * @return array<string, mixed>|null
211
+ */
212
+ private function present(Descriptor $descriptor, Model $record, Request $request): ?array
213
+ {
214
+ $resource = "App\\Http\\Resources\\Nevela\\{$descriptor->name}Resource";
215
+ if (! $record->exists || ! class_exists($resource)) {
216
+ return null;
217
+ }
218
+ $fresh = $record->fresh();
219
+
220
+ return $fresh === null ? null : (new $resource($fresh))->resolve($request);
221
+ }
222
+ }
@@ -0,0 +1,210 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http;
4
+
5
+ use Illuminate\Database\Eloquent\Builder;
6
+ use Illuminate\Database\Eloquent\Model;
7
+ use Illuminate\Http\JsonResponse;
8
+ use Illuminate\Http\Request;
9
+ use Illuminate\Http\Response;
10
+ use Illuminate\Support\Facades\DB;
11
+ use Nevela\Laravel\Media\Uploads;
12
+ use Nevela\Laravel\Models\Upload;
13
+ use Nevela\Laravel\Nevela;
14
+ use Nevela\Laravel\Support\Descriptor;
15
+ use Nevela\Laravel\Support\PublicAccess;
16
+ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
17
+
18
+ /**
19
+ * What anyone can call, signed in or not: a resource's "public" section.
20
+ *
21
+ * GET {prefix}/public/{slug} the list, of the records the model says are public
22
+ * GET {prefix}/public/{slug}/{id} one of them, by id or by the field the section names
23
+ * POST {prefix}/public/{slug} create one, from the fields the section lists
24
+ * PUT {prefix}/public/_nevela/uploads/{Resource}/{field} a file for such a create
25
+ *
26
+ * The routes are generated (routes/nevela-public.php), one set per resource that has a
27
+ * section and only for what it opens, so a resource without one has nothing here to call.
28
+ *
29
+ * Nobody is asked who they are, so nothing here trusts what it is sent beyond what the
30
+ * section allows: fields that aren't listed are never sent out and never taken in, and
31
+ * the rules are the generated request's own.
32
+ */
33
+ final class PublicController
34
+ {
35
+ /** How long a file uploaded for a create waits for that create, in seconds. */
36
+ public const CLAIM_SECONDS = 3600;
37
+
38
+ public function index(Request $request): JsonResponse
39
+ {
40
+ [$descriptor, $public] = $this->open($request, 'read');
41
+ $resourceClass = $this->resourceClass($descriptor);
42
+
43
+ // Sorting, filtering and search are the signed-in list's, over the listed fields only.
44
+ $response = Nevela::listFor($this->readable($descriptor), $descriptor->only($public->read), $request, $resourceClass);
45
+ if ($response->getStatusCode() !== 200) {
46
+ return $response;
47
+ }
48
+ $body = $response->getData(true);
49
+ $body['data'] = array_map(fn (array $row) => $this->visible($descriptor, $public, $row), $body['data']);
50
+
51
+ return response()->json($body);
52
+ }
53
+
54
+ public function show(Request $request): JsonResponse
55
+ {
56
+ [$descriptor, $public] = $this->open($request, 'read');
57
+ $id = (string) $request->route('id');
58
+ $query = $this->readable($descriptor);
59
+ if ($public->by !== null) {
60
+ $query->where($descriptor->fields[$public->by]->column(), $id);
61
+ } elseif ($descriptor->idType === 'int' ? ! preg_match('/^[0-9]{1,18}$/', $id) : ! \Illuminate\Support\Str::isUuid($id)) {
62
+ throw new NotFoundHttpException;
63
+ } else {
64
+ $query->whereKey($id);
65
+ }
66
+ $record = $query->first() ?? throw new NotFoundHttpException;
67
+ $resourceClass = $this->resourceClass($descriptor);
68
+
69
+ return response()->json($this->visible($descriptor, $public, (new $resourceClass($record))->resolve($request)));
70
+ }
71
+
72
+ public function store(Request $request): JsonResponse|Response
73
+ {
74
+ [$descriptor, $public] = $this->open($request, 'create');
75
+ $requestClass = "App\\Http\\Requests\\Nevela\\{$descriptor->name}Request";
76
+ $model = $this->model($descriptor);
77
+ $input = $request->json()->all() ?: $request->request->all();
78
+
79
+ $checked = (new $requestClass)->forFields($public->create, is_array($input) ? $input : []);
80
+ // A file has to be one a visitor uploaded for this, a moment ago, and not yet used.
81
+ foreach ($public->create as $field) {
82
+ $key = $checked['values'][$descriptor->fields[$field]->column()] ?? null;
83
+ if ($descriptor->fields[$field]->kind === 'file' && is_string($key) && $key !== '' && ! $this->claimable($descriptor, $field, $key)) {
84
+ $checked['issues'][] = ['path' => $field, 'message' => 'Upload the file again: this one has been used, or was uploaded too long ago.'];
85
+ }
86
+ }
87
+ if ($checked['issues'] !== []) {
88
+ return response()->json(['error' => 'Validation failed.', 'issues' => $checked['issues']], 422);
89
+ }
90
+
91
+ $record = DB::transaction(function () use ($model, $checked, $descriptor, $public) {
92
+ $record = $model::create($checked['values']);
93
+ // Claimed: the nightly clear-out of uploads nobody used leaves it alone from here on.
94
+ foreach ($public->create as $field) {
95
+ $key = $descriptor->fields[$field]->kind === 'file' ? $record->getAttribute($descriptor->fields[$field]->column()) : null;
96
+ if (is_string($key) && $key !== '') {
97
+ Upload::query()->where('key', $key)->update(['uploaded_by' => Uploads::CLAIMED]);
98
+ }
99
+ }
100
+
101
+ return $record;
102
+ });
103
+
104
+ // What they may read of what they made, or nothing where nothing may be read.
105
+ if ($public->read === null) {
106
+ return response()->noContent(201);
107
+ }
108
+ $resourceClass = $this->resourceClass($descriptor);
109
+
110
+ return response()->json($this->visible($descriptor, $public, (new $resourceClass($record->refresh()))->resolve($request)), 201);
111
+ }
112
+
113
+ /** A file for a public create: the field's own types and size limit, and no sign-in. */
114
+ public function upload(Request $request): JsonResponse
115
+ {
116
+ [$descriptor, $public] = $this->open($request, 'create');
117
+ $field = (string) $request->route('field');
118
+ $definition = $descriptor->fields[$field] ?? null;
119
+ if ($definition === null || $definition->kind !== 'file' || ! in_array($field, $public->create, true)) {
120
+ throw new NotFoundHttpException;
121
+ }
122
+ $upload = UploadController::receive($request, $descriptor, $definition);
123
+ if ($upload instanceof JsonResponse) {
124
+ return $upload;
125
+ }
126
+ // Marked as a visitor's, waiting to be used: see claimable() and Uploads::purgeUnclaimed().
127
+ $upload->update(['uploaded_by' => Uploads::UNCLAIMED]);
128
+
129
+ return response()->json(Uploads::ref($upload->key), 201);
130
+ }
131
+
132
+ /**
133
+ * The resource a route is for, if this part of it is open. The route is only there when
134
+ * it is, so this failing means a route file older than the descriptor.
135
+ *
136
+ * Which resource is a default set on the route. Laravel hands route parameters to a
137
+ * controller in order and not by name, with defaults after what the address holds, so
138
+ * it is asked for by its name here.
139
+ *
140
+ * @return array{Descriptor, PublicAccess}
141
+ */
142
+ private function open(Request $request, string $what): array
143
+ {
144
+ try {
145
+ $descriptor = Nevela::resource((string) $request->route('resource'));
146
+ } catch (\InvalidArgumentException) {
147
+ throw new NotFoundHttpException;
148
+ }
149
+ if ($descriptor->public?->{$what} === null || ! class_exists($this->model($descriptor))) {
150
+ throw new NotFoundHttpException;
151
+ }
152
+
153
+ return [$descriptor, $descriptor->public];
154
+ }
155
+
156
+ /** @return class-string<Model> */
157
+ private function model(Descriptor $descriptor): string
158
+ {
159
+ return "App\\Models\\{$descriptor->name}";
160
+ }
161
+
162
+ /** @return class-string<\Illuminate\Http\Resources\Json\JsonResource> */
163
+ private function resourceClass(Descriptor $descriptor): string
164
+ {
165
+ return "App\\Http\\Resources\\Nevela\\{$descriptor->name}Resource";
166
+ }
167
+
168
+ /**
169
+ * The records anyone may read: what the model's publicQuery scope lets through. A model
170
+ * without the scope has said nothing, and nothing is what is public.
171
+ */
172
+ private function readable(Descriptor $descriptor): Builder
173
+ {
174
+ $model = $this->model($descriptor);
175
+ $query = $model::query();
176
+
177
+ return method_exists($model, 'scopePublicQuery') ? $query->publicQuery() : $query->whereRaw('1 = 0');
178
+ }
179
+
180
+ /**
181
+ * A record as anyone may see it: its id and the listed fields, and for a file the
182
+ * details that go with its key. Nothing else, whatever the API resource holds.
183
+ *
184
+ * @param array<string, mixed> $row
185
+ * @return array<string, mixed>
186
+ */
187
+ private function visible(Descriptor $descriptor, PublicAccess $public, array $row): array
188
+ {
189
+ $seen = ['id' => $row['id'] ?? null];
190
+ foreach ($public->read ?? [] as $field) {
191
+ $seen[$field] = $row[$field] ?? null;
192
+ if ($descriptor->fields[$field]->kind === 'file') {
193
+ $seen[$field.'File'] = $row[$field.'File'] ?? null;
194
+ }
195
+ }
196
+
197
+ return $seen;
198
+ }
199
+
200
+ /** Whether a key is a file a visitor uploaded to this field within the hour, that no record has yet. */
201
+ private function claimable(Descriptor $descriptor, string $field, string $key): bool
202
+ {
203
+ $upload = Upload::query()->where('key', $key)->where('resource', $descriptor->name)->where('field', $field)->first();
204
+
205
+ return $upload !== null
206
+ && $upload->getAttribute('uploaded_by') === Uploads::UNCLAIMED
207
+ && $upload->created_at !== null && $upload->created_at->gt(now()->subSeconds(self::CLAIM_SECONDS))
208
+ && ! DB::table($descriptor->table)->where($descriptor->fields[$field]->column(), $key)->exists();
209
+ }
210
+ }
@@ -0,0 +1,48 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http;
4
+
5
+ use Closure;
6
+ use Illuminate\Routing\Middleware\ThrottleRequests;
7
+ use Nevela\Laravel\Auth\Account;
8
+
9
+ /**
10
+ * The throttle on a resource's public routes: Laravel's own, counting the right thing.
11
+ *
12
+ * A public page or form is usually reached through the web app, whose server makes the
13
+ * call. Counted as they arrive, every visitor would be that one server: ten enquiries a
14
+ * minute for the whole world, and a busy page would lock itself out. So, for a request
15
+ * that proves it comes from the web app (the secret the two share, as for the list of
16
+ * signed-in devices):
17
+ *
18
+ * - one made for a visitor is counted against that visitor's address, which the web app
19
+ * passes on as X-Nevela-Client. It is the address the proxy in front of the web app saw,
20
+ * not the one the request claimed for itself: a visitor who could choose it could have
21
+ * a fresh allowance with every request. Where the web app can't tell (it says
22
+ * "unknown"), visitors are counted together, from the web app's own address;
23
+ * - one made for nobody in particular, such as a page being built to be cached and
24
+ * shown to many, is the site reading its own data and is not counted. Such a request
25
+ * carries the secret and names no visitor at all.
26
+ *
27
+ * Anything else is counted against the address it came from, as ever.
28
+ */
29
+ final class PublicThrottle extends ThrottleRequests
30
+ {
31
+ public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = '')
32
+ {
33
+ if (Account::fromDashboard($request)) {
34
+ $visitor = $request->header('X-Nevela-Client');
35
+ if ($visitor === null) {
36
+ // Only on the strength of the secret: "from this machine, in development" is
37
+ // enough to believe an address, and not enough to stop counting.
38
+ if ((string) config('nevela.auth.proxy_secret', '') !== '') {
39
+ return $next($request);
40
+ }
41
+ } elseif (filter_var($visitor, FILTER_VALIDATE_IP)) {
42
+ $request->server->set('REMOTE_ADDR', $visitor);
43
+ }
44
+ }
45
+
46
+ return parent::handle($request, $next, $maxAttempts, $decayMinutes, $prefix);
47
+ }
48
+ }