create-nevela 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/laravel/src/Generator/ResourceGenerator.php +2 -0
- package/template/laravel/src/Http/PublicController.php +1 -0
- package/template/laravel/src/Nevela.php +1 -1
- package/template/web/app/api/[...path]/route.ts +6 -3
- package/template/web/hooks/use-public-form.ts +123 -35
- package/template/web/package.json +1 -1
package/package.json
CHANGED
|
@@ -527,6 +527,8 @@ final class ResourceGenerator
|
|
|
527
527
|
foreach ($public->create as $field) {
|
|
528
528
|
if ($d->fields[$field]->kind === 'file') {
|
|
529
529
|
$lines[] = "Route::put('_nevela/uploads/{$d->name}/{$field}', [{$controller}, 'upload']){$for}->defaults('field', '{$field}')->middleware([{$throttle($public->createThrottle, 'upload')}{$more}])->name('{$d->slug}.upload.{$field}');";
|
|
530
|
+
// The same, beside the form's own address: a page knows the form by its slug, not by the resource's name.
|
|
531
|
+
$lines[] = "Route::put('{$d->slug}/_uploads/{$field}', [{$controller}, 'upload']){$for}->defaults('field', '{$field}')->middleware([{$throttle($public->createThrottle, 'upload')}{$more}])->name('{$d->slug}.upload.{$field}.here');";
|
|
530
532
|
}
|
|
531
533
|
}
|
|
532
534
|
// What a protected form asks for before it is shown, and after each attempt.
|
|
@@ -25,6 +25,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
25
25
|
* GET {prefix}/public/{slug}/{id} one of them, by id or by the field the section names
|
|
26
26
|
* POST {prefix}/public/{slug} create one, from the fields the section lists
|
|
27
27
|
* PUT {prefix}/public/_nevela/uploads/{Resource}/{field} a file for such a create
|
|
28
|
+
* PUT {prefix}/public/{slug}/_uploads/{field} the same, by the form's own address
|
|
28
29
|
* GET {prefix}/public/_nevela/form/{slug} what a protected form sends back with it
|
|
29
30
|
* POST {prefix}/public/_nevela/confirm/{slug}/{id}/{key} the link that proves an address
|
|
30
31
|
*
|
|
@@ -22,7 +22,7 @@ use Nevela\Laravel\Support\Naming;
|
|
|
22
22
|
final class Nevela
|
|
23
23
|
{
|
|
24
24
|
/** Written by `pnpm release`; the same number as the repository's git tag. */
|
|
25
|
-
public const VERSION = '0.9.
|
|
25
|
+
public const VERSION = '0.9.2';
|
|
26
26
|
|
|
27
27
|
/** @var array<string, Descriptor> */
|
|
28
28
|
private static array $descriptors = [];
|
|
@@ -18,7 +18,7 @@ import { API_URL, callerHeaders, sessionToken } from "@/lib/laravel";
|
|
|
18
18
|
*
|
|
19
19
|
* And for a site's visitors, who have no session: what a resource's "public" section
|
|
20
20
|
* opens (`/api/public/…`). Reading it, a form's create (`POST /api/public/<slug>`) and a
|
|
21
|
-
* file for that form (`PUT /api/public
|
|
21
|
+
* file for that form (`PUT /api/public/<slug>/_uploads/<field>`) go through
|
|
22
22
|
* with no token. Laravel has those routes only for resources whose descriptor opens them.
|
|
23
23
|
*/
|
|
24
24
|
export const dynamic = "force-dynamic";
|
|
@@ -104,8 +104,11 @@ export async function POST(request: NextRequest, context: Context) {
|
|
|
104
104
|
export async function PUT(request: NextRequest, context: Context) {
|
|
105
105
|
const { path } = await context.params;
|
|
106
106
|
const url = target(path, request.nextUrl.search);
|
|
107
|
-
// A visitor's file for a public form: /api/public/_nevela/uploads/<Resource>/<field
|
|
108
|
-
|
|
107
|
+
// A visitor's file for a public form: /api/public/_nevela/uploads/<Resource>/<field>,
|
|
108
|
+
// or by the form's own address, /api/public/<slug>/_uploads/<field>.
|
|
109
|
+
const forVisitor =
|
|
110
|
+
path[0] === "public" &&
|
|
111
|
+
((path.length === 5 && path[1] === "_nevela" && path[2] === "uploads") || (path.length === 4 && !path[1].startsWith("_") && path[2] === "_uploads"));
|
|
109
112
|
if (!url || !(forVisitor || (path[0] === "_nevela" && path[1] === "uploads"))) {
|
|
110
113
|
return Response.json({ error: "Only uploads are sent this way. Records are saved through the dashboard." }, { status: 405 });
|
|
111
114
|
}
|
|
@@ -12,11 +12,19 @@ interface Issued {
|
|
|
12
12
|
action?: string | null;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** What Laravel says when it refuses something. */
|
|
16
|
+
interface Refused {
|
|
17
|
+
error?: string;
|
|
18
|
+
issues?: { path?: string; message?: string }[];
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
export interface PublicForm {
|
|
16
22
|
/** Give this to the form: `<form onSubmit={form.submit}>`. */
|
|
17
23
|
submit: (event: React.FormEvent<HTMLFormElement>) => void;
|
|
18
|
-
/** Being sent. */
|
|
24
|
+
/** Being sent: its files first, then the form. */
|
|
19
25
|
pending: boolean;
|
|
26
|
+
/** The file field being uploaded right now, or null. */
|
|
27
|
+
uploading: string | null;
|
|
20
28
|
/** Sent, and taken. */
|
|
21
29
|
sent: boolean;
|
|
22
30
|
/** What went wrong, where it isn't about one field: the robot check, too many tries, no connection. */
|
|
@@ -25,6 +33,12 @@ export interface PublicForm {
|
|
|
25
33
|
errors: Record<string, string>;
|
|
26
34
|
/** Start again after a form was sent, to send another. */
|
|
27
35
|
again: () => void;
|
|
36
|
+
/**
|
|
37
|
+
* Upload one file for a file field of this form, and get the key to send as its value.
|
|
38
|
+
* `submit` does this for every file input in the form. It is here for a form that
|
|
39
|
+
* builds its own values, or uploads as soon as a file is chosen.
|
|
40
|
+
*/
|
|
41
|
+
upload: (field: string, file: File) => Promise<{ key: string } | { error: string }>;
|
|
28
42
|
/** For `<PublicFormGuard form={form} />`. Not for the form itself to read. */
|
|
29
43
|
guard: {
|
|
30
44
|
honeypot: string | null;
|
|
@@ -37,25 +51,34 @@ export interface PublicForm {
|
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
export interface PublicFormOptions {
|
|
40
|
-
/**
|
|
41
|
-
|
|
54
|
+
/**
|
|
55
|
+
* The record to send, from the form's fields. By default each named field, as typed,
|
|
56
|
+
* with empty ones left out, and for each file input the key of its uploaded file.
|
|
57
|
+
* `uploaded` holds those keys by field name.
|
|
58
|
+
*/
|
|
59
|
+
values?: (data: FormData, uploaded: Record<string, string>) => Record<string, unknown>;
|
|
42
60
|
onSent?: () => void;
|
|
43
61
|
}
|
|
44
62
|
|
|
45
63
|
const UNREACHABLE = "That didn't send. Check your connection and try again.";
|
|
46
64
|
|
|
65
|
+
/** Whether two files are the same choice: an attempt that failed elsewhere doesn't upload it again. */
|
|
66
|
+
const sameFile = (a: File, b: File) => a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
|
|
67
|
+
|
|
47
68
|
/**
|
|
48
69
|
* A form anyone can send: a resource's public create, with whatever its descriptor's
|
|
49
70
|
* "protect" asks for.
|
|
50
71
|
*
|
|
51
72
|
* ```tsx
|
|
52
|
-
* const form = usePublicForm("
|
|
73
|
+
* const form = usePublicForm("internship-applications");
|
|
53
74
|
*
|
|
54
75
|
* <form onSubmit={form.submit}>
|
|
55
76
|
* <input name="email" />
|
|
56
77
|
* {form.errors.email}
|
|
78
|
+
* <input name="cv" type="file" accept="application/pdf" />
|
|
79
|
+
* {form.errors.cv}
|
|
57
80
|
* <PublicFormGuard form={form} />
|
|
58
|
-
* <button disabled={form.pending}>Send</button>
|
|
81
|
+
* <button disabled={form.pending}>{form.uploading ? "Uploading…" : "Send"}</button>
|
|
59
82
|
* {form.error}
|
|
60
83
|
* </form>
|
|
61
84
|
* ```
|
|
@@ -65,6 +88,11 @@ const UNREACHABLE = "That didn't send. Check your connection and try again.";
|
|
|
65
88
|
* `errors` under that field's name, and anything else to `error`. The checks themselves
|
|
66
89
|
* are Laravel's. Nothing here decides who is let through.
|
|
67
90
|
*
|
|
91
|
+
* A file input is sent in two steps, as Laravel wants it: the file first, to the form's
|
|
92
|
+
* own upload address, and then its key as the field's value. The field's types and size
|
|
93
|
+
* limit are Laravel's to enforce, and what it refuses is shown under the field. A file
|
|
94
|
+
* that was uploaded is not uploaded again when the form fails for another reason.
|
|
95
|
+
*
|
|
68
96
|
* A resource with no "protect" works too: there is no token to fetch, and the form is
|
|
69
97
|
* sent as it is.
|
|
70
98
|
*/
|
|
@@ -72,11 +100,14 @@ export function usePublicForm(slug: string, { values, onSent }: PublicFormOption
|
|
|
72
100
|
const [issued, setIssued] = React.useState<Issued | null>(null);
|
|
73
101
|
const [attempt, setAttempt] = React.useState(0);
|
|
74
102
|
const [pending, setPending] = React.useState(false);
|
|
103
|
+
const [uploading, setUploading] = React.useState<string | null>(null);
|
|
75
104
|
const [sent, setSent] = React.useState(false);
|
|
76
105
|
const [error, setError] = React.useState<string | null>(null);
|
|
77
106
|
const [errors, setErrors] = React.useState<Record<string, string>>({});
|
|
78
107
|
const robot = React.useRef<string | null>(null);
|
|
79
108
|
const sending = React.useRef(false);
|
|
109
|
+
/** Files already uploaded for this form and not yet used, by field. */
|
|
110
|
+
const uploaded = React.useRef<Record<string, { file: File; key: string }>>({});
|
|
80
111
|
|
|
81
112
|
// The token says when the form was handed out, so it is asked for when the form
|
|
82
113
|
// appears, and a fresh one after each attempt.
|
|
@@ -99,44 +130,88 @@ export function usePublicForm(slug: string, { values, onSent }: PublicFormOption
|
|
|
99
130
|
robot.current = token;
|
|
100
131
|
}, []);
|
|
101
132
|
|
|
133
|
+
const upload = React.useCallback(
|
|
134
|
+
async (field: string, file: File): Promise<{ key: string } | { error: string }> => {
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch(`/api/public/${encodeURIComponent(slug)}/_uploads/${encodeURIComponent(field)}?name=${encodeURIComponent(file.name)}`, {
|
|
137
|
+
method: "PUT",
|
|
138
|
+
headers: { "Content-Type": file.type || "application/octet-stream", Accept: "application/json" },
|
|
139
|
+
body: file,
|
|
140
|
+
});
|
|
141
|
+
const answer = (await response.json().catch(() => null)) as (Refused & { key?: string }) | null;
|
|
142
|
+
if (response.ok && typeof answer?.key === "string") return { key: answer.key };
|
|
143
|
+
return { error: answer?.issues?.find((issue) => issue.message)?.message ?? answer?.error ?? "That file couldn't be uploaded." };
|
|
144
|
+
} catch {
|
|
145
|
+
return { error: UNREACHABLE };
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
[slug],
|
|
149
|
+
);
|
|
150
|
+
|
|
102
151
|
const submit = React.useCallback(
|
|
103
152
|
(event: React.FormEvent<HTMLFormElement>) => {
|
|
104
153
|
event.preventDefault();
|
|
105
154
|
if (sending.current) return;
|
|
106
155
|
const element = event.currentTarget;
|
|
107
156
|
const data = new FormData(element);
|
|
108
|
-
const body: Record<string, unknown> = values ? { ...values(data) } : {};
|
|
109
|
-
if (!values) {
|
|
110
|
-
for (const [name, value] of data.entries()) {
|
|
111
|
-
// A file is uploaded on its own, and its key sent here: see the public uploads route.
|
|
112
|
-
if (typeof value === "string" && value !== "") body[name] = value;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
// The field no person sees goes as it is, whatever `values` kept: it is how a bot is known.
|
|
116
|
-
if (issued?.honeypot) {
|
|
117
|
-
const bait = data.get(issued.honeypot);
|
|
118
|
-
if (typeof bait === "string" && bait !== "") body[issued.honeypot] = bait;
|
|
119
|
-
}
|
|
120
|
-
if (issued?.token) body._form = issued.token;
|
|
121
|
-
if (robot.current) body._turnstile = robot.current;
|
|
122
157
|
|
|
123
158
|
sending.current = true;
|
|
124
159
|
setPending(true);
|
|
125
160
|
setError(null);
|
|
126
161
|
setErrors({});
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
162
|
+
|
|
163
|
+
void (async () => {
|
|
164
|
+
// The files first: each to its own field's address, for a key to send in its place.
|
|
165
|
+
const keys: Record<string, string> = {};
|
|
166
|
+
for (const [name, value] of data.entries()) {
|
|
167
|
+
// An input nobody chose a file for is an empty file with no name.
|
|
168
|
+
if (typeof value === "string" || value.size === 0) continue;
|
|
169
|
+
const before = uploaded.current[name];
|
|
170
|
+
if (before && sameFile(before.file, value)) {
|
|
171
|
+
keys[name] = before.key;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
setUploading(name);
|
|
175
|
+
const result = await upload(name, value);
|
|
176
|
+
setUploading(null);
|
|
177
|
+
if ("error" in result) {
|
|
178
|
+
// Nothing was sent, so the form's token and the robot check are still good.
|
|
179
|
+
setErrors({ [name]: result.error });
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
uploaded.current[name] = { file: value, key: result.key };
|
|
183
|
+
keys[name] = result.key;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const body: Record<string, unknown> = values ? { ...values(data, keys) } : { ...keys };
|
|
187
|
+
if (!values) {
|
|
188
|
+
for (const [name, value] of data.entries()) {
|
|
189
|
+
if (typeof value === "string" && value !== "") body[name] = value;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// The field no person sees goes as it is, whatever `values` kept: it is how a bot is known.
|
|
193
|
+
if (issued?.honeypot) {
|
|
194
|
+
const bait = data.get(issued.honeypot);
|
|
195
|
+
if (typeof bait === "string" && bait !== "") body[issued.honeypot] = bait;
|
|
196
|
+
}
|
|
197
|
+
if (issued?.token) body._form = issued.token;
|
|
198
|
+
if (robot.current) body._turnstile = robot.current;
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
const response = await fetch(`/api/public/${encodeURIComponent(slug)}`, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
204
|
+
body: JSON.stringify(body),
|
|
205
|
+
});
|
|
133
206
|
if (response.ok) {
|
|
207
|
+
// Its files belong to the record now.
|
|
208
|
+
uploaded.current = {};
|
|
134
209
|
element.reset();
|
|
135
210
|
setSent(true);
|
|
136
211
|
onSent?.();
|
|
137
|
-
return;
|
|
212
|
+
return true;
|
|
138
213
|
}
|
|
139
|
-
const answer = (await response.json().catch(() => null)) as
|
|
214
|
+
const answer = (await response.json().catch(() => null)) as Refused | null;
|
|
140
215
|
const byField: Record<string, string> = {};
|
|
141
216
|
const rest: string[] = [];
|
|
142
217
|
for (const issue of answer?.issues ?? []) {
|
|
@@ -145,19 +220,32 @@ export function usePublicForm(slug: string, { values, onSent }: PublicFormOption
|
|
|
145
220
|
if (issue.path && element.elements.namedItem(issue.path)) byField[issue.path] ??= issue.message;
|
|
146
221
|
else rest.push(issue.path ? `${issue.path}: ${issue.message}` : issue.message);
|
|
147
222
|
}
|
|
223
|
+
// A file Laravel won't take as it is (used, or uploaded too long ago) is uploaded again next time.
|
|
224
|
+
for (const name of Object.keys(byField)) delete uploaded.current[name];
|
|
148
225
|
setErrors(byField);
|
|
149
226
|
setError(Object.keys(byField).length > 0 && rest.length === 0 ? null : (rest[0] ?? answer?.error ?? UNREACHABLE));
|
|
227
|
+
} catch {
|
|
228
|
+
setError(UNREACHABLE);
|
|
229
|
+
}
|
|
230
|
+
return true;
|
|
231
|
+
})()
|
|
232
|
+
// (A `values` of the form's own that throws: nothing was sent.)
|
|
233
|
+
.catch(() => {
|
|
234
|
+
setError(UNREACHABLE);
|
|
235
|
+
return false;
|
|
150
236
|
})
|
|
151
|
-
.
|
|
152
|
-
.
|
|
153
|
-
|
|
237
|
+
.then((attempted) => {
|
|
238
|
+
sending.current = false;
|
|
239
|
+
setUploading(null);
|
|
240
|
+
setPending(false);
|
|
241
|
+
if (attempted) {
|
|
154
242
|
robot.current = null;
|
|
155
|
-
|
|
156
|
-
// Whatever happened, the next attempt needs a new token and a new robot check.
|
|
243
|
+
// Whatever Laravel said, the next attempt needs a new token and a new robot check.
|
|
157
244
|
setAttempt((count) => count + 1);
|
|
158
|
-
}
|
|
245
|
+
}
|
|
246
|
+
});
|
|
159
247
|
},
|
|
160
|
-
[slug, issued, values, onSent],
|
|
248
|
+
[slug, issued, values, onSent, upload],
|
|
161
249
|
);
|
|
162
250
|
|
|
163
251
|
const again = React.useCallback(() => {
|
|
@@ -171,5 +259,5 @@ export function usePublicForm(slug: string, { values, onSent }: PublicFormOption
|
|
|
171
259
|
[issued, attempt, setRobot],
|
|
172
260
|
);
|
|
173
261
|
|
|
174
|
-
return { submit, pending, sent, error, errors, again, guard };
|
|
262
|
+
return { submit, pending, uploading, sent, error, errors, again, upload, guard };
|
|
175
263
|
}
|