create-nevela 0.7.1 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +25 -7
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +66 -3
- 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/Access/Permissions.php +36 -5
- package/template/laravel/src/Concerns/Confirmable.php +81 -0
- package/template/laravel/src/Concerns/Trashable.php +23 -0
- package/template/laravel/src/Console/AdoptCommand.php +139 -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 +38 -3
- package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
- package/template/laravel/src/Console/SeedCommand.php +3 -1
- 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 +17 -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 +356 -32
- 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 +223 -0
- 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 +294 -0
- package/template/laravel/src/Http/PublicThrottle.php +48 -0
- package/template/laravel/src/Http/ResourceRequest.php +193 -4
- package/template/laravel/src/Http/TrashController.php +19 -2
- package/template/laravel/src/Http/UploadController.php +42 -1
- package/template/laravel/src/Media/Uploads.php +111 -0
- package/template/laravel/src/Nevela.php +83 -2
- package/template/laravel/src/NevelaServiceProvider.php +27 -2
- package/template/laravel/src/Support/Action.php +168 -0
- package/template/laravel/src/Support/Adoption.php +352 -0
- package/template/laravel/src/Support/Descriptor.php +83 -4
- package/template/laravel/src/Support/Fake.php +15 -1
- package/template/laravel/src/Support/Field.php +91 -3
- package/template/laravel/src/Support/Html.php +185 -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 +208 -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 +42 -9
- package/template/web/app/confirm/page.tsx +51 -0
- package/template/web/app/dashboard/actions.ts +48 -0
- package/template/web/app/globals.css +46 -0
- package/template/web/components/access/role-form.tsx +40 -5
- package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
- package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
- package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
- package/template/web/components/dashboard/fields/file-field.tsx +9 -5
- package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
- package/template/web/components/dashboard/form-activity.tsx +53 -0
- package/template/web/components/dashboard/record-actions.tsx +33 -4
- package/template/web/components/dashboard/record-detail.tsx +10 -2
- package/template/web/components/dashboard/resource-action.tsx +281 -0
- 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 +46 -4
- package/template/web/components/dashboard/row-actions.tsx +56 -2
- package/template/web/components/dashboard/table-selection.tsx +19 -1
- 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 +175 -0
- package/template/web/lib/access.ts +3 -1
- package/template/web/lib/dashboard.ts +10 -0
- package/template/web/lib/extras.ts +105 -0
- package/template/web/lib/files.ts +14 -0
- package/template/web/lib/laravel.ts +60 -0
- package/template/web/lib/rich.ts +79 -0
- package/template/web/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -75,13 +75,16 @@ const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
|
|
|
75
75
|
nevela dev Run the API and the dashboard
|
|
76
76
|
nevela status Check versions, migrations, users and the dashboard
|
|
77
77
|
nevela resource <Name> --fields="…" [--seed]
|
|
78
|
-
nevela
|
|
78
|
+
nevela adopt <table> Make a resource of a table you already have
|
|
79
|
+
nevela blog Give the site a blog, if it was created without one
|
|
80
|
+
nevela seed | user | generate | migrate | trash | disposable | artisan <command>
|
|
79
81
|
|
|
80
82
|
${bold('Options')}
|
|
81
83
|
--pm <pnpm|npm|yarn|bun> Package manager for the dashboard. Default: the one you ran this with.
|
|
82
84
|
--no-install Don't install the dashboard's dependencies.
|
|
83
85
|
--no-user Don't create the starter admin account, or the sample users.
|
|
84
86
|
--no-sample-users Create the admin only, without the ten sample users.
|
|
87
|
+
--no-blog Leave out the blog: the Post resource, three sample posts and /blog.
|
|
85
88
|
--fast Leave out PHPUnit, Pint and Laravel's other development packages.
|
|
86
89
|
About a third quicker. Add them later: cd apps/api && composer install
|
|
87
90
|
--no-git Don't run git init.
|
|
@@ -193,7 +196,7 @@ async function step(label, work) {
|
|
|
193
196
|
}
|
|
194
197
|
|
|
195
198
|
function parseArgs(argv) {
|
|
196
|
-
const options = { name: undefined, pm: undefined, install: true, user: true, samples: true, git: true, yes: false, bundledPackage: false, devTools: true };
|
|
199
|
+
const options = { name: undefined, pm: undefined, install: true, user: true, samples: true, blog: true, git: true, yes: false, bundledPackage: false, devTools: true };
|
|
197
200
|
for (let i = 0; i < argv.length; i++) {
|
|
198
201
|
const arg = argv[i];
|
|
199
202
|
if (arg === '-h' || arg === '--help') {
|
|
@@ -207,6 +210,7 @@ function parseArgs(argv) {
|
|
|
207
210
|
else if (arg === '--no-install') options.install = false;
|
|
208
211
|
else if (arg === '--no-user') options.user = false;
|
|
209
212
|
else if (arg === '--no-sample-users') options.samples = false;
|
|
213
|
+
else if (arg === '--no-blog') options.blog = false;
|
|
210
214
|
else if (arg === '--no-git') options.git = false;
|
|
211
215
|
else if (arg === '--no-dev-tools' || arg === '--fast') options.devTools = false;
|
|
212
216
|
else if (arg === '--bundled-package') options.bundledPackage = true;
|
|
@@ -285,7 +289,7 @@ function write(file, contents) {
|
|
|
285
289
|
fs.writeFileSync(file, contents);
|
|
286
290
|
}
|
|
287
291
|
|
|
288
|
-
function writeRootFiles(root, { name, title, pm, admin, samples }) {
|
|
292
|
+
function writeRootFiles(root, { name, title, pm, admin, samples, blog }) {
|
|
289
293
|
write(path.join(root, 'package.json'), `${JSON.stringify({
|
|
290
294
|
name,
|
|
291
295
|
version: '0.1.0',
|
|
@@ -337,7 +341,11 @@ php nevela upgrade # bring this app to the latest Nevela
|
|
|
337
341
|
php nevela # everything it can do
|
|
338
342
|
\`\`\`
|
|
339
343
|
|
|
340
|
-
|
|
344
|
+
${blog ? `## The blog
|
|
345
|
+
|
|
346
|
+
The site has a blog at http://localhost:3000/blog. Posts are written under **Posts** in the dashboard, and a post is on the site once it is published. Its pages are in \`apps/web/app/blog\` and its settings in \`apps/web/lib/blog.ts\`: both are yours to change.
|
|
347
|
+
|
|
348
|
+
` : ''}## Add a resource
|
|
341
349
|
|
|
342
350
|
\`\`\`sh
|
|
343
351
|
php nevela resource Product --fields="name:string, sku:string!, price:money, notes:text?"
|
|
@@ -681,7 +689,7 @@ async function main() {
|
|
|
681
689
|
// `nevela upgrade` compares against these to tell the files you changed from the ones
|
|
682
690
|
// you didn't, so an update never overwrites your work.
|
|
683
691
|
write(path.join(web, '.nevela.json'), `${JSON.stringify(templateRecord(VERSION), null, 4)}\n`);
|
|
684
|
-
writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null, samples: options.user && options.samples });
|
|
692
|
+
writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null, samples: options.user && options.samples, blog: options.blog });
|
|
685
693
|
const packages = options.install ? start(pm, ['install'], { cwd: web }) : null;
|
|
686
694
|
|
|
687
695
|
const tokens = await step('Creating the Laravel app', async (progress) => {
|
|
@@ -738,15 +746,20 @@ async function main() {
|
|
|
738
746
|
|
|
739
747
|
let admin = false;
|
|
740
748
|
let samples = false;
|
|
749
|
+
let blog = false;
|
|
750
|
+
let blogMissing = false;
|
|
741
751
|
await step('Setting up the database', () => {
|
|
742
752
|
// One artisan process for the app key, Sanctum's migration, the generated files, the
|
|
743
753
|
// migrations, the starter account and the sample users: each separate call would
|
|
744
754
|
// start Laravel again.
|
|
745
755
|
const account = options.user && tokens ? [`--name=${ADMIN.name}`, `--email=${ADMIN.email}`, `--password=${ADMIN.password}`, ...(options.samples ? [] : ['--no-sample-users'])] : [];
|
|
746
|
-
const setup = run('php', ['artisan', 'nevela:setup', ...account], { cwd: api, allowFailure: true });
|
|
756
|
+
const setup = run('php', ['artisan', 'nevela:setup', ...account, ...(options.blog ? [] : ['--no-blog'])], { cwd: api, allowFailure: true });
|
|
747
757
|
if (setup.ok) {
|
|
748
758
|
admin = account.length > 0;
|
|
749
759
|
samples = admin && options.samples;
|
|
760
|
+
blog = options.blog && fs.existsSync(path.join(web, 'app', 'blog', 'page.tsx'));
|
|
761
|
+
// Asked for, and not there: the setup carried on without it, and the person is told.
|
|
762
|
+
blogMissing = options.blog && !blog;
|
|
750
763
|
return;
|
|
751
764
|
}
|
|
752
765
|
// 2: the app is set up and only the starter account could not be created.
|
|
@@ -797,7 +810,12 @@ async function main() {
|
|
|
797
810
|
console.log(` ${dim(`Ten sample users are there too, with the same password: 2 editors and 8 users. See them under Users, and what each may do under Roles.`)}`);
|
|
798
811
|
}
|
|
799
812
|
}
|
|
800
|
-
|
|
813
|
+
if (blog) {
|
|
814
|
+
console.log(`\n The site has a blog at ${indigo('http://localhost:3000/blog')}, with three posts to start. Write more under Posts in the dashboard.`);
|
|
815
|
+
} else if (blogMissing) {
|
|
816
|
+
console.log(`\n The blog couldn't be set up, so this app has none yet. Everything else is in place. Add it later: ${dim(`${nevela} blog`)}`);
|
|
817
|
+
}
|
|
818
|
+
console.log(`\n Add ${blog ? 'a' : 'your first'} resource: ${dim(`${nevela} resource Product --fields="name:string, price:money"`)}`);
|
|
801
819
|
console.log(` Check the app: ${dim(`${nevela} status`)} Upgrade it later: ${dim(`${nevela} upgrade`)} Everything: ${dim(nevela)}`);
|
|
802
820
|
if (nevela !== 'php nevela' && nevela !== 'nevela') {
|
|
803
821
|
console.log(`\n ${dim(`In this shell "php" isn't a command (your PHP is php.bat), so use ${nevela}. In PowerShell, php nevela works too.`)}`);
|
package/package.json
CHANGED
package/project.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { isNewer, satisfiesCaret } from './packagist.mjs';
|
|
|
11
11
|
import { spawnCommand } from './shell.mjs';
|
|
12
12
|
|
|
13
13
|
/** What `nevela <name>` runs: these go to `php artisan nevela:<name>`. */
|
|
14
|
-
const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'generate', 'seed', 'user', 'trash'];
|
|
14
|
+
const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'adopt', 'blog', 'generate', 'seed', 'user', 'trash', 'digest', 'disposable'];
|
|
15
15
|
/** These go to the artisan command of the same name. */
|
|
16
16
|
const ARTISAN = ['migrate', 'tinker', 'test', 'serve'];
|
|
17
17
|
|
|
@@ -131,6 +131,60 @@ return [
|
|
|
131
131
|
'days' => 30,
|
|
132
132
|
],
|
|
133
133
|
|
|
134
|
+
/*
|
|
135
|
+
| Forms anyone can send: a resource's "public" create, with "protect" or "confirm".
|
|
136
|
+
|
|
|
137
|
+
| turnstile Cloudflare's robot check. The secret key is this app's alone; the site key
|
|
138
|
+
| goes in the web app (NEXT_PUBLIC_TURNSTILE_SITE_KEY). With no secret the
|
|
139
|
+
| check is skipped in development, and in production a form that asks for
|
|
140
|
+
| it answers 503: it is never quietly left open. A check counts only if it
|
|
141
|
+
| was solved on one of `hostnames` (a name stands for its subdomains), and
|
|
142
|
+
| never on localhost in production.
|
|
143
|
+
| form_lifetime How long a form can sit open before it must be loaded again, in seconds.
|
|
144
|
+
| mail The most one address is emailed because someone typed it into a form
|
|
145
|
+
| (PublicMail::toVisitor), across every form. Past it the email is dropped
|
|
146
|
+
| and the record is still saved. This is what stops a form being used to
|
|
147
|
+
| mail strangers.
|
|
148
|
+
| notify Who is told a form was sent (PublicMail::toOwner), and how often: past
|
|
149
|
+
| max_per_hour the rest wait for one email at the top of the hour.
|
|
150
|
+
| confirm How long an address has to follow its link ("confirm"), after which the
|
|
151
|
+
| record is removed, and the page of the web app the link opens.
|
|
152
|
+
*/
|
|
153
|
+
'public' => [
|
|
154
|
+
'turnstile' => [
|
|
155
|
+
'secret' => env('TURNSTILE_SECRET_KEY'),
|
|
156
|
+
/* The sites a check may be solved on, with commas and no spaces between: example.com,shop.example.com. The web app's own (NEVELA_WEB_URL), unless set. */
|
|
157
|
+
'hostnames' => env('TURNSTILE_HOSTNAMES'),
|
|
158
|
+
'verify_url' => 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
|
159
|
+
],
|
|
160
|
+
'form_lifetime' => 2 * 60 * 60,
|
|
161
|
+
'mail' => [
|
|
162
|
+
'visitor_per_hour' => 1,
|
|
163
|
+
'visitor_per_day' => 3,
|
|
164
|
+
],
|
|
165
|
+
'notify' => [
|
|
166
|
+
'to' => env('NEVELA_NOTIFY_TO'),
|
|
167
|
+
'max_per_hour' => 10,
|
|
168
|
+
],
|
|
169
|
+
'confirm' => [
|
|
170
|
+
'hours' => 48,
|
|
171
|
+
'path' => '/confirm',
|
|
172
|
+
],
|
|
173
|
+
],
|
|
174
|
+
|
|
175
|
+
/*
|
|
176
|
+
| Rich text fields (body:richtext). Whatever is sent is parsed and written out again
|
|
177
|
+
| with only these tags and attributes: nothing the editor, a script or a visitor sends
|
|
178
|
+
| is trusted. A tag that isn't listed goes and its words stay. The list matches the
|
|
179
|
+
| dashboard editor's toolbar; add to it only what you would let a stranger put on a page.
|
|
180
|
+
| A link may use these schemes, or none (/about).
|
|
181
|
+
*/
|
|
182
|
+
'rich_text' => [
|
|
183
|
+
'tags' => ['p', 'br', 'strong', 'em', 's', 'a', 'h2', 'h3', 'ul', 'ol', 'li', 'blockquote'],
|
|
184
|
+
'attributes' => ['a' => ['href']],
|
|
185
|
+
'schemes' => ['http', 'https', 'mailto'],
|
|
186
|
+
],
|
|
187
|
+
|
|
134
188
|
/*
|
|
135
189
|
| The most rows POST /{slug}/_bulk creates in one request (the dashboard's "Add several"
|
|
136
190
|
| grid). They are saved in one transaction, so this is also how long one can run.
|
|
@@ -142,14 +196,23 @@ return [
|
|
|
142
196
|
|
|
|
143
197
|
| Files go on a disk from config/filesystems.php and are handed out by Nevela's own
|
|
144
198
|
| route, GET {prefix}/_nevela/files/{key}, so nothing needs linking or publishing. Keys
|
|
145
|
-
| are unguessable, but anyone who has one can fetch the file
|
|
146
|
-
|
|
|
147
|
-
|
|
|
199
|
+
| are unguessable, but anyone who has one can fetch the file. If a CDN or your web
|
|
200
|
+
| server serves the disk, put its address in NEVELA_UPLOADS_URL and the API's links
|
|
201
|
+
| point there.
|
|
202
|
+
|
|
|
203
|
+
| A document that must stay private goes in a private file field ("private": true in
|
|
204
|
+
| the descriptor, or cv:file(pdf|private)). It is kept on private_disk, which must not
|
|
205
|
+
| be one the web server serves, and is handed out by
|
|
206
|
+
| GET {prefix}/_nevela/private/{slug}/{id}/{field} to someone signed in who may view
|
|
207
|
+
| that record. Its key opens nothing.
|
|
148
208
|
*/
|
|
149
209
|
'uploads' => [
|
|
150
210
|
'disk' => env('NEVELA_UPLOADS_DISK', 'public'),
|
|
151
211
|
'url' => env('NEVELA_UPLOADS_URL'),
|
|
152
212
|
|
|
213
|
+
/* Where private files are kept. Never served: not "public", and not a bucket anyone can read. */
|
|
214
|
+
'private_disk' => env('NEVELA_PRIVATE_DISK', 'local'),
|
|
215
|
+
|
|
153
216
|
/* Where untouched originals are kept, so an image can be optimised again later. Never served. */
|
|
154
217
|
'originals_disk' => env('NEVELA_ORIGINALS_DISK', 'local'),
|
|
155
218
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Illuminate\Database\Migrations\Migration;
|
|
4
|
+
use Illuminate\Database\Schema\Blueprint;
|
|
5
|
+
use Illuminate\Support\Facades\Schema;
|
|
6
|
+
|
|
7
|
+
// Public forms: what was refused, and the mail a form causes.
|
|
8
|
+
//
|
|
9
|
+
// A form anyone can send is sent by bots too. What is turned away is counted, so that
|
|
10
|
+
// protection which works can be told from a form nobody uses. And because every send can
|
|
11
|
+
// cause mail, to the address that was typed and to the owner, each of those is counted
|
|
12
|
+
// too: that is what keeps the form from being a way to mail strangers, and the owner's
|
|
13
|
+
// inbox from being buried.
|
|
14
|
+
return new class extends Migration
|
|
15
|
+
{
|
|
16
|
+
public function up(): void
|
|
17
|
+
{
|
|
18
|
+
if (! Schema::hasTable('nevela_form_blocks')) {
|
|
19
|
+
Schema::create('nevela_form_blocks', function (Blueprint $table) {
|
|
20
|
+
$table->uuid('id')->primary();
|
|
21
|
+
$table->string('resource');
|
|
22
|
+
// honeypot, forged, too_fast, expired, robot, disposable, undeliverable, throttled, unconfigured, unreachable
|
|
23
|
+
$table->string('reason', 32);
|
|
24
|
+
$table->date('day');
|
|
25
|
+
$table->unsignedInteger('count')->default(0);
|
|
26
|
+
$table->unique(['resource', 'reason', 'day']);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (! Schema::hasTable('nevela_public_mail')) {
|
|
31
|
+
Schema::create('nevela_public_mail', function (Blueprint $table) {
|
|
32
|
+
$table->uuid('id')->primary();
|
|
33
|
+
// "visitor": to an address a form was given. "owner": a notice that a form was sent.
|
|
34
|
+
$table->string('kind', 16);
|
|
35
|
+
// A visitor's address is kept as a fingerprint: enough to count by, not to read back.
|
|
36
|
+
$table->string('recipient', 64);
|
|
37
|
+
$table->timestamp('sent_at')->index();
|
|
38
|
+
$table->index(['kind', 'recipient', 'sent_at']);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (! Schema::hasTable('nevela_owner_digest')) {
|
|
43
|
+
// Notices to the owner past the hour's limit, held for one email that names them all.
|
|
44
|
+
Schema::create('nevela_owner_digest', function (Blueprint $table) {
|
|
45
|
+
$table->uuid('id')->primary();
|
|
46
|
+
$table->string('subject');
|
|
47
|
+
$table->string('line', 500)->nullable();
|
|
48
|
+
$table->string('url', 2048)->nullable();
|
|
49
|
+
$table->timestamp('held_at')->index();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public function down(): void
|
|
55
|
+
{
|
|
56
|
+
Schema::dropIfExists('nevela_owner_digest');
|
|
57
|
+
Schema::dropIfExists('nevela_public_mail');
|
|
58
|
+
Schema::dropIfExists('nevela_form_blocks');
|
|
59
|
+
}
|
|
60
|
+
};
|