create-nevela 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) 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/Concerns/Trashable.php +23 -0
  7. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  8. package/template/laravel/src/Console/GenerateCommand.php +13 -3
  9. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  10. package/template/laravel/src/Console/SeedCommand.php +3 -1
  11. package/template/laravel/src/Console/TrashCommand.php +8 -0
  12. package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
  13. package/template/laravel/src/Http/ActionController.php +222 -0
  14. package/template/laravel/src/Http/PublicController.php +210 -0
  15. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  16. package/template/laravel/src/Http/ResourceRequest.php +151 -2
  17. package/template/laravel/src/Http/TrashController.php +18 -2
  18. package/template/laravel/src/Http/UploadController.php +42 -1
  19. package/template/laravel/src/Media/Uploads.php +111 -0
  20. package/template/laravel/src/Nevela.php +54 -2
  21. package/template/laravel/src/NevelaServiceProvider.php +17 -1
  22. package/template/laravel/src/Support/Action.php +168 -0
  23. package/template/laravel/src/Support/Adoption.php +352 -0
  24. package/template/laravel/src/Support/Descriptor.php +59 -4
  25. package/template/laravel/src/Support/Fake.php +15 -1
  26. package/template/laravel/src/Support/Field.php +74 -3
  27. package/template/laravel/src/Support/Html.php +185 -0
  28. package/template/laravel/src/Support/PublicAccess.php +133 -0
  29. package/template/web/app/api/[...path]/route.ts +42 -9
  30. package/template/web/app/dashboard/actions.ts +34 -0
  31. package/template/web/app/globals.css +46 -0
  32. package/template/web/components/access/role-form.tsx +40 -5
  33. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  34. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  35. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  36. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  37. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  38. package/template/web/components/dashboard/record-actions.tsx +7 -2
  39. package/template/web/components/dashboard/record-detail.tsx +9 -2
  40. package/template/web/components/dashboard/resource-action.tsx +281 -0
  41. package/template/web/components/dashboard/resource-table.tsx +15 -2
  42. package/template/web/components/dashboard/row-actions.tsx +24 -0
  43. package/template/web/components/dashboard/table-selection.tsx +19 -1
  44. package/template/web/lib/access.ts +3 -1
  45. package/template/web/lib/dashboard.ts +10 -0
  46. package/template/web/lib/extras.ts +66 -0
  47. package/template/web/lib/files.ts +14 -0
  48. package/template/web/lib/laravel.ts +60 -0
  49. package/template/web/lib/rich.ts +79 -0
  50. package/template/web/package.json +1 -1
