@flaredev/cli 0.11.0 → 0.12.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 (45) hide show
  1. package/dist/index.js +4777 -3372
  2. package/package.json +2 -2
  3. package/templates/app/.dev.vars.example +20 -0
  4. package/templates/app/app/dashboard/import-actions.ts +2 -2
  5. package/templates/app/app/dashboard/invitations/actions.ts +62 -0
  6. package/templates/app/app/dashboard/invitations/page.tsx +121 -0
  7. package/templates/app/app/invite/[token]/route.ts +28 -0
  8. package/templates/app/app/sign-up/page.tsx +61 -42
  9. package/templates/app/components/dashboard/fields/file-field.tsx +132 -12
  10. package/templates/app/components/dashboard/invite-form.tsx +86 -0
  11. package/templates/app/components/dashboard/record-detail.tsx +13 -2
  12. package/templates/app/components/dashboard/resource-form-page.tsx +9 -1
  13. package/templates/app/components/dashboard/resource-form.tsx +3 -1
  14. package/templates/app/components/dashboard/resource-table.tsx +7 -1
  15. package/templates/app/lib/api.ts +41 -3
  16. package/templates/app/lib/auth-config.ts +60 -38
  17. package/templates/app/lib/auth.ts +67 -2
  18. package/templates/app/lib/dashboard-nav.ts +5 -0
  19. package/templates/app/lib/invitations.ts +142 -0
  20. package/templates/app/lib/rate-limit.ts +71 -0
  21. package/templates/app/lib/resource/handlers.ts +9 -2
  22. package/templates/app/lib/resource/http.ts +24 -13
  23. package/templates/app/lib/resource/store.ts +79 -27
  24. package/templates/app/lib/storage/cloudinary.ts +146 -0
  25. package/templates/app/lib/storage/uploadthing.ts +122 -0
  26. package/templates/app/lib/storage.ts +72 -6
  27. package/templates/app/lib/user-titles.ts +52 -0
  28. package/templates/app/worker/index.ts +4 -1
  29. package/templates/app/wrangler.jsonc +11 -0
  30. package/templates/locks/cloudflare/package-lock.json +570 -690
  31. package/templates/locks/cloudflare/pnpm-lock.yaml +1061 -1091
  32. package/templates/locks/cloudflare/yarn.lock +363 -381
  33. package/templates/locks/next/package-lock.json +283 -243
  34. package/templates/locks/next/pnpm-lock.yaml +890 -870
  35. package/templates/locks/next/yarn.lock +338 -295
  36. package/templates/next/README.md +35 -3
  37. package/templates/next/lib/auth.ts +65 -2
  38. package/templates/next/lib/dashboard.ts +3 -1
  39. package/templates/next/lib/invitations.ts +119 -0
  40. package/templates/next/lib/rate-limit.ts +83 -0
  41. package/templates/next/lib/resource/prisma-rows.ts +52 -9
  42. package/templates/next/lib/storage/disk.ts +128 -0
  43. package/templates/next/lib/storage/r2.ts +86 -0
  44. package/templates/next/lib/storage.ts +83 -66
  45. package/templates/next/lib/user-titles.ts +49 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaredev/cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "The flare command-line tool: create, generate, migrate, and deploy Flare apps",
5
5
  "license": "MIT",
6
6
  "author": "Muke Johnbaptist",
@@ -41,7 +41,7 @@
41
41
  "jiti": "^2.7.0",
42
42
  "jsonc-parser": "^3.3.1",
43
43
  "picocolors": "^1.1.1",
44
- "@flaredev/core": "0.11.0"
44
+ "@flaredev/core": "0.12.0"
45
45
  },
