@flaredev/cli 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +4858 -3395
- package/package.json +2 -2
- package/templates/app/.dev.vars.example +19 -0
- package/templates/app/app/dashboard/import-actions.ts +2 -2
- package/templates/app/app/dashboard/invitations/actions.ts +62 -0
- package/templates/app/app/dashboard/invitations/page.tsx +121 -0
- package/templates/app/app/invite/[token]/route.ts +28 -0
- package/templates/app/app/sign-up/page.tsx +61 -42
- package/templates/app/components/dashboard/fields/file-field.tsx +132 -12
- package/templates/app/components/dashboard/invite-form.tsx +86 -0
- package/templates/app/components/dashboard/record-detail.tsx +13 -2
- package/templates/app/components/dashboard/resource-form-page.tsx +9 -1
- package/templates/app/components/dashboard/resource-form.tsx +3 -1
- package/templates/app/components/dashboard/resource-table.tsx +7 -1
- package/templates/app/lib/api.ts +41 -3
- package/templates/app/lib/auth-config.ts +60 -38
- package/templates/app/lib/auth.ts +67 -2
- package/templates/app/lib/dashboard-nav.ts +5 -0
- package/templates/app/lib/invitations.ts +142 -0
- package/templates/app/lib/rate-limit.ts +71 -0
- package/templates/app/lib/resource/handlers.ts +9 -2
- package/templates/app/lib/resource/http.ts +24 -13
- package/templates/app/lib/resource/store.ts +79 -27
- package/templates/app/lib/storage/cloudinary.ts +146 -0
- package/templates/app/lib/storage/uploadthing.ts +160 -0
- package/templates/app/lib/storage.ts +72 -6
- package/templates/app/lib/user-titles.ts +52 -0
- package/templates/app/worker/index.ts +4 -1
- package/templates/app/wrangler.jsonc +11 -0
- package/templates/locks/cloudflare/package-lock.json +571 -691
- package/templates/locks/cloudflare/pnpm-lock.yaml +1065 -1095
- package/templates/locks/cloudflare/yarn.lock +363 -381
- package/templates/locks/next/package-lock.json +284 -243
- package/templates/locks/next/pnpm-lock.yaml +898 -878
- package/templates/locks/next/yarn.lock +338 -295
- package/templates/next/README.md +35 -3
- package/templates/next/lib/auth.ts +65 -2
- package/templates/next/lib/dashboard.ts +3 -1
- package/templates/next/lib/invitations.ts +119 -0
- package/templates/next/lib/rate-limit.ts +83 -0
- package/templates/next/lib/resource/prisma-rows.ts +52 -9
- package/templates/next/lib/storage/disk.ts +128 -0
- package/templates/next/lib/storage/r2.ts +86 -0
- package/templates/next/lib/storage.ts +83 -66
- 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.
|
|
3
|
+
"version": "0.12.1",
|
|
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.
|
|
44
|
+
"@flaredev/core": "0.12.1"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22"
|
|
@@ -11,3 +11,22 @@ 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
|
|
23
|
+
# UPLOADTHING_TOKEN= # the app id is inside it; there is no second variable
|
|
24
|
+
|
|
25
|
+
# Both serve files from their own CDN, so a read URL is public and does not expire.
|
|
26
|
+
# Set this to keep every read signed and proxied by the app instead.
|
|
27
|
+
# STORAGE_PUBLIC=false
|
|
28
|
+
|
|
29
|
+
# Images are shrunk in the browser before upload (about 20x smaller). Set this to turn
|
|
30
|
+
# it off and send the original bytes. It is read at build time, so changing it means a
|
|
31
|
+
# rebuild rather than a restart.
|
|
32
|
+
# 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
|
|
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]
|
|
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 {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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}`} />
|