create-nevela 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +1 -0
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +25 -3
- package/template/laravel/src/Access/Permissions.php +36 -5
- package/template/laravel/src/Access/SampleUsers.php +1 -3
- package/template/laravel/src/Concerns/Trashable.php +23 -0
- package/template/laravel/src/Console/AdoptCommand.php +139 -0
- package/template/laravel/src/Console/GenerateCommand.php +13 -3
- package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
- package/template/laravel/src/Console/SeedCommand.php +3 -1
- package/template/laravel/src/Console/TrashCommand.php +8 -0
- package/template/laravel/src/Console/UserCommand.php +5 -1
- package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
- package/template/laravel/src/Http/Access/UsersController.php +2 -4
- package/template/laravel/src/Http/ActionController.php +222 -0
- package/template/laravel/src/Http/PublicController.php +210 -0
- package/template/laravel/src/Http/PublicThrottle.php +48 -0
- package/template/laravel/src/Http/ResourceRequest.php +151 -2
- package/template/laravel/src/Http/TrashController.php +18 -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 +54 -2
- package/template/laravel/src/NevelaServiceProvider.php +17 -1
- 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 +59 -4
- package/template/laravel/src/Support/Fake.php +15 -1
- package/template/laravel/src/Support/Field.php +74 -3
- package/template/laravel/src/Support/Html.php +185 -0
- package/template/laravel/src/Support/PublicAccess.php +133 -0
- package/template/web/app/api/[...path]/route.ts +42 -9
- package/template/web/app/dashboard/actions.ts +34 -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/record-actions.tsx +7 -2
- package/template/web/components/dashboard/record-detail.tsx +9 -2
- package/template/web/components/dashboard/resource-action.tsx +281 -0
- package/template/web/components/dashboard/resource-table.tsx +15 -2
- package/template/web/components/dashboard/row-actions.tsx +24 -0
- package/template/web/components/dashboard/table-selection.tsx +19 -1
- package/template/web/lib/access.ts +3 -1
- package/template/web/lib/dashboard.ts +10 -0
- package/template/web/lib/extras.ts +66 -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
|
@@ -133,11 +133,25 @@ final class Fake
|
|
|
133
133
|
'datetime' => $field->unique
|
|
134
134
|
? gmdate('Y-m-d H:i:s', self::EPOCH + $number)
|
|
135
135
|
: date('Y-m-d H:i:s', time() - mt_rand(0, 365 * 86400)),
|
|
136
|
-
'text' =>
|
|
136
|
+
'text' => $field->isRich()
|
|
137
|
+
? self::article($field->unique ? " {$number}" : '')
|
|
138
|
+
: self::sentence(mt_rand(8, 24)).($field->unique ? " {$number}" : ''),
|
|
137
139
|
default => self::string($field, $number, $first, $last, $isPerson),
|
|
138
140
|
};
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
/** A few paragraphs with a heading and a list, so a seeded record reads like one somebody wrote. */
|
|
144
|
+
private static function article(string $suffix): string
|
|
145
|
+
{
|
|
146
|
+
$items = implode('', array_map(fn () => '<li>'.ucfirst(self::sentence(mt_rand(3, 6))).'</li>', range(1, 3)));
|
|
147
|
+
|
|
148
|
+
return '<p>'.self::sentence(mt_rand(14, 24)).'</p>'
|
|
149
|
+
.'<h2>'.rtrim(self::sentence(mt_rand(3, 5)), '.').$suffix.'</h2>'
|
|
150
|
+
.'<p>'.self::sentence(mt_rand(12, 20)).' <strong>'.rtrim(self::sentence(mt_rand(2, 4)), '.').'</strong>, '.lcfirst(self::sentence(mt_rand(6, 10))).'</p>'
|
|
151
|
+
."<ul>{$items}</ul>"
|
|
152
|
+
.'<p>'.self::sentence(mt_rand(10, 18)).'</p>';
|
|
153
|
+
}
|
|
154
|
+
|
|
141
155
|
private static function string(Field $field, int $number, string $first, string $last, bool $isPerson): string
|
|
142
156
|
{
|
|
143
157
|
$name = strtolower($field->name);
|
|
@@ -14,6 +14,7 @@ final class Field
|
|
|
14
14
|
|
|
15
15
|
public const FORMATS = [
|
|
16
16
|
'string' => ['email', 'url', 'tel', 'slug', 'color'],
|
|
17
|
+
'text' => ['rich'],
|
|
17
18
|
'float' => ['money', 'percent', 'rating'],
|
|
18
19
|
'int' => ['percent', 'rating'],
|
|
19
20
|
];
|
|
@@ -23,7 +24,7 @@ final class Field
|
|
|
23
24
|
|
|
24
25
|
/** Shorthand types accepted by --fields, mapped to kind + format. */
|
|
25
26
|
private const ALIASES = [
|
|
26
|
-
'string' => ['string', null], 'text' => ['text', null],
|
|
27
|
+
'string' => ['string', null], 'text' => ['text', null], 'richtext' => ['text', 'rich'],
|
|
27
28
|
'email' => ['string', 'email'], 'url' => ['string', 'url'], 'tel' => ['string', 'tel'],
|
|
28
29
|
'slug' => ['string', 'slug'], 'color' => ['string', 'color'],
|
|
29
30
|
'int' => ['int', null], 'integer' => ['int', null],
|
|
@@ -40,6 +41,9 @@ final class Field
|
|
|
40
41
|
* @param string|null $target belongsTo: the resource this points at, e.g. "Category"
|
|
41
42
|
* @param list<string> $accept file: the categories it takes, e.g. ["image"]
|
|
42
43
|
* @param string|null $profile file: the image profile uploads are optimised with (config nevela.uploads.profiles)
|
|
44
|
+
* @param int|null $maxLength string and text: the most characters it holds, where that isn't the usual 255 or 65,535
|
|
45
|
+
* @param bool $private file: kept where the web server doesn't reach, and handed only to someone who may view the record
|
|
46
|
+
* @param string|int|float|bool|null $default What the field is when a create leaves it out. Also the column's default.
|
|
43
47
|
*/
|
|
44
48
|
public function __construct(
|
|
45
49
|
public readonly string $name,
|
|
@@ -52,6 +56,9 @@ final class Field
|
|
|
52
56
|
public readonly ?string $target = null,
|
|
53
57
|
public readonly array $accept = [],
|
|
54
58
|
public readonly ?string $profile = null,
|
|
59
|
+
public readonly ?int $maxLength = null,
|
|
60
|
+
public readonly bool $private = false,
|
|
61
|
+
public readonly string|int|float|bool|null $default = null,
|
|
55
62
|
) {
|
|
56
63
|
if (! preg_match('/^[a-z][A-Za-z0-9]*$/', $name)) {
|
|
57
64
|
throw new InvalidArgumentException("Field \"{$name}\" must be camelCase (e.g. publishedAt).");
|
|
@@ -65,6 +72,9 @@ final class Field
|
|
|
65
72
|
if ($format !== null && ! in_array($format, self::FORMATS[$kind] ?? [], true)) {
|
|
66
73
|
throw new InvalidArgumentException("Format \"{$format}\" is not valid for {$kind} field \"{$name}\".");
|
|
67
74
|
}
|
|
75
|
+
if ($maxLength !== null && (! in_array($kind, ['string', 'text'], true) || $format === 'color' || $maxLength < 1)) {
|
|
76
|
+
throw new InvalidArgumentException("\"{$name}\" can't have a length of {$maxLength}: a length is a whole number of 1 or more, on a string or text field.");
|
|
77
|
+
}
|
|
68
78
|
if ($kind === 'enum' && $options === []) {
|
|
69
79
|
throw new InvalidArgumentException("Enum field \"{$name}\" needs values, e.g. {$name}:enum(draft|published).");
|
|
70
80
|
}
|
|
@@ -81,6 +91,25 @@ final class Field
|
|
|
81
91
|
throw new InvalidArgumentException("A belongsTo field holds an id, so its name ends in \"Id\": {$name}Id.");
|
|
82
92
|
}
|
|
83
93
|
}
|
|
94
|
+
if ($default !== null) {
|
|
95
|
+
$fits = match ($kind) {
|
|
96
|
+
'string', 'text' => is_string($default) && ! ($kind === 'text' && $format === 'rich'),
|
|
97
|
+
'int' => is_int($default),
|
|
98
|
+
'float' => is_int($default) || is_float($default),
|
|
99
|
+
'boolean' => is_bool($default),
|
|
100
|
+
'enum' => is_string($default) && in_array($default, $options, true),
|
|
101
|
+
default => false,
|
|
102
|
+
};
|
|
103
|
+
if (! $fits) {
|
|
104
|
+
throw new InvalidArgumentException("The default of \"{$name}\" has to be a value the field could hold".($kind === 'enum' ? ': one of '.implode(', ', $options).'.' : (in_array($kind, ['string', 'text', 'int', 'float', 'boolean'], true) ? '.' : ", and a {$kind} field can't have one.")));
|
|
105
|
+
}
|
|
106
|
+
if ($unique) {
|
|
107
|
+
throw new InvalidArgumentException("\"{$name}\" is unique, so only one record could ever have its default.");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if ($private && $kind !== 'file') {
|
|
111
|
+
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
|
+
}
|
|
84
113
|
if ($kind === 'file') {
|
|
85
114
|
if ($accept === []) {
|
|
86
115
|
throw new InvalidArgumentException("File field \"{$name}\" needs what it accepts, e.g. {$name}:image or {$name}:file(pdf|image).");
|
|
@@ -103,15 +132,40 @@ final class Field
|
|
|
103
132
|
* Parse one `name:type` token. Suffix `?` = optional (nullable), `!` = unique; both may be combined.
|
|
104
133
|
*
|
|
105
134
|
* status:enum(draft|published)
|
|
135
|
+
* excerpt:string(500) a length other than the usual 255
|
|
136
|
+
* body:richtext formatted text, kept as HTML that has been cleaned
|
|
106
137
|
* category:belongsTo(Category) stored as categoryId
|
|
107
138
|
* image:image an optimised image; image:image(product) names a profile
|
|
108
139
|
* manual:file(pdf|document) any other upload
|
|
140
|
+
* cv:file(pdf|private) one only those who may view the record can fetch
|
|
141
|
+
* status:enum(pending|paid)=pending what it is when a create leaves it out
|
|
109
142
|
*
|
|
110
143
|
* Grit's and Flare's spellings are understood too: see normalise().
|
|
111
144
|
*/
|
|
112
145
|
public static function parse(string $token): self
|
|
113
146
|
{
|
|
114
147
|
$written = trim($token);
|
|
148
|
+
// status:enum(pending|paid)=pending: what the field is when a create leaves it out.
|
|
149
|
+
$default = null;
|
|
150
|
+
if (($at = strpos($written, '=')) !== false) {
|
|
151
|
+
$default = trim(substr($written, $at + 1));
|
|
152
|
+
$written = rtrim(substr($written, 0, $at));
|
|
153
|
+
}
|
|
154
|
+
if ($default !== null) {
|
|
155
|
+
$field = self::parse($written);
|
|
156
|
+
$value = match ($field->kind) {
|
|
157
|
+
'int' => filter_var($default, FILTER_VALIDATE_INT) === false ? $default : (int) $default,
|
|
158
|
+
'float' => is_numeric($default) ? (float) $default : $default,
|
|
159
|
+
'boolean' => match (strtolower($default)) {
|
|
160
|
+
'true', '1', 'yes' => true,
|
|
161
|
+
'false', '0', 'no' => false,
|
|
162
|
+
default => $default,
|
|
163
|
+
},
|
|
164
|
+
default => $default,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return self::fromArray($field->name, ['default' => $value] + $field->toArray());
|
|
168
|
+
}
|
|
115
169
|
$token = self::normalise($written);
|
|
116
170
|
if (! preg_match('/^([A-Za-z][A-Za-z0-9_]*):([A-Za-z_]+)(?:\(([^)]*)\))?([?!]{0,2})$/', $token, $m)) {
|
|
117
171
|
throw new InvalidArgumentException("Invalid field \"{$written}\". Expected name:type, e.g. price:money, status:enum(draft|live)?, image:image or category:belongsTo(Category).");
|
|
@@ -135,14 +189,19 @@ final class Field
|
|
|
135
189
|
return new self($name, 'file', null, $required, $unique, accept: ['image'], profile: $list[0] ?? null);
|
|
136
190
|
}
|
|
137
191
|
if ($type === 'file') {
|
|
138
|
-
|
|
192
|
+
// cv:file(pdf|private): "private" among what it takes is how it is kept, not a kind of file.
|
|
193
|
+
$accept = array_values(array_diff($list, ['private']));
|
|
194
|
+
|
|
195
|
+
return new self($name, 'file', null, $required, $unique, accept: $accept ?: ['any'], private: count($accept) !== count($list));
|
|
139
196
|
}
|
|
140
197
|
if (! isset(self::ALIASES[$type])) {
|
|
141
198
|
throw new InvalidArgumentException("Unknown type \"{$type}\" on \"{$name}\". Use one of: ".implode(', ', array_keys(self::ALIASES)).', enum(a|b), belongsTo(Resource), image, file(pdf|…).');
|
|
142
199
|
}
|
|
143
200
|
[$kind, $format] = self::ALIASES[$type];
|
|
201
|
+
// excerpt:string(500): a length of its own.
|
|
202
|
+
$maxLength = in_array($kind, ['string', 'text'], true) && $format !== 'color' && ctype_digit($args) ? (int) $args : null;
|
|
144
203
|
|
|
145
|
-
return new self($name, $kind, $format, $required, $unique);
|
|
204
|
+
return new self($name, $kind, $format, $required, $unique, maxLength: $maxLength);
|
|
146
205
|
}
|
|
147
206
|
|
|
148
207
|
/**
|
|
@@ -183,6 +242,9 @@ final class Field
|
|
|
183
242
|
$data['target'] ?? null,
|
|
184
243
|
array_values($data['accept'] ?? []),
|
|
185
244
|
$data['profile'] ?? null,
|
|
245
|
+
isset($data['maxLength']) ? (int) $data['maxLength'] : null,
|
|
246
|
+
(bool) ($data['private'] ?? false),
|
|
247
|
+
$data['default'] ?? null,
|
|
186
248
|
);
|
|
187
249
|
}
|
|
188
250
|
|
|
@@ -196,6 +258,9 @@ final class Field
|
|
|
196
258
|
'target' => $this->target,
|
|
197
259
|
'accept' => $this->accept ?: null,
|
|
198
260
|
'profile' => $this->profile,
|
|
261
|
+
'maxLength' => $this->maxLength,
|
|
262
|
+
'private' => $this->private ?: null,
|
|
263
|
+
'default' => $this->default,
|
|
199
264
|
'required' => $this->required ? null : false,
|
|
200
265
|
'unique' => $this->unique ?: null,
|
|
201
266
|
'label' => $this->label,
|
|
@@ -219,6 +284,12 @@ final class Field
|
|
|
219
284
|
return substr($this->name, 0, -2);
|
|
220
285
|
}
|
|
221
286
|
|
|
287
|
+
/** Whether this is text with formatting: written in an editor, kept as HTML, cleaned on the way in and out. */
|
|
288
|
+
public function isRich(): bool
|
|
289
|
+
{
|
|
290
|
+
return $this->kind === 'text' && $this->format === 'rich';
|
|
291
|
+
}
|
|
292
|
+
|
|
222
293
|
/** Whether this is a file field that takes images only, and so is optimised and shown as a picture. */
|
|
223
294
|
public function isImage(): bool
|
|
224
295
|
{
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Support;
|
|
4
|
+
|
|
5
|
+
use DOMDocument;
|
|
6
|
+
use DOMElement;
|
|
7
|
+
use DOMNode;
|
|
8
|
+
use DOMText;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Rich text, made safe to keep and to show.
|
|
12
|
+
*
|
|
13
|
+
* What a rich text field receives is whatever was sent: the dashboard's editor, a script,
|
|
14
|
+
* a CSV file, a visitor's form. None of it is trusted. The HTML is parsed, and written
|
|
15
|
+
* out again from what was understood, keeping only the tags and attributes on a list
|
|
16
|
+
* (config: nevela.rich_text). A tag that isn't on it goes and its words stay; a tag whose
|
|
17
|
+
* contents are not words, such as a script, goes with them.
|
|
18
|
+
*
|
|
19
|
+
* The same is done when a record is read, so that HTML which reached the table some other
|
|
20
|
+
* way (a column adopted from an older app, a row written by hand) is never shown as it is.
|
|
21
|
+
*/
|
|
22
|
+
final class Html
|
|
23
|
+
{
|
|
24
|
+
/** What the dashboard's editor can make. */
|
|
25
|
+
public const TAGS = ['p', 'br', 'strong', 'em', 's', 'a', 'h2', 'h3', 'ul', 'ol', 'li', 'blockquote'];
|
|
26
|
+
|
|
27
|
+
/** Attributes kept, by tag. A link keeps where it goes and nothing else. */
|
|
28
|
+
public const ATTRIBUTES = ['a' => ['href']];
|
|
29
|
+
|
|
30
|
+
/** Where a link may go. One with no scheme, such as /about, is the site's own and is kept. */
|
|
31
|
+
public const SCHEMES = ['http', 'https', 'mailto'];
|
|
32
|
+
|
|
33
|
+
/** Other spellings of the same thing, as browsers and word processors write them. */
|
|
34
|
+
private const SAME_AS = ['b' => 'strong', 'i' => 'em', 'strike' => 's', 'del' => 's'];
|
|
35
|
+
|
|
36
|
+
/** Removed with everything inside them: what they hold was never text. */
|
|
37
|
+
private const DROPPED = ['script', 'style', 'iframe', 'object', 'embed', 'noscript', 'template', 'svg', 'math', 'head', 'title', 'textarea', 'select', 'option', 'button', 'audio', 'video', 'canvas', 'applet', 'frame', 'frameset', 'link', 'meta', 'base'];
|
|
38
|
+
|
|
39
|
+
/** Tags that sit inside a line of text. Anything else stands on lines of its own. */
|
|
40
|
+
private const INLINE = ['a', 'strong', 'em', 's', 'b', 'i', 'u', 'strike', 'del', 'ins', 'span', 'code', 'sub', 'sup', 'small', 'mark', 'font', 'br', 'abbr', 'cite', 'q', 'label', 'time', 'img', 'kbd', 'var', 'samp', 'big', 'tt', 'wbr'];
|
|
41
|
+
|
|
42
|
+
private const EMPTY = ['br', 'hr', 'img'];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The HTML with only what the list allows, or an empty string when there is nothing
|
|
46
|
+
* in it to read: an editor that was opened and left sends `<p><br></p>`.
|
|
47
|
+
*
|
|
48
|
+
* @param array{tags?: list<string>, attributes?: array<string, list<string>>, schemes?: list<string>}|null $allow Null for config('nevela.rich_text')
|
|
49
|
+
*/
|
|
50
|
+
public static function clean(?string $html, ?array $allow = null): string
|
|
51
|
+
{
|
|
52
|
+
if ($html === null || trim($html) === '') {
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
$allow ??= function_exists('config') && function_exists('app') && app()->bound('config') ? (array) config('nevela.rich_text', []) : [];
|
|
56
|
+
$tags = array_map('strtolower', (array) ($allow['tags'] ?? self::TAGS));
|
|
57
|
+
$attributes = (array) ($allow['attributes'] ?? self::ATTRIBUTES);
|
|
58
|
+
$schemes = array_map('strtolower', (array) ($allow['schemes'] ?? self::SCHEMES));
|
|
59
|
+
|
|
60
|
+
$document = new DOMDocument;
|
|
61
|
+
$before = libxml_use_internal_errors(true);
|
|
62
|
+
// Characters outside ASCII go in as numbered entities: the parser would otherwise
|
|
63
|
+
// have to guess the encoding, and guesses Latin-1.
|
|
64
|
+
$document->loadHTML('<html><body>'.mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8').'</body></html>');
|
|
65
|
+
libxml_clear_errors();
|
|
66
|
+
libxml_use_internal_errors($before);
|
|
67
|
+
|
|
68
|
+
$body = $document->getElementsByTagName('body')->item(0);
|
|
69
|
+
if ($body === null) {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
$out = self::top($body, $tags, $attributes, $schemes);
|
|
73
|
+
$words = trim(html_entity_decode(strip_tags($out), ENT_QUOTES | ENT_HTML5, 'UTF-8'), " \t\n\r\0\x0B\u{A0}");
|
|
74
|
+
|
|
75
|
+
return $words === '' && ! str_contains($out, '<img') ? '' : $out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The top of the document, where everything is a paragraph, a heading, a list or a
|
|
80
|
+
* quote. Text and inline tags found loose there are gathered into paragraphs, so that
|
|
81
|
+
* plain text from a CSV file or a script is stored as what the editor would have made.
|
|
82
|
+
* A wrapper that isn't kept, such as a div, is looked into the same way.
|
|
83
|
+
*
|
|
84
|
+
* @param list<string> $tags
|
|
85
|
+
* @param array<string, list<string>> $attributes
|
|
86
|
+
* @param list<string> $schemes
|
|
87
|
+
*/
|
|
88
|
+
private static function top(DOMNode $parent, array $tags, array $attributes, array $schemes): string
|
|
89
|
+
{
|
|
90
|
+
$paragraph = fn (string $inline) => trim($inline) === '' ? '' : (in_array('p', $tags, true) ? '<p>'.trim($inline).'</p>' : $inline);
|
|
91
|
+
$out = '';
|
|
92
|
+
$loose = '';
|
|
93
|
+
foreach ($parent->childNodes as $node) {
|
|
94
|
+
$name = $node instanceof DOMElement ? strtolower($node->nodeName) : '';
|
|
95
|
+
$name = self::SAME_AS[$name] ?? $name;
|
|
96
|
+
if ($name === '' || in_array($name, self::INLINE, true)) {
|
|
97
|
+
$loose .= self::write($node, $tags, $attributes, $schemes);
|
|
98
|
+
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
$out .= $paragraph($loose);
|
|
102
|
+
$loose = '';
|
|
103
|
+
$out .= in_array($name, $tags, true) || in_array($name, self::DROPPED, true)
|
|
104
|
+
? self::write($node, $tags, $attributes, $schemes)
|
|
105
|
+
: self::top($node, $tags, $attributes, $schemes);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return $out.$paragraph($loose);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The words alone, on one line: what a table cell or a search result shows. */
|
|
112
|
+
public static function text(?string $html, int $limit = 0): string
|
|
113
|
+
{
|
|
114
|
+
$text = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags((string) preg_replace('/<(br|\/p|\/h[1-6]|\/li|\/blockquote)\b[^>]*>/i', ' ', (string) $html)), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
|
|
115
|
+
|
|
116
|
+
return $limit > 0 && mb_strlen($text) > $limit ? rtrim(mb_substr($text, 0, $limit - 1)).'…' : $text;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param list<string> $tags
|
|
122
|
+
* @param array<string, list<string>> $attributes
|
|
123
|
+
* @param list<string> $schemes
|
|
124
|
+
*/
|
|
125
|
+
private static function write(DOMNode $node, array $tags, array $attributes, array $schemes): string
|
|
126
|
+
{
|
|
127
|
+
if ($node instanceof DOMText) {
|
|
128
|
+
return htmlspecialchars($node->nodeValue ?? '', ENT_NOQUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
|
|
129
|
+
}
|
|
130
|
+
if (! $node instanceof DOMElement) {
|
|
131
|
+
return ''; // a comment, a processing instruction
|
|
132
|
+
}
|
|
133
|
+
$name = strtolower($node->nodeName);
|
|
134
|
+
$name = self::SAME_AS[$name] ?? $name;
|
|
135
|
+
if (in_array($name, self::DROPPED, true)) {
|
|
136
|
+
return '';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
$inner = '';
|
|
140
|
+
foreach ($node->childNodes as $child) {
|
|
141
|
+
$inner .= self::write($child, $tags, $attributes, $schemes);
|
|
142
|
+
}
|
|
143
|
+
if (! in_array($name, $tags, true)) {
|
|
144
|
+
// Not a tag to keep, but its words are. Where it stood on its own line, a space keeps them apart.
|
|
145
|
+
return in_array($name, self::INLINE, true) && $name !== 'br' ? $inner : $inner.' ';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
$kept = '';
|
|
149
|
+
foreach ((array) ($attributes[$name] ?? []) as $attribute) {
|
|
150
|
+
$value = $node->getAttribute($attribute);
|
|
151
|
+
if ($value === '' || (in_array($attribute, ['href', 'src'], true) && ! self::safe($value, $schemes))) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
$kept .= ' '.$attribute.'="'.htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8').'"';
|
|
155
|
+
}
|
|
156
|
+
// A link that goes nowhere is its words.
|
|
157
|
+
if ($name === 'a' && $kept === '') {
|
|
158
|
+
return $inner;
|
|
159
|
+
}
|
|
160
|
+
// A tag with nothing in it is nothing. Browsers leave these behind: Chrome puts a
|
|
161
|
+
// list inside the paragraph it was started in, which leaves an empty paragraph
|
|
162
|
+
// before it once the two are told apart. (A blank line is <p><br></p>, and is kept.)
|
|
163
|
+
if (! in_array($name, self::EMPTY, true) && trim($inner) === '') {
|
|
164
|
+
return '';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return in_array($name, self::EMPTY, true) ? "<{$name}{$kept}>" : "<{$name}{$kept}>{$inner}</{$name}>";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Whether an address is one a link may have. A browser ignores spaces and control
|
|
172
|
+
* characters inside a scheme, so `java\tscript:` is read here the way it would be there.
|
|
173
|
+
*
|
|
174
|
+
* @param list<string> $schemes
|
|
175
|
+
*/
|
|
176
|
+
private static function safe(string $address, array $schemes): bool
|
|
177
|
+
{
|
|
178
|
+
$address = (string) preg_replace('/[\x00-\x20\x7F]+/', '', $address);
|
|
179
|
+
if (! preg_match('/^([a-z][a-z0-9+.\-]*):/i', $address, $m)) {
|
|
180
|
+
return true; // no scheme: /about, #top, //example.com
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return in_array(strtolower($m[1]), $schemes, true);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Support;
|
|
4
|
+
|
|
5
|
+
use InvalidArgumentException;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What of a resource is open to anyone, signed in or not: the descriptor's "public" section.
|
|
9
|
+
*
|
|
10
|
+
* "public": {
|
|
11
|
+
* "read": { "by": "slug", "fields": ["title", "slug", "body", "publishedAt"] },
|
|
12
|
+
* "create": { "fields": ["name", "email", "message"], "throttle": "10,1" }
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* Nothing is open without it, and nothing beyond what it names: a resource with "read"
|
|
16
|
+
* alone can't be written to, and a field that isn't listed is never sent or accepted.
|
|
17
|
+
* Which records can be read is the model's to say, in its publicQuery scope.
|
|
18
|
+
*/
|
|
19
|
+
final class PublicAccess
|
|
20
|
+
{
|
|
21
|
+
/** Requests a minute, when the section doesn't say: plenty for a page, few for a form. */
|
|
22
|
+
public const READ_THROTTLE = '60,1';
|
|
23
|
+
|
|
24
|
+
public const CREATE_THROTTLE = '10,1';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param list<string>|null $read The fields anyone may read, or null when nothing may be
|
|
28
|
+
* @param string|null $by The unique field one record is fetched by, in place of its id
|
|
29
|
+
* @param list<string>|null $create The fields anyone may send to create a record, or null when nobody may
|
|
30
|
+
* @param list<string> $middleware More middleware for these routes: the app's own spam checks
|
|
31
|
+
*/
|
|
32
|
+
public function __construct(
|
|
33
|
+
public readonly ?array $read = null,
|
|
34
|
+
public readonly ?string $by = null,
|
|
35
|
+
public readonly ?array $create = null,
|
|
36
|
+
public readonly string $readThrottle = self::READ_THROTTLE,
|
|
37
|
+
public readonly string $createThrottle = self::CREATE_THROTTLE,
|
|
38
|
+
public readonly array $middleware = [],
|
|
39
|
+
) {
|
|
40
|
+
foreach ([$readThrottle, $createThrottle] as $throttle) {
|
|
41
|
+
if (! preg_match('/^[1-9][0-9]*,[1-9][0-9]*$/', $throttle)) {
|
|
42
|
+
throw new InvalidArgumentException("\"{$throttle}\" is not a throttle. Write how many requests in how many minutes: \"10,1\".");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if ($read === null && $create === null) {
|
|
46
|
+
throw new InvalidArgumentException('A "public" section opens something: give it "read", "create" or both.');
|
|
47
|
+
}
|
|
48
|
+
foreach ($middleware as $name) {
|
|
49
|
+
if (! is_string($name) || ! preg_match('/^[A-Za-z0-9_.:,\\\\-]+$/', $name)) {
|
|
50
|
+
throw new InvalidArgumentException('Public middleware is named as in a route: "honeypot", or "throttle:5,1".');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @param array<string, mixed> $data */
|
|
56
|
+
public static function fromArray(array $data): self
|
|
57
|
+
{
|
|
58
|
+
$read = isset($data['read']) ? (array) $data['read'] : null;
|
|
59
|
+
$create = isset($data['create']) ? (array) $data['create'] : null;
|
|
60
|
+
|
|
61
|
+
return new self(
|
|
62
|
+
$read === null ? null : array_values(array_map('strval', (array) ($read['fields'] ?? []))),
|
|
63
|
+
$read['by'] ?? null,
|
|
64
|
+
$create === null ? null : array_values(array_map('strval', (array) ($create['fields'] ?? []))),
|
|
65
|
+
(string) ($read['throttle'] ?? self::READ_THROTTLE),
|
|
66
|
+
(string) ($create['throttle'] ?? self::CREATE_THROTTLE),
|
|
67
|
+
array_values((array) ($data['middleware'] ?? [])),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @return array<string, mixed> */
|
|
72
|
+
public function toArray(): array
|
|
73
|
+
{
|
|
74
|
+
return array_filter([
|
|
75
|
+
'read' => $this->read === null ? null : array_filter([
|
|
76
|
+
'by' => $this->by,
|
|
77
|
+
'fields' => $this->read,
|
|
78
|
+
'throttle' => $this->readThrottle === self::READ_THROTTLE ? null : $this->readThrottle,
|
|
79
|
+
], fn ($v) => $v !== null),
|
|
80
|
+
'create' => $this->create === null ? null : array_filter([
|
|
81
|
+
'fields' => $this->create,
|
|
82
|
+
'throttle' => $this->createThrottle === self::CREATE_THROTTLE ? null : $this->createThrottle,
|
|
83
|
+
], fn ($v) => $v !== null),
|
|
84
|
+
'middleware' => $this->middleware ?: null,
|
|
85
|
+
], fn ($v) => $v !== null);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Checked against the resource's fields, which the section on its own can't know.
|
|
90
|
+
*
|
|
91
|
+
* @param array<string, Field> $fields
|
|
92
|
+
*
|
|
93
|
+
* @throws InvalidArgumentException
|
|
94
|
+
*/
|
|
95
|
+
public function check(string $resource, array $fields): void
|
|
96
|
+
{
|
|
97
|
+
foreach (['read' => $this->read, 'create' => $this->create] as $what => $listed) {
|
|
98
|
+
if ($listed === null) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if ($listed === []) {
|
|
102
|
+
throw new InvalidArgumentException("{$resource}: public {$what} names no fields, so there would be nothing to {$what}.");
|
|
103
|
+
}
|
|
104
|
+
foreach ($listed as $field) {
|
|
105
|
+
if (! isset($fields[$field])) {
|
|
106
|
+
throw new InvalidArgumentException("{$resource}: public {$what} lists \"{$field}\", which is not one of its fields.");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
foreach ($this->read ?? [] as $field) {
|
|
111
|
+
if ($fields[$field]->private) {
|
|
112
|
+
throw new InvalidArgumentException("{$resource}: \"{$field}\" is a private file, so it can't be read by anyone who asks.");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if ($this->by !== null) {
|
|
116
|
+
if ($this->read === null) {
|
|
117
|
+
throw new InvalidArgumentException("{$resource}: \"by\" says how one record is read, and nothing is.");
|
|
118
|
+
}
|
|
119
|
+
if (! isset($fields[$this->by]) || ! $fields[$this->by]->unique) {
|
|
120
|
+
throw new InvalidArgumentException("{$resource}: a record is fetched by \"{$this->by}\", which has to be a unique field ({$this->by}:slug!).");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if ($this->create !== null) {
|
|
124
|
+
foreach ($fields as $field) {
|
|
125
|
+
// Left out of the form, a field needs something to be: nothing, or a default.
|
|
126
|
+
// (A yes/no column is no unless said otherwise.)
|
|
127
|
+
if ($field->required && $field->default === null && $field->kind !== 'boolean' && ! in_array($field->name, $this->create, true)) {
|
|
128
|
+
throw new InvalidArgumentException("{$resource}: \"{$field->name}\" is required and a public create can't send it. List it under create, give it a default, or make it optional.");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { NextRequest } from "next/server";
|
|
2
|
-
import { API_URL, sessionToken } from "@/lib/laravel";
|
|
2
|
+
import { API_URL, callerHeaders, sessionToken } from "@/lib/laravel";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* The browser's way to the Laravel API, for the few things a server action can't do:
|
|
6
6
|
*
|
|
7
7
|
* - reading, for the relation picker's search (`/api/categories?q=…`),
|
|
8
|
-
* - showing stored files (`/api/_nevela/files/<key>`),
|
|
8
|
+
* - showing stored files (`/api/_nevela/files/<key>`),
|
|
9
|
+
* - downloading a private one (`/api/_nevela/private/<slug>/<id>/<field>`), which Laravel
|
|
10
|
+
* hands only to someone who may view that record, and
|
|
9
11
|
* - uploading one (`PUT /api/_nevela/uploads/<Resource>/<field>`), which needs the
|
|
10
12
|
* browser's upload progress and is larger than a server action accepts.
|
|
11
13
|
*
|
|
@@ -13,6 +15,11 @@ import { API_URL, sessionToken } from "@/lib/laravel";
|
|
|
13
15
|
* as a Bearer token. Laravel still decides what the person may see or store. Everything
|
|
14
16
|
* else that changes data goes through the server actions, so nothing but an upload is
|
|
15
17
|
* let through here as a write.
|
|
18
|
+
*
|
|
19
|
+
* And for a site's visitors, who have no session: what a resource's "public" section
|
|
20
|
+
* opens (`/api/public/…`). Reading it, a form's create (`POST /api/public/<slug>`) and a
|
|
21
|
+
* file for that form (`PUT /api/public/_nevela/uploads/<Resource>/<field>`) go through
|
|
22
|
+
* with no token. Laravel has those routes only for resources whose descriptor opens them.
|
|
16
23
|
*/
|
|
17
24
|
export const dynamic = "force-dynamic";
|
|
18
25
|
|
|
@@ -23,7 +30,7 @@ const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
|
|
|
23
30
|
* What is passed back from Laravel's answer. Not its length or encoding: fetch() hands
|
|
24
31
|
* over the body already decompressed, so the length Laravel gave may no longer be true.
|
|
25
32
|
*/
|
|
26
|
-
const RESPONSE_HEADERS = ["content-type", "cache-control", "etag", "last-modified", "location", "x-content-type-options"];
|
|
33
|
+
const RESPONSE_HEADERS = ["content-type", "content-disposition", "cache-control", "etag", "last-modified", "location", "x-content-type-options"];
|
|
27
34
|
|
|
28
35
|
type Context = { params: Promise<{ path: string[] }> };
|
|
29
36
|
|
|
@@ -32,11 +39,16 @@ function target(path: string[], search: string): string | null {
|
|
|
32
39
|
return `${API_URL}/${path.map(encodeURIComponent).join("/")}${search}`;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
|
|
42
|
+
/** Whether an address is under what a resource's "public" section opens. */
|
|
43
|
+
const isPublic = (path: string[]) => path[0] === "public" && path.length > 1;
|
|
44
|
+
|
|
45
|
+
async function forward(url: string, init: RequestInit, withToken: boolean, forVisitor = false): Promise<Response> {
|
|
36
46
|
const headers = new Headers(init.headers);
|
|
37
47
|
headers.set("Accept", headers.get("Accept") ?? "application/json");
|
|
38
48
|
const token = withToken ? await sessionToken() : undefined;
|
|
39
49
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
50
|
+
// Whose request this is, so Laravel's throttle counts the visitor and not this server.
|
|
51
|
+
if (forVisitor) for (const [name, value] of Object.entries(await callerHeaders())) headers.set(name, value);
|
|
40
52
|
|
|
41
53
|
let upstream: Response;
|
|
42
54
|
try {
|
|
@@ -68,17 +80,38 @@ export async function GET(request: NextRequest, context: Context) {
|
|
|
68
80
|
// a request with no session is told 401; asked as a page, Laravel would try to send it
|
|
69
81
|
// to a sign-in route this app doesn't have. A file is asked for as the browser wants it.
|
|
70
82
|
const accept = isFile ? (request.headers.get("accept") ?? "*/*") : "application/json";
|
|
71
|
-
|
|
83
|
+
// What is public is the same for everyone, so it is asked for as nobody.
|
|
84
|
+
return forward(url, { method: "GET", headers: { Accept: accept, ...conditional } }, !isFile && !isPublic(path), isPublic(path));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A visitor's form: a public create. Nothing else is posted this way. A signed-in
|
|
89
|
+
* person's saves go through the dashboard's server actions.
|
|
90
|
+
*/
|
|
91
|
+
export async function POST(request: NextRequest, context: Context) {
|
|
92
|
+
const { path } = await context.params;
|
|
93
|
+
const url = target(path, request.nextUrl.search);
|
|
94
|
+
if (!url || path.length !== 2 || path[0] !== "public" || path[1].startsWith("_")) {
|
|
95
|
+
return Response.json({ error: "Only a public form is sent this way. Records are saved through the dashboard." }, { status: 405 });
|
|
96
|
+
}
|
|
97
|
+
const length = Number(request.headers.get("content-length") ?? 0);
|
|
98
|
+
if (length > 1024 * 1024) return Response.json({ error: "That is too much to send." }, { status: 413 });
|
|
99
|
+
const body = await request.text();
|
|
100
|
+
if (body.length > 1024 * 1024) return Response.json({ error: "That is too much to send." }, { status: 413 });
|
|
101
|
+
return forward(url, { method: "POST", body, headers: { "Content-Type": "application/json" } }, false, true);
|
|
72
102
|
}
|
|
73
103
|
|
|
74
104
|
export async function PUT(request: NextRequest, context: Context) {
|
|
75
105
|
const { path } = await context.params;
|
|
76
106
|
const url = target(path, request.nextUrl.search);
|
|
77
|
-
|
|
107
|
+
// A visitor's file for a public form: /api/public/_nevela/uploads/<Resource>/<field>.
|
|
108
|
+
const forVisitor = path.length === 5 && path[0] === "public" && path[1] === "_nevela" && path[2] === "uploads";
|
|
109
|
+
if (!url || !(forVisitor || (path[0] === "_nevela" && path[1] === "uploads"))) {
|
|
78
110
|
return Response.json({ error: "Only uploads are sent this way. Records are saved through the dashboard." }, { status: 405 });
|
|
79
111
|
}
|
|
80
|
-
// Nothing is read until it is known who is sending it and how much there is.
|
|
81
|
-
|
|
112
|
+
// Nothing is read until it is known who is sending it and how much there is. (A visitor
|
|
113
|
+
// is nobody, by design: Laravel takes their file only for a field a public form lists.)
|
|
114
|
+
if (!forVisitor && !(await sessionToken())) return Response.json({ error: "Sign in first." }, { status: 401 });
|
|
82
115
|
const length = Number(request.headers.get("content-length"));
|
|
83
116
|
if (!Number.isFinite(length) || length <= 0) return Response.json({ error: "The upload didn't say how large it is." }, { status: 411 });
|
|
84
117
|
if (length > MAX_UPLOAD_BYTES) {
|
|
@@ -91,5 +124,5 @@ export async function PUT(request: NextRequest, context: Context) {
|
|
|
91
124
|
if (body.byteLength > MAX_UPLOAD_BYTES) {
|
|
92
125
|
return Response.json({ error: "That file is too large to upload." }, { status: 413 });
|
|
93
126
|
}
|
|
94
|
-
return forward(url, { method: "PUT", body, headers: { "Content-Type": request.headers.get("content-type") ?? "application/octet-stream" } },
|
|
127
|
+
return forward(url, { method: "PUT", body, headers: { "Content-Type": request.headers.get("content-type") ?? "application/octet-stream" } }, !forVisitor, forVisitor);
|
|
95
128
|
}
|