46
46
  "engines": {
47
47
  "node": ">=22"
@@ -11,3 +11,23 @@ BETTER_AUTH_SECRET=
11
11
  RESEND_API_KEY=
12
12
  # Sender on a domain verified in Resend, e.g. "Acme <hello@acme.com>"
13
13
  MAIL_FROM=
14
+
15
+ # Where uploads go. The default is the bound STORAGE bucket, which needs nothing set.
16
+ # Set a driver only to send files to a CDN instead — see the file-uploads guide.
17
+ # STORAGE_DRIVER=cloudinary
18
+ # CLOUDINARY_CLOUD_NAME=
19
+ # CLOUDINARY_API_KEY=
20
+ # CLOUDINARY_API_SECRET=
21
+
22
+ # STORAGE_DRIVER=uploadthing # needs: pnpm add uploadthing
23
+ # UPLOADTHING_TOKEN=
24
+ # UPLOADTHING_APP_ID=
25
+
26
+ # Both serve files from their own CDN, so a read URL is public and does not expire.
27
+ # Set this to keep every read signed and proxied by the app instead.
28
+ # STORAGE_PUBLIC=false
29
+
30
+ # Images are shrunk in the browser before upload (about 20x smaller). Set this to turn
31
+ # it off and send the original bytes. It is read at build time, so changing it means a
32
+ # rebuild rather than a restart.
33
+ # NEXT_PUBLIC_OPTIMISE_UPLOADS=false
@@ -37,10 +37,10 @@ async function allowed(resourceName: string, action: PolicyAction): Promise<Acti
37
37
  /** A store failure as one line of text, attributed to a field when the issues name one. */
38
38
  function describe(failure: Failure): { error: string; field?: string } {
39
39
  const issues = failure.issues ?? [];
40
- if (issues.length === 0) return { error: failure.error, field: failure.field };
40
+ if (issues.length === 0) return { error: failure.error };
41
41
  return {
42
42
  error: issues.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message)).join("; "),
43
- field: issues[0]?.path.split(".")[0] || failure.field,
43
+ field: issues[0]?.path.split(".")[0],
44
44
  };
45
45
  }
46
46
 
