create-nevela 0.8.0 → 0.9.2

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 (62) hide show
  1. package/index.mjs +24 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +41 -0
  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/Concerns/Confirmable.php +81 -0
  9. package/template/laravel/src/Console/BlogCommand.php +254 -0
  10. package/template/laravel/src/Console/DevCommand.php +9 -0
  11. package/template/laravel/src/Console/DigestCommand.php +28 -0
  12. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  13. package/template/laravel/src/Console/GenerateCommand.php +25 -0
  14. package/template/laravel/src/Console/SetupCommand.php +15 -2
  15. package/template/laravel/src/Console/StatusCommand.php +14 -0
  16. package/template/laravel/src/Console/TrashCommand.php +9 -0
  17. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  18. package/template/laravel/src/Forms/Confirmation.php +88 -0
  19. package/template/laravel/src/Forms/Disposable.php +74 -0
  20. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  21. package/template/laravel/src/Forms/FormGuard.php +359 -0
  22. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  23. package/template/laravel/src/Forms/PublicMail.php +217 -0
  24. package/template/laravel/src/Forms/Refusal.php +50 -0
  25. package/template/laravel/src/Generator/BlogPages.php +409 -0
  26. package/template/laravel/src/Generator/ResourceGenerator.php +133 -7
  27. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  28. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  29. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  30. package/template/laravel/src/Http/ActionController.php +2 -1
  31. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  32. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  33. package/template/laravel/src/Http/FormsController.php +45 -0
  34. package/template/laravel/src/Http/PublicController.php +89 -4
  35. package/template/laravel/src/Http/ResourceRequest.php +43 -3
  36. package/template/laravel/src/Http/TrashController.php +2 -1
  37. package/template/laravel/src/Http/UploadController.php +2 -2
  38. package/template/laravel/src/Nevela.php +30 -1
  39. package/template/laravel/src/NevelaServiceProvider.php +11 -2
  40. package/template/laravel/src/Support/Descriptor.php +24 -0
  41. package/template/laravel/src/Support/Field.php +17 -0
  42. package/template/laravel/src/Support/Key.php +57 -0
  43. package/template/laravel/src/Support/ListQuery.php +29 -1
  44. package/template/laravel/src/Support/PublicAccess.php +75 -0
  45. package/template/laravel/src/Support/Slug.php +43 -0
  46. package/template/laravel/src/Trash/Trash.php +3 -1
  47. package/template/web/.env.example +4 -0
  48. package/template/web/app/api/[...path]/route.ts +6 -3
  49. package/template/web/app/confirm/page.tsx +51 -0
  50. package/template/web/app/dashboard/actions.ts +15 -1
  51. package/template/web/components/dashboard/form-activity.tsx +53 -0
  52. package/template/web/components/dashboard/record-actions.tsx +29 -5
  53. package/template/web/components/dashboard/record-detail.tsx +2 -1
  54. package/template/web/components/dashboard/resource-form.tsx +19 -2
  55. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  56. package/template/web/components/dashboard/resource-table.tsx +32 -3
  57. package/template/web/components/dashboard/row-actions.tsx +33 -3
  58. package/template/web/components/marketing/site-header.tsx +9 -2
  59. package/template/web/components/public-form-guard.tsx +125 -0
  60. package/template/web/hooks/use-public-form.ts +263 -0
  61. package/template/web/lib/extras.ts +39 -0
  62. package/template/web/package.json +1 -1
@@ -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;
@@ -176,7 +177,7 @@ final class TrashController
176
177
  {
177
178
  // Where ids are integers, something that isn't one is nobody's id. Asked anyway,
178
179
  // some databases answer with an error instead of with nothing.
179
- if ((new $model)->getKeyType() === 'int' && ! preg_match('/^[0-9]{1,18}$/', $id)) {
180
+ if (! Key::fits($model, $id)) {
180
181
  return null;
181
182
  }
182
183
 
@@ -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
  /**
@@ -171,8 +172,7 @@ final class UploadController
171
172
  }
172
173
  $definition = $descriptor?->fields[$field] ?? null;
173
174
  $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))) {
175
+ if ($descriptor === null || $definition === null || $definition->kind !== 'file' || ! $definition->private || ! class_exists($model) || ! Key::fits($model, $id)) {
176
176
  abort(404);
177
177
  }
178
178
  // A record in the trash is found by nobody here: trashing it exposes nothing.
@@ -22,7 +22,7 @@ use Nevela\Laravel\Support\Naming;
22
22
  final class Nevela
23
23
  {
24
24
  /** Written by `pnpm release`; the same number as the repository's git tag. */
25
- public const VERSION = '0.8.0';
25
+ public const VERSION = '0.9.2';
26
26
 
27
27
  /** @var array<string, Descriptor> */
28
28
  private static array $descriptors = [];
@@ -136,6 +136,21 @@ final class Nevela
136
136
  }
137
137
 
138
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
+ }
139
154
  $field = $resource->fields[$filter['field']] ?? null;