@@ -0,0 +1,210 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http;
4
+
5
+ use Illuminate\Database\Eloquent\Builder;
6
+ use Illuminate\Database\Eloquent\Model;
7
+ use Illuminate\Http\JsonResponse;
8
+ use Illuminate\Http\Request;
9
+ use Illuminate\Http\Response;
10
+ use Illuminate\Support\Facades\DB;
11
+ use Nevela\Laravel\Media\Uploads;
12
+ use Nevela\Laravel\Models\Upload;
13
+ use Nevela\Laravel\Nevela;
14
+ use Nevela\Laravel\Support\Descriptor;
15
+ use Nevela\Laravel\Support\PublicAccess;
16
+ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
17
+
18
+ /**
19
+ * What anyone can call, signed in or not: a resource's "public" section.
20
+ *
21
+ * GET {prefix}/public/{slug} the list, of the records the model says are public
22
+ * GET {prefix}/public/{slug}/{id} one of them, by id or by the field the section names
23
+ * POST {prefix}/public/{slug} create one, from the fields the section lists
24
+ * PUT {prefix}/public/_nevela/uploads/{Resource}/{field} a file for such a create
25
+ *
26
+ * The routes are generated (routes/nevela-public.php), one set per resource that has a
27
+ * section and only for what it opens, so a resource without one has nothing here to call.
28
+ *
29
+ * Nobody is asked who they are, so nothing here trusts what it is sent beyond what the
30
+ * section allows: fields that aren't listed are never sent out and never taken in, and
31
+ * the rules are the generated request's own.
32
+ */
33
+ final class PublicController
34
+ {
35
+ /** How long a file uploaded for a create waits for that create, in seconds. */
36
+ public const CLAIM_SECONDS = 3600;
37
+
38
+ public function index(Request $request): JsonResponse
39
+ {
40
+ [$descriptor, $public] = $this->open($request, 'read');
41
+ $resourceClass = $this->resourceClass($descriptor);
42
+
43
+ // Sorting, filtering and search are the signed-in list's, over the listed fields only.
44
+ $response = Nevela::listFor($this->readable($descriptor), $descriptor->only($public->read), $request, $resourceClass);
45
+ if ($response->getStatusCode() !== 200) {
46
+ return $response;
47
+ }
48
+ $body = $response->getData(true);
49
+ $body['data'] = array_map(fn (array $row) => $this->visible($descriptor, $public, $row), $body['data']);
50
+
51
+ return response()->json($body);
52
+ }
53
+
54
+ public function show(Request $request): JsonResponse
55
+ {
56
+ [$descriptor, $public] = $this->open($request, 'read');
57
+ $id = (string) $request->route('id');
58
+ $query = $this->readable($descriptor);
59
+ if ($public->by !== null) {
60
+ $query->where($descriptor->fields[$public->by]->column(), $id);
61
+ } elseif ($descriptor->idType === 'int' ? ! preg_match('/^[0-9]{1,18}$/', $id) : ! \Illuminate\Support\Str::isUuid($id)) {
62
+ throw new NotFoundHttpException;
63
+ } else {
64
+ $query->whereKey($id);
65
+ }
66
+ $record = $query->first() ?? throw new NotFoundHttpException;
67
+ $resourceClass = $this->resourceClass($descriptor);
68
+
69
+ return response()->json($this->visible($descriptor, $public, (new $resourceClass($record))->resolve($request)));
70
+ }
71
+
72
+ public function store(Request $request): JsonResponse|Response
73
+ {
74
+ [$descriptor, $public] = $this->open($request, 'create');
75
+ $requestClass = "App\\Http\\Requests\\Nevela\\{$descriptor->name}Request";
76
+ $model = $this->model($descriptor);
77
+ $input = $request->json()->all() ?: $request->request->all();
78
+
79
+ $checked = (new $requestClass)->forFields($public->create, is_array($input) ? $input : []);
80
+ // A file has to be one a visitor uploaded for this, a moment ago, and not yet used.
81
+ foreach ($public->create as $field) {
82
+ $key = $checked['values'][$descriptor->fields[$field]->column()] ?? null;
83
+ if ($descriptor->fields[$field]->kind === 'file' && is_string($key) && $key !== '' && ! $this->claimable($descriptor, $field, $key)) {
84
+ $checked['issues'][] = ['path' => $field, 'message' => 'Upload the file again: this one has been used, or was uploaded too long ago.'];
85
+ }
86
+ }
87
+ if ($checked['issues'] !== []) {
88
+ return response()->json(['error' => 'Validation failed.', 'issues' => $checked['issues']], 422);
89
+ }
90
+
91
+ $record = DB::transaction(function () use ($model, $checked, $descriptor, $public) {
92
+ $record = $model::create($checked['values']);
93
+ // Claimed: the nightly clear-out of uploads nobody used leaves it alone from here on.
94
+ foreach ($public->create as $field) {
95
+ $key = $descriptor->fields[$field]->kind === 'file' ? $record->getAttribute($descriptor->fields[$field]->column()) : null;
96
+ if (is_string($key) && $key !== '') {
97
+ Upload::query()->where('key', $key)->update(['uploaded_by' => Uploads::CLAIMED]);
98
+ }
99
+ }
100
+
101
+ return $record;
102
+ });
103
+
104
+ // What they may read of what they made, or nothing where nothing may be read.
105
+ if ($public->read === null) {
106
+ return response()->noContent(201);
107
+ }
108
+ $resourceClass = $this->resourceClass($descriptor);
109
+
110
+ return response()->json($this->visible($descriptor, $public, (new $resourceClass($record->refresh()))->resolve($request)), 201);
111
+ }
112
+
113
+ /** A file for a public create: the field's own types and size limit, and no sign-in. */
114
+ public function upload(Request $request): JsonResponse
115
+ {
116
+ [$descriptor, $public] = $this->open($request, 'create');
117
+ $field = (string) $request->route('field');
118
+ $definition = $descriptor->fields[$field] ?? null;
119
+ if ($definition === null || $definition->kind !== 'file' || ! in_array($field, $public->create, true)) {
120
+ throw new NotFoundHttpException;
121
+ }
122
+ $upload = UploadController::receive($request, $descriptor, $definition);
123
+ if ($upload instanceof JsonResponse) {
124
+ return $upload;
125
+ }
126
+ // Marked as a visitor's, waiting to be used: see claimable() and Uploads::purgeUnclaimed().
127
+ $upload->update(['uploaded_by' => Uploads::UNCLAIMED]);
128
+
129
+ return response()->json(Uploads::ref($upload->key), 201);
130
+ }
131
+
132
+ /**
133
+ * The resource a route is for, if this part of it is open. The route is only there when
134
+ * it is, so this failing means a route file older than the descriptor.
135
+ *
136
+ * Which resource is a default set on the route. Laravel hands route parameters to a
137
+ * controller in order and not by name, with defaults after what the address holds, so
138
+ * it is asked for by its name here.
139
+ *
140
+ * @return array{Descriptor, PublicAccess}
141
+ */
142
+ private function open(Request $request, string $what): array
143
+ {
144
+ try {
145
+ $descriptor = Nevela::resource((string) $request->route('resource'));
146
+ } catch (\InvalidArgumentException) {
147
+ throw new NotFoundHttpException;
148
+ }
149
+ if ($descriptor->public?->{$what} === null || ! class_exists($this->model($descriptor))) {
150
+ throw new NotFoundHttpException;
151
+ }
152
+
153
+ return [$descriptor, $descriptor->public];
154
+ }
155
+
156
+ /** @return class-string<Model> */
157
+ private function model(Descriptor $descriptor): string
158
+ {
159
+ return "App\\Models\\{$descriptor->name}";
160
+ }
161
+
162
+ /** @return class-string<\Illuminate\Http\Resources\Json\JsonResource> */
163
+ private function resourceClass(Descriptor $descriptor): string
164
+ {
165
+ return "App\\Http\\Resources\\Nevela\\{$descriptor->name}Resource";
166
+ }
167
+
168
+ /**
169
+ * The records anyone may read: what the model's publicQuery scope lets through. A model
170
+ * without the scope has said nothing, and nothing is what is public.
171
+ */
172
+ private function readable(Descriptor $descriptor): Builder
173
+ {
174
+ $model = $this->model($descriptor);
175
+ $query = $model::query();
176
+
177
+ return method_exists($model, 'scopePublicQuery') ? $query->publicQuery() : $query->whereRaw('1 = 0');
178
+ }
179
+
180
+ /**
181
+ * A record as anyone may see it: its id and the listed fields, and for a file the
182
+ * details that go with its key. Nothing else, whatever the API resource holds.
183
+ *
184
+ * @param array<string, mixed> $row
185
+ * @return array<string, mixed>
186
+ */
187
+ private function visible(Descriptor $descriptor, PublicAccess $public, array $row): array
188
+ {
189
+ $seen = ['id' => $row['id'] ?? null];
190
+ foreach ($public->read ?? [] as $field) {
191
+ $seen[$field] = $row[$field] ?? null;
192
+ if ($descriptor->fields[$field]->kind === 'file') {
193
+ $seen[$field.'File'] = $row[$field.'File'] ?? null;
194
+ }
195
+ }
196
+
197
+ return $seen;
198
+ }
199
+
200
+ /** Whether a key is a file a visitor uploaded to this field within the hour, that no record has yet. */
201
+ private function claimable(Descriptor $descriptor, string $field, string $key): bool
202
+ {
203
+ $upload = Upload::query()->where('key', $key)->where('resource', $descriptor->name)->where('field', $field)->first();
204
+
205
+ return $upload !== null
206
+ && $upload->getAttribute('uploaded_by') === Uploads::UNCLAIMED
207
+ && $upload->created_at !== null && $upload->created_at->gt(now()->subSeconds(self::CLAIM_SECONDS))
208
+ && ! DB::table($descriptor->table)->where($descriptor->fields[$field]->column(), $key)->exists();
209
+ }
210
+ }
@@ -0,0 +1,48 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Http;
4
+
5
+ use Closure;
6
+ use Illuminate\Routing\Middleware\ThrottleRequests;
7
+ use Nevela\Laravel\Auth\Account;
8
+
9
+ /**
10
+ * The throttle on a resource's public routes: Laravel's own, counting the right thing.
11
+ *
12
+ * A public page or form is usually reached through the web app, whose server makes the
13
+ * call. Counted as they arrive, every visitor would be that one server: ten enquiries a
14
+ * minute for the whole world, and a busy page would lock itself out. So, for a request
15
+ * that proves it comes from the web app (the secret the two share, as for the list of
16
+ * signed-in devices):
17
+ *
18
+ * - one made for a visitor is counted against that visitor's address, which the web app
19
+ * passes on as X-Nevela-Client. It is the address the proxy in front of the web app saw,
20
+ * not the one the request claimed for itself: a visitor who could choose it could have
21
+ * a fresh allowance with every request. Where the web app can't tell (it says
22
+ * "unknown"), visitors are counted together, from the web app's own address;
23
+ * - one made for nobody in particular, such as a page being built to be cached and
24
+ * shown to many, is the site reading its own data and is not counted. Such a request
25
+ * carries the secret and names no visitor at all.
26
+ *
27
+ * Anything else is counted against the address it came from, as ever.
28
+ */
29
+ final class PublicThrottle extends ThrottleRequests
30
+ {
31
+ public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = '')
32
+ {
33
+ if (Account::fromDashboard($request)) {
34
+ $visitor = $request->header('X-Nevela-Client');
35
+ if ($visitor === null) {
36
+ // Only on the strength of the secret: "from this machine, in development" is
37
+ // enough to believe an address, and not enough to stop counting.
38
+ if ((string) config('nevela.auth.proxy_secret', '') !== '') {
39
+ return $next($request);
40
+ }
41
+ } elseif (filter_var($visitor, FILTER_VALIDATE_IP)) {
42
+ $request->server->set('REMOTE_ADDR', $visitor);
43
+ }
44
+ }
45
+
46
+ return parent::handle($request, $next, $maxAttempts, $decayMinutes, $prefix);
47
+ }
48
+ }
@@ -6,8 +6,10 @@ use Illuminate\Foundation\Http\FormRequest;
6
6
  use Illuminate\Support\Facades\DB;
