create-nevela 0.7.1 → 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.
- package/index.mjs +1 -0
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +25 -3
- package/template/laravel/src/Access/Permissions.php +36 -5
- package/template/laravel/src/Concerns/Trashable.php +23 -0
- package/template/laravel/src/Console/AdoptCommand.php +139 -0
- package/template/laravel/src/Console/GenerateCommand.php +13 -3
- package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
- package/template/laravel/src/Console/SeedCommand.php +3 -1
- package/template/laravel/src/Console/TrashCommand.php +8 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
- package/template/laravel/src/Http/ActionController.php +222 -0
- package/template/laravel/src/Http/PublicController.php +210 -0
- package/template/laravel/src/Http/PublicThrottle.php +48 -0
- package/template/laravel/src/Http/ResourceRequest.php +151 -2
- package/template/laravel/src/Http/TrashController.php +18 -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 +54 -2
- package/template/laravel/src/NevelaServiceProvider.php +17 -1
- 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 +59 -4
- package/template/laravel/src/Support/Fake.php +15 -1
- package/template/laravel/src/Support/Field.php +74 -3
- package/template/laravel/src/Support/Html.php +185 -0
- package/template/laravel/src/Support/PublicAccess.php +133 -0
- package/template/web/app/api/[...path]/route.ts +42 -9
- package/template/web/app/dashboard/actions.ts +34 -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/record-actions.tsx +7 -2
- package/template/web/components/dashboard/record-detail.tsx +9 -2
- package/template/web/components/dashboard/resource-action.tsx +281 -0
- package/template/web/components/dashboard/resource-table.tsx +15 -2
- package/template/web/components/dashboard/row-actions.tsx +24 -0
- package/template/web/components/dashboard/table-selection.tsx +19 -1
- package/template/web/lib/access.ts +3 -1
- package/template/web/lib/dashboard.ts +10 -0
- package/template/web/lib/extras.ts +66 -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,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
|
+
}
|
|
@@ -6,8 +6,10 @@ use Illuminate\Foundation\Http\FormRequest;
|
|
|
6
6
|
use Illuminate\Support\Facades\DB;
|
|
7
7
|
use Illuminate\Support\Facades\Schema;
|
|
8
8
|
use Illuminate\Support\Facades\Validator as Validation;
|
|
9
|
+
use Illuminate\Validation\Rules\Exists;
|
|
9
10
|
use Illuminate\Validation\Rules\Unique;
|
|
10
11
|
use Illuminate\Validation\Validator;
|
|
12
|
+
use Nevela\Laravel\Support\Html;
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* Base for generated resource requests.
|
|
@@ -31,9 +33,102 @@ abstract class ResourceRequest extends FormRequest
|
|
|
31
33
|
/** @return array<string, string> field => column */
|
|
32
34
|
abstract protected function columns(): array;
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Fields that hold rich text. A generated request with any says which.
|
|
38
|
+
*
|
|
39
|
+
* @return list<string>
|
|
40
|
+
*/
|
|
41
|
+
protected function rich(): array
|
|
42
|
+
{
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What a field is when a create leaves it out. A generated request with any says which.
|
|
48
|
+
*
|
|
49
|
+
* @return array<string, mixed> field => value
|
|
50
|
+
*/
|
|
51
|
+
protected function defaults(): array
|
|
52
|
+
{
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A create from some of the fields only: a public form's, where nobody is asked who
|
|
58
|
+
* they are and so only what the descriptor lists is taken.
|
|
59
|
+
*
|
|
60
|
+
* The rules are this request's own. A key that isn't listed is refused the way an
|
|
61
|
+
* unknown one is, and a field left out takes its default, or is left to the column.
|
|
62
|
+
*
|
|
63
|
+
* @param list<string> $fields The fields that may be sent
|
|
64
|
+
* @param array<string, mixed> $input
|
|
65
|
+
* @return array{values: array<string, mixed>, issues: list<array{path: string, message: string}>}
|
|
66
|
+
*/
|
|
67
|
+
public function forFields(array $fields, array $input): array
|
|
68
|
+
{
|
|
69
|
+
$input = self::cleaned($this->rich(), $input) + $input;
|
|
70
|
+
$issues = [];
|
|
71
|
+
foreach (array_keys($input) as $key) {
|
|
72
|
+
if (! in_array($key, $fields, true)) {
|
|
73
|
+
$issues[] = ['path' => (string) $key, 'message' => 'Unknown field.'];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
$validator = Validation::make($input, array_intersect_key(self::withoutTrashed($this->rules()), array_flip($fields)));
|
|
77
|
+
if ($validator->fails()) {
|
|
78
|
+
self::explainTrashed($validator, $input);
|
|
79
|
+
array_push($issues, ...FlareErrors::issues($validator->errors()->toArray()));
|
|
80
|
+
}
|
|
81
|
+
if ($issues !== []) {
|
|
82
|
+
return ['values' => [], 'issues' => $issues];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
$validated = $validator->validated();
|
|
86
|
+
$defaults = $this->defaults();
|
|
87
|
+
$values = [];
|
|
88
|
+
foreach ($this->columns() as $field => $column) {
|
|
89
|
+
if (array_key_exists($field, $validated)) {
|
|
90
|
+
$values[$column] = $validated[$field];
|
|
91
|
+
} elseif (array_key_exists($field, $defaults)) {
|
|
92
|
+
$values[$column] = $defaults[$field];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return ['values' => $values, 'issues' => []];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Rich text is cleaned before anything looks at it: what is checked for being there,
|
|
101
|
+
* and for being short enough, is the HTML that would be stored.
|
|
102
|
+
*/
|
|
103
|
+
protected function prepareForValidation(): void
|
|
104
|
+
{
|
|
105
|
+
$this->merge(self::cleaned($this->rich(), $this->all()));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param list<string> $fields
|
|
110
|
+
* @param array<string, mixed> $input
|
|
111
|
+
* @return array<string, mixed> The fields that were sent as text, cleaned. One with nothing left to read is empty.
|
|
112
|
+
*/
|
|
113
|
+
private static function cleaned(array $fields, array $input): array
|
|
114
|
+
{
|
|
115
|
+
$clean = [];
|
|
116
|
+
foreach ($fields as $field) {
|
|
117
|
+
if (is_string($input[$field] ?? null)) {
|
|
118
|
+
$html = Html::clean($input[$field]);
|
|
119
|
+
$clean[$field] = $html === '' ? null : $html;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return $clean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Tables known to have a trash. Only "yes" is remembered: the column can arrive with a migration. */
|
|
127
|
+
private static array $trashes = [];
|
|
128
|
+
|
|
34
129
|
protected function validationRules()
|
|
35
130
|
{
|
|
36
|
-
$rules = parent::validationRules();
|
|
131
|
+
$rules = self::withoutTrashed(parent::validationRules());
|
|
37
132
|
if (! $this->isMethod('PATCH')) {
|
|
38
133
|
return $rules;
|
|
39
134
|
}
|
|
@@ -57,9 +152,36 @@ abstract class ResourceRequest extends FormRequest
|
|
|
57
152
|
});
|
|
58
153
|
}
|
|
59
154
|
|
|
155
|
+
/**
|
|
156
|
+
* A relation points at a record that is there, and one in the trash is not: nothing
|
|
157
|
+
* lists it and its own page answers 404. Laravel's exists rule looks at the table,
|
|
158
|
+
* where a deleted record still is, so it is told to leave those out.
|
|
159
|
+
*
|
|
160
|
+
* @param array<string, mixed> $rules
|
|
161
|
+
* @return array<string, mixed>
|
|
162
|
+
*/
|
|
163
|
+
private static function withoutTrashed(array $rules): array
|
|
164
|
+
{
|
|
165
|
+
foreach ($rules as $set) {
|
|
166
|
+
foreach (is_array($set) ? $set : [] as $rule) {
|
|
167
|
+
if (! $rule instanceof Exists) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
// "exists:categories,id": the rule keeps its table to itself.
|
|
171
|
+
$table = (string) strtok(substr((string) $rule, strlen('exists:')), ',');
|
|
172
|
+
if (self::$trashes[$table] ?? (Schema::hasColumn($table, 'deleted_at') && (self::$trashes[$table] = true))) {
|
|
173
|
+
$rule->withoutTrashed();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return $rules;
|
|
179
|
+
}
|
|
180
|
+
|
|
60
181
|
/**
|
|
61
182
|
* A unique value can be held by a record nobody can see: one in the trash. "Already
|
|
62
183
|
* taken" would send someone looking through a list it isn't in, so say where it is.
|
|
184
|
+
* The same goes for a relation to a record that is in the trash.
|
|
63
185
|
*
|
|
64
186
|
* @param array<string, mixed> $data
|
|
65
187
|
* @return list<array{path: string, message: string}> The messages that were replaced
|
|
@@ -82,6 +204,25 @@ abstract class ResourceRequest extends FormRequest
|
|
|
82
204
|
$validator->errors()->add($field, $message);
|
|
83
205
|
$replaced[] = ['path' => (string) $field, 'message' => $message];
|
|
84
206
|
}
|
|
207
|
+
foreach ($validator->failed() as $field => $rules) {
|
|
208
|
+
// And for a failed exists rule: the table and the column, then what else it asked.
|
|
209
|
+
[$table, $column] = array_pad($rules['Exists'] ?? [], 2, null);
|
|
210
|
+
$value = $data[$field] ?? null;
|
|
211
|
+
if (! is_string($table) || ! is_string($column) || ! is_scalar($value) || ! (self::$trashes[$table] ?? false)) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
$trashed = DB::table($table)->where($column, $value)->whereNotNull('deleted_at')->exists();
|
|
216
|
+
} catch (\Throwable) {
|
|
217
|
+
continue; // not something that could be an id at all
|
|
218
|
+
}
|
|
219
|
+
if ($trashed) {
|
|
220
|
+
$message = 'This one is in the trash. Restore it, or choose another.';
|
|
221
|
+
$validator->errors()->forget($field);
|
|
222
|
+
$validator->errors()->add($field, $message);
|
|
223
|
+
$replaced[] = ['path' => (string) $field, 'message' => $message];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
85
226
|
|
|
86
227
|
return $replaced;
|
|
87
228
|
}
|
|
@@ -101,8 +242,9 @@ abstract class ResourceRequest extends FormRequest
|
|
|
101
242
|
*/
|
|
102
243
|
public function many(array $rows): array
|
|
103
244
|
{
|
|
104
|
-
$rules = $this->rules();
|
|
245
|
+
$rules = self::withoutTrashed($this->rules());
|
|
105
246
|
$columns = $this->columns();
|
|
247
|
+
$defaults = $this->defaults();
|
|
106
248
|
$known = array_keys($columns);
|
|
107
249
|
$computed = array_map(fn (string $field) => $field.'File', $known);
|
|
108
250
|
$unique = array_keys(array_filter($rules, fn ($rule) => is_array($rule) && array_filter($rule, fn ($one) => $one instanceof Unique) !== []));
|
|
@@ -124,6 +266,7 @@ abstract class ResourceRequest extends FormRequest
|
|
|
124
266
|
$issues[] = ['path' => (string) $key, 'message' => 'Unknown field.'];
|
|
125
267
|
}
|
|
126
268
|
}
|
|
269
|
+
$row = self::cleaned($this->rich(), $row) + $row;
|
|
127
270
|
$validator = Validation::make($row, $rules);
|
|
128
271
|
if ($validator->fails()) {
|
|
129
272
|
self::explainTrashed($validator, $row);
|
|
@@ -152,6 +295,8 @@ abstract class ResourceRequest extends FormRequest
|
|
|
152
295
|
foreach ($columns as $field => $column) {
|
|
153
296
|
if (array_key_exists($field, $validated)) {
|
|
154
297
|
$mapped[$column] = $validated[$field];
|
|
298
|
+
} elseif (array_key_exists($field, $defaults)) {
|
|
299
|
+
$mapped[$column] = $defaults[$field];
|
|
155
300
|
}
|
|
156
301
|
}
|
|
157
302
|
$values[] = $mapped;
|
|
@@ -164,10 +309,14 @@ abstract class ResourceRequest extends FormRequest
|
|
|
164
309
|
public function values(): array
|
|
165
310
|
{
|
|
166
311
|
$validated = $this->validated();
|
|
312
|
+
$defaults = $this->defaults();
|
|
167
313
|
$values = [];
|
|
168
314
|
foreach ($this->columns() as $field => $column) {
|
|
169
315
|
if (array_key_exists($field, $validated)) {
|
|
170
316
|
$values[$column] = $validated[$field];
|
|
317
|
+
} elseif (array_key_exists($field, $defaults) && ! $this->isMethod('PATCH')) {
|
|
318
|
+
// Left out of a create, or of a replace, it is what it usually is.
|
|
319
|
+
$values[$column] = $defaults[$field];
|
|
171
320
|
} elseif ($this->isMethod('PUT')) {
|
|
172
321
|
$values[$column] = null;
|
|
173
322
|
}
|
|
@@ -92,7 +92,7 @@ final class TrashController
|
|
|
92
92
|
public function restore(string $slug, string $id): JsonResponse
|
|
93
93
|
{
|
|
94
94
|
['descriptor' => $descriptor, 'model' => $model] = $this->resource($slug);
|
|
95
|
-
$record = $
|
|
95
|
+
$record = $this->deleted($model, $id) ?? throw new NotFoundHttpException("No deleted {$this->noun($descriptor)} with that id.");
|
|
96
96
|
if (! Trash::may('restore', $record)) {
|
|
97
97
|
throw new AccessDeniedHttpException("You don't have access to this.");
|
|
98
98
|
}
|
|
@@ -119,7 +119,7 @@ final class TrashController
|
|
|
119
119
|
public function destroy(string $slug, string $id): JsonResponse|Response
|
|
120
120
|
{
|
|
121
121
|
['descriptor' => $descriptor, 'model' => $model] = $this->resource($slug);
|
|
122
|
-
$record = $
|
|
122
|
+
$record = $this->deleted($model, $id) ?? throw new NotFoundHttpException("No deleted {$this->noun($descriptor)} with that id.");
|
|
123
123
|
if (! Trash::may('forceDelete', $record)) {
|
|
124
124
|
throw new AccessDeniedHttpException("You don't have access to this.");
|
|
125
125
|
}
|
|
@@ -167,6 +167,22 @@ final class TrashController
|
|
|
167
167
|
return $resource;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The deleted record with this id, if there is one.
|
|
172
|
+
*
|
|
173
|
+
* @param class-string<\Illuminate\Database\Eloquent\Model> $model
|
|
174
|
+
*/
|
|
175
|
+
private function deleted(string $model, string $id): ?\Illuminate\Database\Eloquent\Model
|
|
176
|
+
{
|
|
177
|
+
// Where ids are integers, something that isn't one is nobody's id. Asked anyway,
|
|
178
|
+
// some databases answer with an error instead of with nothing.
|
|
179
|
+
if ((new $model)->getKeyType() === 'int' && ! preg_match('/^[0-9]{1,18}$/', $id)) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return $model::onlyTrashed()->find($id);
|
|
184
|
+
}
|
|
185
|
+
|
|
170
186
|
private function noun(Descriptor $descriptor): string
|
|
171
187
|
{
|
|
172
188
|
return strtolower($descriptor->label);
|
|
@@ -110,7 +110,8 @@ final class UploadController
|
|
|
110
110
|
public function show(string $path): Response
|
|
111
111
|
{
|
|
112
112
|
// A key is letters, digits, dashes, dots and slashes. Anything else was never one.
|
|
113
|
-
|
|
113
|
+
// And a private file's key is not an address, whoever has it.
|
|
114
|
+
if ($path === '' || str_contains($path, '..') || ! preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $path) || Uploads::isPrivate($path)) {
|
|
114
115
|
abort(404);
|
|
115
116
|
}
|
|
116
117
|
$cache ='public, max-age=31536000, immutable';
|
|
@@ -152,6 +153,46 @@ final class UploadController
|
|
|
152
153
|
]);
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
/**
|
|
157
|
+
* A private file, to someone who may view the record it belongs to.
|
|
158
|
+
*
|
|
159
|
+
* GET {prefix}/_nevela/private/{slug}/{id}/{field}
|
|
160
|
+
*
|
|
161
|
+
* The address names the record and the field, and the file is found from them: there
|
|
162
|
+
* is no key in it to pass around. It is never to be kept by a cache on the way.
|
|
163
|
+
*/
|
|
164
|
+
public function download(string $slug, string $id, string $field): Response
|
|
165
|
+
{
|
|
166
|
+
$descriptor = null;
|
|
167
|
+
foreach (Nevela::all() as $candidate) {
|
|
168
|
+
if ($candidate->slug === $slug) {
|
|
169
|
+
$descriptor = $candidate;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
$definition = $descriptor?->fields[$field] ?? null;
|
|
173
|
+
$model = 'App\\Models\\'.($descriptor?->name ?? '');
|
|
174
|
+
if ($descriptor === null || $definition === null || $definition->kind !== 'file' || ! $definition->private || ! class_exists($model)
|
|
175
|
+
|| ($descriptor->idType === 'int' && ! preg_match('/^[0-9]{1,18}$/', $id))) {
|
|
176
|
+
abort(404);
|
|
177
|
+
}
|
|
178
|
+
// A record in the trash is found by nobody here: trashing it exposes nothing.
|
|
179
|
+
$record = $model::query()->find($id) ?? abort(404);
|
|
180
|
+
Gate::authorize('view', $record);
|
|
181
|
+
|
|
182
|
+
$key = $record->getAttribute($definition->column());
|
|
183
|
+
$upload = Uploads::isPrivate($key) ? Upload::query()->where('key', $key)->first() : null;
|
|
184
|
+
$disk = $upload === null ? null : Storage::disk($upload->disk);
|
|
185
|
+
if ($upload === null || $disk === null || ! $disk->exists(Uploads::privatePath($key))) {
|
|
186
|
+
abort(404);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return $disk->response(Uploads::privatePath($key), $upload->name, [
|
|
190
|
+
'Content-Type' => $upload->mime,
|
|
191
|
+
'Cache-Control' => 'private, no-store',
|
|
192
|
+
'X-Content-Type-Options' => 'nosniff',
|
|
193
|
+
], 'attachment');
|
|
194
|
+
}
|
|
195
|
+
|
|
155
196
|
private static function refuse(int $status, string $message): JsonResponse
|
|
156
197
|
{
|
|
157
198
|
return response()->json(['error' => $message], $status);
|