140
155
  if ($filter['value'] !== null && $field?->kind === 'belongsTo' && ! self::couldBeId((string) $field->target, (string) $filter['value'])) {
141
156
  // Nothing belongs to a record that can't exist. Asked anyway, some databases
@@ -150,6 +165,18 @@ final class Nevela
150
165
  }
151
166
  }
152
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
+
153
180
  /** Whether a value has the shape of one of a resource's ids: digits, or a UUID. */
154
181
  private static function couldBeId(string $name, string $value): bool
155
182
  {
@@ -186,6 +213,7 @@ final class Nevela
186
213
  return response()->json(['error' => 'Invalid query.', 'issues' => $list->issues], 400);
187
214
  }
188
215
  self::narrow($query, $resource, $list);
216
+ self::proved($query, $resource);
189
217
  $base = fn () => (clone $query)->reorder();
190
218
 
191
219
  // Counted per day by the database, one grouped query over the created_at index,
@@ -320,6 +348,7 @@ final class Nevela
320
348
  $now = now();
321
349
  $since = $now->copy()->subDays($days);
322
350
  $before = $now->copy()->subDays($days * 2);
351
+ self::proved($query, $resource);
323
352
  $base = fn () => (clone $query)->reorder();
324
353
 
325
354
  $values = [];
@@ -14,7 +14,10 @@ use Illuminate\Support\ServiceProvider;
14
14
  use Nevela\Laravel\Access\Access;
15
15
  use Nevela\Laravel\Access\Permissions;
16
16
  use Nevela\Laravel\Console\AdoptCommand;
17
+ use Nevela\Laravel\Console\BlogCommand;
17
18
  use Nevela\Laravel\Console\DevCommand;
19
+ use Nevela\Laravel\Console\DigestCommand;
20
+ use Nevela\Laravel\Console\DisposableCommand;
18
21
  use Nevela\Laravel\Console\GenerateCommand;
19
22
  use Nevela\Laravel\Console\MakeResourceCommand;
20
23
  use Nevela\Laravel\Console\SeedCommand;
@@ -29,6 +32,7 @@ use Nevela\Laravel\Http\Access\RolesAreSetUp;
29
32
  use Nevela\Laravel\Http\Access\RolesController;
30
33
  use Nevela\Laravel\Http\Access\UsersController;
31
34
  use Nevela\Laravel\Http\FlareErrors;
35
+ use Nevela\Laravel\Http\FormsController;
32
36
  use Nevela\Laravel\Http\Auth\AccountController;
33
37
  use Nevela\Laravel\Http\Auth\SecurityController;
34
38
  use Nevela\Laravel\Http\Auth\SignInController;
@@ -61,9 +65,9 @@ final class NevelaServiceProvider extends ServiceProvider
61
65
 
62
66
  $this->publishes([__DIR__.'/../config/nevela.php' => config_path('nevela.php')], 'nevela-config');
63
67
  $this->commands([
64
- MakeResourceCommand::class, AdoptCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
68
+ MakeResourceCommand::class, AdoptCommand::class, BlogCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
65
69
  UpdateCommand::class, DevCommand::class, StatusCommand::class, VersionCommand::class, SetupCommand::class,
66
- TrashCommand::class,
70
+ TrashCommand::class, DigestCommand::class, DisposableCommand::class,
67
71
  ]);
68
72
 
69
73
  // What has been in the trash too long is removed each night, wherever the app's
@@ -71,6 +75,8 @@ final class NevelaServiceProvider extends ServiceProvider
71
75
  // happens whenever someone opens the trash.
72
76
  $this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
73
77
  $schedule->command(TrashCommand::class)->dailyAt('03:40')->withoutOverlapping();
78
+ // The notices to the owner that were held back in a busy hour, as one email.
79
+ $schedule->command(DigestCommand::class)->hourly()->withoutOverlapping();
74
80
  });
75
81
  }
76
82
 
@@ -204,6 +210,9 @@ final class NevelaServiceProvider extends ServiceProvider
204
210
  Route::delete('_nevela/trash/{slug}/{id}', [TrashController::class, 'destroy'])->name('trash.destroy');
