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,223 @@
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 Nevela\Laravel\Support\Key;
16
+ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
17
+ use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
18
+ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
19
+ use Throwable;
20
+
21
+ /**
22
+ * A resource's own actions: what its descriptor's "actions" section names.
23
+ *
24
+ * POST {slug}/{id}/_actions/{action} { input } on one record
25
+ * POST {slug}/_actions/{action} { input } on the resource as a whole
26
+ * POST {slug}/_actions/{action} { ids, input } a record action, on a selection
27
+ *
28
+ * One controller for every resource, like the trash: every action is reached the same
29
+ * way and differs only in the class that does it, App\Nevela\Actions\<Resource>\<Action>.
30
+ * Here is everything around that class: who may, whether it applies, whether what was
31
+ * asked for is valid, and what the person is told when it fails.
32
+ */
33
+ final class ActionController
34
+ {
35
+ /** The most records a selection can be: what the dashboard's table can select at once, with room. */
36
+ private const MOST = 500;
37
+
38
+ public function record(Request $request, string $slug, string $id, string $action): JsonResponse
39
+ {
40
+ [$descriptor, $definition] = $this->find($slug, $action, 'record');
41
+ $this->authorize($request, $descriptor, $definition);
42
+ $record = $this->one($descriptor, $id) ?? throw new NotFoundHttpException;
43
+ if (! $this->applies($descriptor, $definition, $record)) {
44
+ return response()->json(['error' => "{$definition->label} can't be done to this ".strtolower($descriptor->label).' as it is now.', 'code' => 'NOT_APPLICABLE'], 409);
45
+ }
46
+ $input = $this->input($request, $definition);
47
+
48
+ return $this->run($definition, function () use ($descriptor, $definition, $record, $input, $request) {
49
+ $message = $this->handler($descriptor, $definition)->handle($record, $input);
50
+
51
+ return ['message' => is_string($message) && $message !== '' ? $message : "{$definition->label}: done.", 'record' => $this->present($descriptor, $record, $request)];
52
+ });
53
+ }
54
+
55
+ public function collection(Request $request, string $slug, string $action): JsonResponse
56
+ {
57
+ [$descriptor, $definition] = $this->find($slug, $action, null);
58
+ $this->authorize($request, $descriptor, $definition);
59
+ $input = $this->input($request, $definition);
60
+
61
+ if ($definition->on === 'collection') {
62
+ return $this->run($definition, function () use ($descriptor, $definition, $input) {
63
+ $message = $this->handler($descriptor, $definition)->handle($input);
64
+
65
+ return ['message' => is_string($message) && $message !== '' ? $message : "{$definition->label}: done."];
66
+ });
67
+ }
68
+
69
+ // A record action, on several records at once.
70
+ if (! $definition->many) {
71
+ throw new NotFoundHttpException;
72
+ }
73
+ $ids = $request->input('ids');
74
+ if (! is_array($ids) || ! array_is_list($ids) || $ids === [] || count($ids) > self::MOST) {
75
+ return response()->json(['error' => 'Send the records as "ids": a list of 1 to '.self::MOST.'.'], 422);
76
+ }
77
+
78
+ return $this->run($definition, function () use ($descriptor, $definition, $input, $ids) {
79
+ $handler = $this->handler($descriptor, $definition);
80
+ $done = 0;
81
+ $skipped = 0;
82
+ foreach (array_unique(array_map('strval', array_filter($ids, 'is_scalar'))) as $id) {
83
+ $record = $this->one($descriptor, $id);
84
+ // One it no longer applies to, or that has gone, is passed over: the rest still want doing.
85
+ if ($record === null || ! $this->applies($descriptor, $definition, $record)) {
86
+ $skipped++;
87
+
88
+ continue;
89
+ }
90
+ $handler->handle($record, $input);
91
+ $done++;
92
+ }
93
+ $what = strtolower($done === 1 ? $descriptor->label : $descriptor->pluralLabel);
94
+
95
+ return [
96
+ 'message' => "{$definition->label}: {$done} {$what}".($skipped > 0 ? ", and {$skipped} left as ".($skipped === 1 ? 'it was' : 'they were').'.' : '.'),
97
+ 'done' => $done,
98
+ 'skipped' => $skipped,
99
+ ];
100
+ });
101
+ }
102
+
103
+ /**
104
+ * The resource and the action an address names.
105
+ *
106
+ * @param 'record'|null $on Where it must be, or either
107
+ * @return array{Descriptor, Action}
108
+ */
109
+ private function find(string $slug, string $action, ?string $on): array
110
+ {
111
+ foreach (Nevela::all() as $descriptor) {
112
+ $definition = $descriptor->slug === $slug ? ($descriptor->actions[$action] ?? null) : null;
113
+ if ($definition !== null && ($on === null || $definition->on === $on)) {
114
+ return [$descriptor, $definition];
115
+ }
116
+ }
117
+ throw new NotFoundHttpException;
118
+ }
119
+
120
+ /** Its own permission, by name. Being able to edit the records is not being able to do this to them. */
121
+ private function authorize(Request $request, Descriptor $descriptor, Action $action): void
122
+ {
123
+ if (! $request->user()?->can("{$descriptor->table}.{$action->key()}")) {
124
+ throw new AccessDeniedHttpException("You don't have access to this.");
125
+ }
126
+ }
127
+
128
+ /** The record with this id, if there is one and the person may see it. */
129
+ private function one(Descriptor $descriptor, string $id): ?Model
130
+ {
131
+ $model = "App\\Models\\{$descriptor->name}";
132
+ if (! class_exists($model) || ! Key::fits($model, $id)) {
133
+ return null;
134
+ }
135
+ $record = $model::query()->find($id);
136
+
137
+ return $record !== null && Gate::allows('view', $record) ? $record : null;
138
+ }
139
+
140
+ private function applies(Descriptor $descriptor, Action $action, Model $record): bool
141
+ {
142
+ $values = [];
143
+ foreach (array_keys($action->when) as $field) {
144
+ $values[$field] = $record->getAttribute($descriptor->fields[$field]->column());
145
+ }
146
+
147
+ return $action->applies($values);
148
+ }
149
+
150
+ /**
151
+ * What the action asked for, checked. A problem is reported against its field, the way
152
+ * a form's is, so the sheet can show it there.
153
+ *
154
+ * @return array<string, mixed>
155
+ */
156
+ private function input(Request $request, Action $action): array
157
+ {
158
+ $sent = $request->input('input', []);
159
+ $sent = is_array($sent) ? $sent : [];
160
+ $validator = Validator::make($sent, $action->rules());
161
+ $validator->after(function ($validator) use ($sent, $action) {
162
+ foreach (array_keys($sent) as $key) {
163
+ if (! isset($action->fields[$key])) {
164
+ $validator->errors()->add((string) $key, 'Unknown field.');
165
+ }
166
+ }
167
+ });
168
+ if ($validator->fails()) {
169
+ throw new ValidationException($validator);
170
+ }
171
+
172
+ return $validator->validated();
173
+ }
174
+
175
+ private function handler(Descriptor $descriptor, Action $action): object
176
+ {
177
+ $class = "App\\Nevela\\Actions\\{$descriptor->name}\\{$action->className()}";
178
+ if (! class_exists($class) || ! method_exists($class, 'handle')) {
179
+ throw new \LogicException("{$action->label} has no class to do it. Run: php artisan nevela:generate {$descriptor->name}");
180
+ }
181
+
182
+ return app($class);
183
+ }
184
+
185
+ /**
186
+ * Do it, all of it or none: what the class changed in the database is undone when it
187
+ * throws, and the person is told that it didn't work and that nothing was changed.
188
+ *
189
+ * @param callable(): array<string, mixed> $do
190
+ */
191
+ private function run(Action $action, callable $do): JsonResponse
192
+ {
193
+ try {
194
+ return response()->json(DB::transaction($do));
195
+ } catch (HttpExceptionInterface|ValidationException $e) {
196
+ throw $e; // the class said why itself: abort(409, …), or a validation error
197
+ } catch (Throwable $e) {
198
+ report($e);
199
+
200
+ return response()->json(array_filter([
201
+ 'error' => "{$action->label} didn't work, and nothing was changed.",
202
+ // Said to whoever is building the app, not to whoever is using it.
203
+ 'detail' => config('app.debug') ? $e->getMessage() : null,
204
+ ]), 500);
205
+ }
206
+ }
207
+
208
+ /**
209
+ * The record as the API shows it, after the action: what the page it was pressed on now needs.
210
+ *
211
+ * @return array<string, mixed>|null
212
+ */
213
+ private function present(Descriptor $descriptor, Model $record, Request $request): ?array
214
+ {
215
+ $resource = "App\\Http\\Resources\\Nevela\\{$descriptor->name}Resource";
216
+ if (! $record->exists || ! class_exists($resource)) {
217
+ return null;
218
+ }
219
+ $fresh = $record->fresh();
220
+
221
+ return $fresh === null ? null : (new $resource($fresh))->resolve($request);
222
+ }
223
+ }
@@ -21,6 +21,7 @@ use Nevela\Laravel\Models\Upload;
21
21
  use Nevela\Laravel\Rules\UploadKey;
