@gilhrpenner/convex-files-control 0.1.1 → 0.2.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/README.md +243 -207
- package/dist/client/http.d.ts +4 -4
- package/dist/client/http.d.ts.map +1 -1
- package/dist/client/http.js +39 -10
- package/dist/client/http.js.map +1 -1
- package/dist/client/index.d.ts +66 -11
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +136 -41
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +2 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/download.d.ts +2 -0
- package/dist/component/download.d.ts.map +1 -1
- package/dist/component/download.js +33 -12
- package/dist/component/download.js.map +1 -1
- package/dist/component/schema.d.ts +3 -1
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +1 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +10 -6
- package/dist/react/index.js.map +1 -1
- package/package.json +4 -3
- package/src/__tests__/client-extra.test.ts +157 -4
- package/src/__tests__/client.test.ts +572 -46
- package/src/__tests__/download-core.test.ts +70 -0
- package/src/__tests__/entrypoints.test.ts +13 -0
- package/src/__tests__/http.test.ts +34 -0
- package/src/__tests__/react.test.ts +10 -16
- package/src/__tests__/shared.test.ts +0 -5
- package/src/__tests__/transfer.test.ts +103 -0
- package/src/client/http.ts +51 -10
- package/src/client/index.ts +242 -51
- package/src/component/_generated/component.ts +2 -0
- package/src/component/download.ts +35 -14
- package/src/component/schema.ts +1 -0
- package/src/react/index.ts +11 -10
package/README.md
CHANGED
|
@@ -1,90 +1,111 @@
|
|
|
1
1
|
# Convex Files Control
|
|
2
2
|
|
|
3
|
-
A
|
|
4
|
-
|
|
3
|
+
A Convex component for secure file uploads, access control, download grants, and
|
|
4
|
+
lifecycle cleanup. Works with Convex storage and Cloudflare R2, and ships with
|
|
5
|
+
an optional HTTP upload/download router plus a React upload hook.
|
|
5
6
|
|
|
6
7
|
## Features
|
|
7
8
|
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
background cleanup.
|
|
16
|
-
- **Metadata**: Computes and returns SHA-256 checksums, size, and MIME type.
|
|
9
|
+
- Two-step uploads (presigned URL) with access keys and optional expiration.
|
|
10
|
+
- Optional HTTP upload/download routes with auth hooks.
|
|
11
|
+
- Download grants with max uses, expiration, optional password, and shareable links.
|
|
12
|
+
- Access-key based authorization (user IDs, tenant IDs, etc.).
|
|
13
|
+
- Built-in cleanup for expired uploads, grants, and files.
|
|
14
|
+
- Transfer files between Convex and R2.
|
|
15
|
+
- React hook for presigned or HTTP uploads.
|
|
17
16
|
|
|
18
|
-
##
|
|
17
|
+
## Install
|
|
19
18
|
|
|
20
19
|
```bash
|
|
21
20
|
npm install @gilhrpenner/convex-files-control
|
|
22
21
|
```
|
|
23
22
|
|
|
24
|
-
##
|
|
23
|
+
## Quick start
|
|
25
24
|
|
|
26
|
-
### 1
|
|
25
|
+
### 1) Add the component
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
```typescript
|
|
27
|
+
```ts
|
|
31
28
|
// convex.config.ts
|
|
32
29
|
import { defineApp } from "convex/server";
|
|
33
|
-
import
|
|
30
|
+
import convexFilesControl from "@gilhrpenner/convex-files-control/convex.config";
|
|
34
31
|
|
|
35
32
|
const app = defineApp();
|
|
36
|
-
app.use(
|
|
33
|
+
app.use(convexFilesControl);
|
|
37
34
|
|
|
38
35
|
export default app;
|
|
39
36
|
```
|
|
40
37
|
|
|
41
|
-
### 2
|
|
38
|
+
### 2) Create wrapper functions in your app
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
The component stores access control and download grants. Your app should store
|
|
41
|
+
its own file metadata (name, owner, etc.) and enforce auth. The wrappers below
|
|
42
|
+
mirror the example app in `example/convex/files.ts`.
|
|
45
43
|
|
|
46
|
-
```
|
|
44
|
+
```ts
|
|
47
45
|
// convex/files.ts
|
|
48
|
-
import { v } from "convex/values";
|
|
46
|
+
import { ConvexError, v } from "convex/values";
|
|
49
47
|
import { mutation } from "./_generated/server";
|
|
50
48
|
import { components } from "./_generated/api";
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
export const createDownloadGrant = mutation({
|
|
50
|
+
export const generateUploadUrl = mutation({
|
|
54
51
|
args: {
|
|
55
|
-
|
|
56
|
-
maxUses: v.optional(v.union(v.null(), v.number())),
|
|
57
|
-
expiresAt: v.optional(v.union(v.null(), v.number())),
|
|
52
|
+
provider: v.union(v.literal("convex"), v.literal("r2")),
|
|
58
53
|
},
|
|
59
54
|
handler: async (ctx, args) => {
|
|
60
|
-
|
|
61
|
-
|
|
55
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
56
|
+
if (!identity) throw new ConvexError("Unauthorized");
|
|
57
|
+
|
|
62
58
|
return await ctx.runMutation(
|
|
63
|
-
components.convexFilesControl.
|
|
64
|
-
|
|
59
|
+
components.convexFilesControl.upload.generateUploadUrl,
|
|
60
|
+
{
|
|
61
|
+
provider: args.provider,
|
|
62
|
+
// r2Config: { accountId, accessKeyId, secretAccessKey, bucketName },
|
|
63
|
+
},
|
|
65
64
|
);
|
|
66
65
|
},
|
|
67
66
|
});
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
{
|
|
68
|
+
export const finalizeUpload = mutation({
|
|
69
|
+
args: {
|
|
70
|
+
uploadToken: v.string(),
|
|
71
|
+
storageId: v.string(),
|
|
72
|
+
fileName: v.string(),
|
|
73
|
+
expiresAt: v.optional(v.union(v.null(), v.number())),
|
|
74
|
+
metadata: v.optional(
|
|
75
|
+
v.object({
|
|
76
|
+
size: v.number(),
|
|
77
|
+
sha256: v.string(),
|
|
78
|
+
contentType: v.union(v.string(), v.null()),
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
},
|
|
82
|
+
handler: async (ctx, args) => {
|
|
83
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
84
|
+
if (!identity) throw new ConvexError("Unauthorized");
|
|
85
|
+
|
|
86
|
+
const { fileName, ...componentArgs } = args;
|
|
87
|
+
const result = await ctx.runMutation(
|
|
88
|
+
components.convexFilesControl.upload.finalizeUpload,
|
|
89
|
+
{
|
|
90
|
+
...componentArgs,
|
|
91
|
+
accessKeys: [identity.subject],
|
|
92
|
+
},
|
|
77
93
|
);
|
|
94
|
+
|
|
95
|
+
// Store your own file record (name, owner, etc.) here.
|
|
96
|
+
// await ctx.db.insert("files", { ... });
|
|
97
|
+
|
|
98
|
+
return result;
|
|
78
99
|
},
|
|
79
100
|
});
|
|
80
101
|
```
|
|
81
102
|
|
|
82
|
-
### 3
|
|
103
|
+
### 3) Optional HTTP routes
|
|
83
104
|
|
|
84
|
-
If you want
|
|
85
|
-
|
|
105
|
+
If you want `/files/upload` and `/files/download`, register the router in
|
|
106
|
+
`convex/http.ts`. Access keys are provided by your hook (not via the form).
|
|
86
107
|
|
|
87
|
-
```
|
|
108
|
+
```ts
|
|
88
109
|
// convex/http.ts
|
|
89
110
|
import { httpRouter } from "convex/server";
|
|
90
111
|
import { registerRoutes } from "@gilhrpenner/convex-files-control";
|
|
@@ -93,70 +114,78 @@ import { components } from "./_generated/api";
|
|
|
93
114
|
const http = httpRouter();
|
|
94
115
|
|
|
95
116
|
registerRoutes(http, components.convexFilesControl, {
|
|
96
|
-
pathPrefix: "
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
pathPrefix: "files",
|
|
118
|
+
enableUploadRoute: true,
|
|
119
|
+
|
|
120
|
+
// Required when enableUploadRoute is true
|
|
121
|
+
checkUploadRequest: async (ctx) => {
|
|
122
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
123
|
+
if (!identity) {
|
|
124
|
+
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
|
125
|
+
status: 401,
|
|
126
|
+
headers: { "Content-Type": "application/json" },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { accessKeys: [identity.subject] };
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
// Optional: persist file metadata after a successful HTTP upload
|
|
134
|
+
onUploadComplete: async (ctx, { result, file }) => {
|
|
135
|
+
const fileName = (file as File).name ?? "untitled";
|
|
136
|
+
// await ctx.runMutation(api.files.recordUpload, { ...result, fileName });
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
// Optional: provide accessKey for downloads
|
|
140
|
+
checkDownloadRequest: async (ctx) => {
|
|
141
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
142
|
+
if (identity) return { accessKey: identity.subject };
|
|
143
|
+
},
|
|
99
144
|
});
|
|
100
145
|
|
|
101
146
|
export default http;
|
|
102
147
|
```
|
|
103
148
|
|
|
104
|
-
|
|
149
|
+
HTTP upload requires `multipart/form-data` with fields:
|
|
150
|
+
- `file` (required)
|
|
151
|
+
- `provider` (optional, "convex" | "r2")
|
|
152
|
+
- `expiresAt` (optional, timestamp or `null`)
|
|
105
153
|
|
|
106
|
-
|
|
154
|
+
Access keys are not accepted via the form; they must come from
|
|
155
|
+
`checkUploadRequest`.
|
|
107
156
|
|
|
108
|
-
|
|
157
|
+
Useful route options:
|
|
158
|
+
- `pathPrefix` (default: `/files`)
|
|
159
|
+
- `defaultUploadProvider` (`\"convex\"` or `\"r2\"`)
|
|
160
|
+
- `enableDownloadRoute` (default: `true`)
|
|
161
|
+
- `requireAccessKey` (force `checkDownloadRequest` to return an access key)
|
|
162
|
+
- `passwordHeader` / `passwordQueryParam` (override or disable password inputs)
|
|
109
163
|
|
|
110
|
-
|
|
111
|
-
storage.
|
|
164
|
+
## Uploading files
|
|
112
165
|
|
|
113
|
-
|
|
114
|
-
2. **Upload File**: POST the file to the returned `uploadUrl`.
|
|
115
|
-
3. **Finalize**: Call `finalizeUpload` with the `uploadToken` and `storageId`.
|
|
166
|
+
### Presigned URL flow
|
|
116
167
|
|
|
117
|
-
```
|
|
118
|
-
// Client-side
|
|
119
|
-
const { uploadUrl, uploadToken } = await generateUploadUrl();
|
|
168
|
+
```ts
|
|
169
|
+
// Client-side
|
|
170
|
+
const { uploadUrl, uploadToken } = await generateUploadUrl({ provider: "convex" });
|
|
120
171
|
|
|
121
|
-
const
|
|
172
|
+
const uploadResponse = await fetch(uploadUrl, {
|
|
122
173
|
method: "POST",
|
|
123
|
-
body:
|
|
124
|
-
headers: { "Content-Type":
|
|
174
|
+
body: file,
|
|
175
|
+
headers: { "Content-Type": file.type || "application/octet-stream" },
|
|
125
176
|
});
|
|
126
|
-
const { storageId } = await result.json();
|
|
127
177
|
|
|
128
|
-
const
|
|
178
|
+
const { storageId } = await uploadResponse.json();
|
|
179
|
+
|
|
180
|
+
const result = await finalizeUpload({
|
|
129
181
|
uploadToken,
|
|
130
182
|
storageId,
|
|
131
|
-
|
|
132
|
-
expiresAt: Date.now() +
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
const metadata = await finalizeUpload(fileParams);
|
|
136
|
-
console.log("File uploaded:", metadata);
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
#### Option B: HTTP Action
|
|
140
|
-
|
|
141
|
-
Upload directly via your configured HTTP endpoint.
|
|
142
|
-
|
|
143
|
-
```typescript
|
|
144
|
-
const formData = new FormData();
|
|
145
|
-
formData.append("file", fileBlob);
|
|
146
|
-
formData.append("accessKeys", JSON.stringify(["user_123"]));
|
|
147
|
-
// Optional: formData.append("expiresAt", timestamp);
|
|
148
|
-
|
|
149
|
-
await fetch("https://<your-convex-site>/files/upload", {
|
|
150
|
-
method: "POST",
|
|
151
|
-
body: formData,
|
|
183
|
+
fileName: file.name,
|
|
184
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
152
185
|
});
|
|
153
186
|
```
|
|
154
187
|
|
|
155
|
-
### React
|
|
156
|
-
|
|
157
|
-
If you're using React, the component exports a `useUploadFile` hook that supports
|
|
158
|
-
both upload methods with a single API. The HTTP method requires `registerRoutes`
|
|
159
|
-
to be set up in `convex/http.ts`.
|
|
188
|
+
### React hook
|
|
160
189
|
|
|
161
190
|
```tsx
|
|
162
191
|
import { useUploadFile } from "@gilhrpenner/convex-files-control/react";
|
|
@@ -165,164 +194,171 @@ import { api } from "../convex/_generated/api";
|
|
|
165
194
|
const convexSiteUrl = import.meta.env.VITE_CONVEX_URL.replace(".cloud", ".site");
|
|
166
195
|
|
|
167
196
|
const { uploadFile } = useUploadFile(api.files, {
|
|
197
|
+
method: "presigned",
|
|
168
198
|
http: { baseUrl: convexSiteUrl },
|
|
169
199
|
});
|
|
170
200
|
|
|
171
|
-
// Presigned
|
|
172
|
-
await uploadFile({
|
|
173
|
-
file,
|
|
174
|
-
accessKeys: ["user_123"],
|
|
175
|
-
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
176
|
-
method: "presigned",
|
|
177
|
-
});
|
|
201
|
+
// Presigned
|
|
202
|
+
await uploadFile({ file, provider: "convex" });
|
|
178
203
|
|
|
179
|
-
// HTTP
|
|
204
|
+
// HTTP route
|
|
180
205
|
await uploadFile({
|
|
181
206
|
file,
|
|
182
|
-
accessKeys: ["user_123"],
|
|
183
207
|
method: "http",
|
|
208
|
+
provider: "convex",
|
|
209
|
+
http: {
|
|
210
|
+
baseUrl: convexSiteUrl,
|
|
211
|
+
// authToken: useAuthToken() from @convex-dev/auth/react
|
|
212
|
+
},
|
|
184
213
|
});
|
|
185
214
|
```
|
|
186
215
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
216
|
+
`uploadFile` accepts:
|
|
217
|
+
- `file` (required)
|
|
218
|
+
- `provider` ("convex" | "r2")
|
|
219
|
+
- `expiresAt` (timestamp or `null`)
|
|
220
|
+
- `method` ("presigned" | "http")
|
|
191
221
|
|
|
192
|
-
|
|
193
|
-
token that can be exchanged for the file content.
|
|
222
|
+
## Downloading files
|
|
194
223
|
|
|
195
|
-
|
|
196
|
-
2. **Build URL**: Use the helper to construct the download link.
|
|
224
|
+
### Create a grant + build a URL
|
|
197
225
|
|
|
198
|
-
```
|
|
226
|
+
```ts
|
|
199
227
|
import { buildDownloadUrl } from "@gilhrpenner/convex-files-control";
|
|
200
228
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
maxUses: 1, // Optional: limit uses
|
|
209
|
-
expiresAt: Date.now() + 60 * 1000, // Optional: limit time
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
// 2. Build the URL
|
|
213
|
-
// You'll need your Convex HTTP site URL e.g. from process.env.CONVEX_SITE_URL
|
|
214
|
-
return buildDownloadUrl({
|
|
215
|
-
baseUrl: "https://<your-convex-site>",
|
|
216
|
-
downloadToken: grant.downloadToken,
|
|
217
|
-
filename: "report.pdf", // Optional: force filename
|
|
218
|
-
// accessKey: "user_123" // Optional: if you want to embed the key (less secure)
|
|
219
|
-
});
|
|
229
|
+
const grant = await ctx.runMutation(
|
|
230
|
+
components.convexFilesControl.download.createDownloadGrant,
|
|
231
|
+
{
|
|
232
|
+
storageId,
|
|
233
|
+
maxUses: 1,
|
|
234
|
+
expiresAt: Date.now() + 10 * 60 * 1000,
|
|
235
|
+
shareableLink: false,
|
|
220
236
|
},
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const url = buildDownloadUrl({
|
|
240
|
+
baseUrl: "https://<your-convex-site>",
|
|
241
|
+
downloadToken: grant.downloadToken,
|
|
242
|
+
filename: "report.pdf",
|
|
243
|
+
// pathPrefix: "/files", // Optional if you changed the HTTP route prefix
|
|
221
244
|
});
|
|
222
245
|
```
|
|
223
246
|
|
|
224
|
-
|
|
225
|
-
|
|
247
|
+
Access keys are not placed in the URL. For private grants, supply them via
|
|
248
|
+
`checkDownloadRequest` (HTTP route) or pass `accessKey` when calling
|
|
249
|
+
`consumeDownloadGrantForUrl`.
|
|
226
250
|
|
|
227
|
-
|
|
251
|
+
### Shareable links
|
|
228
252
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// Server-side (Mutation)
|
|
234
|
-
export const generatePasswordLink = mutation({
|
|
235
|
-
args: { storageId: v.id("_storage") },
|
|
236
|
-
handler: async (ctx, args) => {
|
|
237
|
-
const grant = await ctx.runMutation(api.files.createDownloadGrant, {
|
|
238
|
-
storageId: args.storageId,
|
|
239
|
-
password: "secret-passphrase",
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
return buildDownloadUrl({
|
|
243
|
-
baseUrl: "https://<your-convex-site>",
|
|
244
|
-
downloadToken: grant.downloadToken,
|
|
245
|
-
filename: "report.pdf",
|
|
246
|
-
});
|
|
247
|
-
},
|
|
248
|
-
});
|
|
249
|
-
```
|
|
253
|
+
Set `shareableLink: true` to allow unauthenticated downloads (no access key
|
|
254
|
+
required). This is how the example app generates public links.
|
|
255
|
+
If you enable `requireAccessKey` on the HTTP route, shareable links will still
|
|
256
|
+
require `checkDownloadRequest` to return an access key.
|
|
250
257
|
|
|
251
|
-
|
|
252
|
-
`consumeDownloadGrantForUrl`:
|
|
258
|
+
### Password-protected grants
|
|
253
259
|
|
|
254
|
-
```
|
|
255
|
-
const
|
|
256
|
-
components.convexFilesControl.download.
|
|
257
|
-
{
|
|
260
|
+
```ts
|
|
261
|
+
const grant = await ctx.runMutation(
|
|
262
|
+
components.convexFilesControl.download.createDownloadGrant,
|
|
263
|
+
{ storageId, password: "secret-passphrase" },
|
|
258
264
|
);
|
|
259
265
|
```
|
|
260
266
|
|
|
261
|
-
|
|
262
|
-
`
|
|
263
|
-
|
|
267
|
+
To consume a password-protected grant, pass `password` to
|
|
268
|
+
`consumeDownloadGrantForUrl`, or send it to the HTTP route via the
|
|
269
|
+
`x-download-password` header (preferred) or the `password` query param. Query
|
|
270
|
+
params can leak into logs, so headers or POST flows are safer.
|
|
264
271
|
|
|
265
|
-
|
|
272
|
+
## Access control & queries
|
|
266
273
|
|
|
267
|
-
|
|
268
|
-
|
|
274
|
+
Access keys are normalized (trimmed) and must contain at least one non-empty
|
|
275
|
+
value.
|
|
269
276
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
277
|
+
- `accessControl.addAccessKey(storageId, accessKey)`
|
|
278
|
+
- `accessControl.removeAccessKey(storageId, accessKey)`
|
|
279
|
+
- `accessControl.updateFileExpiration(storageId, expiresAt)`
|
|
280
|
+
- `queries.hasAccessKey(storageId, accessKey)`
|
|
281
|
+
- `queries.listAccessKeysPage(storageId, paginationOpts)`
|
|
282
|
+
- `queries.listFilesPage(paginationOpts)`
|
|
283
|
+
- `queries.listFilesByAccessKeyPage(accessKey, paginationOpts)`
|
|
284
|
+
- `queries.listDownloadGrantsPage(paginationOpts)`
|
|
285
|
+
- `queries.getFile({ storageId })`
|
|
286
|
+
|
|
287
|
+
Pagination uses `{ numItems: number, cursor: string | null }`.
|
|
288
|
+
|
|
289
|
+
## Cleanup
|
|
290
|
+
|
|
291
|
+
Use `cleanUp.cleanupExpired` to delete expired uploads, grants, and files. The
|
|
292
|
+
example app wraps this in a mutation and runs it in a cron job.
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
// convex/crons.ts
|
|
296
|
+
import { cronJobs } from "convex/server";
|
|
297
|
+
import { internal } from "./_generated/api";
|
|
298
|
+
|
|
299
|
+
const crons = cronJobs();
|
|
300
|
+
crons.hourly("cleanup-expired-files", { minuteUTC: 0 }, internal.files.cleanupExpiredFiles, {});
|
|
301
|
+
export default crons;
|
|
287
302
|
```
|
|
288
303
|
|
|
289
|
-
|
|
304
|
+
## Server-side helper (FilesControl)
|
|
290
305
|
|
|
291
|
-
|
|
306
|
+
If you prefer a class wrapper around component calls, use `FilesControl`:
|
|
292
307
|
|
|
293
|
-
|
|
294
|
-
|
|
308
|
+
```ts
|
|
309
|
+
import { FilesControl } from "@gilhrpenner/convex-files-control";
|
|
310
|
+
import { components } from "./_generated/api";
|
|
295
311
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
- `listAccessKeysPage(storageId, paginationOpts)`
|
|
312
|
+
const files = new FilesControl(components.convexFilesControl, {
|
|
313
|
+
// r2: { accountId, accessKeyId, secretAccessKey, bucketName },
|
|
314
|
+
});
|
|
300
315
|
|
|
301
|
-
|
|
316
|
+
await files.generateUploadUrl(ctx, { provider: "convex" });
|
|
317
|
+
```
|
|
302
318
|
|
|
303
|
-
|
|
304
|
-
|
|
319
|
+
`FilesControl.clientApi()` also returns a ready-to-export API surface with
|
|
320
|
+
optional hooks if you want the component to generate your Convex mutations and
|
|
321
|
+
queries for you.
|
|
305
322
|
|
|
306
|
-
|
|
307
|
-
`{ numItems: number, cursor: string | null }`.
|
|
323
|
+
## R2 configuration
|
|
308
324
|
|
|
309
|
-
|
|
325
|
+
Provide R2 credentials when you use R2 for uploads, downloads, deletes, or
|
|
326
|
+
transfers. You can pass `r2Config` to the component calls or supply env vars
|
|
327
|
+
for the HTTP routes:
|
|
310
328
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
329
|
+
- `R2_ACCOUNT_ID`
|
|
330
|
+
- `R2_ACCESS_KEY_ID`
|
|
331
|
+
- `R2_SECRET_ACCESS_KEY`
|
|
332
|
+
- `R2_BUCKET_NAME`
|
|
314
333
|
|
|
315
|
-
|
|
316
|
-
// convex/crons.ts
|
|
317
|
-
import { cronJobs } from "convex/server";
|
|
318
|
-
import { api } from "./_generated/api";
|
|
334
|
+
## Transfer between providers
|
|
319
335
|
|
|
320
|
-
|
|
336
|
+
```ts
|
|
337
|
+
const result = await ctx.runAction(
|
|
338
|
+
components.convexFilesControl.transfer.transferFile,
|
|
339
|
+
{ storageId, targetProvider: "r2", r2Config },
|
|
340
|
+
);
|
|
341
|
+
```
|
|
321
342
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
limit: 100,
|
|
325
|
-
});
|
|
343
|
+
The transfer preserves access keys and download grants, updates the file record,
|
|
344
|
+
and deletes the original storage object.
|
|
326
345
|
|
|
327
|
-
|
|
346
|
+
## Testing helper
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
import { convexTest } from "convex-test";
|
|
350
|
+
import { register } from "@gilhrpenner/convex-files-control/test";
|
|
351
|
+
|
|
352
|
+
const t = convexTest(schema, modules);
|
|
353
|
+
register(t, "convexFilesControl");
|
|
328
354
|
```
|
|
355
|
+
|
|
356
|
+
## Example app
|
|
357
|
+
|
|
358
|
+
A full Convex + React + Convex Auth implementation lives in `example/`.
|
|
359
|
+
It demonstrates:
|
|
360
|
+
- presigned and HTTP uploads
|
|
361
|
+
- authenticated downloads and shareable links
|
|
362
|
+
- access key management
|
|
363
|
+
- transfer between Convex and R2
|
|
364
|
+
- scheduled cleanup
|
package/dist/client/http.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
export declare function corsHeaders(): Headers;
|
|
2
|
-
export declare function corsResponse(): Response;
|
|
3
|
-
export declare function jsonSuccess(data: unknown): Response;
|
|
4
|
-
export declare function jsonError(message: string, status: number): Response;
|
|
1
|
+
export declare function corsHeaders(origin?: string, allowHeaders?: string[]): Headers;
|
|
2
|
+
export declare function corsResponse(origin?: string, allowHeaders?: string[]): Response;
|
|
3
|
+
export declare function jsonSuccess(data: unknown, origin?: string, allowHeaders?: string[]): Response;
|
|
4
|
+
export declare function jsonError(message: string, status: number, origin?: string, allowHeaders?: string[]): Response;
|
|
5
5
|
export declare function parseJsonStringArray(value: string): string[] | null;
|
|
6
6
|
export declare function parseOptionalTimestamp(value: FormDataEntryValue | null): number | null | undefined | "invalid";
|
|
7
7
|
export declare function sanitizeFilename(value: string | null): string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/client/http.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/client/http.ts"],"names":[],"mappings":"AAyBA,wBAAgB,WAAW,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAU7E;AAED,wBAAgB,YAAY,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,QAAQ,CAK/E;AAED,wBAAgB,WAAW,CACzB,IAAI,EAAE,OAAO,EACb,MAAM,CAAC,EAAE,MAAM,EACf,YAAY,CAAC,EAAE,MAAM,EAAE,GACtB,QAAQ,CAKV;AAED,wBAAgB,SAAS,CACvB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,MAAM,EACf,YAAY,CAAC,EAAE,MAAM,EAAE,GACtB,QAAQ,CAKV;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAenE;AAED,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,kBAAkB,GAAG,IAAI,GAC/B,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,SAAS,CAiBvC;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAO7D;AAED,wBAAgB,0BAA0B,CACxC,MAAM,EACF,SAAS,GACT,WAAW,GACX,cAAc,GACd,eAAe,GACf,mBAAmB,GACnB,kBAAkB,GAClB,MAAM,GACT,MAAM,CAcR"}
|
package/dist/client/http.js
CHANGED
|
@@ -1,20 +1,49 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
const DEFAULT_ALLOW_HEADERS = ["Content-Type", "Authorization"];
|
|
2
|
+
function buildAllowHeaders(extra) {
|
|
3
|
+
const headers = [];
|
|
4
|
+
const seen = new Set();
|
|
5
|
+
const addHeader = (value) => {
|
|
6
|
+
const trimmed = value.trim();
|
|
7
|
+
if (!trimmed)
|
|
8
|
+
return;
|
|
9
|
+
const key = trimmed.toLowerCase();
|
|
10
|
+
if (seen.has(key))
|
|
11
|
+
return;
|
|
12
|
+
seen.add(key);
|
|
13
|
+
headers.push(trimmed);
|
|
14
|
+
};
|
|
15
|
+
for (const header of DEFAULT_ALLOW_HEADERS) {
|
|
16
|
+
addHeader(header);
|
|
17
|
+
}
|
|
18
|
+
for (const header of extra ?? []) {
|
|
19
|
+
addHeader(header);
|
|
20
|
+
}
|
|
21
|
+
return headers.join(", ");
|
|
22
|
+
}
|
|
23
|
+
export function corsHeaders(origin, allowHeaders) {
|
|
24
|
+
const headers = new Headers({
|
|
25
|
+
"Access-Control-Allow-Origin": origin || "*",
|
|
4
26
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
5
|
-
"Access-Control-Allow-Headers":
|
|
27
|
+
"Access-Control-Allow-Headers": buildAllowHeaders(allowHeaders),
|
|
6
28
|
});
|
|
29
|
+
if (origin) {
|
|
30
|
+
headers.set("Access-Control-Allow-Credentials", "true");
|
|
31
|
+
}
|
|
32
|
+
return headers;
|
|
7
33
|
}
|
|
8
|
-
export function corsResponse() {
|
|
9
|
-
return new Response(null, {
|
|
34
|
+
export function corsResponse(origin, allowHeaders) {
|
|
35
|
+
return new Response(null, {
|
|
36
|
+
status: 204,
|
|
37
|
+
headers: corsHeaders(origin, allowHeaders),
|
|
38
|
+
});
|
|
10
39
|
}
|
|
11
|
-
export function jsonSuccess(data) {
|
|
12
|
-
const headers = corsHeaders();
|
|
40
|
+
export function jsonSuccess(data, origin, allowHeaders) {
|
|
41
|
+
const headers = corsHeaders(origin, allowHeaders);
|
|
13
42
|
headers.set("Content-Type", "application/json");
|
|
14
43
|
return new Response(JSON.stringify(data), { status: 200, headers });
|
|
15
44
|
}
|
|
16
|
-
export function jsonError(message, status) {
|
|
17
|
-
const headers = corsHeaders();
|
|
45
|
+
export function jsonError(message, status, origin, allowHeaders) {
|
|
46
|
+
const headers = corsHeaders(origin, allowHeaders);
|
|
18
47
|
headers.set("Content-Type", "application/json");
|
|
19
48
|
return new Response(JSON.stringify({ error: message }), { status, headers });
|
|
20
49
|
}
|