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.
- package/index.mjs +24 -7
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +41 -0
- package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
- package/template/laravel/resources/disposable-domains.txt +9880 -0
- package/template/laravel/src/Access/ClosedAccounts.php +2 -1
- package/template/laravel/src/Concerns/Confirmable.php +81 -0
- package/template/laravel/src/Console/BlogCommand.php +254 -0
- package/template/laravel/src/Console/DevCommand.php +9 -0
- package/template/laravel/src/Console/DigestCommand.php +28 -0
- package/template/laravel/src/Console/DisposableCommand.php +49 -0
- package/template/laravel/src/Console/GenerateCommand.php +25 -0
- package/template/laravel/src/Console/SetupCommand.php +15 -2
- package/template/laravel/src/Console/StatusCommand.php +14 -0
- package/template/laravel/src/Console/TrashCommand.php +9 -0
- package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
- package/template/laravel/src/Forms/Confirmation.php +88 -0
- package/template/laravel/src/Forms/Disposable.php +74 -0
- package/template/laravel/src/Forms/FormBlocks.php +76 -0
- package/template/laravel/src/Forms/FormGuard.php +359 -0
- package/template/laravel/src/Forms/OwnerDigest.php +63 -0
- package/template/laravel/src/Forms/PublicMail.php +217 -0
- package/template/laravel/src/Forms/Refusal.php +50 -0
- package/template/laravel/src/Generator/BlogPages.php +409 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +133 -7
- package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
- package/template/laravel/src/Http/Access/RolesController.php +2 -1
- package/template/laravel/src/Http/Access/UsersController.php +2 -1
- package/template/laravel/src/Http/ActionController.php +2 -1
- package/template/laravel/src/Http/Auth/AccountController.php +6 -3
- package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
- package/template/laravel/src/Http/FormsController.php +45 -0
- package/template/laravel/src/Http/PublicController.php +89 -4
- package/template/laravel/src/Http/ResourceRequest.php +43 -3
- package/template/laravel/src/Http/TrashController.php +2 -1
- package/template/laravel/src/Http/UploadController.php +2 -2
- package/template/laravel/src/Nevela.php +30 -1
- package/template/laravel/src/NevelaServiceProvider.php +11 -2
- package/template/laravel/src/Support/Descriptor.php +24 -0
- package/template/laravel/src/Support/Field.php +17 -0
- package/template/laravel/src/Support/Key.php +57 -0
- package/template/laravel/src/Support/ListQuery.php +29 -1
- package/template/laravel/src/Support/PublicAccess.php +75 -0
- package/template/laravel/src/Support/Slug.php +43 -0
- package/template/laravel/src/Trash/Trash.php +3 -1
- package/template/web/.env.example +4 -0
- package/template/web/app/api/[...path]/route.ts +6 -3
- package/template/web/app/confirm/page.tsx +51 -0
- package/template/web/app/dashboard/actions.ts +15 -1
- package/template/web/components/dashboard/form-activity.tsx +53 -0
- package/template/web/components/dashboard/record-actions.tsx +29 -5
- package/template/web/components/dashboard/record-detail.tsx +2 -1
- package/template/web/components/dashboard/resource-form.tsx +19 -2
- package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
- package/template/web/components/dashboard/resource-table.tsx +32 -3
- package/template/web/components/dashboard/row-actions.tsx +33 -3
- package/template/web/components/marketing/site-header.tsx +9 -2
- package/template/web/components/public-form-guard.tsx +125 -0
- package/template/web/hooks/use-public-form.ts +263 -0
- package/template/web/lib/extras.ts +39 -0
- 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 ((
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|