7
7
  use Illuminate\Support\Facades\Schema;
8
8
  use Illuminate\Support\Facades\Validator as Validation;
9
+ use Illuminate\Validation\Rules\Exists;
9
10
  use Illuminate\Validation\Rules\Unique;
10
11
  use Illuminate\Validation\Validator;
12
+ use Nevela\Laravel\Support\Html;
11
13
 
12
14
  /**
13
15
  * Base for generated resource requests.
@@ -31,9 +33,102 @@ abstract class ResourceRequest extends FormRequest
31
33
  /** @return array<string, string> field => column */
32
34
  abstract protected function columns(): array;
33
35
 
36
+ /**
37
+ * Fields that hold rich text. A generated request with any says which.
38
+ *
39
+ * @return list<string>
40
+ */
41
+ protected function rich(): array
42
+ {
43
+ return [];
44
+ }
45
+
46
+ /**
47
+ * What a field is when a create leaves it out. A generated request with any says which.
48
+ *
49
+ * @return array<string, mixed> field => value
50
+ */
51
+ protected function defaults(): array
52
+ {
53
+ return [];
54
+ }
55
+
56
+ /**
57
+ * A create from some of the fields only: a public form's, where nobody is asked who
58
+ * they are and so only what the descriptor lists is taken.
59
+ *
60
+ * The rules are this request's own. A key that isn't listed is refused the way an
61
+ * unknown one is, and a field left out takes its default, or is left to the column.
62
+ *
63
+ * @param list<string> $fields The fields that may be sent
64
+ * @param array<string, mixed> $input
65
+ * @return array{values: array<string, mixed>, issues: list<array{path: string, message: string}>}
66
+ */
67
+ public function forFields(array $fields, array $input): array
68
+ {
69
+ $input = self::cleaned($this->rich(), $input) + $input;
70
+ $issues = [];
71
+ foreach (array_keys($input) as $key) {
72
+ if (! in_array($key, $fields, true)) {
73
+ $issues[] = ['path' => (string) $key, 'message' => 'Unknown field.'];
74
+ }
75
+ }
76
+ $validator = Validation::make($input, array_intersect_key(self::withoutTrashed($this->rules()), array_flip($fields)));
77
+ if ($validator->fails()) {
78
+ self::explainTrashed($validator, $input);
79
+ array_push($issues, ...FlareErrors::issues($validator->errors()->toArray()));
80
+ }
81
+ if ($issues !== []) {
82
+ return ['values' => [], 'issues' => $issues];
83
+ }
84
+
85
+ $validated = $validator->validated();
86
+ $defaults = $this->defaults();
87
+ $values = [];
88
+ foreach ($this->columns() as $field => $column) {
89
+ if (array_key_exists($field, $validated)) {
90
+ $values[$column] = $validated[$field];
91
+ } elseif (array_key_exists($field, $defaults)) {
92
+ $values[$column] = $defaults[$field];
93
+ }
94
+ }
95
+
96
+ return ['values' => $values, 'issues' => []];
97
+ }
98
+
99
+ /**
100
+ * Rich text is cleaned before anything looks at it: what is checked for being there,
101
+ * and for being short enough, is the HTML that would be stored.
102
+ */
103
+ protected function prepareForValidation(): void
104
+ {
105
+ $this->merge(self::cleaned($this->rich(), $this->all()));
106
+ }
107
+
108
+ /**
109
+ * @param list<string> $fields
110
+ * @param array<string, mixed> $input
111
+ * @return array<string, mixed> The fields that were sent as text, cleaned. One with nothing left to read is empty.
112
+ */
113
+ private static function cleaned(array $fields, array $input): array
114
+ {
115
+ $clean = [];
116
+ foreach ($fields as $field) {
117
+ if (is_string($input[$field] ?? null)) {
118
+ $html = Html::clean($input[$field]);
119
+ $clean[$field] = $html === '' ? null : $html;
120
+ }
121
+ }
122
+
123
+ return $clean;
124
+ }
125
+
126
+ /** Tables known to have a trash. Only "yes" is remembered: the column can arrive with a migration. */
127
+ private static array $trashes = [];
128
+
34
129
  protected function validationRules()