22
22
  use Nevela\Laravel\Support\Descriptor;
23
23
  use Nevela\Laravel\Support\Field;
24
+ use Nevela\Laravel\Support\Key;
24
25
 
25
26
  /**
26
27
  * An account and what its owner can do with it: create it, prove the email address,
@@ -281,9 +282,11 @@ final class AccountController
281
282
  public function revokeSession(Request $request, string $id): Response
282
283
  {
283
284
  $user = $request->user();
284
- DB::table('personal_access_tokens')
285
- ->where('tokenable_type', $user->getMorphClass())->where('tokenable_id', $user->getAuthIdentifier())
286
- ->where('id', $id)->delete();
285
+ if (Key::isInt($id)) {
286
+ DB::table('personal_access_tokens')
287
+ ->where('tokenable_type', $user->getMorphClass())->where('tokenable_id', $user->getAuthIdentifier())
288
+ ->where('id', $id)->delete();
289
+ }
287
290
 
288
291
  return response()->noContent();
289
292
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  namespace Nevela\Laravel\Http\Auth;
4
4
 
5
+ use Illuminate\Database\Eloquent\ModelNotFoundException;
5
6
  use Illuminate\Http\JsonResponse;
6
7
  use Illuminate\Http\Request;
7
8
  use Illuminate\Http\Response;
@@ -13,6 +14,7 @@ use Nevela\Laravel\Auth\Passkeys;
13
14
  use Nevela\Laravel\Auth\Totp;
14
15
  use Nevela\Laravel\Models\Passkey;
15
16
  use Nevela\Laravel\Models\TwoFactor;
17
+ use Nevela\Laravel\Support\Key;
16
18
  use RuntimeException;
17
19
 
18
20
  /**
@@ -161,6 +163,9 @@ final class SecurityController
161
163
  public function renamePasskey(Request $request, string $id): JsonResponse
162
164
  {
163
165
  $input = $request->validate(['name' => ['required', 'string', 'max:80']]);
166
+ if (! Key::fits(Passkey::class, $id)) {
167
+ throw (new ModelNotFoundException)->setModel(Passkey::class, [$id]);
168
+ }
164
169
  $passkey = Passkey::query()->where('user_id', (string) $request->user()->getAuthIdentifier())->findOrFail($id);
165
170
  $passkey->fill(['name' => trim($input['name'])])->save();
166
171
 
@@ -170,7 +175,9 @@ final class SecurityController
170
175
  /** DELETE auth/passkeys/{id} */