205
211
  Route::delete('_nevela/trash/{slug}', [TrashController::class, 'empty'])->name('trash.empty');
206
212
 
213
+ // A public form, from the inside: what it checks and what it has turned away.
214
+ Route::get('_nevela/forms/{slug}', [FormsController::class, 'show'])->name('forms.show');
215
+
207
216
  // Users and roles, for whoever holds the permissions to manage them.
208
217
  Route::middleware(RolesAreSetUp::class)->group(function () {
209
218
  Route::get('_nevela/users', [UsersController::class, 'index'])->name('users.index');
@@ -21,6 +21,8 @@ final class Descriptor
21
21
  * @param bool $adopted The table was there before Nevela, so no migration is written to create it
22
22
  * @param array<string, Action> $actions What can be done to a record or to the list beyond create, edit and delete, by name
23
23
  * @param PublicAccess|null $public What of it anyone may read or create without signing in. Nothing, when null.
24
+ * @param string|null $publish The date-and-time field that says when a record is published: empty is a draft, a time to come is scheduled
25
+ * @param string|null $blog Where on the site its public records are a blog, e.g. "/blog". The pages are written by `nevela:blog`.
24
26
  */
25
27
  public function __construct(
26
28
  public readonly string $name,
@@ -36,6 +38,8 @@ final class Descriptor
36
38
  public readonly bool $adopted = false,
37
39
  public readonly array $actions = [],
38
40
  public readonly ?PublicAccess $public = null,
41
+ public readonly ?string $publish = null,
42
+ public readonly ?string $blog = null,
39
43
  ) {
40
44
  if (! preg_match('/^[A-Z][A-Za-z0-9]*$/', $name)) {
41
45
  throw new InvalidArgumentException('Resource name must be PascalCase, e.g. Product or BlogPost.');
@@ -63,6 +67,22 @@ final class Descriptor
63
67
  $keys[$action->key()] = true;
64
68
  }
65
69
  $public?->check($name, $fields);
70
+ foreach ($fields as $field) {
71
+ if ($field->from !== null && (! isset($fields[$field->from]) || ! in_array($fields[$field->from]->kind, ['string', 'text'], true))) {
72
+ throw new InvalidArgumentException("{$name}: \"{$field->name}\" is made from \"{$field->from}\", which has to be a string field of {$name}.");
73
+ }
74
+ }
75
+ if ($publish !== null && (! isset($fields[$publish]) || $fields[$publish]->kind !== 'datetime' || $fields[$publish]->required)) {
76
+ throw new InvalidArgumentException("{$name}: \"publish\" names the field that says when a record is published. It has to be an optional datetime: {$publish}:datetime?");
77
+ }
78
+ if ($blog !== null) {
79
+ if (! preg_match('#^/[a-z0-9][a-z0-9/-]*$#', $blog)) {
80
+ throw new InvalidArgumentException("{$name}: \"blog\" is where the blog is on the site, such as \"/blog\".");
81
+ }
82
+ if ($public?->read === null || $public->by === null) {
83
+ throw new InvalidArgumentException("{$name}: a blog reads its posts publicly, each by a unique field. Give it \"public\": { \"read\": { \"by\": \"slug\", \"fields\": […] } }.");
84
+ }
85
+ }
66
86
  }
67
87
 
68
88
  /** Build from a name and a `--fields` string: "name:string, price:money, status:enum(draft|live)?". */
@@ -127,6 +147,8 @@ final class Descriptor
127
147
  (bool) ($data['adopted'] ?? false),
128
148
  $actions,
129
149
  isset($data['public']) ? PublicAccess::fromArray((array) $data['public']) : null,
150
+ $data['publish'] ?? null,
151
+ $data['blog'] ?? null,
130
152
  );
131
153
  }
132
154
 
@@ -155,6 +177,8 @@ final class Descriptor
155
177
  'icon' => $this->icon,
156
178
  'group' => $this->group,
157
179
  'titleField' => $this->titleField,
180
+ 'publish' => $this->publish,
181
+ 'blog' => $this->blog,
158
182
  'fields' => array_map(fn (Field $f) => $f->toArray(), $this->fields),
159
183
  'actions' => $this->actions ? array_map(fn (Action $a) => $a->toArray(), $this->actions) : null,
160
184
  'public' => $this->public?->toArray(),
@@ -44,6 +44,7 @@ final class Field
44
44
  * @param int|null $maxLength string and text: the most characters it holds, where that isn't the usual 255 or 65,535
45
45
  * @param bool $private file: kept where the web server doesn't reach, and handed only to someone who may view the record