@@ -0,0 +1,62 @@
1
+ "use server";
2
+
3
+ import { headers } from "next/headers";
4
+ import { revalidatePath } from "next/cache";
5
+ import { ADMIN_ROLES, requireDashboard } from "@/lib/dashboard";
6
+ import { currentUser } from "@/lib/api";
7
+ import { createInvitation, invitationLink, revokeInvitation } from "@/lib/invitations";
8
+
9
+ /**
10
+ * Issuing and revoking invitations.
11
+ *
12
+ * Both are admin-only, checked here rather than trusted from the page: a server action is
13
+ * an endpoint, and an endpoint that relies on not being linked to is not protected.
14
+ */
15
+ async function requireAdmin(): Promise<void> {
16
+ await requireDashboard("/dashboard/invitations");
17
+ const account = await currentUser();
18
+ if (!account || !ADMIN_ROLES.includes(account.role ?? "")) {
19
+ throw new Error("Only an admin can manage invitations.");
20
+ }
21
+ }
22
+
23
+ /** The app's own origin, from the request, so the link works wherever it is deployed. */
24
+ async function origin(): Promise<string> {
25
+ const incoming = await headers();
26
+ const host = incoming.get("x-forwarded-host") ?? incoming.get("host") ?? "localhost:3000";
27
+ const scheme = incoming.get("x-forwarded-proto") ?? (host.startsWith("localhost") || host.startsWith("127.") ? "http" : "https");
28
+ return `${scheme}://${host}`;
29
+ }
30
+
31
+ export interface IssuedInvitation {
32
+ link: string;
33
+ email: string | null;
34
+ expiresAt: string;
35
+ }
36
+
37
+ /**
38
+ * Create one and return the link.
39
+ *
40
+ * Returned rather than emailed, and shown once: the token is not stored, so there is no
41
+ * second chance to read it. Send it however you like — that keeps this working in an app
42
+ * with no mail configured, which a fresh app is.
43
+ */
44
+ export async function issueInvitation(formData: FormData): Promise<IssuedInvitation> {
45
+ await requireAdmin();
46
+ const raw = String(formData.get("email") ?? "").trim();
47
+ const days = Number(formData.get("days")) || undefined;
48
+ const invitation = await createInvitation({ for: raw ? { email: raw } : { anyone: true }, days });
49
+ revalidatePath("/dashboard/invitations");
50
+ return {
51
+ link: invitationLink(await origin(), invitation.token),
52
+ email: invitation.email,
53
+ expiresAt: invitation.expiresAt.toISOString(),
54
+ };
55
+ }
56
+
57
+ /** Revoke one, so a link sent to the wrong address stops working. */
58
+ export async function cancelInvitation(id: string): Promise<void> {
59
+ await requireAdmin();
60
+ await revokeInvitation(id);
61
+ revalidatePath("/dashboard/invitations");
62
+ }
@@ -0,0 +1,121 @@
1
+ import { MailIcon } from "lucide-react";
2
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
3
+ import { Badge } from "@/components/ui/badge";
4
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
+ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
6
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
7
+ import { PageHeader } from "@/components/dashboard/page-header";
8
+ import { LocalTime } from "@/components/dashboard/local-time";
9
+ import { InviteForm } from "@/components/dashboard/invite-form";
10
+ import { authConfig } from "@/lib/auth-config";
11
+ import { ADMIN_ROLES, requireDashboard } from "@/lib/dashboard";
12
+ import { currentUser } from "@/lib/api";
13
+ import { listInvitations } from "@/lib/invitations";
14
+ import { cancelInvitation, issueInvitation } from "./actions";
15
+
16
+ export const metadata = { title: "Invitations" };
17
+
18
+ /**
19
+ * Who has been invited, and a form to invite somebody.
20
+ *
21
+ * Only useful when `authConfig.signup` is `invite`, so the page says so rather than
22
+ * quietly issuing links that nothing checks — an invitation that is not required is a
23
+ * link that looks like a gate and is not one.
24
+ */
25
+ export default async function InvitationsPage() {
26
+ await requireDashboard("/dashboard/invitations");
27
+ const account = await currentUser();
28
+ const isAdmin = Boolean(account && ADMIN_ROLES.includes(account.role ?? ""));
29
+ const invitations = authConfig.signup === "invite" ? await listInvitations() : [];
30
+
31
+ return (
32
+ <div className="flex flex-col gap-6">
33
+ <PageHeader title="Invitations" description="Links that let somebody create an account." />
34
+
35
+ {authConfig.signup !== "invite" && (
36
+ <Alert>
37
+ <AlertTitle>Sign-up is {authConfig.signup}</AlertTitle>
38
+ <AlertDescription>
39
+ {authConfig.signup === "open"
40
+ ? "Anybody can create an account, so an invitation is not needed. Set signup to \"invite\" in lib/auth-config.ts to require one."
41
+ : "Nobody can create an account. Make them with `flare user:add <email>`, or set signup to \"invite\" in lib/auth-config.ts to use links instead."}
42
+ </AlertDescription>
43
+ </Alert>
44
+ )}
45
+
46
+ {authConfig.signup === "invite" && !isAdmin && (
47
+ <Alert>
48
+ <AlertTitle>Admins only</AlertTitle>
49
+ <AlertDescription>Your role cannot issue invitations.</AlertDescription>
50
+ </Alert>
51
+ )}
52
+
53
+ {authConfig.signup === "invite" && isAdmin && (
54
+ <Card>
55
+ <CardHeader>
56
+ <CardTitle>Invite somebody</CardTitle>
57
+ <CardDescription>
58
+ Leave the address empty for a link anybody may use. The link is shown once — it is not stored, so there is no way to read it again.
59
+ </CardDescription>
60
+ </CardHeader>
61
+ <CardContent>
62
+ <InviteForm issue={issueInvitation} />
63
+ </CardContent>
64
+ </Card>
65
+ )}
66
+
67
+ {authConfig.signup === "invite" && (
68
+ <Card>
69
+ <CardHeader>
70
+ <CardTitle>Outstanding</CardTitle>
71
+ <CardDescription>Invitations that have not been used and have not expired.</CardDescription>
72
+ </CardHeader>
73
+ <CardContent>
74
+ {invitations.length === 0 ? (
75
+ <Empty>
76
+ <EmptyHeader>
77
+ <MailIcon />
78
+ <EmptyTitle>Nothing outstanding</EmptyTitle>
79
+ <EmptyDescription>An invitation disappears from here once it is used.</EmptyDescription>
80
+ </EmptyHeader>
81
+ </Empty>
82
+ ) : (
83
+ <Table>
84
+ <TableHeader>
85
+ <TableRow>
86
+ <TableHead>For</TableHead>
87
+ <TableHead>Issued</TableHead>
88
+ <TableHead>Expires</TableHead>
89
+ <TableHead className="w-24" />
90
+ </TableRow>
91
+ </TableHeader>
92
+ <TableBody>
93
+ {invitations.map((invitation) => (
94
+ <TableRow key={invitation.id}>
95
+ <TableCell>{invitation.email ?? <Badge variant="secondary">anybody with the link</Badge>}</TableCell>
96
+ <TableCell>
97
+ <LocalTime value={invitation.createdAt.toISOString()} />
98
+ </TableCell>
99
+ <TableCell>
100
+ <LocalTime value={invitation.expiresAt.toISOString()} />
101
+ </TableCell>
102
+ <TableCell>
103
+ {isAdmin && (
104
+ <form action={cancelInvitation.bind(null, invitation.id)}>
105
+ <button type="submit" className="text-sm text-destructive underline-offset-4 hover:underline">
106
+ Revoke
107
+ </button>
108
+ </form>
109
+ )}
110
+ </TableCell>
111
+ </TableRow>
112
+ ))}
113
+ </TableBody>
114
+ </Table>
115
+ )}
116
+ </CardContent>
117
+ </Card>
118
+ )}
119
+ </div>
120
+ );
121
+ }
@@ -0,0 +1,28 @@
1
+ import { redirect } from "next/navigation";
2
+ import { cookies } from "next/headers";
3
+ import { INVITE_COOKIE, INVITE_COOKIE_MAX_AGE } from "@/lib/invitations";
4
+
5
+ /**
6
+ * Where an invitation link lands.
7
+ *
8
+ * It does one thing: put the token in a short-lived cookie and send the person to the
9
+ * sign-up page. The token stays out of the sign-up URL, so it is not in a bookmark, a
10
+ * referrer header or a screenshot of the address bar — and the sign-up form needs no
11
+ * hidden field, which means every way of signing up is covered by the same check in
12
+ * lib/auth.ts.
13
+ *
14
+ * The invitation is not validated here. A bad token simply fails at sign-up, which is
15
+ * one place to get right rather than two; validating twice is how the two drift apart.
16
+ */
17
+ export async function GET(_request: Request, context: { params: Promise<{ token: string }> }): Promise<never> {
18
+ const { token } = await context.params;
19
+ const jar = await cookies();
20
+ jar.set(INVITE_COOKIE, token, {
21
+ httpOnly: true,
22
+ sameSite: "lax",
23
+ secure: process.env.NODE_ENV === "production",
24
+ path: "/",
25
+ maxAge: INVITE_COOKIE_MAX_AGE,
26
+ });
27
+ redirect("/sign-up?invited=1");
28
+ }
@@ -1,42 +1,61 @@
1
- import Link from "next/link";
2
- import { redirect } from "next/navigation";
3
- import { AuthShell } from "@/components/auth/auth-shell";
4
- import { SignUpForm } from "@/components/auth/sign-up-form";
5
- import { enabledSocialProviders } from "@/lib/auth";
6
- import { authConfig } from "@/lib/auth-config";
7
- import { getSession, safeRedirectPath } from "@/lib/session";
8
- import { site } from "@/lib/site";
9
- import { theme } from "@/lib/theme";
10
-
11
- export const metadata = { title: "Create an account" };
12
-
13
- export default async function SignUpPage({ searchParams }: { searchParams: Promise<{ next?: string }> }) {
14
- const params = await searchParams;
15
- const next = safeRedirectPath(params.next);
16
- if (await getSession()) redirect(next);
17
-
18
- const copy = theme.signUp(site.name);
19
- return (
20
- <AuthShell
21
- page="sign-up"
22
- title={copy.title}
23
- subtitle={(copy as { subtitle?: string }).subtitle}
24
- footer={
25
- <>
26
- Already have an account?{" "}
27
- <Link href={`/sign-in?next=${encodeURIComponent(next)}`} className="font-medium text-link hover:underline">
28
- Sign in
29
- </Link>
30
- </>
31
- }
32
- >
33
- <SignUpForm
34
- providers={enabledSocialProviders()}
35
- socialPlacement={theme.social}
36
- socialStyle={theme.socialStyle}
37
- next={next}
38
- verifyFirst={authConfig.requireEmailVerification}
39
- />
40
- </AuthShell>
41
- );
42
- }
1
+ import Link from "next/link";
2
+ import { redirect } from "next/navigation";
3
+ import { AuthShell } from "@/components/auth/auth-shell";
4
+ import { SignUpForm } from "@/components/auth/sign-up-form";
5
+ import { enabledSocialProviders } from "@/lib/auth";
6
+ import { authConfig, signupOffered } from "@/lib/auth-config";
7
+ import { anyAccountExists } from "@/lib/user-titles";
8
+ import { getSession, safeRedirectPath } from "@/lib/session";
9
+ import { site } from "@/lib/site";
10
+ import { theme } from "@/lib/theme";
11
+
12
+ export const metadata = { title: "Create an account" };
13
+
14
+ export default async function SignUpPage({ searchParams }: { searchParams: Promise<{ next?: string }> }) {
15
+ const params = await searchParams;
16
+ const next = safeRedirectPath(params.next);
17
+ if (await getSession()) redirect(next);
18
+
19
+ // An app with sign-up closed does not offer a form that would be refused: the gate is
20
+ // in lib/auth.ts, and this is only about not wasting somebody's time at it. The first
21
+ // account is exempt there, so a brand-new app can still be set up — which is why this
22
+ // checks whether anybody exists rather than only the setting.
23
+ if (!signupOffered && (await anyAccountExists())) {
24
+ return (
25
+ <AuthShell page="sign-up" title="Sign-up is closed" subtitle={`${site.name} is not accepting new accounts.`} footer={null}>
26
+ <p className="text-sm text-muted-foreground">
27
+ If you should have an account, ask whoever runs {site.name} to make you one.{" "}
28
+ <Link href={`/sign-in?next=${encodeURIComponent(next)}`} className="underline underline-offset-4">
29
+ Sign in
30
+ </Link>
31
+ .
32
+ </p>
33
+ </AuthShell>
34
+ );
35
+ }
36
+
37
+ const copy = theme.signUp(site.name);
38
+ return (
39
+ <AuthShell
40
+ page="sign-up"
41
+ title={copy.title}
42
+ subtitle={(copy as { subtitle?: string }).subtitle}
43
+ footer={
44
+ <>
45
+ Already have an account?{" "}
46
+ <Link href={`/sign-in?next=${encodeURIComponent(next)}`} className="font-medium text-link hover:underline">
47
+ Sign in
48
+ </Link>
49
+ </>
50
+ }
51
+ >
52
+ <SignUpForm
53
+ providers={enabledSocialProviders()}
54
+ socialPlacement={theme.social}
55
+ socialStyle={theme.socialStyle}
56
+ next={next}
57
+ verifyFirst={authConfig.requireEmailVerification}
58
+ />
59
+ </AuthShell>
60
+ );
61
+ }
@@ -27,7 +27,7 @@ interface Props {
27
27
  fieldKey: string;
28
28
  }
