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.
Files changed (53) hide show
  1. package/index.mjs +1 -0
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +25 -3
  5. package/template/laravel/src/Access/Permissions.php +36 -5
  6. package/template/laravel/src/Access/SampleUsers.php +1 -3
  7. package/template/laravel/src/Concerns/Trashable.php +23 -0
  8. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  9. package/template/laravel/src/Console/GenerateCommand.php +13 -3
  10. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  11. package/template/laravel/src/Console/SeedCommand.php +3 -1
  12. package/template/laravel/src/Console/TrashCommand.php +8 -0
  13. package/template/laravel/src/Console/UserCommand.php +5 -1
  14. package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
  15. package/template/laravel/src/Http/Access/UsersController.php +2 -4
  16. package/template/laravel/src/Http/ActionController.php +222 -0
  17. package/template/laravel/src/Http/PublicController.php +210 -0
  18. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  19. package/template/laravel/src/Http/ResourceRequest.php +151 -2
  20. package/template/laravel/src/Http/TrashController.php +18 -2
  21. package/template/laravel/src/Http/UploadController.php +42 -1
  22. package/template/laravel/src/Media/Uploads.php +111 -0
  23. package/template/laravel/src/Nevela.php +54 -2
  24. package/template/laravel/src/NevelaServiceProvider.php +17 -1
  25. package/template/laravel/src/Support/Action.php +168 -0
  26. package/template/laravel/src/Support/Adoption.php +352 -0
  27. package/template/laravel/src/Support/Descriptor.php +59 -4
  28. package/template/laravel/src/Support/Fake.php +15 -1
  29. package/template/laravel/src/Support/Field.php +74 -3
  30. package/template/laravel/src/Support/Html.php +185 -0
  31. package/template/laravel/src/Support/PublicAccess.php +133 -0
  32. package/template/web/app/api/[...path]/route.ts +42 -9
  33. package/template/web/app/dashboard/actions.ts +34 -0
  34. package/template/web/app/globals.css +46 -0
  35. package/template/web/components/access/role-form.tsx +40 -5
  36. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  37. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  38. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  39. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  40. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  41. package/template/web/components/dashboard/record-actions.tsx +7 -2
  42. package/template/web/components/dashboard/record-detail.tsx +9 -2
  43. package/template/web/components/dashboard/resource-action.tsx +281 -0
  44. package/template/web/components/dashboard/resource-table.tsx +15 -2
  45. package/template/web/components/dashboard/row-actions.tsx +24 -0
  46. package/template/web/components/dashboard/table-selection.tsx +19 -1
  47. package/template/web/lib/access.ts +3 -1
  48. package/template/web/lib/dashboard.ts +10 -0
  49. package/template/web/lib/extras.ts +66 -0
  50. package/template/web/lib/files.ts +14 -0
  51. package/template/web/lib/laravel.ts +60 -0
  52. package/template/web/lib/rich.ts +79 -0
  53. 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 = $model::onlyTrashed()->find($id) ?? throw new NotFoundHttpException("No deleted {$this->noun($descriptor)} with that id.");
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 = $model::onlyTrashed()->find($id) ?? throw new NotFoundHttpException("No deleted {$this->noun($descriptor)} with that id.");
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
- if ($path === '' || str_contains($path, '..') || ! preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $path)) {
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.7.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
- $resource = self::resource($name);
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
  }