35
130
  {
36
- $rules = parent::validationRules();
131
+ $rules = self::withoutTrashed(parent::validationRules());
37
132
  if (! $this->isMethod('PATCH')) {
38
133
  return $rules;
39
134
  }
@@ -57,9 +152,36 @@ abstract class ResourceRequest extends FormRequest
57
152
  });
58
153
  }
59
154
 
155
+ /**
156
+ * A relation points at a record that is there, and one in the trash is not: nothing
157
+ * lists it and its own page answers 404. Laravel's exists rule looks at the table,
158
+ * where a deleted record still is, so it is told to leave those out.
159
+ *
160
+ * @param array<string, mixed> $rules
161
+ * @return array<string, mixed>
162
+ */
163
+ private static function withoutTrashed(array $rules): array
164
+ {
165
+ foreach ($rules as $set) {
166
+ foreach (is_array($set) ? $set : [] as $rule) {
167
+ if (! $rule instanceof Exists) {
168
+ continue;
169
+ }
170
+ // "exists:categories,id": the rule keeps its table to itself.
171
+ $table = (string) strtok(substr((string) $rule, strlen('exists:')), ',');
172
+ if (self::$trashes[$table] ?? (Schema::hasColumn($table, 'deleted_at') && (self::$trashes[$table] = true))) {
173
+ $rule->withoutTrashed();
174
+ }
175
+ }
176
+ }
177
+
178
+ return $rules;
179
+ }
180
+
60
181
  /**
61
182
  * A unique value can be held by a record nobody can see: one in the trash. "Already
62
183
  * taken" would send someone looking through a list it isn't in, so say where it is.
184
+ * The same goes for a relation to a record that is in the trash.
63
185
  *
64
186
  * @param array<string, mixed> $data
65
187
  * @return list<array{path: string, message: string}> The messages that were replaced
@@ -82,6 +204,25 @@ abstract class ResourceRequest extends FormRequest
82
204
  $validator->errors()->add($field, $message);
83
205
  $replaced[] = ['path' => (string) $field, 'message' => $message];
84
206
  }
207
+ foreach ($validator->failed() as $field => $rules) {
208
+ // And for a failed exists rule: the table and the column, then what else it asked.
209
+ [$table, $column] = array_pad($rules['Exists'] ?? [], 2, null);
210
+ $value = $data[$field] ?? null;
211
+ if (! is_string($table) || ! is_string($column) || ! is_scalar($value) || ! (self::$trashes[$table] ?? false)) {
212
+ continue;
213
+ }
214
+ try {
215
+ $trashed = DB::table($table)->where($column, $value)->whereNotNull('deleted_at')->exists();
216
+ } catch (\Throwable) {
217
+ continue; // not something that could be an id at all
218
+ }
219
+ if ($trashed) {
220
+ $message = 'This one is in the trash. Restore it, or choose another.';
221
+ $validator->errors()->forget($field);
222
+ $validator->errors()->add($field, $message);
223
+ $replaced[] = ['path' => (string) $field, 'message' => $message];
224
+ }
225
+ }
85
226
 
86
227
  return $replaced;
87
228
  }
@@ -101,8 +242,9 @@ abstract class ResourceRequest extends FormRequest
101
242
  */