29
29
 
30
- type Upload = { name: string; loaded: number; total: number; abort: () => void };
30
+ type Upload = { name: string; loaded: number; total: number; originalSize?: number; abort: () => void };
31
31
 
32
32
  /** "contacts/avatar/2026/09/<uuid>-me.png" → "me.png" */
33
33
  const fileName = (key: string) => key.split("/").pop()!.replace(/^[0-9a-f-]{36}-/, "");
@@ -35,6 +35,68 @@ const fileName = (key: string) => key.split("/").pop()!.replace(/^[0-9a-f-]{36}-
35
35
  const IMAGE_TYPES = new Set<string>(FILE_CATEGORIES.image);
36
36
  const isImageKey = (key: string) => /\.(png|jpe?g|gif|webp|avif)$/i.test(key);
37
37
 
38
+ /**
39
+ * How a picked image is re-encoded before it is uploaded.
40
+ *
41
+ * 1600px is larger than any screen shows a photograph at, and 0.82 is the quality
42
+ * where WebP stops being visibly lossy for photographs. `auto` keeps anything with
43
+ * real transparency lossless, so a logo is never flattened onto a black background.
44
+ *
45
+ * Change these and every upload changes with them; they are deliberately one object
46
+ * rather than scattered through the call.
47
+ */
48
+ const IMAGE_PROFILE = {
49
+ name: "flare",
50
+ max_width: 1600,
51
+ max_height: 1600,
52
+ crop: false,
53
+ quality: 0.82,
54
+ format: "auto",
55
+ // A crash guard, not a security control: a solid-colour PNG of 144 megapixels is a
56
+ // small file that will kill a mobile browser tab when it is decoded.
57
+ max_pixels: 50_000_000,
58
+ } as const;
59
+
60
+ /**
61
+ * Optimisation is on unless this says otherwise.
62
+ *
63
+ * It has to be a NEXT_PUBLIC_ variable because this decision is made in the browser,
64
+ * and those are substituted at build time — so turning it off is a rebuild, not a
65
+ * restart. That is the trade for not shipping a round trip to ask the server whether
66
+ * to compress before every upload.
67
+ *
68
+ * Reasons to turn it off: a field that must keep the file byte-for-byte, a photography
69
+ * site where 1600px is a downgrade, or anything where the original is the deliverable.
70
+ */
71
+ const OPTIMISE_IMAGES = process.env.NEXT_PUBLIC_OPTIMISE_UPLOADS !== "false";
72
+
73
+ /**
74
+ * The most we will decode in the browser, whatever the field's own limit is.
75
+ *
76
+ * The size check moves to *after* optimisation for images, because a 6 MB photo that
77
+ * becomes 32 KB should not be refused for being 6 MB. This is the bound that stops
78
+ * that being an invitation to hand the tab a 500 MB file.
79
+ */
80
+ const MAX_DECODE_BYTES = 80 * 1024 * 1024;
81
+
82
+ /** GIF loses every frame but the first on a canvas, so it is passed through untouched. */
83
+ const OPTIMISABLE = new Set(["image/png", "image/jpeg", "image/webp", "image/avif"]);
84
+
85
+ /** What `prepare` hands to the uploader: the bytes to send, and what to call them. */
86
+ interface Prepared {
87
+ blob: Blob;
88
+ name: string;
89
+ type: string;
90
+ /** The size of what the user picked, when that differs from what is being sent. */
91
+ originalSize?: number;
92
+ }
93
+
94
+ /** "me.PNG" + "image/webp" → "me.webp", so the stored key says what the bytes are. */
95
+ function renameTo(name: string, mime: string): string {
96
+ const extension = mime.split("/")[1]?.replace("jpeg", "jpg") ?? "bin";
97
+ return `${name.replace(/\.[^./\\]+$/, "") || "image"}.${extension}`;
98
+ }
99
+
38
100
  const oneDecimal = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 });
