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
|
@@ -6,8 +6,11 @@ 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;
|
|
13
|
+
use Nevela\Laravel\Support\Slug;
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* Base for generated resource requests.
|
|
@@ -31,9 +34,139 @@ abstract class ResourceRequest extends FormRequest
|
|
|
31
34
|
/** @return array<string, string> field => column */
|
|
32
35
|
abstract protected function columns(): array;
|
|
33
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Fields that hold rich text. A generated request with any says which.
|
|
39
|
+
*
|
|
40
|
+
* @return list<string>
|
|
41
|
+
*/
|
|
42
|
+
protected function rich(): array
|
|
43
|
+
{
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What a field is when a create leaves it out. A generated request with any says which.
|
|
49
|
+
*
|
|
50
|
+
* @return array<string, mixed> field => value
|
|
51
|
+
*/
|
|
52
|
+
protected function defaults(): array
|
|
53
|
+
{
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Fields made from another when a create leaves them empty: a slug from a title. A
|
|
59
|
+
* generated request with any says which, and where to look for the ones taken.
|
|
60
|
+
*
|
|
61
|
+
* @return array<string, array{from: string, table: string, column: string}>
|
|
62
|
+
*/
|
|
63
|
+
protected function made(): array
|
|
64
|
+
{
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Fill what a create left empty and can be made, and keep a later save from emptying
|
|
70
|
+
* it: once made, a slug is an address, and an address doesn't move.
|
|
71
|
+
*
|
|
72
|
+
* @param array<string, mixed> $values by column
|
|
73
|
+
* @param array<string, true> $taken Values made for earlier rows of the same batch
|
|
74
|
+
* @return array<string, mixed>
|
|
75
|
+
*/
|
|
76
|
+
private function make(array $values, bool $creating, array &$taken = []): array
|
|
77
|
+
{
|
|
78
|
+
$columns = $this->columns();
|
|
79
|
+
foreach ($this->made() as $definition) {
|
|
80
|
+
$column = $definition['column'];
|
|
81
|
+
$empty = ! isset($values[$column]) || $values[$column] === '';
|
|
82
|
+
if (! $empty) {
|
|
83
|
+
$taken[(string) $values[$column]] = true;
|
|
84
|
+
} elseif ($creating) {
|
|
85
|
+
$values[$column] = Slug::free($definition['table'], $column, (string) ($values[$columns[$definition['from']] ?? ''] ?? ''), $taken);
|
|
86
|
+
} else {
|
|
87
|
+
unset($values[$column]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return $values;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A create from some of the fields only: a public form's, where nobody is asked who
|
|
96
|
+
* they are and so only what the descriptor lists is taken.
|
|
97
|
+
*
|
|
98
|
+
* The rules are this request's own. A key that isn't listed is refused the way an
|
|
99
|
+
* unknown one is, and a field left out takes its default, or is left to the column.
|
|
100
|
+
*
|
|
101
|
+
* @param list<string> $fields The fields that may be sent
|
|
102
|
+
* @param array<string, mixed> $input
|
|
103
|
+
* @return array{values: array<string, mixed>, issues: list<array{path: string, message: string}>}
|
|
104
|
+
*/
|
|
105
|
+
public function forFields(array $fields, array $input): array
|
|
106
|
+
{
|
|
107
|
+
$input = self::cleaned($this->rich(), $input) + $input;
|
|
108
|
+
$issues = [];
|
|
109
|
+
foreach (array_keys($input) as $key) {
|
|
110
|
+
if (! in_array($key, $fields, true)) {
|
|
111
|
+
$issues[] = ['path' => (string) $key, 'message' => 'Unknown field.'];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
$validator = Validation::make($input, array_intersect_key(self::withoutTrashed($this->rules()), array_flip($fields)));
|
|
115
|
+
if ($validator->fails()) {
|
|
116
|
+
self::explainTrashed($validator, $input);
|
|
117
|
+
array_push($issues, ...FlareErrors::issues($validator->errors()->toArray()));
|
|
118
|
+
}
|
|
119
|
+
if ($issues !== []) {
|
|
120
|
+
return ['values' => [], 'issues' => $issues];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
$validated = $validator->validated();
|
|
124
|
+
$defaults = $this->defaults();
|
|
125
|
+
$values = [];
|
|
126
|
+
foreach ($this->columns() as $field => $column) {
|
|
127
|
+
if (array_key_exists($field, $validated)) {
|
|
128
|
+
$values[$column] = $validated[$field];
|
|
129
|
+
} elseif (array_key_exists($field, $defaults)) {
|
|
130
|
+
$values[$column] = $defaults[$field];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return ['values' => $this->make($values, true), 'issues' => []];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Rich text is cleaned before anything looks at it: what is checked for being there,
|
|
139
|
+
* and for being short enough, is the HTML that would be stored.
|
|
140
|
+
*/
|
|
141
|
+
protected function prepareForValidation(): void
|
|
142
|
+
{
|
|
143
|
+
$this->merge(self::cleaned($this->rich(), $this->all()));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param list<string> $fields
|
|
148
|
+
* @param array<string, mixed> $input
|
|
149
|
+
* @return array<string, mixed> The fields that were sent as text, cleaned. One with nothing left to read is empty.
|
|
150
|
+
*/
|
|
151
|
+
private static function cleaned(array $fields, array $input): array
|
|
152
|
+
{
|
|
153
|
+
$clean = [];
|
|
154
|
+
foreach ($fields as $field) {
|
|
155
|
+
if (is_string($input[$field] ?? null)) {
|
|
156
|
+
$html = Html::clean($input[$field]);
|
|
157
|
+
$clean[$field] = $html === '' ? null : $html;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return $clean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Tables known to have a trash. Only "yes" is remembered: the column can arrive with a migration. */
|
|
165
|
+
private static array $trashes = [];
|
|
166
|
+
|
|
34
167
|
protected function validationRules()
|
|
35
168
|
{
|
|
36
|
-
$rules = parent::validationRules();
|
|
169
|
+
$rules = self::withoutTrashed(parent::validationRules());
|
|
37
170
|
if (! $this->isMethod('PATCH')) {
|
|
38
171
|
return $rules;
|
|
39
172
|
}
|
|
@@ -57,9 +190,36 @@ abstract class ResourceRequest extends FormRequest
|
|
|
57
190
|
});
|
|
58
191
|
}
|
|
59
192
|
|
|
193
|
+
/**
|
|
194
|
+
* A relation points at a record that is there, and one in the trash is not: nothing
|
|
195
|
+
* lists it and its own page answers 404. Laravel's exists rule looks at the table,
|
|
196
|
+
* where a deleted record still is, so it is told to leave those out.
|
|
197
|
+
*
|
|
198
|
+
* @param array<string, mixed> $rules
|
|
199
|
+
* @return array<string, mixed>
|
|
200
|
+
*/
|
|
201
|
+
private static function withoutTrashed(array $rules): array
|
|
202
|
+
{
|
|
203
|
+
foreach ($rules as $set) {
|
|
204
|
+
foreach (is_array($set) ? $set : [] as $rule) {
|
|
205
|
+
if (! $rule instanceof Exists) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// "exists:categories,id": the rule keeps its table to itself.
|
|
209
|
+
$table = (string) strtok(substr((string) $rule, strlen('exists:')), ',');
|
|
210
|
+
if (self::$trashes[$table] ?? (Schema::hasColumn($table, 'deleted_at') && (self::$trashes[$table] = true))) {
|
|
211
|
+
$rule->withoutTrashed();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return $rules;
|
|
217
|
+
}
|
|
218
|
+
|
|
60
219
|
/**
|
|
61
220
|
* A unique value can be held by a record nobody can see: one in the trash. "Already
|
|
62
221
|
* taken" would send someone looking through a list it isn't in, so say where it is.
|
|
222
|
+
* The same goes for a relation to a record that is in the trash.
|
|
63
223
|
*
|
|
64
224
|
* @param array<string, mixed> $data
|
|
65
225
|
* @return list<array{path: string, message: string}> The messages that were replaced
|
|
@@ -82,6 +242,25 @@ abstract class ResourceRequest extends FormRequest
|
|
|
82
242
|
$validator->errors()->add($field, $message);
|
|
83
243
|
$replaced[] = ['path' => (string) $field, 'message' => $message];
|
|
84
244
|
}
|
|
245
|
+
foreach ($validator->failed() as $field => $rules) {
|
|
246
|
+
// And for a failed exists rule: the table and the column, then what else it asked.
|
|
247
|
+
[$table, $column] = array_pad($rules['Exists'] ?? [], 2, null);
|
|
248
|
+
$value = $data[$field] ?? null;
|
|
249
|
+
if (! is_string($table) || ! is_string($column) || ! is_scalar($value) || ! (self::$trashes[$table] ?? false)) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
$trashed = DB::table($table)->where($column, $value)->whereNotNull('deleted_at')->exists();
|
|
254
|
+
} catch (\Throwable) {
|
|
255
|
+
continue; // not something that could be an id at all
|
|
256
|
+
}
|
|
257
|
+
if ($trashed) {
|
|
258
|
+
$message = 'This one is in the trash. Restore it, or choose another.';
|
|
259
|
+
$validator->errors()->forget($field);
|
|
260
|
+
$validator->errors()->add($field, $message);
|
|
261
|
+
$replaced[] = ['path' => (string) $field, 'message' => $message];
|
|
262
|
+
}
|
|
263
|
+
}
|
|
85
264
|
|
|
86
265
|
return $replaced;
|
|
87
266
|
}
|
|
@@ -101,8 +280,9 @@ abstract class ResourceRequest extends FormRequest
|
|
|
101
280
|
*/
|
|
102
281
|
public function many(array $rows): array
|
|
103
282
|
{
|
|
104
|
-
$rules = $this->rules();
|
|
283
|
+
$rules = self::withoutTrashed($this->rules());
|
|
105
284
|
$columns = $this->columns();
|
|
285
|
+
$defaults = $this->defaults();
|
|
106
286
|
$known = array_keys($columns);
|
|
107
287
|
$computed = array_map(fn (string $field) => $field.'File', $known);
|
|
108
288
|
$unique = array_keys(array_filter($rules, fn ($rule) => is_array($rule) && array_filter($rule, fn ($one) => $one instanceof Unique) !== []));
|
|
@@ -110,6 +290,7 @@ abstract class ResourceRequest extends FormRequest
|
|
|
110
290
|
$values = [];
|
|
111
291
|
$problems = [];
|
|
112
292
|
$taken = [];
|
|
293
|
+
$made = [];
|
|
113
294
|
foreach (array_values($rows) as $index => $row) {
|
|
114
295
|
$number = $index + 1;
|
|
115
296
|
if (! is_array($row) || array_is_list($row)) {
|
|
@@ -124,6 +305,7 @@ abstract class ResourceRequest extends FormRequest
|
|
|
124
305
|
$issues[] = ['path' => (string) $key, 'message' => 'Unknown field.'];
|
|
125
306
|
}
|
|
126
307
|
}
|
|
308
|
+
$row = self::cleaned($this->rich(), $row) + $row;
|
|
127
309
|
$validator = Validation::make($row, $rules);
|
|
128
310
|
if ($validator->fails()) {
|
|
129
311
|
self::explainTrashed($validator, $row);
|
|
@@ -152,9 +334,12 @@ abstract class ResourceRequest extends FormRequest
|
|
|
152
334
|
foreach ($columns as $field => $column) {
|
|
153
335
|
if (array_key_exists($field, $validated)) {
|
|
154
336
|
$mapped[$column] = $validated[$field];
|
|
337
|
+
} elseif (array_key_exists($field, $defaults)) {
|
|
338
|
+
$mapped[$column] = $defaults[$field];
|
|
155
339
|
}
|
|
156
340
|
}
|
|
157
|
-
|
|
341
|
+
// Two rows with one title get two slugs: what was made for an earlier row is remembered.
|
|
342
|
+
$values[] = $this->make($mapped, true, $made);
|
|
158
343
|
}
|
|
159
344
|
|
|
160
345
|
return ['values' => $values, 'problems' => $problems];
|
|
@@ -164,15 +349,19 @@ abstract class ResourceRequest extends FormRequest
|
|
|
164
349
|
public function values(): array
|
|
165
350
|
{
|
|
166
351
|
$validated = $this->validated();
|
|
352
|
+
$defaults = $this->defaults();
|
|
167
353
|
$values = [];
|
|
168
354
|
foreach ($this->columns() as $field => $column) {
|
|
169
355
|
if (array_key_exists($field, $validated)) {
|
|
170
356
|
$values[$column] = $validated[$field];
|
|
357
|
+
} elseif (array_key_exists($field, $defaults) && ! $this->isMethod('PATCH')) {
|
|
358
|
+
// Left out of a create, or of a replace, it is what it usually is.
|
|
359
|
+
$values[$column] = $defaults[$field];
|
|
171
360
|
} elseif ($this->isMethod('PUT')) {
|
|
172
361
|
$values[$column] = null;
|
|
173
362
|
}
|
|
174
363
|
}
|
|
175
364
|
|
|
176
|
-
return $values;
|
|
365
|
+
return $this->make($values, $this->isMethod('POST'));
|
|
177
366
|
}
|
|
178
367
|
}
|
|
@@ -6,6 +6,7 @@ use Illuminate\Http\JsonResponse;
|
|
|
6
6
|
use Illuminate\Http\Request;
|
|
7
7
|
use Illuminate\Http\Response;
|
|
8
8
|
use Nevela\Laravel\Support\Descriptor;
|
|
9
|
+
use Nevela\Laravel\Support\Key;
|
|
9
10
|
use Nevela\Laravel\Trash\Trash;
|
|
10
11
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
11
12
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
@@ -92,7 +93,7 @@ final class TrashController
|
|
|
92
93
|
public function restore(string $slug, string $id): JsonResponse
|
|
93
94
|
{
|
|
94
95
|
['descriptor' => $descriptor, 'model' => $model] = $this->resource($slug);
|
|
95
|
-
$record = $
|
|
96
|
+
$record = $this->deleted($model, $id) ?? throw new NotFoundHttpException("No deleted {$this->noun($descriptor)} with that id.");
|
|
96
97
|
if (! Trash::may('restore', $record)) {
|
|
97
98
|
throw new AccessDeniedHttpException("You don't have access to this.");
|
|
98
99
|
}
|
|
@@ -119,7 +120,7 @@ final class TrashController
|
|
|
119
120
|
public function destroy(string $slug, string $id): JsonResponse|Response
|
|
120
121
|
{
|
|
121
122
|
['descriptor' => $descriptor, 'model' => $model] = $this->resource($slug);
|
|
122
|
-
$record = $
|
|
123
|
+
$record = $this->deleted($model, $id) ?? throw new NotFoundHttpException("No deleted {$this->noun($descriptor)} with that id.");
|
|
123
124
|
if (! Trash::may('forceDelete', $record)) {
|
|
124
125
|
throw new AccessDeniedHttpException("You don't have access to this.");
|
|
125
126
|
}
|
|
@@ -167,6 +168,22 @@ final class TrashController
|
|
|
167
168
|
return $resource;
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
/**
|
|
172
|
+
* The deleted record with this id, if there is one.
|
|
173
|
+
*
|
|
174
|
+
* @param class-string<\Illuminate\Database\Eloquent\Model> $model
|
|
175
|
+
*/
|
|
176
|
+
private function deleted(string $model, string $id): ?\Illuminate\Database\Eloquent\Model
|
|
177
|
+
{
|
|
178
|
+
// Where ids are integers, something that isn't one is nobody's id. Asked anyway,
|
|
179
|
+
// some databases answer with an error instead of with nothing.
|
|
180
|
+
if (! Key::fits($model, $id)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return $model::onlyTrashed()->find($id);
|
|
185
|
+
}
|
|
186
|
+
|
|
170
187
|
private function noun(Descriptor $descriptor): string
|
|
171
188
|
{
|
|
172
189
|
return strtolower($descriptor->label);
|
|
@@ -14,6 +14,7 @@ use Nevela\Laravel\Models\Upload;
|
|
|
14
14
|
use Nevela\Laravel\Nevela;
|
|
15
15
|
use Nevela\Laravel\Support\Descriptor;
|
|
16
16
|
use Nevela\Laravel\Support\Field;
|
|
17
|
+
use Nevela\Laravel\Support\Key;
|
|
17
18
|
use Symfony\Component\HttpFoundation\Response;
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -110,7 +111,8 @@ final class UploadController
|
|
|
110
111
|
public function show(string $path): Response
|
|
111
112
|
{
|
|
112
113
|
// A key is letters, digits, dashes, dots and slashes. Anything else was never one.
|
|
113
|
-
|
|
114
|
+
// And a private file's key is not an address, whoever has it.
|
|
115
|
+
if ($path === '' || str_contains($path, '..') || ! preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $path) || Uploads::isPrivate($path)) {
|
|
114
116
|
abort(404);
|
|
115
117
|
}
|
|
116
118
|
$cache ='public, max-age=31536000, immutable';
|
|
@@ -152,6 +154,45 @@ final class UploadController
|
|
|
152
154
|
]);
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
/**
|
|
158
|
+
* A private file, to someone who may view the record it belongs to.
|
|
159
|
+
*
|
|
160
|
+
* GET {prefix}/_nevela/private/{slug}/{id}/{field}
|
|
161
|
+
*
|
|
162
|
+
* The address names the record and the field, and the file is found from them: there
|
|
163
|
+
* is no key in it to pass around. It is never to be kept by a cache on the way.
|
|
164
|
+
*/
|
|
165
|
+
public function download(string $slug, string $id, string $field): Response
|
|
166
|
+
{
|
|
167
|
+
$descriptor = null;
|
|
168
|
+
foreach (Nevela::all() as $candidate) {
|
|
169
|
+
if ($candidate->slug === $slug) {
|
|
170
|
+
$descriptor = $candidate;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
$definition = $descriptor?->fields[$field] ?? null;
|
|
174
|
+
$model = 'App\\Models\\'.($descriptor?->name ?? '');
|
|
175
|
+
if ($descriptor === null || $definition === null || $definition->kind !== 'file' || ! $definition->private || ! class_exists($model) || ! Key::fits($model, $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);
|
|
@@ -22,6 +22,15 @@ use Throwable;
|
|
|
22
22
|
*/
|
|
23
23
|
final class Uploads
|
|
24
24
|
{
|
|
25
|
+
/** Who uploaded a file that came in through a public form and hasn't been used in a create yet. */
|
|
26
|
+
public const UNCLAIMED = 'public:unclaimed';
|
|
27
|
+
|
|
28
|
+
/** And one that has: it belongs to a record now, like any other. */
|
|
29
|
+
public const CLAIMED = 'public';
|
|
30
|
+
|
|
31
|
+
/** What every private file's key starts with. */
|
|
32
|
+
public const PRIVATE_PREFIX = 'private/';
|
|
33
|
+
|
|
25
34
|
/** @var array<string, Upload|null> Rows already looked up in this request, by key. */
|
|
26
35
|
private static array $loaded = [];
|
|
27
36
|
|
|
@@ -30,6 +39,27 @@ final class Uploads
|
|
|
30
39
|
return (string) config('nevela.uploads.disk', 'public');
|
|
31
40
|
}
|
|
32
41
|
|
|
42
|
+
/** Where private files are kept: a disk the web server doesn't serve. */
|
|
43
|
+
public static function privateDisk(): string
|
|
44
|
+
{
|
|
45
|
+
return (string) config('nevela.uploads.private_disk', 'local');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a key is a private file's. It is said by the key itself, so that nothing
|
|
50
|
+
* which hands out files by key has to look anything up to know it must not.
|
|
51
|
+
*/
|
|
52
|
+
public static function isPrivate(?string $key): bool
|
|
53
|
+
{
|
|
54
|
+
return is_string($key) && str_starts_with($key, self::PRIVATE_PREFIX);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Where a private file is on its disk, beside the originals Nevela keeps there. */
|
|
58
|
+
public static function privatePath(string $key): string
|
|
59
|
+
{
|
|
60
|
+
return 'nevela/'.$key;
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
public static function maxBytes(): int
|
|
34
64
|
{
|
|
35
65
|
return (int) config('nevela.uploads.max_bytes', 10 * 1024 * 1024);
|
|
@@ -73,6 +103,23 @@ final class Uploads
|
|
|
73
103
|
];
|
|
74
104
|
$profile = self::profile($field->profile);
|
|
75
105
|
|
|
106
|
+
// A private file is stored as it came, where the web server doesn't reach. Nothing
|
|
107
|
+
// is made from it: a thumbnail would be a copy of it on a disk anyone can read.
|
|
108
|
+
if ($field->private) {
|
|
109
|
+
$key = self::PRIVATE_PREFIX."{$base}.{$extension}";
|
|
110
|
+
self::write(Storage::disk(self::privateDisk()), self::privatePath($key), $bytes);
|
|
111
|
+
$size = str_starts_with($mime, 'image/') ? @getimagesizefromstring($bytes) : false;
|
|
112
|
+
|
|
113
|
+
return self::remember(Upload::create(['disk' => self::privateDisk()] + $row + [
|
|
114
|
+
'key' => $key,
|
|
115
|
+
'mime' => $mime,
|
|
116
|
+
'size' => strlen($bytes),
|
|
117
|
+
'width' => $size ? $size[0] : null,
|
|
118
|
+
'height' => $size ? $size[1] : null,
|
|
119
|
+
'optimised' => false,
|
|
120
|
+
]));
|
|
121
|
+
}
|
|
122
|
+
|
|
76
123
|
if ($field->isImage() && in_array($mime, ImageOptimizer::OPTIMISABLE, true)) {
|
|
77
124
|
$written = [];
|
|
78
125
|
$originals = Storage::disk((string) config('nevela.uploads.originals_disk', 'local'));
|
|
@@ -193,6 +240,11 @@ final class Uploads
|
|
|
193
240
|
return null;
|
|
194
241
|
}
|
|
195
242
|
$upload = self::find($key);
|
|
243
|
+
// A private file has no address of its own. It is fetched through the record it
|
|
244
|
+
// belongs to (see privateRef), by someone who may view that record.
|
|
245
|
+
if (self::isPrivate($key)) {
|
|
246
|
+
return ['key' => $key, 'url' => null, 'private' => true, 'name' => $upload?->name, 'mime' => $upload?->mime, 'size' => $upload?->size];
|
|
247
|
+
}
|
|
196
248
|
if ($upload === null) {
|
|
197
249
|
return ['key' => $key, 'url' => self::url($key)];
|
|
198
250
|
}
|
|
@@ -219,6 +271,65 @@ final class Uploads
|
|
|
219
271
|
];
|
|
220
272
|
}
|
|
221
273
|
|
|
274
|
+
/**
|
|
275
|
+
* A private file as a record shows it: what it is, and the address it is fetched from,
|
|
276
|
+
* which names the record and the field and never the key.
|
|
277
|
+
*
|
|
278
|
+
* @return array<string, mixed>|null
|
|
279
|
+
*/
|
|
280
|
+
public static function privateRef(?string $key, string $slug, mixed $id, string $field): ?array
|
|
281
|
+
{
|
|
282
|
+
$ref = self::ref($key);
|
|
283
|
+
if ($ref === null || ! self::isPrivate($key)) {
|
|
284
|
+
return $ref;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return ['url' => route('nevela.private.show', ['slug' => $slug, 'id' => $id, 'field' => $field])] + $ref;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Remove a private file for good: from its disk, and the record of it. For when the
|
|
292
|
+
* record it belonged to has been removed for good.
|
|
293
|
+
*/
|
|
294
|
+
public static function discard(string $key): void
|
|
295
|
+
{
|
|
296
|
+
if (! self::isPrivate($key)) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
$upload = Upload::query()->where('key', $key)->first();
|
|
300
|
+
Storage::disk($upload?->disk ?? self::privateDisk())->delete(self::privatePath($key));
|
|
301
|
+
$upload?->delete();
|
|
302
|
+
unset(self::$loaded[$key]);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Remove what visitors uploaded and never used: a form that was started and left.
|
|
307
|
+
* Each such file waits an hour for its create (PublicController::CLAIM_SECONDS), and
|
|
308
|
+
* goes after that, with anything that was made from it.
|
|
309
|
+
*
|
|
310
|
+
* @return int How many were removed
|
|
311
|
+
*/
|
|
312
|
+
public static function purgeUnclaimed(int $olderThanSeconds = 3600): int
|
|
313
|
+
{
|
|
314
|
+
$removed = 0;
|
|
315
|
+
Upload::query()->where('uploaded_by', self::UNCLAIMED)->where('created_at', '<', now()->subSeconds($olderThanSeconds))->orderBy('id')
|
|
316
|
+
->chunkById(200, function ($uploads) use (&$removed) {
|
|
317
|
+
foreach ($uploads as $upload) {
|
|
318
|
+
$disk = Storage::disk($upload->disk);
|
|
319
|
+
$disk->delete(self::isPrivate($upload->key) ? self::privatePath($upload->key) : $upload->key);
|
|
320
|
+
$disk->delete(array_column($upload->renditions ?? [], 'key'));
|
|
321
|
+
if ($upload->original_key !== null) {
|
|
322
|
+
Storage::disk((string) config('nevela.uploads.originals_disk', 'local'))->delete($upload->original_key);
|
|
323
|
+
}
|
|
324
|
+
unset(self::$loaded[$upload->key]);
|
|
325
|
+
$upload->delete();
|
|
326
|
+
$removed++;
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
return $removed;
|
|
331
|
+
}
|
|
332
|
+
|
|
222
333
|
public static function forget(): void
|
|
223
334
|
{
|
|
224
335
|
self::$loaded = [];
|
|
@@ -7,6 +7,7 @@ use Illuminate\Database\UniqueConstraintViolationException;
|
|
|
7
7
|
use Illuminate\Http\JsonResponse;
|
|
8
8
|
use Illuminate\Http\Request;
|
|
9
9
|
use Illuminate\Support\Facades\DB;
|
|
10
|
+
use Illuminate\Support\Str;
|
|
10
11
|
use InvalidArgumentException;
|
|
11
12
|
use Nevela\Laravel\Media\Uploads;
|
|
12
13
|
use Nevela\Laravel\Support\Descriptor;
|
|
@@ -21,7 +22,7 @@ use Nevela\Laravel\Support\Naming;
|
|
|
21
22
|
final class Nevela
|
|
22
23
|
{
|
|
23
24
|
/** Written by `pnpm release`; the same number as the repository's git tag. */
|
|
24
|
-
public const VERSION = '0.
|
|
25
|
+
public const VERSION = '0.9.1';
|
|
25
26
|
|
|
26
27
|
/** @var array<string, Descriptor> */
|
|
27
28
|
private static array $descriptors = [];
|
|
@@ -76,7 +77,18 @@ final class Nevela
|
|
|
76
77
|
*/
|
|
77
78
|
public static function list(Builder $query, string $name, Request $request, string $resourceClass): JsonResponse
|
|
78
79
|
{
|
|
79
|
-
$
|
|
80
|
+
return self::listFor($query, self::resource($name), $request, $resourceClass);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The same, for a descriptor that was handed over and not looked up: the public list
|
|
85
|
+
* is the resource with only the fields anyone may see, so nothing else can be sorted,
|
|
86
|
+
* filtered or searched by.
|
|
87
|
+
*
|
|
88
|
+
* @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass
|
|
89
|
+
*/
|
|
90
|
+
public static function listFor(Builder $query, Descriptor $resource, Request $request, string $resourceClass): JsonResponse
|
|
91
|
+
{
|
|
80
92
|
$list = ListQuery::parse($resource, $request->query(), (int) config('nevela.per_page', 25), (int) config('nevela.max_per_page', 100));
|
|
81
93
|
if (! $list->ok()) {
|
|
82
94
|
return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
|
|
@@ -124,12 +136,59 @@ final class Nevela
|
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
foreach ($list->filters as $filter) {
|
|
139
|
+
if (isset($filter['state'])) {
|
|
140
|
+
// Published: its time has come. Scheduled: it has one, still to come. Draft: it has none.
|
|
141
|
+
match ($filter['state']) {
|
|
142
|
+
'draft' => $query->whereNull($filter['column']),
|
|
143
|
+
'scheduled' => $query->where($filter['column'], '>', now()),
|
|
144
|
+
default => $query->whereNotNull($filter['column'])->where($filter['column'], '<=', now()),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (isset($filter['confirmed'])) {
|
|
150
|
+
$filter['confirmed'] ? $query->whereNotNull($filter['column']) : $query->whereNull($filter['column']);
|
|
151
|
+
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
$field = $resource->fields[$filter['field']] ?? null;
|
|
155
|
+
if ($filter['value'] !== null && $field?->kind === 'belongsTo' && ! self::couldBeId((string) $field->target, (string) $filter['value'])) {
|
|
156
|
+
// Nothing belongs to a record that can't exist. Asked anyway, some databases
|
|
157
|
+
// answer a number column given letters with an error instead of with no rows.
|
|
158
|
+
$query->whereRaw('1 = 0');
|
|
159
|
+
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
127
162
|
$filter['value'] === null
|
|
128
163
|
? $query->whereNull($filter['column'])
|
|
129
164
|
: $query->where($filter['column'], $filter['value']);
|
|
130
165
|
}
|
|
131
166
|
}
|
|
132
167
|
|
|
168
|
+
/**
|
|
169
|
+
* A count is of the people who are really there: where a resource's form asks for an
|
|
170
|
+
* address to be proved, the ones who haven't yet are left out. The list still shows
|
|
171
|
+
* them, marked.
|
|
172
|
+
*/
|
|
173
|
+
private static function proved(Builder $query, Descriptor $resource): void
|
|
174
|
+
{
|
|
175
|
+
if ($resource->public?->confirm !== null && method_exists($query->getModel(), 'scopeConfirmed')) {
|
|
176
|
+
$query->confirmed();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Whether a value has the shape of one of a resource's ids: digits, or a UUID. */
|
|
181
|
+
private static function couldBeId(string $name, string $value): bool
|
|
182
|
+
{
|
|
183
|
+
try {
|
|
184
|
+
$integers = self::resource($name)->idType === 'int';
|
|
185
|
+
} catch (InvalidArgumentException) {
|
|
186
|
+
return true; // not a resource this app describes: its ids are its own business
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return $integers ? preg_match('/^[0-9]{1,18}$/', $value) === 1 : Str::isUuid($value);
|
|
190
|
+
}
|
|
191
|
+
|
|
133
192
|
/**
|
|
134
193
|
* GET /{slug}/_insights?unit=day|week|month&q&filter[field] → { total, unit, series, breakdown }.
|
|
135
194
|
*
|
|
@@ -154,6 +213,7 @@ final class Nevela
|
|
|
154
213
|
return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
|
|
155
214
|
}
|
|
156
215
|
self::narrow($query, $resource, $list);
|
|
216
|
+
self::proved($query, $resource);
|
|
157
217
|
$base = fn () => (clone $query)->reorder();
|
|
158
218
|
|
|
159
219
|
// Counted per day by the database, one grouped query over the created_at index,
|
|
@@ -288,6 +348,7 @@ final class Nevela
|
|
|
288
348
|
$now = now();
|
|
289
349
|
$since = $now->copy()->subDays($days);
|
|
290
350
|
$before = $now->copy()->subDays($days * 2);
|
|
351
|
+
self::proved($query, $resource);
|
|
291
352
|
$base = fn () => (clone $query)->reorder();
|
|
292
353
|
|
|
293
354
|
$values = [];
|
|
@@ -318,6 +379,26 @@ final class Nevela
|
|
|
318
379
|
return Uploads::ref($key);
|
|
319
380
|
}
|
|
320
381
|
|
|
382
|
+
/**
|
|
383
|
+
* The same for a private file: what it is, and the address it is fetched from by
|
|
384
|
+
* someone who may view the record, which names the record and never the file's key.
|
|
385
|
+
*
|
|
386
|
+
* @return array<string, mixed>|null
|
|
387
|
+
*/
|
|
388
|
+
public static function privateFile(?string $key, string $slug, mixed $id, string $field): ?array
|
|
389
|
+
{
|
|
390
|
+
return Uploads::privateRef($key, $slug, $id, $field);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* A rich text field's HTML, as it is safe to show: only the tags and attributes in
|
|
395
|
+
* config('nevela.rich_text'). Generated API resources send every rich text field through it.
|
|
396
|
+
*/
|
|
397
|
+
public static function html(?string $html): ?string
|
|
398
|
+
{
|
|
399
|
+
return $html === null ? null : Support\Html::clean($html);
|
|
400
|
+
}
|
|
401
|
+
|
|
321
402
|
/** Whether a request is one Nevela answers (so errors use Flare's shape). */
|
|
322
403
|
public static function handles(Request $request): bool
|
|
323
404
|
{
|