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.
Files changed (87) hide show
  1. package/index.mjs +25 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +66 -3
  5. package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
  6. package/template/laravel/resources/disposable-domains.txt +9880 -0
  7. package/template/laravel/src/Access/ClosedAccounts.php +2 -1
  8. package/template/laravel/src/Access/Permissions.php +36 -5
  9. package/template/laravel/src/Concerns/Confirmable.php +81 -0
  10. package/template/laravel/src/Concerns/Trashable.php +23 -0
  11. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  12. package/template/laravel/src/Console/BlogCommand.php +254 -0
  13. package/template/laravel/src/Console/DevCommand.php +9 -0
  14. package/template/laravel/src/Console/DigestCommand.php +28 -0
  15. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  16. package/template/laravel/src/Console/GenerateCommand.php +38 -3
  17. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  18. package/template/laravel/src/Console/SeedCommand.php +3 -1
  19. package/template/laravel/src/Console/SetupCommand.php +15 -2
  20. package/template/laravel/src/Console/StatusCommand.php +14 -0
  21. package/template/laravel/src/Console/TrashCommand.php +17 -0
  22. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  23. package/template/laravel/src/Forms/Confirmation.php +88 -0
  24. package/template/laravel/src/Forms/Disposable.php +74 -0
  25. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  26. package/template/laravel/src/Forms/FormGuard.php +359 -0
  27. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  28. package/template/laravel/src/Forms/PublicMail.php +217 -0
  29. package/template/laravel/src/Forms/Refusal.php +50 -0
  30. package/template/laravel/src/Generator/BlogPages.php +409 -0
  31. package/template/laravel/src/Generator/ResourceGenerator.php +356 -32
  32. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  33. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  34. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  35. package/template/laravel/src/Http/ActionController.php +223 -0
  36. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  37. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  38. package/template/laravel/src/Http/FormsController.php +45 -0
  39. package/template/laravel/src/Http/PublicController.php +294 -0
  40. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  41. package/template/laravel/src/Http/ResourceRequest.php +193 -4
  42. package/template/laravel/src/Http/TrashController.php +19 -2
  43. package/template/laravel/src/Http/UploadController.php +42 -1
  44. package/template/laravel/src/Media/Uploads.php +111 -0
  45. package/template/laravel/src/Nevela.php +83 -2
  46. package/template/laravel/src/NevelaServiceProvider.php +27 -2
  47. package/template/laravel/src/Support/Action.php +168 -0
  48. package/template/laravel/src/Support/Adoption.php +352 -0
  49. package/template/laravel/src/Support/Descriptor.php +83 -4
  50. package/template/laravel/src/Support/Fake.php +15 -1
  51. package/template/laravel/src/Support/Field.php +91 -3
  52. package/template/laravel/src/Support/Html.php +185 -0
  53. package/template/laravel/src/Support/Key.php +57 -0
  54. package/template/laravel/src/Support/ListQuery.php +29 -1
  55. package/template/laravel/src/Support/PublicAccess.php +208 -0
  56. package/template/laravel/src/Support/Slug.php +43 -0
  57. package/template/laravel/src/Trash/Trash.php +3 -1
  58. package/template/web/.env.example +4 -0
  59. package/template/web/app/api/[...path]/route.ts +42 -9
  60. package/template/web/app/confirm/page.tsx +51 -0
  61. package/template/web/app/dashboard/actions.ts +48 -0
  62. package/template/web/app/globals.css +46 -0
  63. package/template/web/components/access/role-form.tsx +40 -5
  64. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  65. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  66. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  67. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  68. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  69. package/template/web/components/dashboard/form-activity.tsx +53 -0
  70. package/template/web/components/dashboard/record-actions.tsx +33 -4
  71. package/template/web/components/dashboard/record-detail.tsx +10 -2
  72. package/template/web/components/dashboard/resource-action.tsx +281 -0
  73. package/template/web/components/dashboard/resource-form.tsx +19 -2
  74. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  75. package/template/web/components/dashboard/resource-table.tsx +46 -4
  76. package/template/web/components/dashboard/row-actions.tsx +56 -2
  77. package/template/web/components/dashboard/table-selection.tsx +19 -1
  78. package/template/web/components/marketing/site-header.tsx +9 -2
  79. package/template/web/components/public-form-guard.tsx +125 -0
  80. package/template/web/hooks/use-public-form.ts +175 -0
  81. package/template/web/lib/access.ts +3 -1
  82. package/template/web/lib/dashboard.ts +10 -0
  83. package/template/web/lib/extras.ts +105 -0
  84. package/template/web/lib/files.ts +14 -0
  85. package/template/web/lib/laravel.ts +60 -0
  86. package/template/web/lib/rich.ts +79 -0
  87. 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
- $values[] = $mapped;
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 = $model::onlyTrashed()->find($id) ?? throw new NotFoundHttpException("No deleted {$this->noun($descriptor)} with that id.");
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 = $model::onlyTrashed()->find($id) ?? throw new NotFoundHttpException("No deleted {$this->noun($descriptor)} with that id.");
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
- if ($path === '' || str_contains($path, '..') || ! preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $path)) {
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.7.1';
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
- $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,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
  {