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
|
@@ -7,6 +7,7 @@ use Illuminate\Support\Facades\DB;
|
|
|
7
7
|
use Illuminate\Support\Facades\Schema;
|
|
8
8
|
use Illuminate\Support\Str;
|
|
9
9
|
use Nevela\Laravel\Auth\Account;
|
|
10
|
+
use Nevela\Laravel\Support\Key;
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Accounts that were closed, and the emails that may not have a new one.
|
|
@@ -207,7 +208,7 @@ final class ClosedAccounts
|
|
|
207
208
|
/** Let an email whose account was removed for good be used again. False when there is no such entry. */
|
|
208
209
|
public static function allow(string $id): bool
|
|
209
210
|
{
|
|
210
|
-
return DB::table(self::BLOCKED)->where('id', $id)->whereNull('user_id')->delete() > 0;
|
|
211
|
+
return Key::isUuid($id) && DB::table(self::BLOCKED)->where('id', $id)->whereNull('user_id')->delete() > 0;
|
|
211
212
|
}
|
|
212
213
|
|
|
213
214
|
/** Remember an email by its fingerprint: for a closed account, or with no account left. */
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Concerns;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Database\Eloquent\Builder;
|
|
6
|
+
use Illuminate\Support\Facades\Schema;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A model whose records hold an address that has to be proved: a list people join.
|
|
10
|
+
*
|
|
11
|
+
* A record made through the public form starts unconfirmed (`confirmed_at` is null) and
|
|
12
|
+
* is confirmed when the link emailed to its address is followed. Until then it is left
|
|
13
|
+
* out of counts and exports, and after two days it is removed. Whatever goes to
|
|
14
|
+
* "everyone on the list" goes to the confirmed:
|
|
15
|
+
*
|
|
16
|
+
* Subscriber::confirmed()->each(fn ($subscriber) => …);
|
|
17
|
+
*
|
|
18
|
+
* A record made any other way (in the dashboard, by a seeder, by an import) is confirmed
|
|
19
|
+
* as it is made: someone signed in put it there, and there is nobody to ask.
|
|
20
|
+
*
|
|
21
|
+
* Generated models use it when their descriptor's public create has "confirm".
|
|
22
|
+
*
|
|
23
|
+
* @mixin \Illuminate\Database\Eloquent\Model
|
|
24
|
+
*/
|
|
25
|
+
trait Confirmable
|
|
26
|
+
{
|
|
27
|
+
/** Whether this model's table has the column. Only "yes" is remembered: it can arrive later in a process. */
|
|
28
|
+
private static bool $hasConfirmation = false;
|
|
29
|
+
|
|
30
|
+
public static function confirms(): bool
|
|
31
|
+
{
|
|
32
|
+
if (static::$hasConfirmation) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
$model = new static;
|
|
36
|
+
|
|
37
|
+
return static::$hasConfirmation = Schema::connection($model->getConnectionName())->hasColumn($model->getTable(), 'confirmed_at');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Ask the database again next time. For a test that adds the column within one process. */
|
|
41
|
+
public static function forgetConfirmation(): void
|
|
42
|
+
{
|
|
43
|
+
static::$hasConfirmation = false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public static function bootConfirmable(): void
|
|
47
|
+
{
|
|
48
|
+
static::creating(function (self $model) {
|
|
49
|
+
// The public form says "not yet" by setting the column to null itself. Anything
|
|
50
|
+
// else that makes a record says nothing, and is taken at its word.
|
|
51
|
+
if (! array_key_exists('confirmed_at', $model->getAttributes()) && static::confirms()) {
|
|
52
|
+
$model->setAttribute('confirmed_at', $model->freshTimestamp());
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public function initializeConfirmable(): void
|
|
58
|
+
{
|
|
59
|
+
$this->mergeCasts(['confirmed_at' => 'datetime']);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether this record's address has been proved. */
|
|
63
|
+
public function isConfirmed(): bool
|
|
64
|
+
{
|
|
65
|
+
return $this->getAttribute('confirmed_at') !== null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The records whose address has been proved: the ones to count, export and write to. */
|
|
69
|
+
public function scopeConfirmed(Builder $query): void
|
|
70
|
+
{
|
|
71
|
+
if (static::confirms()) {
|
|
72
|
+
$query->whereNotNull($this->qualifyColumn('confirmed_at'));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The ones still waiting for their link to be followed. */
|
|
77
|
+
public function scopeUnconfirmed(Builder $query): void
|
|
78
|
+
{
|
|
79
|
+
static::confirms() ? $query->whereNull($this->qualifyColumn('confirmed_at')) : $query->whereRaw('1 = 0');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Console;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Console\Command;
|
|
6
|
+
use Illuminate\Support\Facades\Schema;
|
|
7
|
+
use InvalidArgumentException;
|
|
8
|
+
use Nevela\Laravel\Generator\BlogPages;
|
|
9
|
+
use Nevela\Laravel\Generator\GeneratedFile;
|
|
10
|
+
use Nevela\Laravel\Generator\Writer;
|
|
11
|
+
use Nevela\Laravel\Media\Uploads;
|
|
12
|
+
use Nevela\Laravel\Nevela;
|
|
13
|
+
use Nevela\Laravel\Support\Descriptor;
|
|
14
|
+
use Nevela\Laravel\Support\Naming;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A blog for the site: posts written in the dashboard, and /blog to read them at.
|
|
18
|
+
*
|
|
19
|
+
* php artisan nevela:blog a Post resource, and the pages
|
|
20
|
+
* php artisan nevela:blog --resource=Article … the pages only, reading a resource the app has
|
|
21
|
+
*
|
|
22
|
+
* Everything a blog is made of is already a resource's to have: rich text, an image, a
|
|
23
|
+
* slug, a date it is published on, and public reading. This puts them together, and
|
|
24
|
+
* writes the pages that show the result.
|
|
25
|
+
*/
|
|
26
|
+
final class BlogCommand extends Command
|
|
27
|
+
{
|
|
28
|
+
/** The resource this makes when the app has none to read. */
|
|
29
|
+
public const RESOURCE = 'Post';
|
|
30
|
+
|
|
31
|
+
public const PATH = '/blog';
|
|
32
|
+
|
|
33
|
+
protected $signature = 'nevela:blog
|
|
34
|
+
{--resource= : Read the posts from a resource you already have, and write only the pages}
|
|
35
|
+
{--title= : That resource\'s field for a post\'s title. Default: title}
|
|
36
|
+
{--body= : Its field for the post itself. Default: body}
|
|
37
|
+
{--excerpt= : Its field for a sentence or two about the post, if it has one}
|
|
38
|
+
{--image= : Its field for a picture, if it has one}
|
|
39
|
+
{--published= : Its field for when a post was published. Default: the one the resource is published by}
|
|
40
|
+
{--sample : Add three posts to start with. For development: refused in production}
|
|
41
|
+
{--no-migrate : Don\'t create the posts table yet}';
|
|
42
|
+
|
|
43
|
+
protected $description = 'Give the site a blog: a Post resource to write in, and /blog to read it at';
|
|
44
|
+
|
|
45
|
+
public function handle(): int
|
|
46
|
+
{
|
|
47
|
+
Nevela::forget();
|
|
48
|
+
try {
|
|
49
|
+
$all = Nevela::all();
|
|
50
|
+
$given = $this->option('resource');
|
|
51
|
+
[$descriptor, $fields, $created] = $given === null
|
|
52
|
+
? $this->ours($all)
|
|
53
|
+
: [...$this->theirs((string) $given, $all), false];
|
|
54
|
+
} catch (InvalidArgumentException $e) {
|
|
55
|
+
$this->components->error($e->getMessage());
|
|
56
|
+
|
|
57
|
+
return self::FAILURE;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
$dir = rtrim(config('nevela.descriptors_path'), '/\\');
|
|
61
|
+
if (! is_dir($dir)) {
|
|
62
|
+
mkdir($dir, 0775, true);
|
|
63
|
+
}
|
|
64
|
+
// The descriptor says where the blog is, so the site's header can link to it.
|
|
65
|
+
file_put_contents($dir.DIRECTORY_SEPARATOR.Naming::kebab($descriptor->name).'.json', $descriptor->toJson());
|
|
66
|
+
Nevela::forget();
|
|
67
|
+
if (GenerateCommand::generate($this, [$descriptor], Nevela::all(), false) !== self::SUCCESS) {
|
|
68
|
+
return self::FAILURE;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
$web = config('nevela.web_path');
|
|
72
|
+
if (! $web || ! is_dir($web)) {
|
|
73
|
+
$this->components->warn('There is no web app at nevela.web_path, so the pages were not written. Run this again when there is.');
|
|
74
|
+
} else {
|
|
75
|
+
foreach ((new Writer([GeneratedFile::TARGET_WEB => $web]))->write(BlogPages::files($descriptor, $fields)) as $row) {
|
|
76
|
+
$path = str_replace('\\', '/', substr($row['path'], strlen(rtrim($web, '/\\')) + 1));
|
|
77
|
+
$this->components->twoColumnDetail("../web/{$path}", $row['status'] === 'created' ? '<fg=green>created</>' : '<fg=gray>exists (yours)</>');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
$status = self::SUCCESS;
|
|
82
|
+
if ($created && ! $this->option('no-migrate')) {
|
|
83
|
+
$status = $this->call('migrate', ['--force' => true]);
|
|
84
|
+
}
|
|
85
|
+
if ($status === self::SUCCESS && $this->option('sample')) {
|
|
86
|
+
$status = $this->sample($descriptor);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
$this->newLine();
|
|
90
|
+
$this->line(' The blog is at <fg=cyan>'.self::PATH.'</>, and '.$descriptor->pluralLabel.' is in the dashboard. A post is on the site once it has been published.');
|
|
91
|
+
if (! $created) {
|
|
92
|
+
$this->line(' Which records are public is the scope on <fg=cyan>app/Models/'.$descriptor->name.'.php</> (scopePublicQuery). The pages and <fg=cyan>lib/blog.ts</> are yours to change.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return $status;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The Post resource, made here: what a post is, that it is published by a date, and
|
|
100
|
+
* that anyone may read a published one by its slug.
|
|
101
|
+
*
|
|
102
|
+
* @param list<Descriptor> $all
|
|
103
|
+
* @return array{Descriptor, array<string, string|null>, bool}
|
|
104
|
+
*/
|
|
105
|
+
private function ours(array $all): array
|
|
106
|
+
{
|
|
107
|
+
$fields = ['title' => 'title', 'body' => 'body', 'excerpt' => 'excerpt', 'image' => 'image', 'published' => 'publishedAt'];
|
|
108
|
+
foreach ($all as $existing) {
|
|
109
|
+
if ($existing->name === self::RESOURCE || $existing->table === 'posts') {
|
|
110
|
+
// Run again on an app that has it: the pages are written if they aren't there, and nothing else changes.
|
|
111
|
+
if ($existing->blog !== null) {
|
|
112
|
+
return [$existing, array_map(fn (?string $field) => $field !== null && isset($existing->fields[$field]) ? $field : null, $fields), false];
|
|
113
|
+
}
|
|
114
|
+
throw new InvalidArgumentException("There is a {$existing->name} resource already. To have the blog read it: php artisan nevela:blog --resource={$existing->name}");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if ($this->tableExists('posts')) {
|
|
118
|
+
throw new InvalidArgumentException('There is a posts table already, which is not a resource. Adopt it (php artisan nevela:adopt posts), then: php artisan nevela:blog --resource=Post');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
$descriptor = Descriptor::fromArray([
|
|
122
|
+
'name' => self::RESOURCE,
|
|
123
|
+
'icon' => 'book',
|
|
124
|
+
'publish' => 'publishedAt',
|
|
125
|
+
'blog' => self::PATH,
|
|
126
|
+
'fields' => [
|
|
127
|
+
'title' => ['kind' => 'string'],
|
|
128
|
+
'slug' => ['kind' => 'string', 'format' => 'slug', 'unique' => true, 'from' => 'title'],
|
|
129
|
+
'excerpt' => ['kind' => 'string', 'maxLength' => 500, 'required' => false],
|
|
130
|
+
'body' => ['kind' => 'text', 'format' => 'rich'],
|
|
131
|
+
'image' => ['kind' => 'file', 'accept' => ['image'], 'profile' => 'cover', 'required' => false],
|
|
132
|
+
'publishedAt' => ['kind' => 'datetime', 'required' => false],
|
|
133
|
+
],
|
|
134
|
+
'public' => ['read' => ['by' => 'slug', 'fields' => ['title', 'slug', 'excerpt', 'body', 'image', 'publishedAt']]],
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
return [$descriptor, $fields, true];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A resource the app already has, read as posts. Nothing about it is changed but the
|
|
142
|
+
* note of where its blog is; what it lacks for a blog is said, and nothing is written.
|
|
143
|
+
*
|
|
144
|
+
* @param list<Descriptor> $all
|
|
145
|
+
* @return array{Descriptor, array<string, string|null>}
|
|
146
|
+
*
|
|
147
|
+
* @throws InvalidArgumentException
|
|
148
|
+
*/
|
|
149
|
+
private function theirs(string $name, array $all): array
|
|
150
|
+
{
|
|
151
|
+
$descriptor = null;
|
|
152
|
+
foreach ($all as $candidate) {
|
|
153
|
+
if (strcasecmp($candidate->name, $name) === 0) {
|
|
154
|
+
$descriptor = $candidate;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if ($descriptor === null) {
|
|
158
|
+
throw new InvalidArgumentException("There is no resource called {$name}.");
|
|
159
|
+
}
|
|
160
|
+
$public = $descriptor->public;
|
|
161
|
+
if ($public?->read === null) {
|
|
162
|
+
throw new InvalidArgumentException("{$descriptor->name} can't be read publicly, and a blog is public. Add to its descriptor: \"public\": { \"read\": { \"by\": \"slug\", \"fields\": [ … ] } }");
|
|
163
|
+
}
|
|
164
|
+
if ($public->by === null) {
|
|
165
|
+
throw new InvalidArgumentException("{$descriptor->name}'s public section doesn't say which unique field a record is fetched by, and a post's address is made from one. Add \"by\": \"slug\" to its \"read\".");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
$fields = [];
|
|
169
|
+
foreach (['title' => 'title', 'body' => 'body', 'excerpt' => null, 'image' => null, 'published' => $descriptor->publish] as $part => $default) {
|
|
170
|
+
$asked = $this->option($part);
|
|
171
|
+
// A column's name is understood as well as the field's: published_at, publishedAt.
|
|
172
|
+
$field = $asked === null ? $default : Naming::camel((string) $asked);
|
|
173
|
+
if ($field === null) {
|
|
174
|
+
$fields[$part] = null;
|
|
175
|
+
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (! isset($descriptor->fields[$field])) {
|
|
179
|
+
throw new InvalidArgumentException("{$descriptor->name} has no field called {$field}. Name the one that is a post's {$part}: --{$part}=…");
|
|
180
|
+
}
|
|
181
|
+
if (! in_array($field, $public->read, true)) {
|
|
182
|
+
throw new InvalidArgumentException("\"{$field}\" is not among the fields anyone may read of {$descriptor->name}, so the blog couldn't show it. Add it to \"public.read.fields\" in its descriptor.");
|
|
183
|
+
}
|
|
184
|
+
$fields[$part] = $field;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
$data = $descriptor->toArray();
|
|
188
|
+
$data['blog'] = self::PATH;
|
|
189
|
+
|
|
190
|
+
return [Descriptor::fromArray($data), $fields];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Three posts, so /blog has something on it the first time it is opened. Like the
|
|
195
|
+
* sample users: for looking around a new app, and never for a live one.
|
|
196
|
+
*/
|
|
197
|
+
private function sample(Descriptor $descriptor): int
|
|
198
|
+
{
|
|
199
|
+
if (app()->environment('production')) {
|
|
200
|
+
$this->components->error('Sample posts are for development. This app is in production, so none were made.');
|
|
201
|
+
|
|
202
|
+
return self::FAILURE;
|
|
203
|
+
}
|
|
204
|
+
$model = "App\\Models\\{$descriptor->name}";
|
|
205
|
+
if ($descriptor->name !== self::RESOURCE || ! class_exists($model) || ! $this->tableExists($descriptor->table)) {
|
|
206
|
+
$this->components->warn('Sample posts are for the Post resource this command makes, once its table is there.');
|
|
207
|
+
|
|
208
|
+
return self::SUCCESS;
|
|
209
|
+
}
|
|
210
|
+
if ($model::query()->exists()) {
|
|
211
|
+
return self::SUCCESS; // it has posts already; these would only be in the way
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// A picture each, where pictures can be made here.
|
|
215
|
+
$pictures = Uploads::placeholders($descriptor, $descriptor->fields['image'], 3);
|
|
216
|
+
foreach (self::POSTS as $index => $post) {
|
|
217
|
+
$model::create($post + ['image' => $pictures[$index] ?? null, 'published_at' => now()->subDays(1 + $index * 3)]);
|
|
218
|
+
}
|
|
219
|
+
$this->components->info('Added three sample posts. Edit or delete them under Posts in the dashboard.');
|
|
220
|
+
|
|
221
|
+
return self::SUCCESS;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private function tableExists(string $table): bool
|
|
225
|
+
{
|
|
226
|
+
try {
|
|
227
|
+
return Schema::hasTable($table);
|
|
228
|
+
} catch (\Throwable) {
|
|
229
|
+
return false; // no database to ask yet
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** @var list<array{title: string, slug: string, excerpt: string, body: string}> */
|
|
234
|
+
private const POSTS = [
|
|
235
|
+
[
|
|
236
|
+
'title' => 'Welcome to your blog',
|
|
237
|
+
'slug' => 'welcome-to-your-blog',
|
|
238
|
+
'excerpt' => 'This post came with the app. It is here so the blog has something on it the first time you open it.',
|
|
239
|
+
'body' => '<p>This post came with the app. It is here so the blog has something on it the first time you open it, and you can delete it whenever you like.</p><h2>Where posts are written</h2><p>Sign in and open <strong>Posts</strong> in the dashboard. A post has a title, an excerpt for the list, a picture and a body with headings, lists, links and quotes.</p><h2>Where they are read</h2><p>At <a href="/blog">/blog</a>, newest first, and each at an address of its own made from its title.</p>',
|
|
240
|
+
],
|
|
241
|
+
[
|
|
242
|
+
'title' => 'Drafts, publishing and scheduling',
|
|
243
|
+
'slug' => 'drafts-publishing-and-scheduling',
|
|
244
|
+
'excerpt' => 'A post is not on the site until it is published. Until then it is a draft, and only people who sign in can see it.',
|
|
245
|
+
'body' => '<p>A post is not on the site until it is published. Until then it is a draft, and only people who sign in can see it.</p><ul><li><strong>Publish</strong> puts it on the site at once.</li><li><strong>Unpublish</strong> takes it off again and keeps it as a draft.</li><li>Give it a date and time still to come and it is <strong>scheduled</strong>: it appears by itself when that time arrives.</li></ul><p>The list in the dashboard says which of the three each post is, and can be filtered by it.</p>',
|
|
246
|
+
],
|
|
247
|
+
[
|
|
248
|
+
'title' => 'Making the blog yours',
|
|
249
|
+
'slug' => 'making-the-blog-yours',
|
|
250
|
+
'excerpt' => 'The pages that show the blog were written once, into your own app, and are yours to change.',
|
|
251
|
+
'body' => '<p>The pages that show the blog were written once, into your own app, and are yours to change.</p><h3>What to open</h3><ol><li><strong>lib/blog.ts</strong> for the blog\'s title, its description and how many posts are on a page.</li><li><strong>app/blog</strong> for the list and for a post\'s own page.</li></ol><blockquote><p>There is an RSS feed at /blog/feed.xml, and every published post is in the sitemap.</p></blockquote>',
|
|
252
|
+
],
|
|
253
|
+
];
|
|
254
|
+
}
|
|
@@ -40,6 +40,13 @@ final class DevCommand extends Command
|
|
|
40
40
|
$web = Process::fromShellCommandline(self::packageManager($webPath).' run dev', $webPath, ['NEVELA_API_URL' => "{$url}/api"], null, null);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Mail a public form causes is queued, and a queue with nobody working it sends
|
|
44
|
+
// nothing: a confirmation link would never arrive. So the queue is worked here too,
|
|
45
|
+
// unless the app runs jobs as they are made.
|
|
46
|
+
$queue = in_array(config('queue.default'), ['sync', 'null', null], true)
|
|
47
|
+
? null
|
|
48
|
+
: new Process([$php, 'artisan', 'queue:listen', '--tries=1', '--sleep=2'], base_path(), null, null, null);
|
|
49
|
+
|
|
43
50
|
$this->components->info('Starting '.($web ? 'the API and the dashboard' : 'the API').'. Press Ctrl+C to stop.');
|
|
44
51
|
$this->components->twoColumnDetail('API', $url);
|
|
45
52
|
if ($web) {
|
|
@@ -49,6 +56,7 @@ final class DevCommand extends Command
|
|
|
49
56
|
|
|
50
57
|
$api->start($this->printer('api', 35));
|
|
51
58
|
$web?->start($this->printer('web', 36));
|
|
59
|
+
$queue?->start($this->printer('queue', 33));
|
|
52
60
|
|
|
53
61
|
try {
|
|
54
62
|
while ($api->isRunning() && ($web === null || $web->isRunning())) {
|
|
@@ -56,6 +64,7 @@ final class DevCommand extends Command
|
|
|
56
64
|
}
|
|
57
65
|
} finally {
|
|
58
66
|
// Whichever stopped, or Ctrl+C: don't leave the other one running behind.
|
|
67
|
+
$queue?->stop(3);
|
|
59
68
|
$web?->stop(3);
|
|
60
69
|
$api->stop(3);
|
|
61
70
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Console;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Console\Command;
|
|
6
|
+
use Nevela\Laravel\Forms\PublicMail;
|
|
7
|
+
|
|
8
|
+
final class DigestCommand extends Command
|
|
9
|
+
{
|
|
10
|
+
protected $signature = 'nevela:digest';
|
|
11
|
+
|
|
12
|
+
protected $description = 'Send the owner one email for the form notices that were held back in a busy hour';
|
|
13
|
+
|
|
14
|
+
public function handle(): int
|
|
15
|
+
{
|
|
16
|
+
if (PublicMail::owner() === null) {
|
|
17
|
+
$this->components->warn('There is nobody to send it to. Set NEVELA_NOTIFY_TO, or the address the app sends mail from.');
|
|
18
|
+
|
|
19
|
+
return self::SUCCESS;
|
|
20
|
+
}
|
|
21
|
+
$count = PublicMail::digest();
|
|
22
|
+
$this->components->info($count === 0
|
|
23
|
+
? 'No notices are waiting.'
|
|
24
|
+
: 'Sent one email naming '.number_format($count).' notice(s) to '.PublicMail::owner().'.');
|
|
25
|
+
|
|
26
|
+
return self::SUCCESS;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Console;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Console\Command;
|
|
6
|
+
use Illuminate\Support\Facades\Http;
|
|
7
|
+
use Nevela\Laravel\Forms\Disposable;
|
|
8
|
+
use Throwable;
|
|
9
|
+
|
|
10
|
+
final class DisposableCommand extends Command
|
|
11
|
+
{
|
|
12
|
+
protected $signature = 'nevela:disposable {--url= : Fetch the list from somewhere else}';
|
|
13
|
+
|
|
14
|
+
protected $description = 'Fetch the current list of throwaway email domains a protected form refuses';
|
|
15
|
+
|
|
16
|
+
public function handle(): int
|
|
17
|
+
{
|
|
18
|
+
$url = (string) ($this->option('url') ?: Disposable::SOURCE);
|
|
19
|
+
try {
|
|
20
|
+
$response = Http::timeout(30)->get($url);
|
|
21
|
+
} catch (Throwable $e) {
|
|
22
|
+
$this->components->error("Couldn't fetch the list: ".$e->getMessage());
|
|
23
|
+
|
|
24
|
+
return self::FAILURE;
|
|
25
|
+
}
|
|
26
|
+
$domains = [];
|
|
27
|
+
foreach (preg_split('/\R/', $response->successful() ? $response->body() : '') ?: [] as $line) {
|
|
28
|
+
$line = strtolower(trim($line));
|
|
29
|
+
// One domain a line. Anything else is not the list: an error page, a redirect to a sign-in.
|
|
30
|
+
if ($line !== '' && $line[0] !== '#' && preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/', $line)) {
|
|
31
|
+
$domains[$line] = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// The list has thousands. A few lines is something else, and would let every throwaway address through.
|
|
35
|
+
if (count($domains) < 1000) {
|
|
36
|
+
$this->components->error("What came back from {$url} isn't the list (".count($domains).' domains). The one in use is unchanged.');
|
|
37
|
+
|
|
38
|
+
return self::FAILURE;
|
|
39
|
+
}
|
|
40
|
+
ksort($domains);
|
|
41
|
+
$path = Disposable::refreshedPath();
|
|
42
|
+
is_dir(dirname($path)) || mkdir(dirname($path), 0775, true);
|
|
43
|
+
file_put_contents($path, implode("\n", array_keys($domains))."\n", LOCK_EX);
|
|
44
|
+
Disposable::forget();
|
|
45
|
+
$this->components->info(number_format(count($domains)).' throwaway domains, kept in '.str_replace(base_path().DIRECTORY_SEPARATOR, '', $path).'.');
|
|
46
|
+
|
|
47
|
+
return self::SUCCESS;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -44,6 +44,27 @@ final class GenerateCommand extends Command
|
|
|
44
44
|
return $written || ! $columnExists;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Whether a resource whose form proves an address needs the migration that adds the
|
|
49
|
+
* column for it: when its table was made without one. A new resource's own migration
|
|
50
|
+
* has it.
|
|
51
|
+
*
|
|
52
|
+
* @param list<string> $createMigrations The contents of the table's own migration(s); none for a resource that is new
|
|
53
|
+
*/
|
|
54
|
+
public static function needsConfirmMigration(array $createMigrations, bool $adopted = false): bool
|
|
55
|
+
{
|
|
56
|
+
if ($createMigrations === []) {
|
|
57
|
+
return $adopted;
|
|
58
|
+
}
|
|
59
|
+
foreach ($createMigrations as $contents) {
|
|
60
|
+
if (str_contains($contents, 'confirmed_at')) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
/** Whether the table has the column now. No, where there is no database to ask. */
|
|
48
69
|
private static function hasTrashColumn(string $table): bool
|
|
49
70
|
{
|
|
@@ -131,6 +152,10 @@ final class GenerateCommand extends Command
|
|
|
131
152
|
if (self::needsTrashMigration($made, $written, self::hasTrashColumn($descriptor->table), $descriptor->adopted)) {
|
|
132
153
|
$files[] = $generator->trashMigrationFile($descriptor);
|
|
133
154
|
}
|
|
155
|
+
// The same for "confirm" added to a resource whose table is already made.
|
|
156
|
+
if ($descriptor->public?->confirm !== null && self::needsConfirmMigration($made, $descriptor->adopted)) {
|
|
157
|
+
$files[] = $generator->confirmMigrationFile($descriptor);
|
|
158
|
+
}
|
|
134
159
|
}
|
|
135
160
|
$files[] = $generator->routes($all);
|
|
136
161
|
$files[] = $generator->publicRoutes($all);
|
|
@@ -16,9 +16,10 @@ final class SetupCommand extends Command
|
|
|
16
16
|
{--name= : Name for the starter account}
|
|
17
17
|
{--email= : Email for the starter account. Leave out to create no account}
|
|
18
18
|
{--password= : Password for the starter account}
|
|
19
|
-
{--no-sample-users : Create the starter account only, without the ten sample users}
|
|
19
|
+
{--no-sample-users : Create the starter account only, without the ten sample users}
|
|
20
|
+
{--no-blog : Leave out the blog: the Post resource, its three sample posts and the /blog pages}';
|
|
20
21
|
|
|
21
|
-
protected $description = 'Finish setting up a new app: key, Sanctum\'s migration, generated files, migrations, a starter account and sample users';
|
|
22
|
+
protected $description = 'Finish setting up a new app: key, Sanctum\'s migration, generated files, migrations, the blog, a starter account and sample users';
|
|
22
23
|
|
|
23
24
|
protected $hidden = true;
|
|
24
25
|
|
|
@@ -39,6 +40,18 @@ final class SetupCommand extends Command
|
|
|
39
40
|
return self::FAILURE;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// A blog, as a first resource and a first public page: a Post to write in the
|
|
44
|
+
// dashboard, /blog to read it at, and three posts so neither is empty. Not having
|
|
45
|
+
// one is no reason to call the setup failed.
|
|
46
|
+
if (! $this->option('no-blog')) {
|
|
47
|
+
// The sample posts are for development: asked for in production they are refused,
|
|
48
|
+
// which would read as the blog having failed.
|
|
49
|
+
$blog = $this->call('nevela:blog', app()->environment('production') ? [] : ['--sample' => true]);
|
|
50
|
+
if ($blog !== self::SUCCESS) {
|
|
51
|
+
$this->components->warn('The blog could not be set up, so this app has none yet. Everything else is in place. Add it later: php nevela blog');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
42
55
|
if ($this->option('email')) {
|
|
43
56
|
// The first account in an app is its administrator.
|
|
44
57
|
$status = $this->call('nevela:user', [
|
|
@@ -7,6 +7,7 @@ use Illuminate\Support\Facades\DB;
|
|
|
7
7
|
use Illuminate\Support\Facades\Http;
|
|
8
8
|
use Illuminate\Support\Facades\Schema;
|
|
9
9
|
use Nevela\Laravel\Access\ClosedAccounts;
|
|
10
|
+
use Nevela\Laravel\Forms\FormGuard;
|
|
10
11
|
use Nevela\Laravel\Nevela;
|
|
11
12
|
use Nevela\Laravel\Support\DashboardState;
|
|
12
13
|
use Nevela\Laravel\Support\Releases;
|
|
@@ -95,6 +96,19 @@ final class StatusCommand extends Command
|
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
// --- public forms ---
|
|
100
|
+
// A form that asks for the robot check can only make it with the secret key.
|
|
101
|
+
$checked = array_map(fn ($r) => $r->name, array_values(array_filter($resources, fn ($r) => $r->public?->protect['turnstile'] ?? false)));
|
|
102
|
+
if ($checked !== []) {
|
|
103
|
+
$forms = implode(', ', $checked);
|
|
104
|
+
$row('Robot check (Turnstile)', match (true) {
|
|
105
|
+
FormGuard::robotCheck() === 'on' => "on for {$forms}, solved on ".implode(', ', FormGuard::hostnames()),
|
|
106
|
+
FormGuard::secret() === '' && app()->environment('production') => $bad("TURNSTILE_SECRET_KEY is not set, so the form for {$forms} answers 503 to everyone"),
|
|
107
|
+
FormGuard::secret() === '' => "<fg=yellow>skipped: TURNSTILE_SECRET_KEY is not set, so nothing checks {$forms}. Set it before going live.</>",
|
|
108
|
+
default => $bad("no site to accept a check from, so the form for {$forms} answers 503 to everyone: set NEVELA_WEB_URL to the site's address, or TURNSTILE_HOSTNAMES"),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
98
112
|
// --- dashboard ---
|
|
99
113
|
$web = config('nevela.web_path');
|
|
100
114
|
if (! $web || ! is_dir($web)) {
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
namespace Nevela\Laravel\Console;
|
|
4
4
|
|
|
5
5
|
use Illuminate\Console\Command;
|
|
6
|
+
use Nevela\Laravel\Forms\Confirmation;
|
|
7
|
+
use Nevela\Laravel\Forms\PublicMail;
|
|
6
8
|
use Nevela\Laravel\Media\Uploads;
|
|
7
9
|
use Nevela\Laravel\Trash\Trash;
|
|
8
10
|
|
|
@@ -21,6 +23,13 @@ final class TrashCommand extends Command
|
|
|
21
23
|
$this->components->info('Removed '.number_format($unclaimed).' file(s) uploaded through a public form and never used.');
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
// People who joined a list and never followed the link that proves their address.
|
|
27
|
+
$hours = Confirmation::hours();
|
|
28
|
+
foreach (Confirmation::purge() as $resource => $count) {
|
|
29
|
+
$this->components->info('Removed '.number_format($count)." {$resource} record(s) whose address wasn't confirmed within {$hours} hours.");
|
|
30
|
+
}
|
|
31
|
+
PublicMail::prune();
|
|
32
|
+
|
|
24
33
|
$days = Trash::days();
|
|
25
34
|
if ($days === null) {
|
|
26
35
|
$this->components->info('Deleted records are kept until someone removes them (nevela.trash.days is off), so there is nothing to do.');
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Forms;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Bus\Queueable;
|
|
6
|
+
use Illuminate\Mail\Mailable;
|
|
7
|
+
use Illuminate\Notifications\Messages\MailMessage;
|
|
8
|
+
use Illuminate\Queue\SerializesModels;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The email with the link that proves an address: see Confirmation.
|
|
12
|
+
*
|
|
13
|
+
* Written like the emails signing in sends, so the two look alike. To say it in your own
|
|
14
|
+
* words, send your own Mailable with Confirmation::link() in it, through
|
|
15
|
+
* PublicMail::toVisitor().
|
|
16
|
+
*/
|
|
17
|
+
final class ConfirmAddress extends Mailable
|
|
18
|
+
{
|
|
19
|
+
use Queueable, SerializesModels;
|
|
20
|
+
|
|
21
|
+
public function __construct(public string $app, public string $url, public int $hours) {}
|
|
22
|
+
|
|
23
|
+
public function build(): self
|
|
24
|
+
{
|
|
25
|
+
$message = (new MailMessage)
|
|
26
|
+
->subject("Confirm your email address for {$this->app}")
|
|
27
|
+
->line("Someone, we hope you, gave this address to {$this->app}. Confirm it and you're on the list.")
|
|
28
|
+
->action('Confirm my address', $this->url)
|
|
29
|
+
->line("The link works for {$this->hours} hours. If this wasn't you, do nothing: the address is removed and you won't hear from us again.");
|
|
30
|
+
|
|
31
|
+
return $this->subject("Confirm your email address for {$this->app}")->html((string) $message->render());
|
|
32
|
+
}
|
|
33
|
+
}
|