171
176
  public function removePasskey(Request $request, string $id): Response
172
177
  {
173
- Passkey::query()->where('user_id', (string) $request->user()->getAuthIdentifier())->where('id', $id)->delete();
178
+ if (Key::fits(Passkey::class, $id)) {
179
+ Passkey::query()->where('user_id', (string) $request->user()->getAuthIdentifier())->where('id', $id)->delete();
180
+ }
174
181
 
175
182
  return response()->noContent();
176
183
  }
@@ -0,0 +1,45 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http;
4
+
5
+ use Illuminate\Http\JsonResponse;
6
+ use Illuminate\Support\Facades\Gate;
7
+ use Nevela\Laravel\Forms\FormBlocks;
8
+ use Nevela\Laravel\Forms\FormGuard;
9
+ use Nevela\Laravel\Nevela;
10
+ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
11
+
12
+ /**
13
+ * What a resource's public form has been doing, for whoever may see the resource:
14
+ *
15
+ * GET {prefix}/_nevela/forms/{slug} → { protect, confirm, robotCheck, blocked }
16
+ *
17
+ * `blocked` is what was turned away in the last week, in all and by reason. It is how a
18
+ * form that is well protected is told from one nobody uses.
19
+ */
20
+ final class FormsController
21
+ {
22
+ public function show(string $slug): JsonResponse
23
+ {
24
+ $descriptor = null;
25
+ foreach (Nevela::all() as $candidate) {
26
+ if ($candidate->slug === $slug) {
27
+ $descriptor = $candidate;
28
+ }
29
+ }
30
+ $model = $descriptor === null ? null : "App\\Models\\{$descriptor->name}";
31
+ if ($descriptor === null || $descriptor->public?->create === null || ! class_exists((string) $model)) {
32
+ throw new NotFoundHttpException;
33
+ }
34
+ Gate::authorize('viewAny', $model);
35
+ $protect = $descriptor->public->protect;
36
+
37
+ return response()->json([
38
+ 'protect' => $protect,
39
+ 'confirm' => $descriptor->public->confirm,
40
+ // Asked for and not possible: the form is refusing everyone (production) or checking nobody (development).
41
+ 'robotCheck' => ($protect['turnstile'] ?? false) ? FormGuard::robotCheck() : 'off',
42
+ 'blocked' => FormBlocks::summary($descriptor->name),
43
+ ]);
44
+ }
45
+ }
@@ -0,0 +1,294 @@
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\Forms\Confirmation;
12
+ use Nevela\Laravel\Forms\FormBlocks;
13
+ use Nevela\Laravel\Forms\FormGuard;
14
+ use Nevela\Laravel\Media\Uploads;
15
+ use Nevela\Laravel\Models\Upload;
16
+ use Nevela\Laravel\Nevela;
17
+ use Nevela\Laravel\Support\Descriptor;
18
+ use Nevela\Laravel\Support\PublicAccess;
19
+ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
20
+
21
+ /**
22
+ * What anyone can call, signed in or not: a resource's "public" section.
23
+ *
24
+ * GET {prefix}/public/{slug} the list, of the records the model says are public
25
+ * GET {prefix}/public/{slug}/{id} one of them, by id or by the field the section names
26
+ * POST {prefix}/public/{slug} create one, from the fields the section lists
27
+ * PUT {prefix}/public/_nevela/uploads/{Resource}/{field} a file for such a create
28
+ * GET {prefix}/public/_nevela/form/{slug} what a protected form sends back with it
29
+ * POST {prefix}/public/_nevela/confirm/{slug}/{id}/{key} the link that proves an address
30
+ *
31
+ * The routes are generated (routes/nevela-public.php), one set per resource that has a
32
+ * section and only for what it opens, so a resource without one has nothing here to call.
33
+ *
34
+ * Nobody is asked who they are, so nothing here trusts what it is sent beyond what the
35
+ * section allows: fields that aren't listed are never sent out and never taken in, and
36
+ * the rules are the generated request's own.
37
+ */
38
+ final class PublicController
39
+ {
40
+ /** How long a file uploaded for a create waits for that create, in seconds. */
41
+ public const CLAIM_SECONDS = 3600;
42
+
43
+ public function index(Request $request): JsonResponse
44
+ {
45
+ [$descriptor, $public] = $this->open($request, 'read');
46
+ $resourceClass = $this->resourceClass($descriptor);
47
+
48
+ // Sorting, filtering and search are the signed-in list's, over the listed fields only.
49
+ $response = Nevela::listFor($this->readable($descriptor), $descriptor->only($public->read), $request, $resourceClass);
50
+ if ($response->getStatusCode() !== 200) {
51
+ return $response;
52
+ }
53
+ $body = $response->getData(true);
54
+ $body['data'] = array_map(fn (array $row) => $this->visible($descriptor, $public, $row), $body['data']);
55
+
56
+ return response()->json($body);
57
+ }
58
+
59
+ public function show(Request $request): JsonResponse
60
+ {
61
+ [$descriptor, $public] = $this->open($request, 'read');
62
+ $id = (string) $request->route('id');
63
+ $query = $this->readable($descriptor);
64
+ if ($public->by !== null) {
65
+ $query->where($descriptor->fields[$public->by]->column(), $id);
66
+ } elseif ($descriptor->idType === 'int' ? ! preg_match('/^[0-9]{1,18}$/', $id) : ! \Illuminate\Support\Str::isUuid($id)) {
67
+ throw new NotFoundHttpException;
68
+ } else {
69
+ $query->whereKey($id);
70
+ }
71
+ $record = $query->first() ?? throw new NotFoundHttpException;
72
+ $resourceClass = $this->resourceClass($descriptor);
73
+
74
+ return response()->json($this->visible($descriptor, $public, (new $resourceClass($record))->resolve($request)));
75
+ }
76
+
77
+ public function store(Request $request): JsonResponse|Response
78
+ {
79
+ [$descriptor, $public] = $this->open($request, 'create');
80
+ $requestClass = "App\\Http\\Requests\\Nevela\\{$descriptor->name}Request";
81
+ $model = $this->model($descriptor);
82
+ $input = $request->json()->all() ?: $request->request->all();
83
+ $input = is_array($input) ? $input : [];
84
+
85
+ // Before anything is validated or stored: what "protect" asks for. What is turned
86
+ // away is counted, and a program is told it worked.
87
+ if (($refusal = FormGuard::check($descriptor, $request, $input)) !== null) {
88
+ FormBlocks::record($descriptor->name, $refusal->reason);
89
+
90
+ return $refusal->response();
91
+ }
92
+ $input = FormGuard::strip($descriptor, $input);
93
+
94
+ $address = null;
95
+ if ($public->confirm !== null) {
96
+ $column = $descriptor->fields[$public->confirm]->column();
97
+ $typed = $input[$public->confirm] ?? null;
98
+ if (is_string($typed) && filter_var(trim($typed), FILTER_VALIDATE_EMAIL)) {
99
+ // One spelling of an address, so the same one can't join twice.
100
+ $address = $input[$public->confirm] = strtolower(trim($typed));
101
+ // Already on the list, or once was: answered exactly as the first time, so the
102
+ // form can't be used to ask who is on it. Someone still waiting gets the link
103
+ // again, as often as an address may be written to.
104
+ $existing = DB::table($descriptor->table)->whereRaw("lower({$column}) = ?", [$address])->first();
105
+ if ($existing !== null) {
106
+ if (property_exists($existing, 'confirmed_at') && $existing->confirmed_at === null && ($existing->deleted_at ?? null) === null) {
107
+ Confirmation::send($descriptor, $existing->id, $address, $request);
108
+ }
109
+
110
+ return response()->noContent(201);
111
+ }
112
+ }
113
+ }
114
+
115
+ $checked = (new $requestClass)->forFields($public->create, $input);
116
+ // A file has to be one a visitor uploaded for this, a moment ago, and not yet used.
117
+ foreach ($public->create as $field) {
118
+ $key = $checked['values'][$descriptor->fields[$field]->column()] ?? null;
119
+ if ($descriptor->fields[$field]->kind === 'file' && is_string($key) && $key !== '' && ! $this->claimable($descriptor, $field, $key)) {
120
+ $checked['issues'][] = ['path' => $field, 'message' => 'Upload the file again: this one has been used, or was uploaded too long ago.'];
121
+ }
122
+ }
123
+ if ($checked['issues'] !== []) {
124
+ return response()->json(['error' => 'Validation failed.', 'issues' => $checked['issues']], 422);
125
+ }
126
+
127
+ $record = DB::transaction(function () use ($model, $checked, $descriptor, $public) {
128
+ $record = new $model($checked['values']);
129
+ if ($public->confirm !== null) {
130
+ // Not yet: see Concerns\Confirmable.
131
+ $record->setAttribute('confirmed_at', null);
132
+ }
133
+ $record->save();
134
+ // Claimed: the nightly clear-out of uploads nobody used leaves it alone from here on.
135
+ foreach ($public->create as $field) {
136
+ $key = $descriptor->fields[$field]->kind === 'file' ? $record->getAttribute($descriptor->fields[$field]->column()) : null;
137
+ if (is_string($key) && $key !== '') {
138
+ Upload::query()->where('key', $key)->update(['uploaded_by' => Uploads::CLAIMED]);
139
+ }
140
+ }
141
+
142
+ return $record;
143
+ });
144
+
145
+ if ($address !== null) {
146
+ Confirmation::send($descriptor, $record->getKey(), $address, $request);
147
+ }
148
+
149
+ // What they may read of what they made, or nothing where nothing may be read. A
150
+ // form with checks, or an address to prove, says nothing either way: the answer to
151
+ // one that was sent has to be the answer to one that was quietly turned away.
152
+ if ($public->read === null || $public->protected() || $public->confirm !== null) {
153
+ return response()->noContent(201);
154
+ }
155
+ $resourceClass = $this->resourceClass($descriptor);
156
+
157
+ return response()->json($this->visible($descriptor, $public, (new $resourceClass($record->refresh()))->resolve($request)), 201);
158
+ }
159
+
160
+ /**
161
+ * What a protected form needs before it is shown: a token that says when it was handed
162
+ * out, the name of the field a person never sees, and whether the robot check is on.
163
+ */
164
+ public function form(Request $request): JsonResponse
165
+ {
166
+ [$descriptor] = $this->open($request, 'create');
167
+
168
+ return response()->json(FormGuard::issue($descriptor))->header('Cache-Control', 'no-store');
169
+ }
170
+
171
+ /**
172
+ * The link in the email: the record it names is confirmed. The key is what only this
173
+ * app could have written for that record and that address, so a link can't be made up
174
+ * and stops working if the address is changed. Following it twice does no harm.
175
+ */
176
+ public function confirm(Request $request): JsonResponse
177
+ {
178
+ [$descriptor, $public] = $this->open($request, 'create');
179
+ $id = (string) $request->route('id');
180
+ $model = $this->model($descriptor);
181
+ $gone = fn () => response()->json(['error' => 'This link is no longer good. It may have been used too late: sign up again for a new one.'], 404);
182
+ if ($public->confirm === null || ($descriptor->idType === 'int' ? ! preg_match('/^[0-9]{1,18}$/', $id) : ! \Illuminate\Support\Str::isUuid($id))) {
183
+ return $gone();
184
+ }
185
+ $record = $model::query()->whereKey($id)->first();
186
+ $address = $record?->getAttribute($descriptor->fields[$public->confirm]->column());
187
+ if ($record === null || ! is_string($address) || ! hash_equals(Confirmation::hash($descriptor, $id, $address), (string) $request->route('key'))) {
188
+ return $gone();
189
+ }
190
+ if ($record->getAttribute('confirmed_at') === null) {
191
+ $record->forceFill(['confirmed_at' => now()])->save();
192
+ }
193
+
194
+ return response()->json(['confirmed' => true]);
195
+ }
196
+
197
+ /** A file for a public create: the field's own types and size limit, and no sign-in. */
198
+ public function upload(Request $request): JsonResponse
199
+ {
200
+ [$descriptor, $public] = $this->open($request, 'create');
201
+ $field = (string) $request->route('field');
202
+ $definition = $descriptor->fields[$field] ?? null;
203
+ if ($definition === null || $definition->kind !== 'file' || ! in_array($field, $public->create, true)) {
204
+ throw new NotFoundHttpException;
205
+ }
206
+ $upload = UploadController::receive($request, $descriptor, $definition);
207
+ if ($upload instanceof JsonResponse) {
208
+ return $upload;
209
+ }
210
+ // Marked as a visitor's, waiting to be used: see claimable() and Uploads::purgeUnclaimed().
211
+ $upload->update(['uploaded_by' => Uploads::UNCLAIMED]);
212
+
213
+ return response()->json(Uploads::ref($upload->key), 201);
214
+ }
215
+
216
+ /**
217
+ * The resource a route is for, if this part of it is open. The route is only there when
218
+ * it is, so this failing means a route file older than the descriptor.
219
+ *
220
+ * Which resource is a default set on the route. Laravel hands route parameters to a
221
+ * controller in order and not by name, with defaults after what the address holds, so
222
+ * it is asked for by its name here.
223
+ *
224
+ * @return array{Descriptor, PublicAccess}
225
+ */
226
+ private function open(Request $request, string $what): array
227
+ {
228
+ try {
229
+ $descriptor = Nevela::resource((string) $request->route('resource'));
230
+ } catch (\InvalidArgumentException) {
231
+ throw new NotFoundHttpException;
232
+ }
233
+ if ($descriptor->public?->{$what} === null || ! class_exists($this->model($descriptor))) {
234
+ throw new NotFoundHttpException;
235
+ }
236
+
237
+ return [$descriptor, $descriptor->public];
238
+ }
239
+
240
+ /** @return class-string<Model> */
241
+ private function model(Descriptor $descriptor): string
242
+ {
243
+ return "App\\Models\\{$descriptor->name}";
244
+ }
245
+
246
+ /** @return class-string<\Illuminate\Http\Resources\Json\JsonResource> */
247
+ private function resourceClass(Descriptor $descriptor): string
248
+ {
249
+ return "App\\Http\\Resources\\Nevela\\{$descriptor->name}Resource";
250
+ }
251
+
252
+ /**
253
+ * The records anyone may read: what the model's publicQuery scope lets through. A model
254
+ * without the scope has said nothing, and nothing is what is public.
255
+ */
256
+ private function readable(Descriptor $descriptor): Builder
257
+ {
258
+ $model = $this->model($descriptor);
259
+ $query = $model::query();
260
+
261
+ return method_exists($model, 'scopePublicQuery') ? $query->publicQuery() : $query->whereRaw('1 = 0');
262
+ }
263
+
264
+ /**
265
+ * A record as anyone may see it: its id and the listed fields, and for a file the
266
+ * details that go with its key. Nothing else, whatever the API resource holds.
267
+ *
268
+ * @param array<string, mixed> $row
269
+ * @return array<string, mixed>
270
+ */
271
+ private function visible(Descriptor $descriptor, PublicAccess $public, array $row): array
272
+ {
273
+ $seen = ['id' => $row['id'] ?? null];
274
+ foreach ($public->read ?? [] as $field) {
275
+ $seen[$field] = $row[$field] ?? null;
276
+ if ($descriptor->fields[$field]->kind === 'file') {
277
+ $seen[$field.'File'] = $row[$field.'File'] ?? null;
278
+ }
279
+ }
280
+
281
+ return $seen;
282
+ }
283
+
284
+ /** Whether a key is a file a visitor uploaded to this field within the hour, that no record has yet. */
285
+ private function claimable(Descriptor $descriptor, string $field, string $key): bool
286
+ {
287
+ $upload = Upload::query()->where('key', $key)->where('resource', $descriptor->name)->where('field', $field)->first();
288
+
289
+ return $upload !== null
290
+ && $upload->getAttribute('uploaded_by') === Uploads::UNCLAIMED
291
+ && $upload->created_at !== null && $upload->created_at->gt(now()->subSeconds(self::CLAIM_SECONDS))
292
+ && ! DB::table($descriptor->table)->where($descriptor->fields[$field]->column(), $key)->exists();
293
+ }
294
+ }
@@ -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
+ }