@flaredev/cli 0.10.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.
- package/dist/index.js +4516 -3014
- package/package.json +2 -2
- package/templates/app/.dev.vars.example +20 -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 +17 -4
- 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 +112 -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/drizzle-rows.ts +7 -2
- package/templates/app/lib/resource/handlers.ts +9 -2
- package/templates/app/lib/resource/http.ts +24 -13
- package/templates/app/lib/resource/rows.ts +5 -1
- package/templates/app/lib/resource/store.ts +126 -38
- package/templates/app/lib/storage/cloudinary.ts +146 -0
- package/templates/app/lib/storage/uploadthing.ts +122 -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/{package-lock.json → cloudflare/package-lock.json} +1674 -1997
- package/templates/locks/{pnpm-lock.yaml → cloudflare/pnpm-lock.yaml} +1591 -1844
- package/templates/locks/{yarn.lock → cloudflare/yarn.lock} +1312 -1460
- package/templates/locks/next/package-lock.json +13713 -0
- package/templates/locks/next/pnpm-lock.yaml +8252 -0
- package/templates/locks/next/yarn.lock +5790 -0
- package/templates/next/.env.example +26 -10
- package/templates/next/README.md +35 -3
- package/templates/next/docker-compose.yml +74 -0
- package/templates/next/lib/auth.ts +108 -2
- package/templates/next/lib/dashboard.ts +3 -1
- package/templates/next/lib/db.ts +5 -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 +55 -11
- 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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { StorageBucket, StorageObjectBody } from "@flaredev/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Files on Cloudinary.
|
|
5
|
+
*
|
|
6
|
+
* Cloudinary is a CDN with an image pipeline attached, so this bucket serves reads from
|
|
7
|
+
* `res.cloudinary.com` rather than streaming them back through the app — see
|
|
8
|
+
* `publicUrl` below, and read what it says about privacy before choosing this one.
|
|
9
|
+
*
|
|
10
|
+
* Plain `fetch` and Web Crypto, no SDK: the signing is a sorted query string and a
|
|
11
|
+
* hash, and the upload is a multipart POST.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface CloudinaryOptions {
|
|
15
|
+
cloudName: string;
|
|
16
|
+
apiKey: string;
|
|
17
|
+
apiSecret: string;
|
|
18
|
+
/**
|
|
19
|
+
* Serve reads straight from Cloudinary's CDN. Default true, because it is the reason
|
|
20
|
+
* to use Cloudinary. See {@link StorageBucket.publicUrl}: those files are readable by
|
|
21
|
+
* anyone with the link. Set it false to keep every read signed and proxied, and
|
|
22
|
+
* Cloudinary becomes storage with a good image pipeline you reach for deliberately.
|
|
23
|
+
*/
|
|
24
|
+
publicDelivery?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Cloudinary stores an asset under one of three resource types and the type is part of
|
|
29
|
+
* its URL, so upload and read have to agree on which. The key's extension decides, and
|
|
30
|
+
* it decides the same way in both directions — `createObjectKey` keeps the extension
|
|
31
|
+
* from the uploaded filename, so in practice this is the type the file really is.
|
|
32
|
+
*
|
|
33
|
+
* A key with no extension is `raw`, which serves the bytes back unchanged. That is the
|
|
34
|
+
* safe answer rather than the clever one.
|
|
35
|
+
*/
|
|
36
|
+
const IMAGE = /\.(png|jpe?g|gif|webp|avif|svg|bmp|ico|tiff?|heic|heif)$/i;
|
|
37
|
+
const VIDEO = /\.(mp4|webm|mov|m4v|avi|mkv|ogv|mp3|m4a|wav|ogg|aac|flac)$/i;
|
|
38
|
+
|
|
39
|
+
export function resourceType(key: string): "image" | "video" | "raw" {
|
|
40
|
+
if (IMAGE.test(key)) return "image";
|
|
41
|
+
// Cloudinary files audio under "video"; it is one pipeline.
|
|
42
|
+
if (VIDEO.test(key)) return "video";
|
|
43
|
+
return "raw";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Hex SHA-1, which is what Cloudinary's signatures use by default. */
|
|
47
|
+
async function sha1Hex(input: string): Promise<string> {
|
|
48
|
+
const digest = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(input));
|
|
49
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Cloudinary's signature: every signed parameter as `name=value`, sorted by name,
|
|
54
|
+
* joined with `&`, the API secret appended with no separator, then SHA-1.
|
|
55
|
+
*
|
|
56
|
+
* `file`, `api_key`, `resource_type` and `cloud_name` are never signed.
|
|
57
|
+
*/
|
|
58
|
+
export async function sign(params: Record<string, string>, apiSecret: string): Promise<string> {
|
|
59
|
+
const toSign = Object.keys(params)
|
|
60
|
+
.sort()
|
|
61
|
+
.map((name) => `${name}=${params[name]}`)
|
|
62
|
+
.join("&");
|
|
63
|
+
return sha1Hex(toSign + apiSecret);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function cloudinaryBucket(options: CloudinaryOptions): StorageBucket {
|
|
67
|
+
const { cloudName, apiKey, apiSecret } = options;
|
|
68
|
+
const publicDelivery = options.publicDelivery ?? true;
|
|
69
|
+
const api = (type: string, action: string) => `https://api.cloudinary.com/v1_1/${cloudName}/${type}/${action}`;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The key, as a Cloudinary public id.
|
|
73
|
+
*
|
|
74
|
+
* An `image` or `video` public id carries no extension — Cloudinary appends the
|
|
75
|
+
* format, and leaving it on produces `photo.png.png`. A `raw` asset keeps it, because
|
|
76
|
+
* for raw the public id is the whole filename.
|
|
77
|
+
*/
|
|
78
|
+
const publicId = (key: string) => (resourceType(key) === "raw" ? key : key.replace(/\.[^./]+$/, ""));
|
|
79
|
+
|
|
80
|
+
/** The delivery URL for a key, which is public whether or not it is handed out. */
|
|
81
|
+
const deliveryUrl = (key: string) =>
|
|
82
|
+
`https://res.cloudinary.com/${cloudName}/${resourceType(key)}/upload/${publicId(key)
|
|
83
|
+
.split("/")
|
|
84
|
+
.map(encodeURIComponent)
|
|
85
|
+
.join("/")}`;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
async get(key: string): Promise<StorageObjectBody | null> {
|
|
89
|
+
const response = await fetch(deliveryUrl(key));
|
|
90
|
+
if (!response.ok || !response.body) return null;
|
|
91
|
+
return {
|
|
92
|
+
body: response.body,
|
|
93
|
+
size: Number(response.headers.get("content-length") ?? 0),
|
|
94
|
+
httpEtag: response.headers.get("etag") ?? "",
|
|
95
|
+
httpMetadata: { contentType: response.headers.get("content-type") ?? undefined },
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async put(key, value, putOptions) {
|
|
100
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
101
|
+
// `overwrite` and `invalidate` so re-uploading a key replaces what the CDN serves
|
|
102
|
+
// rather than leaving the old bytes cached at the edge under the same URL.
|
|
103
|
+
const signed: Record<string, string> = {
|
|
104
|
+
public_id: publicId(key),
|
|
105
|
+
timestamp,
|
|
106
|
+
overwrite: "true",
|
|
107
|
+
invalidate: "true",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const form = new FormData();
|
|
111
|
+
const body = value === null ? new ArrayBuffer(0) : await new Response(value as BodyInit).arrayBuffer();
|
|
112
|
+
const contentType = putOptions?.httpMetadata?.contentType;
|
|
113
|
+
form.append("file", new Blob([body], contentType ? { type: contentType } : undefined), key.split("/").pop() ?? "file");
|
|
114
|
+
form.append("api_key", apiKey);
|
|
115
|
+
form.append("signature", await sign(signed, apiSecret));
|
|
116
|
+
for (const [name, entry] of Object.entries(signed)) form.append(name, entry);
|
|
117
|
+
|
|
118
|
+
const response = await fetch(api(resourceType(key), "upload"), { method: "POST", body: form });
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
const detail = await response.text().catch(() => "");
|
|
121
|
+
throw new Error(`Cloudinary upload failed for "${key}": ${response.status} ${detail.slice(0, 300)}`);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async delete(keys) {
|
|
126
|
+
for (const key of [keys].flat()) {
|
|
127
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
128
|
+
const signed = { public_id: publicId(key), timestamp, invalidate: "true" };
|
|
129
|
+
const form = new FormData();
|
|
130
|
+
form.append("api_key", apiKey);
|
|
131
|
+
form.append("signature", await sign(signed, apiSecret));
|
|
132
|
+
for (const [name, entry] of Object.entries(signed)) form.append(name, entry);
|
|
133
|
+
|
|
134
|
+
const response = await fetch(api(resourceType(key), "destroy"), { method: "POST", body: form });
|
|
135
|
+
// `destroy` answers 200 with result "not found" for a key that was never there,
|
|
136
|
+
// which is the outcome asked for. Only a transport-level failure is an error.
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
const detail = await response.text().catch(() => "");
|
|
139
|
+
throw new Error(`Cloudinary delete failed for "${key}": ${response.status} ${detail.slice(0, 300)}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
publicUrl: publicDelivery ? (key: string) => deliveryUrl(key) : undefined,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { StorageBucket, StorageObjectBody } from "@flaredev/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Files on UploadThing.
|
|
5
|
+
*
|
|
6
|
+
* One wrinkle shapes this whole file: UploadThing mints its own file key on upload, and
|
|
7
|
+
* a bucket is asked to store bytes under a key it was *given*. The two are reconciled
|
|
8
|
+
* with `customId`, which UploadThing accepts at upload, addresses in a delivery URL,
|
|
9
|
+
* and filters by on delete. So Flare's key stays Flare's key and never has to be
|
|
10
|
+
* stored anywhere, which is what would otherwise be needed.
|
|
11
|
+
*
|
|
12
|
+
* A customId is one path segment, so the slashes in `uploads/2026/09/…` are encoded on
|
|
13
|
+
* the way in. {@link customIdFor} is the only place that knows the encoding and both
|
|
14
|
+
* directions go through it.
|
|
15
|
+
*
|
|
16
|
+
* Requires the `uploadthing` package:
|
|
17
|
+
*
|
|
18
|
+
* pnpm add uploadthing
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface UploadThingOptions {
|
|
22
|
+
/** From the UploadThing dashboard. Defaults to `UPLOADTHING_TOKEN`. */
|
|
23
|
+
token?: string;
|
|
24
|
+
/**
|
|
25
|
+
* The app id, which is the subdomain files are served from. Defaults to
|
|
26
|
+
* `UPLOADTHING_APP_ID`.
|
|
27
|
+
*/
|
|
28
|
+
appId: string;
|
|
29
|
+
/**
|
|
30
|
+
* Serve reads from UploadThing's CDN. Default true, which is the reason to use it.
|
|
31
|
+
* See {@link StorageBucket.publicUrl}: those files are readable by anyone with the
|
|
32
|
+
* link. Set it false and reads are signed and proxied through this app instead.
|
|
33
|
+
*/
|
|
34
|
+
publicDelivery?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A Flare key as an UploadThing customId.
|
|
39
|
+
*
|
|
40
|
+
* Percent-encoding with `_` in place of `%`, because `%` in a customId would be decoded
|
|
41
|
+
* again by whatever reads the URL. Every character outside `[A-Za-z0-9.-]` becomes `_`
|
|
42
|
+
* and two hex digits — including `_` itself, which is what makes it reversible.
|
|
43
|
+
*
|
|
44
|
+
* Reversible matters more than it looks. A lossy encoding maps two different keys onto
|
|
45
|
+
* one id, and then an upload overwrites a file it has never heard of and a delete
|
|
46
|
+
* removes somebody else's. The first draft of this mapped `a/b.png` and `a__b.png` to
|
|
47
|
+
* the same id.
|
|
48
|
+
*/
|
|
49
|
+
export function customIdFor(key: string): string {
|
|
50
|
+
return key.replace(/[^A-Za-z0-9.-]/g, (char) => `_${char.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The part of UTApi this driver uses.
|
|
55
|
+
*
|
|
56
|
+
* Described here rather than imported, so an app that does not use this driver still
|
|
57
|
+
* typechecks without the package installed — which is every app that picked one of the
|
|
58
|
+
* other three.
|
|
59
|
+
*/
|
|
60
|
+
interface UTApiLike {
|
|
61
|
+
uploadFiles(file: unknown): Promise<{ error: { message: string } | null }>;
|
|
62
|
+
deleteFiles(keys: string[], options: { keyType: "customId" }): Promise<unknown>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The SDK, loaded only if this driver is actually used.
|
|
67
|
+
*
|
|
68
|
+
* The specifier is a variable so that neither TypeScript nor the bundler tries to
|
|
69
|
+
* resolve a package that an app using a different driver has no reason to install.
|
|
70
|
+
*/
|
|
71
|
+
const SDK = "uploadthing/server";
|
|
72
|
+
|
|
73
|
+
async function api(token: string | undefined): Promise<UTApiLike> {
|
|
74
|
+
let module: { UTApi: new (options: Record<string, unknown>) => UTApiLike };
|
|
75
|
+
try {
|
|
76
|
+
module = (await import(SDK)) as never;
|
|
77
|
+
} catch {
|
|
78
|
+
throw new Error('STORAGE_DRIVER is "uploadthing" but the `uploadthing` package is not installed. Run: pnpm add uploadthing');
|
|
79
|
+
}
|
|
80
|
+
return new module.UTApi(token ? { token } : {});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function uploadThingBucket(options: UploadThingOptions): StorageBucket {
|
|
84
|
+
const publicDelivery = options.publicDelivery ?? true;
|
|
85
|
+
const deliveryUrl = (key: string) => `https://${options.appId}.ufs.sh/f/${customIdFor(key)}`;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
async get(key: string): Promise<StorageObjectBody | null> {
|
|
89
|
+
const response = await fetch(deliveryUrl(key));
|
|
90
|
+
if (!response.ok || !response.body) return null;
|
|
91
|
+
return {
|
|
92
|
+
body: response.body,
|
|
93
|
+
size: Number(response.headers.get("content-length") ?? 0),
|
|
94
|
+
httpEtag: response.headers.get("etag") ?? "",
|
|
95
|
+
httpMetadata: { contentType: response.headers.get("content-type") ?? undefined },
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
async put(key, value, putOptions) {
|
|
100
|
+
const body = value === null ? new ArrayBuffer(0) : await new Response(value as BodyInit).arrayBuffer();
|
|
101
|
+
const contentType = putOptions?.httpMetadata?.contentType;
|
|
102
|
+
const name = key.split("/").pop() ?? "file";
|
|
103
|
+
|
|
104
|
+
// UTApi takes a Blob with a `name`, and reads `customId` off it. A global `File`
|
|
105
|
+
// is exactly that on Node 20+, which is the floor Flare builds on anyway.
|
|
106
|
+
const file = new File([body], name, contentType ? { type: contentType } : undefined) as File & { customId?: string };
|
|
107
|
+
file.customId = customIdFor(key);
|
|
108
|
+
|
|
109
|
+
const { error } = await (await api(options.token)).uploadFiles(file);
|
|
110
|
+
if (error) throw new Error(`UploadThing upload failed for "${key}": ${error.message}`);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async delete(keys) {
|
|
114
|
+
const ids = [keys].flat().map(customIdFor);
|
|
115
|
+
if (ids.length === 0) return;
|
|
116
|
+
// Deleting what was never there is not an error, which is the outcome asked for.
|
|
117
|
+
await (await api(options.token)).deleteFiles(ids, { keyType: "customId" });
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
publicUrl: publicDelivery ? (key: string) => deliveryUrl(key) : undefined,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
import { env } from "cloudflare:workers";
|
|
2
|
-
import { createObjectKey, createStorage } from "@flaredev/core";
|
|
2
|
+
import { createObjectKey, createStorage, type StorageBucket } from "@flaredev/core";
|
|
3
|
+
import { cloudinaryBucket } from "./storage/cloudinary";
|
|
4
|
+
import { uploadThingBucket } from "./storage/uploadthing";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
|
-
*
|
|
7
|
+
* Where uploaded files go.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* The default is the `STORAGE` R2 bucket this app is already bound to: no account to
|
|
10
|
+
* create, no keys to copy, and Wrangler simulates it locally as a directory. Set
|
|
11
|
+
* `STORAGE_DRIVER` to send files to a content delivery network instead.
|
|
12
|
+
*
|
|
13
|
+
* r2 The bound bucket. The default, and nothing to configure.
|
|
14
|
+
* cloudinary A CDN with an image pipeline. Serves from res.cloudinary.com.
|
|
15
|
+
* uploadthing A CDN with an uploader. Serves from <app>.ufs.sh.
|
|
16
|
+
*
|
|
17
|
+
* The drivers are in `lib/storage/`, in your repository, and are ordinary files you can
|
|
18
|
+
* read and change. Adding one means writing `get`, `put` and `delete`.
|
|
19
|
+
*
|
|
20
|
+
* `disk` exists on the Next.js stack and not here: a Worker has no filesystem to write
|
|
21
|
+
* to, and the bound bucket already covers what it is for.
|
|
22
|
+
*
|
|
23
|
+
* ## Private by default, and what breaks that
|
|
24
|
+
*
|
|
25
|
+
* With `r2`, a read URL is a short-lived signed token redeemed by
|
|
26
|
+
* `app/api/storage/route.ts`, so whoever issued it decided the viewer was allowed it.
|
|
27
|
+
*
|
|
28
|
+
* `cloudinary` and `uploadthing` are content delivery networks, and serving through
|
|
29
|
+
* this Worker would throw away the thing you are paying them for. So they hand back
|
|
30
|
+
* their own URL, which **anyone with the link can read, forever**. That is the normal
|
|
31
|
+
* way to use them and the wrong way to store a medical record. Set
|
|
32
|
+
* `STORAGE_PUBLIC=false` to keep every read signed and proxied.
|
|
10
33
|
*
|
|
11
34
|
* const { url } = await storage.createUploadUrl({
|
|
12
35
|
* key: createObjectKey(file.name, `users/${user.id}`),
|
|
@@ -17,8 +40,51 @@ import { createObjectKey, createStorage } from "@flaredev/core";
|
|
|
17
40
|
*
|
|
18
41
|
* const src = await storage.createReadUrl({ key });
|
|
19
42
|
*/
|
|
43
|
+
|
|
44
|
+
type StorageDriver = "r2" | "cloudinary" | "uploadthing";
|
|
45
|
+
|
|
46
|
+
const DRIVERS: StorageDriver[] = ["r2", "cloudinary", "uploadthing"];
|
|
47
|
+
|
|
48
|
+
const vars = env as unknown as Record<string, string | undefined>;
|
|
49
|
+
|
|
50
|
+
const configured = (vars.STORAGE_DRIVER ?? "r2").trim().toLowerCase();
|
|
51
|
+
if (!DRIVERS.includes(configured as StorageDriver)) {
|
|
52
|
+
throw new Error(`Unknown STORAGE_DRIVER "${configured}". One of: ${DRIVERS.join(", ")}.`);
|
|
53
|
+
}
|
|
54
|
+
const driver = configured as StorageDriver;
|
|
55
|
+
|
|
56
|
+
/** Required configuration, named in the error rather than left as `undefined`. */
|
|
57
|
+
function required(name: string): string {
|
|
58
|
+
const value = vars[name];
|
|
59
|
+
if (!value) throw new Error(`STORAGE_DRIVER is "${driver}" and needs ${name}, which is not set. Add it with: wrangler secret put ${name}`);
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Public delivery is the point of a CDN, so it is on unless it is turned off. */
|
|
64
|
+
const publicDelivery = vars.STORAGE_PUBLIC !== "false";
|
|
65
|
+
|
|
66
|
+
function selectBucket(): StorageBucket {
|
|
67
|
+
switch (driver) {
|
|
68
|
+
case "cloudinary":
|
|
69
|
+
return cloudinaryBucket({
|
|
70
|
+
cloudName: required("CLOUDINARY_CLOUD_NAME"),
|
|
71
|
+
apiKey: required("CLOUDINARY_API_KEY"),
|
|
72
|
+
apiSecret: required("CLOUDINARY_API_SECRET"),
|
|
73
|
+
publicDelivery,
|
|
74
|
+
});
|
|
75
|
+
case "uploadthing":
|
|
76
|
+
return uploadThingBucket({
|
|
77
|
+
token: required("UPLOADTHING_TOKEN"),
|
|
78
|
+
appId: required("UPLOADTHING_APP_ID"),
|
|
79
|
+
publicDelivery,
|
|
80
|
+
});
|
|
81
|
+
case "r2":
|
|
82
|
+
return env.STORAGE;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
20
86
|
export const storage = createStorage({
|
|
21
|
-
bucket:
|
|
87
|
+
bucket: selectBucket(),
|
|
22
88
|
// Signing keys are derived from the auth secret with a storage-specific label.
|
|
23
89
|
secret: env.BETTER_AUTH_SECRET,
|
|
24
90
|
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// This file is yours.
|
|
2
|
+
//
|
|
3
|
+
// Flare copies the code that turns a resource descriptor into a working endpoint into
|
|
4
|
+
// your app rather than hiding it in node_modules, for the same reason shadcn/ui copies
|
|
5
|
+
// a component: you cannot fix, read or reason about what you cannot see.
|
|
6
|
+
//
|
|
7
|
+
// `flare diff` shows how your copy differs from the version Flare ships, and
|
|
8
|
+
// `flare update` applies the parts you choose. Neither runs unless you ask.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Names for the accounts a `belongsTo("User")` field points at.
|
|
12
|
+
*
|
|
13
|
+
* Better Auth owns the account table, so it is not a Flare resource and has no store to
|
|
14
|
+
* ask — but the admin still has to show "Ada Lovelace" where the column holds a uuid.
|
|
15
|
+
* This is the one place that reads it, and it reads two columns and no more.
|
|
16
|
+
*
|
|
17
|
+
* An account may have no name (a passkey-only sign-up, say), so the email stands in. It
|
|
18
|
+
* is the same fallback `/api/users` uses, so a record reads the same in the table, on
|
|
19
|
+
* the detail page and in the picker.
|
|
20
|
+
*/
|
|
21
|
+
import { inArray } from "drizzle-orm";
|
|
22
|
+
import { getDb } from "@/db";
|
|
23
|
+
import { user } from "@/db/auth-schema";
|
|
24
|
+
|
|
25
|
+
/** What the admin needs to treat Better Auth's account table as a relation target. */
|
|
26
|
+
export const AUTH_USER_RELATION = {
|
|
27
|
+
name: "User",
|
|
28
|
+
label: "User",
|
|
29
|
+
pluralLabel: "Users",
|
|
30
|
+
slug: "users",
|
|
31
|
+
titleField: "name",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
/** id → display name, for the ids that exist. Unknown ids are simply absent. */
|
|
35
|
+
export async function userTitles(ids: string[]): Promise<Record<string, string>> {
|
|
36
|
+
const wanted = [...new Set(ids.filter(Boolean))];
|
|
37
|
+
if (wanted.length === 0) return {};
|
|
38
|
+
const rows = await getDb().select({ id: user.id, name: user.name, email: user.email }).from(user).where(inArray(user.id, wanted));
|
|
39
|
+
return Object.fromEntries(rows.map((row) => [row.id, row.name?.trim() || row.email]));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether anybody has an account yet.
|
|
44
|
+
*
|
|
45
|
+
* Asked by the sign-up page: the gate in lib/auth.ts always lets the *first* account
|
|
46
|
+
* through, whatever `authConfig.signup` says, because an app nobody can sign into cannot
|
|
47
|
+
* be set up. So "sign-up is closed" is only true once somebody exists.
|
|
48
|
+
*/
|
|
49
|
+
export async function anyAccountExists(): Promise<boolean> {
|
|
50
|
+
const [first] = await getDb().select({ id: user.id }).from(user).limit(1);
|
|
51
|
+
return first !== undefined;
|
|
52
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import app from "vinext/server/app-router-entry";
|
|
2
|
+
import { RateLimitCounter } from "@flaredev/core/rate-limit/server";
|
|
2
3
|
import { RealtimeChannel, handleRealtimeUpgrade } from "@flaredev/core/realtime/server";
|
|
3
4
|
import { SecurityMonitor } from "@flaredev/core/security/server";
|
|
4
5
|
import { authorizeRealtime } from "../lib/realtime";
|
|
@@ -9,12 +10,14 @@ import { protect } from "../lib/security";
|
|
|
9
10
|
// the `cloudflare:workers` module.
|
|
10
11
|
|
|
11
12
|
// Durable Object classes must be exported from the Worker's main module.
|
|
12
|
-
export { RealtimeChannel, SecurityMonitor };
|
|
13
|
+
export { RateLimitCounter, RealtimeChannel, SecurityMonitor };
|
|
13
14
|
|
|
14
15
|
export interface AppEnv {
|
|
15
16
|
ASSETS: Fetcher;
|
|
16
17
|
DB: D1Database;
|
|
17
18
|
FLARE_REALTIME: DurableObjectNamespace<RealtimeChannel>;
|
|
19
|
+
/** Counters for the rate limits a descriptor declares (see lib/rate-limit.ts). */
|
|
20
|
+
FLARE_RATE_LIMIT: DurableObjectNamespace<RateLimitCounter>;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export interface AppContext {
|
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
// Object instance created on demand; wrangler provisions nothing upfront.
|
|
17
17
|
"name": "FLARE_REALTIME",
|
|
18
18
|
"class_name": "RealtimeChannel"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
// Rate limit counters (see lib/rate-limit.ts). One instance per resource and
|
|
22
|
+
// action, holding a count per caller — so a limited endpoint costs one object
|
|
23
|
+
// request, not one per person calling it.
|
|
24
|
+
"name": "FLARE_RATE_LIMIT",
|
|
25
|
+
"class_name": "RateLimitCounter"
|
|
19
26
|
}
|
|
20
27
|
]
|
|
21
28
|
},
|
|
@@ -23,6 +30,10 @@
|
|
|
23
30
|
{
|
|
24
31
|
"tag": "v1",
|
|
25
32
|
"new_sqlite_classes": ["RealtimeChannel"]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"tag": "v2",
|
|
36
|
+
"new_sqlite_classes": ["RateLimitCounter"]
|
|
26
37
|
}
|
|
27
38
|
],
|
|
28
39
|
"d1_databases": [
|