46
46
  * @param string|int|float|bool|null $default What the field is when a create leaves it out. Also the column's default.
47
+ * @param string|null $from slug: the field it is made from when a create leaves it empty, e.g. "title"
47
48
  */
48
49
  public function __construct(
49
50
  public readonly string $name,
@@ -59,6 +60,7 @@ final class Field
59
60
  public readonly ?int $maxLength = null,
60
61
  public readonly bool $private = false,
61
62
  public readonly string|int|float|bool|null $default = null,
63
+ public readonly ?string $from = null,
62
64
  ) {
63
65
  if (! preg_match('/^[a-z][A-Za-z0-9]*$/', $name)) {
64
66
  throw new InvalidArgumentException("Field \"{$name}\" must be camelCase (e.g. publishedAt).");
@@ -107,6 +109,14 @@ final class Field
107
109
  throw new InvalidArgumentException("\"{$name}\" is unique, so only one record could ever have its default.");
108
110
  }
109
111
  }
112
+ if ($from !== null) {
113
+ if ($kind !== 'string' || $format !== 'slug') {
114
+ throw new InvalidArgumentException("\"{$name}\" can't be made from another field: that is for a slug ({$name}:slug({$from})).");
115
+ }
116
+ if (! preg_match('/^[a-z][A-Za-z0-9]*$/', $from) || $from === $name) {
117
+ throw new InvalidArgumentException("\"{$name}\" is made from a field of the same resource, named as it is there: {$name}:slug(title).");
118
+ }
119
+ }
110
120
  if ($private && $kind !== 'file') {
111
121
  throw new InvalidArgumentException("\"{$name}\" can't be private: that is for file fields. Who may see a record's other fields is its policy's to say.");
112
122
  }
@@ -139,6 +149,7 @@ final class Field
139
149
  * manual:file(pdf|document) any other upload
140
150
  * cv:file(pdf|private) one only those who may view the record can fetch
141
151
  * status:enum(pending|paid)=pending what it is when a create leaves it out
152
+ * slug:slug(title)! made from the title when left empty
142
153
  *
143
154
  * Grit's and Flare's spellings are understood too: see normalise().
144
155
  */
@@ -198,6 +209,10 @@ final class Field
198
209
  throw new InvalidArgumentException("Unknown type \"{$type}\" on \"{$name}\". Use one of: ".implode(', ', array_keys(self::ALIASES)).', enum(a|b), belongsTo(Resource), image, file(pdf|…).');
199
210
  }
200
211
  [$kind, $format] = self::ALIASES[$type];
212
+ // slug:slug(title)!: made from the title when a create leaves it empty.
213
+ if ($format === 'slug' && $args !== '' && ! ctype_digit($args)) {
214
+ return new self($name, $kind, $format, $required, $unique, from: Naming::camel($args));
215
+ }
201
216
  // excerpt:string(500): a length of its own.
202
217
  $maxLength = in_array($kind, ['string', 'text'], true) && $format !== 'color' && ctype_digit($args) ? (int) $args : null;
203
218
 
@@ -245,6 +260,7 @@ final class Field
245
260
  isset($data['maxLength']) ? (int) $data['maxLength'] : null,
246
261
  (bool) ($data['private'] ?? false),
247
262
  $data['default'] ?? null,
263
+ $data['from'] ?? null,
248
264
  );
249
265
  }
250
266
 
@@ -261,6 +277,7 @@ final class Field
261
277
  'maxLength' => $this->maxLength,
262
278
  'private' => $this->private ?: null,
263
279
  'default' => $this->default,
280
+ 'from' => $this->from,
264
281
  'required' => $this->required ? null : false,
265
282
  'unique' => $this->unique ?: null,
266
283
  'label' => $this->label,
