create-nevela 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/Access/SampleUsers.php +1 -3
- 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/Console/UserCommand.php +5 -1
- package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
- package/template/laravel/src/Http/Access/UsersController.php +2 -4
- 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
|
@@ -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);
|
|
@@ -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.8.0';
|
|
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,32 @@ final class Nevela
|
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
foreach ($list->filters as $filter) {
|
|
139
|
+
$field = $resource->fields[$filter['field']] ?? null;
|
|
140
|
+
if ($filter['value'] !== null && $field?->kind === 'belongsTo' && ! self::couldBeId((string) $field->target, (string) $filter['value'])) {
|
|
141
|
+
// Nothing belongs to a record that can't exist. Asked anyway, some databases
|
|
142
|
+
// answer a number column given letters with an error instead of with no rows.
|
|
143
|
+
$query->whereRaw('1 = 0');
|
|
144
|
+
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
127
147
|
$filter['value'] === null
|
|
128
148
|
? $query->whereNull($filter['column'])
|
|
129
149
|
: $query->where($filter['column'], $filter['value']);
|
|
130
150
|
}
|
|
131
151
|
}
|
|
132
152
|
|
|
153
|
+
/** Whether a value has the shape of one of a resource's ids: digits, or a UUID. */
|
|
154
|
+
private static function couldBeId(string $name, string $value): bool
|
|
155
|
+
{
|
|
156
|
+
try {
|
|
157
|
+
$integers = self::resource($name)->idType === 'int';
|
|
158
|
+
} catch (InvalidArgumentException) {
|
|
159
|
+
return true; // not a resource this app describes: its ids are its own business
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return $integers ? preg_match('/^[0-9]{1,18}$/', $value) === 1 : Str::isUuid($value);
|
|
163
|
+
}
|
|
164
|
+
|
|
133
165
|
/**
|
|
134
166
|
* GET /{slug}/_insights?unit=day|week|month&q&filter[field] → { total, unit, series, breakdown }.
|
|
135
167
|
*
|
|
@@ -318,6 +350,26 @@ final class Nevela
|
|
|
318
350
|
return Uploads::ref($key);
|
|
319
351
|
}
|
|
320
352
|
|
|
353
|
+
/**
|
|
354
|
+
* The same for a private file: what it is, and the address it is fetched from by
|
|
355
|
+
* someone who may view the record, which names the record and never the file's key.
|
|
356
|
+
*
|
|
357
|
+
* @return array<string, mixed>|null
|
|
358
|
+
*/
|
|
359
|
+
public static function privateFile(?string $key, string $slug, mixed $id, string $field): ?array
|
|
360
|
+
{
|
|
361
|
+
return Uploads::privateRef($key, $slug, $id, $field);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* A rich text field's HTML, as it is safe to show: only the tags and attributes in
|
|
366
|
+
* config('nevela.rich_text'). Generated API resources send every rich text field through it.
|
|
367
|
+
*/
|
|
368
|
+
public static function html(?string $html): ?string
|
|
369
|
+
{
|
|
370
|
+
return $html === null ? null : Support\Html::clean($html);
|
|
371
|
+
}
|
|
372
|
+
|
|
321
373
|
/** Whether a request is one Nevela answers (so errors use Flare's shape). */
|
|
322
374
|
public static function handles(Request $request): bool
|
|
323
375
|
{
|
|
@@ -13,6 +13,7 @@ use Illuminate\Support\Facades\Route;
|
|
|
13
13
|
use Illuminate\Support\ServiceProvider;
|
|
14
14
|
use Nevela\Laravel\Access\Access;
|
|
15
15
|
use Nevela\Laravel\Access\Permissions;
|
|
16
|
+
use Nevela\Laravel\Console\AdoptCommand;
|
|
16
17
|
use Nevela\Laravel\Console\DevCommand;
|
|
17
18
|
use Nevela\Laravel\Console\GenerateCommand;
|
|
18
19
|
use Nevela\Laravel\Console\MakeResourceCommand;
|
|
@@ -31,7 +32,9 @@ use Nevela\Laravel\Http\FlareErrors;
|
|
|
31
32
|
use Nevela\Laravel\Http\Auth\AccountController;
|
|
32
33
|
use Nevela\Laravel\Http\Auth\SecurityController;
|
|
33
34
|
use Nevela\Laravel\Http\Auth\SignInController;
|
|
35
|
+
use Nevela\Laravel\Http\PublicThrottle;
|
|
34
36
|
use Nevela\Laravel\Http\TokenController;
|
|
37
|
+
use Nevela\Laravel\Http\ActionController;
|
|
35
38
|
use Nevela\Laravel\Http\TrashController;
|
|
36
39
|
use Nevela\Laravel\Http\UploadController;
|
|
37
40
|
use Nevela\Laravel\Media\Uploads;
|
|
@@ -58,7 +61,7 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
58
61
|
|
|
59
62
|
$this->publishes([__DIR__.'/../config/nevela.php' => config_path('nevela.php')], 'nevela-config');
|
|
60
63
|
$this->commands([
|
|
61
|
-
MakeResourceCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
|
|
64
|
+
MakeResourceCommand::class, AdoptCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
|
|
62
65
|
UpdateCommand::class, DevCommand::class, StatusCommand::class, VersionCommand::class, SetupCommand::class,
|
|
63
66
|
TrashCommand::class,
|
|
64
67
|
]);
|
|
@@ -172,12 +175,21 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
172
175
|
'app' => Nevela::fingerprint(),
|
|
173
176
|
]))->name('nevela.ping');
|
|
174
177
|
|
|
178
|
+
Route::aliasMiddleware('nevela.throttle', PublicThrottle::class);
|
|
179
|
+
|
|
180
|
+
// What a resource's "public" section opens: no sign-in, and each route its own throttle.
|
|
181
|
+
if (is_file($open = base_path('routes/nevela-public.php'))) {
|
|
182
|
+
Route::prefix(trim($prefix.'/public', '/'))->middleware('api')->name('nevela.public.')->group($open);
|
|
183
|
+
}
|
|
184
|
+
|
|
175
185
|
// Stored files. Open, like any image on a website: the key is the secret.
|
|
176
186
|
Route::prefix($prefix)->middleware('api')->get('_nevela/files/{path}', [UploadController::class, 'show'])
|
|
177
187
|
->where('path', '.*')->name('nevela.files.show');
|
|
178
188
|
|
|
179
189
|
Route::prefix($prefix)->middleware(config('nevela.middleware', ['api', 'auth:sanctum']))->name('nevela.')->group(function () {
|
|
180
190
|
Route::put('_nevela/uploads/{resource}/{field}', [UploadController::class, 'store'])->name('uploads.store');
|
|
191
|
+
// A private file: only for someone who may view the record it belongs to.
|
|
192
|
+
Route::get('_nevela/private/{slug}/{id}/{field}', [UploadController::class, 'download'])->name('private.show');
|
|
181
193
|
|
|
182
194
|
// The image profiles, for a client that wants to know which renditions exist.
|
|
183
195
|
Route::get('_nevela/profiles', fn () => response()->json([
|
|
@@ -221,6 +233,10 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
221
233
|
'data' => array_map(fn ($d) => $d->toArray(), Nevela::all()),
|
|
222
234
|
]))->name('resources');
|
|
223
235
|
|
|
236
|
+
// A resource's own actions: on one record, and on the list or a selection from it.
|
|
237
|
+
Route::post('{slug}/_actions/{action}', [ActionController::class, 'collection'])->name('actions.collection');
|
|
238
|
+
Route::post('{slug}/{id}/_actions/{action}', [ActionController::class, 'record'])->name('actions.record');
|
|
239
|
+
|
|
224
240
|
if (is_file($routes = base_path('routes/nevela.php'))) {
|
|
225
241
|
require $routes;
|
|
226
242
|
}
|