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