102
243
  public function many(array $rows): array
103
244
  {
104
- $rules = $this->rules();
245
+ $rules = self::withoutTrashed($this->rules());
105
246
  $columns = $this->columns();
247
+ $defaults = $this->defaults();
106
248
  $known = array_keys($columns);
107
249
  $computed = array_map(fn (string $field) => $field.'File', $known);
108
250
  $unique = array_keys(array_filter($rules, fn ($rule) => is_array($rule) && array_filter($rule, fn ($one) => $one instanceof Unique) !== []));
@@ -124,6 +266,7 @@ abstract class ResourceRequest extends FormRequest
124
266
  $issues[] = ['path' => (string) $key, 'message' => 'Unknown field.'];
125
267
  }
126
268
  }
269
+ $row = self::cleaned($this->rich(), $row) + $row;
127
270
  $validator = Validation::make($row, $rules);
128
271
  if ($validator->fails()) {
129
272
  self::explainTrashed($validator, $row);
@@ -152,6 +295,8 @@ abstract class ResourceRequest extends FormRequest
152
295
  foreach ($columns as $field => $column) {
153
296
  if (array_key_exists($field, $validated)) {
154
297
  $mapped[$column] = $validated[$field];
298
+ } elseif (array_key_exists($field, $defaults)) {
299
+ $mapped[$column] = $defaults[$field];
155
300
  }
156
301
  }
157
302
  $values[] = $mapped;
@@ -164,10 +309,14 @@ abstract class ResourceRequest extends FormRequest
164
309
  public function values(): array
165
310
  {
166
311
  $validated = $this->validated();
312
+ $defaults = $this->defaults();
167
313
  $values = [];
168
314
  foreach ($this->columns() as $field => $column) {
169
315
  if (array_key_exists($field, $validated)) {
170
316
  $values[$column] = $validated[$field];
317
+ } elseif (array_key_exists($field, $defaults) && ! $this->isMethod('PATCH')) {
318
+ // Left out of a create, or of a replace, it is what it usually is.
319
+ $values[$column] = $defaults[$field];
171
320
  } elseif ($this->isMethod('PUT')) {
172
321
  $values[$column] = null;
173
322
  }
@@ -92,7 +92,7 @@ final class TrashController
92
92
  public function restore(string $slug, string $id): JsonResponse
93
93
  {
94
94
  ['descriptor' => $descriptor, 'model' => $model] = $this->resource($slug);
95
- $record = $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);