@@ -0,0 +1,57 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Support;
4
+
5
+ use Illuminate\Database\Eloquent\Concerns\HasUlids;
6
+ use Illuminate\Database\Eloquent\Concerns\HasUuids;
7
+ use Illuminate\Database\Eloquent\Model;
8
+ use Illuminate\Support\Str;
9
+
10
+ /**
11
+ * Whether a string from an address could be a record's id at all.
12
+ *
13
+ * SQLite and MySQL answer "where id = 'nobody'" with no rows. Postgres checks the type
14
+ * first: an id column is a number or a UUID, 'nobody' is neither, and the query is an
15
+ * error, which reaches the visitor as a 500 where a 404 was meant. Inside a transaction
16
+ * it also aborts everything else in it. So an id is looked at before the database is
17
+ * asked, and one of the wrong shape is nobody's.
18
+ */
19
+ final class Key
20
+ {
21
+ /** A whole number that fits a bigint column. */
22
+ public static function isInt(mixed $id): bool
23
+ {
24
+ return is_scalar($id) && preg_match('/^[0-9]{1,18}$/', (string) $id) === 1;
25
+ }
26
+
27
+ public static function isUuid(mixed $id): bool
28
+ {
29
+ return is_string($id) && Str::isUuid($id);
30
+ }
31
+
32
+ /**
33
+ * Whether `$id` has the shape of this model's key: digits for a numbered key, and
34
+ * whatever a model with Laravel's UUID or ULID keys says is one of its own. A model
35
+ * with some other key of its own making is taken at its word.
36
+ *
37
+ * @param Model|class-string<Model> $model
38
+ */
39
+ public static function fits(Model|string $model, mixed $id): bool
40
+ {
41
+ $model = is_string($model) ? new $model : $model;
42
+ if (! is_scalar($id)) {
43
+ return false;
44
+ }
45
+ if (in_array($model->getKeyType(), ['int', 'integer'], true)) {
46
+ return self::isInt($id);
47
+ }
48
+
49
+ $traits = class_uses_recursive($model);
50
+
51
+ return match (true) {
52
+ isset($traits[HasUuids::class]) => Str::isUuid((string) $id),
53
+ isset($traits[HasUlids::class]) => Str::isUlid((string) $id),
54
+ default => true,
55
+ };
56
+ }
57
+ }
@@ -25,7 +25,17 @@ final class ListQuery
25
25
 
26
26
  public ?string $q = null;
27
27
 
28
- /** @var list<array{field: string, column: string, value: string|int|float|bool|null}> */
28
+ /** The three things a record with a publish date can be. */
29
+ public const PUBLISHED = ['published', 'draft', 'scheduled'];
30
+
31
+ /** The filter that isn't a field: whether a record's address has been proved ("confirm"). */
32
+ public const CONFIRMED = 'confirmed';
33
+
34
+ /**
35
+ * A filter on the field a resource is published by carries `state` in place of a value.
36
+ *
37
+ * @var list<array{field: string, column: string, value: string|int|float|bool|null, state?: string, confirmed?: bool}>
38
+ */
29
39
  public array $filters = [];
30
40
 
31
41
  /** @param array<string, mixed> $params */
