create-nevela 0.7.1 → 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.
Files changed (50) hide show
  1. package/index.mjs +1 -0
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +25 -3
  5. package/template/laravel/src/Access/Permissions.php +36 -5
  6. package/template/laravel/src/Concerns/Trashable.php +23 -0
  7. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  8. package/template/laravel/src/Console/GenerateCommand.php +13 -3
  9. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  10. package/template/laravel/src/Console/SeedCommand.php +3 -1
  11. package/template/laravel/src/Console/TrashCommand.php +8 -0
  12. package/template/laravel/src/Generator/ResourceGenerator.php +229 -29
  13. package/template/laravel/src/Http/ActionController.php +222 -0
  14. package/template/laravel/src/Http/PublicController.php +210 -0
  15. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  16. package/template/laravel/src/Http/ResourceRequest.php +151 -2
  17. package/template/laravel/src/Http/TrashController.php +18 -2
  18. package/template/laravel/src/Http/UploadController.php +42 -1
  19. package/template/laravel/src/Media/Uploads.php +111 -0
  20. package/template/laravel/src/Nevela.php +54 -2
  21. package/template/laravel/src/NevelaServiceProvider.php +17 -1
  22. package/template/laravel/src/Support/Action.php +168 -0
  23. package/template/laravel/src/Support/Adoption.php +352 -0
  24. package/template/laravel/src/Support/Descriptor.php +59 -4
  25. package/template/laravel/src/Support/Fake.php +15 -1
  26. package/template/laravel/src/Support/Field.php +74 -3
  27. package/template/laravel/src/Support/Html.php +185 -0
  28. package/template/laravel/src/Support/PublicAccess.php +133 -0
  29. package/template/web/app/api/[...path]/route.ts +42 -9
  30. package/template/web/app/dashboard/actions.ts +34 -0
  31. package/template/web/app/globals.css +46 -0
  32. package/template/web/components/access/role-form.tsx +40 -5
  33. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  34. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  35. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  36. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  37. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  38. package/template/web/components/dashboard/record-actions.tsx +7 -2
  39. package/template/web/components/dashboard/record-detail.tsx +9 -2
  40. package/template/web/components/dashboard/resource-action.tsx +281 -0
  41. package/template/web/components/dashboard/resource-table.tsx +15 -2
  42. package/template/web/components/dashboard/row-actions.tsx +24 -0
  43. package/template/web/components/dashboard/table-selection.tsx +19 -1
  44. package/template/web/lib/access.ts +3 -1
  45. package/template/web/lib/dashboard.ts +10 -0
  46. package/template/web/lib/extras.ts +66 -0
  47. package/template/web/lib/files.ts +14 -0
  48. package/template/web/lib/laravel.ts +60 -0
  49. package/template/web/lib/rich.ts +79 -0
  50. package/template/web/package.json +1 -1
@@ -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
- return new self($name, 'file', null, $required, $unique, accept: $list ?: ['any']);
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>`), and
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
- async function forward(url: string, init: RequestInit, withToken: boolean): Promise<Response> {
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
- return forward(url, { method: "GET", headers: { Accept: accept, ...conditional } }, !isFile);
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
- if (!url || path[0] !== "_nevela" || path[1] !== "uploads") {
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
- if (!(await sessionToken())) return Response.json({ error: "Sign in first." }, { status: 401 });
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" } }, true);
127
+ return forward(url, { method: "PUT", body, headers: { "Content-Type": request.headers.get("content-type") ?? "application/octet-stream" } }, !forVisitor, forVisitor);
95
128
  }
@@ -8,6 +8,7 @@ import { revalidateResource } from "@/lib/cache";
8
8
  import { fileUrl } from "@/lib/files";
9
9
  import { laravel } from "@/lib/laravel";
10
10
  import { allResources, resourcePath, dashboardSession, dashboardStore, mayDo } from "@/lib/dashboard";
11
+ import { actionsOf } from "@/lib/extras";
11
12
 
12
13
  export type ActionResult<T = unknown> =
13
14
  | { ok: true; data: T }
@@ -36,6 +37,39 @@ export async function deleteRecordAction(resourceName: string, id: string): Prom
36
37
  return result;
37
38
  }
38
39
 
40
+ /**
41
+ * Run one of a resource's own actions (the descriptor's "actions"): on one record, on
42
+ * the rows selected, or on the list as a whole.
43
+ *
44
+ * Laravel does it, in the class the app wrote for it, and decides everything: who may,
45
+ * whether it applies, whether what was filled in is valid. Its answer comes back as it
46
+ * is, so a problem with a field can be shown on that field.
47
+ */
48
+ export async function runResourceAction(
49
+ resourceName: string,
50
+ key: string,
51
+ target: { id?: string; ids?: string[] },
52
+ input: Record<string, unknown> = {},
53
+ ): Promise<ActionResult<{ message: string; done?: number; skipped?: number }>> {
54
+ const { allowed: inAdmin, may } = await dashboardSession();
55
+ const resource = allResources().find((item) => item.name === resourceName);
56
+ const action = actionsOf(resourceName).find((item) => item.key === key);
57
+ if (!inAdmin || !resource || !action || !may(action.permission)) return forbidden();
58
+ if (target.ids && target.ids.length > 500) return { ok: false, status: 400, error: "That's more than 500 records. Do them in smaller batches." };
59
+
60
+ const path = target.id ? `${resource.slug}/${encodeURIComponent(target.id)}/_actions/${key}` : `${resource.slug}/_actions/${key}`;
61
+ const response = await laravel(path, { method: "POST", body: { input, ...(target.ids ? { ids: target.ids } : {}) } });
62
+ const body = (response.body ?? {}) as { message?: string; done?: number; skipped?: number; error?: string; detail?: string; issues?: FieldIssue[] };
63
+ if (!response.ok) {
64
+ // `detail` is only sent while the Laravel app is in debug mode: the reason, for whoever is building it.
65
+ return { ok: false, status: response.status, error: body.detail ? `${body.error ?? "It didn't work."} ${body.detail}` : (body.error ?? "It didn't work."), issues: body.issues };
66
+ }
67
+ // What it changed could be anything of this resource's, so everything of it is read again.
68
+ revalidateResource({ resource: resourceName, action: "update", id: target.id ?? "" });
69
+ revalidatePath(resourcePath(resource));
70
+ return { ok: true, data: { message: body.message ?? `${action.label}: done.`, done: body.done, skipped: body.skipped } };
71
+ }
72
+
39
73
  /** How many rows one export may fetch: enough for a spreadsheet. */
40
74
  const EXPORT_LIMIT = 50_000;
41
75
  /** Laravel's `nevela.max_per_page`. */