39
101
  const whole = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
40
102
 
@@ -59,7 +121,7 @@ const capitalize = (text: string) => text.charAt(0).toUpperCase() + text.slice(1
59
121
  const typeName = (type: string) => (type.split("/")[1] ?? type).replace(/^vnd\..*\./, "").toUpperCase();
60
122
 
61
123
  /** PUT with progress events, which fetch() can't report for uploads. */
62
- function put(url: string, file: File, onProgress: (loaded: number) => void, signal: { abort?: () => void }) {
124
+ function put(url: string, file: Prepared, onProgress: (loaded: number) => void, signal: { abort?: () => void }) {
63
125
  return new Promise<void>((resolve, reject) => {
64
126
  const request = new XMLHttpRequest();
65
127
  signal.abort = () => request.abort();
@@ -78,7 +140,7 @@ function put(url: string, file: File, onProgress: (loaded: number) => void, sign
78
140
  request.onabort = () => reject(new DOMException("Upload cancelled.", "AbortError"));
79
141
  request.open("PUT", url);
80
142
  request.setRequestHeader("Content-Type", file.type || "application/octet-stream");
81
- request.send(file);
143
+ request.send(file.blob);
82
144
  });
83
145
  }
84
146
 
@@ -99,7 +161,8 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
99
161
 
100
162
  const accepted = mimeTypesFor(field.accept);
101
163
  const maxBytes = fileMaxBytes(field);
102
- const help = `${capitalize(describeAccept(field.accept))}, up to ${formatBytes(maxBytes)}`;
164
+ const shrinks = OPTIMISE_IMAGES && field.accept.includes("image");
165
+ const help = `${capitalize(describeAccept(field.accept))}, up to ${formatBytes(maxBytes)}${shrinks ? " — images are shrunk before upload" : ""}`;
103
166
  const busy = upload !== null;
104
167
 
105
168
  // Thumbnail for a stored image (a local preview replaces it while uploading).
@@ -117,7 +180,12 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
117
180
  async function check(file: File): Promise<string | null> {
118
181
  const type = file.type || "application/octet-stream";
119
182
  if (!matchesContentType(type, accepted)) return `${field.label} accepts ${describeAccept(field.accept)} files.`;
120
- if (file.size > maxBytes) return `${file.name} is ${formatBytes(file.size)}; the limit is ${formatBytes(maxBytes)}.`;
183
+ // An image is measured after it has been shrunk — a 6 MB photo that becomes 32 KB
184
+ // is not over a 1 MB limit, and refusing it before trying would be a lie. Anything
185
+ // else is measured as it is, because nothing is going to make it smaller.
186
+ const willShrink = OPTIMISE_IMAGES && OPTIMISABLE.has(type);
187
+ const limit = willShrink ? Math.max(maxBytes, MAX_DECODE_BYTES) : maxBytes;
188
+ if (file.size > limit) return `${file.name} is ${formatBytes(file.size)}; the limit is ${formatBytes(limit)}.`;
121
189
  const head = new Uint8Array(await file.slice(0, SNIFF_BYTES).arrayBuffer());
122
190
  if (sniffMatches(type, head) === false) {
123
191
  return `${file.name} isn't a valid ${typeName(type)} file. It may have been renamed; choose the original file.`;
@@ -125,24 +193,73 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
125
193
  return null;
126
194
  }
127
195
 
196
+ /**
197
+ * The bytes to upload: the picked file, or a smaller version of it.
198
+ *
199
+ * The optimiser is imported here rather than at the top of the file, so a form with
200
+ * no image field never loads a canvas encoder, and a page that only reads records
201
+ * never loads it at all.
202
+ *
203
+ * A file that cannot be decoded is uploaded untouched. Losing somebody's upload
204
+ * because a codec choked is the worse of the two failures.
205
+ */
206
+ async function prepare(file: File): Promise<Prepared> {
207
+ const type = file.type || "application/octet-stream";
208
+ const asIs: Prepared = { blob: file, name: file.name, type };
209
+ if (!OPTIMISE_IMAGES || !OPTIMISABLE.has(type)) return asIs;
210
+
211
+ try {
212
+ setStatus(`Optimising ${file.name}…`);
213
+ const { optimizeImage } = await import("@gritframework/upload/web");
214
+ const { primary } = await optimizeImage(file, IMAGE_PROFILE);
215
+ // Only if it actually helped: re-encoding a small, already-optimised image can
216
+ // come out larger, and uploading the bigger one would be a straight loss.
217
+ if (primary.blob.size >= file.size) return asIs;
218
+ return {
219
+ blob: primary.blob,
220
+ name: renameTo(file.name, primary.mime),
221
+ type: primary.mime,
222
+ originalSize: file.size,
223
+ };
224
+ } catch {
225
+ return asIs;
226
+ }
227
+ }
228
+
128
229
  async function start(file: File) {
129
230
  setError(null);
130
231
  const problem = await check(file);
131
232
  if (problem) return setError(problem);
132
233
 
133
- // A browser leaves `type` empty for an extension it doesn't know, and an upload has
134
- // to say what it is sending, so fall back to the type that means "bytes".
135
- const contentType = file.type || "application/octet-stream";
136
234
  const signal: { abort?: () => void } = {};
137
235
  const localPreview = IMAGE_TYPES.has(file.type) ? URL.createObjectURL(file) : null;
138
- setUpload({ name: file.name, loaded: 0, total: file.size, abort: () => signal.abort?.() });
139
236
  if (localPreview) setPreview(localPreview);
140
- setStatus(`Uploading ${file.name}…`);
141
237
 
142
238
  try {
143
- const signed = await createUploadUrlAction(resourceName, fieldKey, { name: file.name, type: contentType, size: file.size });
239
+ // Shrink first, then sign: the URL carries the size it is allowed to accept, and
240
+ // that has to be the size of what is actually being sent.
241
+ const sending = await prepare(file);
242
+ if (sending.blob.size > maxBytes) {
243
+ const was = sending.originalSize ? ` (${formatBytes(sending.originalSize)} before optimising)` : "";
244
+ throw new Error(`${file.name} is ${formatBytes(sending.blob.size)}${was}; the limit is ${formatBytes(maxBytes)}.`);
245
+ }
246
+
247
+ setUpload({
248
+ name: file.name,
249
+ loaded: 0,
250
+ total: sending.blob.size,
251
+ originalSize: sending.originalSize,
252
+ abort: () => signal.abort?.(),
253
+ });
254
+ setStatus(`Uploading ${file.name}…`);
255
+
256
+ const signed = await createUploadUrlAction(resourceName, fieldKey, {
257
+ name: sending.name,
258
+ type: sending.type,
259
+ size: sending.blob.size,
260
+ });
144
261
  if (!signed.ok) throw new Error(signed.error);
145
- await put(signed.data.url, file, (loaded) => setUpload((current) => current && { ...current, loaded }), signal);
262
+ await put(signed.data.url, sending, (loaded) => setUpload((current) => current && { ...current, loaded }), signal);
146
263
  onChange(signed.data.key);
147
264
  setStatus(`${file.name} uploaded`);
148
265
  } catch (cause) {
@@ -225,6 +342,9 @@ export function FileField({ id, value, onChange, invalid, disabled, field, resou
225
342
  <span className="truncate font-medium">{upload.name}</span>
226
343
  <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
227
344
  {percent}% · {formatBytes(upload.loaded)} of {formatBytes(upload.total)}
345
+ {upload.originalSize !== undefined && (
346
+ <span className="text-primary"> · from {formatBytes(upload.originalSize)}</span>
347
+ )}
228
348
  </span>
229
349
  </div>
230
350
  <Progress value={percent} aria-label={`Uploading ${upload.name}`} />