@@ -66,6 +76,24 @@ final class ListQuery
66
76
  foreach ($filters as $key => $value) {
67
77
  $param = "filter[{$key}]";
68
78
  $field = $resource->fields[$key] ?? null;
79
+ // filter[publishedAt]=draft: not a date to match, but which of the three it is.
80
+ if ($field !== null && $resource->publish === $key) {
81
+ $state = is_string($value) ? strtolower($value) : '';
82
+ in_array($state, self::PUBLISHED, true)
83
+ ? $query->filters[] = ['field' => $key, 'column' => $field->column(), 'value' => null, 'state' => $state]
84
+ : $query->issue($param, 'Must be one of: '.implode(', ', self::PUBLISHED).'.');
85
+
86
+ continue;
87
+ }
88
+ // filter[confirmed]=true: whether the address was proved, for a resource whose form asks.
89
+ if ($field === null && $key === self::CONFIRMED && $resource->public?->confirm !== null) {
90
+ $proved = is_array($value) ? null : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
91
+ $proved === null
92
+ ? $query->issue($param, 'Must be true or false.')
93
+ : $query->filters[] = ['field' => $key, 'column' => 'confirmed_at', 'value' => null, 'confirmed' => $proved];
94
+
95
+ continue;
96
+ }
69
97
  if ($field === null || ! $field->filterable()) {
70
98
  $query->issue($param, "Can't filter by \"{$key}\".");
71
99
 
@@ -15,6 +15,17 @@ use InvalidArgumentException;
15
15
  * Nothing is open without it, and nothing beyond what it names: a resource with "read"
16
16
  * alone can't be written to, and a field that isn't listed is never sent or accepted.
17
17
  * Which records can be read is the model's to say, in its publicQuery scope.
18
+ *
19
+ * A form anyone can send is sent by bots too, so a create can say what stands in their
20
+ * way, and whether the address it is given has to be proved:
21
+ *
22
+ * "create": {
23
+ * "fields": ["email"],
24
+ * "protect": { "turnstile": true, "honeypot": true, "minSeconds": 3, "email": "email" },
25
+ * "confirm": "email"
26
+ * }
27
+ *
28
+ * See Forms\FormGuard for the checks, and Forms\Confirmation for the link.
18
29
  */
19
30
  final class PublicAccess
20
31
  {
@@ -23,11 +34,19 @@ final class PublicAccess
23
34
 
24
35
  public const CREATE_THROTTLE = '10,1';
25
36
 
37
+ /** What "protect" can turn on. */
38
+ public const CHECKS = ['turnstile', 'honeypot', 'minSeconds', 'email'];
39
+
40
+ /** The longest a form can be made to wait before it is sent, in seconds. */
41
+ public const MAX_SECONDS = 3600;
42
+
26
43
  /**
27
44
  * @param list<string>|null $read The fields anyone may read, or null when nothing may be
28
45
  * @param string|null $by The unique field one record is fetched by, in place of its id
29
46
  * @param list<string>|null $create The fields anyone may send to create a record, or null when nobody may
30
47
  * @param list<string> $middleware More middleware for these routes: the app's own spam checks
48
+ * @param array{turnstile: bool, honeypot: bool, minSeconds: int, email: string|null}|null $protect The checks a create passes before it is looked at, or null for none
49
+ * @param string|null $confirm The field holding an address its owner has to prove, by a link sent to it
31
50
  */
32
51
  public function __construct(
33
52
  public readonly ?array $read = null,
@@ -36,6 +55,8 @@ final class PublicAccess
36
55
  public readonly string $readThrottle = self::READ_THROTTLE,
37
56
  public readonly string $createThrottle = self::CREATE_THROTTLE,
38
57
  public readonly array $middleware = [],
58
+ public readonly ?array $protect = null,
59
+ public readonly ?string $confirm = null,
39
60
  ) {
40
61
  foreach ([$readThrottle, $createThrottle] as $throttle) {
41
62
  if (! preg_match('/^[1-9][0-9]*,[1-9][0-9]*$/', $throttle)) {
@@ -50,6 +71,43 @@ final class PublicAccess
50
71
  throw new InvalidArgumentException('Public middleware is named as in a route: "honeypot", or "throttle:5,1".');
51
72
  }
52
73
  }
74
+ if ($protect !== null && ! $protect['turnstile'] && ! $protect['honeypot'] && $protect['minSeconds'] === 0 && $protect['email'] === null) {
75
+ throw new InvalidArgumentException('"protect" turns nothing on. Give it "turnstile", "honeypot", "minSeconds" or "email", or leave it out.');
76
+ }
77
+ if ($protect !== null && ($protect['minSeconds'] < 0 || $protect['minSeconds'] > self::MAX_SECONDS)) {
78
+ throw new InvalidArgumentException('"minSeconds" is how long a person takes to fill the form in: between 0 and '.self::MAX_SECONDS.'.');
79
+ }
80
+ }
81
+
82
+ /** Whether a create has checks to pass. */
83
+ public function protected(): bool
84
+ {
85
+ return $this->protect !== null;
86
+ }
87
+
88
+ /**
89
+ * "protect" as it is written, made whole: every check said yes or no to.
90
+ *
91
+ * @return array{turnstile: bool, honeypot: bool, minSeconds: int, email: string|null}
92
+ */
93
+ private static function protection(mixed $given): array
94
+ {
95
+ if (! is_array($given)) {
96
+ throw new InvalidArgumentException('"protect" lists the checks to make: { "turnstile": true, "honeypot": true, "minSeconds": 3, "email": "email" }.');
97
+ }
98
+ if (($unknown = array_diff(array_keys($given), self::CHECKS)) !== []) {
99
+ throw new InvalidArgumentException('"protect" has no check called "'.reset($unknown).'". There are: '.implode(', ', self::CHECKS).'.');
100
+ }
101
+ $seconds = $given['minSeconds'] ?? 0;
102
+ if (! is_int($seconds)) {
103
+ throw new InvalidArgumentException('"minSeconds" is a whole number of seconds.');
104
+ }
105
+ $email = $given['email'] ?? null;
106
+ if ($email !== null && ! is_string($email)) {
107
+ throw new InvalidArgumentException('"email" in "protect" names the field that holds the address.');
108
+ }
109
+
110
+ return ['turnstile' => ($given['turnstile'] ?? false) === true, 'honeypot' => ($given['honeypot'] ?? false) === true, 'minSeconds' => $seconds, 'email' => $email];
53
111
  }
54
112
 
55
113
  /** @param array<string, mixed> $data */
@@ -65,6 +123,8 @@ final class PublicAccess
65
123
  (string) ($read['throttle'] ?? self::READ_THROTTLE),
66
124
  (string) ($create['throttle'] ?? self::CREATE_THROTTLE),
67
125
  array_values((array) ($data['middleware'] ?? [])),
126
+ isset($create['protect']) ? self::protection($create['protect']) : null,
127
+ isset($create['confirm']) ? (string) $create['confirm'] : null,
68
128
  );
69
129
  }
70
130
 
@@ -80,6 +140,9 @@ final class PublicAccess
80
140
  'create' => $this->create === null ? null : array_filter([
81
141
  'fields' => $this->create,
82
142
  'throttle' => $this->createThrottle === self::CREATE_THROTTLE ? null : $this->createThrottle,
143
+ // Only what is on, so a descriptor reads as it was written.
144
+ 'protect' => $this->protect === null ? null : array_filter($this->protect, fn ($v) => $v !== false && $v !== 0 && $v !== null),
145
+ 'confirm' => $this->confirm,
83
146
  ], fn ($v) => $v !== null),
84
147
  'middleware' => $this->middleware ?: null,
85
148
  ], fn ($v) => $v !== null);
@@ -120,6 +183,18 @@ final class PublicAccess
120
183
  throw new InvalidArgumentException("{$resource}: a record is fetched by \"{$this->by}\", which has to be a unique field ({$this->by}:slug!).");
121
184
  }
122
185
  }
186
+ if ($this->create === null && ($this->protect !== null || $this->confirm !== null)) {
187
+ throw new InvalidArgumentException("{$resource}: \"protect\" and \"confirm\" are about a public create, and there isn't one.");
188
+ }
189
+ foreach (['protect.email' => $this->protect['email'] ?? null, 'confirm' => $this->confirm] as $what => $named) {
190
+ if ($named === null) {
191
+ continue;
192
+ }
193
+ // The address has to be something a visitor types into the form.
194
+ if (! in_array($named, $this->create ?? [], true) || $fields[$named]->kind !== 'string') {
195
+ throw new InvalidArgumentException("{$resource}: {$what} names \"{$named}\", which has to be a text field the public create lists.");
196
+ }
197
+ }
123
198
  if ($this->create !== null) {
124
199
  foreach ($fields as $field) {
125
200
  // Left out of the form, a field needs something to be: nothing, or a default.
@@ -0,0 +1,43 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Support;
4
+
5
+ use Illuminate\Support\Facades\DB;
6
+ use Illuminate\Support\Str;
7
+
8
+ /**
9
+ * A slug nobody typed: made from another field, and made different from every one
10
+ * already in the table.
11
+ *
12
+ * "Opening day" is `opening-day`; the next post of that name is `opening-day-2`. Records
13
+ * in the trash are counted, because they still hold their value and can come back with it.
14
+ */
15
+ final class Slug
16
+ {
17
+ /** Room left at the end of the column's 255 for the "-12" that makes one different. */
18
+ private const LONGEST = 240;
19
+
20
+ /**
21
+ * @param array<string, true> $taken Values spoken for that the table doesn't hold yet: earlier rows of the same batch. The one returned is added.
22
+ */
23
+ public static function free(string $table, string $column, string $from, array &$taken = []): string
24
+ {
25
+ $base = Str::limit(Str::slug($from), self::LONGEST, '');
26
+ // A title with nothing a slug can keep (all symbols, say) still needs an address.
27
+ $base = trim($base, '-') !== '' ? trim($base, '-') : 'untitled';
28
+
29
+ $held = $taken;
30
+ // Straight from the table, past the model's scopes: a record in the trash holds its slug too.
31
+ foreach (DB::table($table)->where($column, $base)->orWhere($column, 'like', $base.'-%')->pluck($column) as $existing) {
32
+ $held[(string) $existing] = true;
33
+ }
34
+
35
+ $slug = $base;
36
+ for ($n = 2; isset($held[$slug]); $n++) {
37
+ $slug = "{$base}-{$n}";
38
+ }
39
+ $taken[$slug] = true;
40
+
41
+ return $slug;
42
+ }
43
+ }
@@ -102,7 +102,9 @@ final class Trash
102
102
  public static function purge(Model $record): bool
103
103
  {
104
104
  try {
105
- $record->forceDelete();
105
+ // In a transaction of its own. Called inside another, that makes it a savepoint:
106
+ // on Postgres a refused delete would otherwise abort everything around it.
107
+ $record->getConnection()->transaction(fn () => $record->forceDelete());
106
108
 
107
109
  return true;
108
110
  } catch (QueryException $e) {
@@ -1,2 +1,6 @@
1
1
  # Where Nevela's routes live in the Laravel app (apps/api), including the prefix.
2
2
  NEVELA_API_URL=http://127.0.0.1:8000/api
3
+
4
+ # Cloudflare Turnstile's site key, for a public form whose descriptor asks for the robot
5
+ # check. Its secret key goes in apps/api/.env as TURNSTILE_SECRET_KEY.
6
+ # NEXT_PUBLIC_TURNSTILE_SITE_KEY=
@@ -18,7 +18,7 @@ import { API_URL, callerHeaders, sessionToken } from "@/lib/laravel";
18
18
  *
19
19
  * And for a site's visitors, who have no session: what a resource's "public" section
20
20
  * opens (`/api/public/…`). Reading it, a form's create (`POST /api/public/<slug>`) and a
21
- * file for that form (`PUT /api/public/_nevela/uploads/<Resource>/<field>`) go through
21
+ * file for that form (`PUT /api/public/<slug>/_uploads/<field>`) go through
22
22
  * with no token. Laravel has those routes only for resources whose descriptor opens them.
23
23
  */
24
24
  export const dynamic = "force-dynamic";
@@ -104,8 +104,11 @@ export async function POST(request: NextRequest, context: Context) {
104
104
  export async function PUT(request: NextRequest, context: Context) {
105
105
  const { path } = await context.params;
106
106
  const url = target(path, request.nextUrl.search);
107
- // A visitor's file for a public form: /api/public/_nevela/uploads/<Resource>/<field>.
108
- const forVisitor = path.length === 5 && path[0] === "public" && path[1] === "_nevela" && path[2] === "uploads";
107
+ // A visitor's file for a public form: /api/public/_nevela/uploads/<Resource>/<field>,
108
+ // or by the form's own address, /api/public/<slug>/_uploads/<field>.
109
+ const forVisitor =
110
+ path[0] === "public" &&
111
+ ((path.length === 5 && path[1] === "_nevela" && path[2] === "uploads") || (path.length === 4 && !path[1].startsWith("_") && path[2] === "_uploads"));
109
112
  if (!url || !(forVisitor || (path[0] === "_nevela" && path[1] === "uploads"))) {
110
113
  return Response.json({ error: "Only uploads are sent this way. Records are saved through the dashboard." }, { status: 405 });
111
114
  }
@@ -0,0 +1,51 @@
1
+ import Link from "next/link";
2
+ import { AuthShell } from "@/components/auth/auth-shell";
3
+ import { laravel } from "@/lib/laravel";
4
+
5
+ export const metadata = { title: "Confirm your address", robots: { index: false } };
6
+
7
+ /**
8
+ * Where the link in a confirmation email leads: a list someone joined through a public
9
+ * form whose descriptor has "confirm". Opening it proves the address is theirs.
10
+ *
11
+ * Laravel made the link and Laravel checks it. This page passes on what the link
12
+ * carries and says how it went. To send people somewhere of your own, change what is
13
+ * shown here, or point nevela.public.confirm.path at another page that does the same.
14
+ */
15
+ export default async function ConfirmPage({ searchParams }: { searchParams: Promise<{ list?: string; id?: string; key?: string }> }) {
16
+ const { list = "", id = "", key = "" } = await searchParams;
17
+ // Only what a link of ours looks like is passed on.
18
+ const wellFormed = /^[a-z0-9][a-z0-9-]{0,80}$/.test(list) && /^[A-Za-z0-9-]{1,40}$/.test(id) && /^[a-f0-9]{64}$/.test(key);
19
+
20
+ let confirmed = false;
21
+ let unreachable = false;
22
+ if (wellFormed) {
23
+ try {
24
+ const response = await laravel(`public/_nevela/confirm/${list}/${id}/${key}`, { method: "POST", token: null });
25
+ confirmed = response.ok;
26
+ unreachable = response.status >= 500 || response.status === 429;
27
+ } catch {
28
+ unreachable = true;
29
+ }
30
+ }
31
+
32
+ const [title, subtitle] = confirmed
33
+ ? ["You're confirmed", "Thank you. Your address is on the list."]
34
+ : unreachable
35
+ ? ["We couldn't check that just now", "Nothing is wrong with your link. Open it again in a moment."]
36
+ : ["This link is no longer good", "It may have been used too late. Sign up again and we'll send a new one."];
37
+
38
+ return (
39
+ <AuthShell
40
+ title={title}
41
+ subtitle={subtitle}
42
+ footer={
43
+ <Link href="/" className="font-medium text-link hover:underline">
44
+ Back to the site
45
+ </Link>
46
+ }
47
+ >
48
+ {null}
49
+ </AuthShell>
50
+ );
51
+ }