@gilhrpenner/convex-files-control 0.1.1 → 0.3.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 +264 -204
- 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 +67 -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 +7 -5
- 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 +243 -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,114 @@
|
|
|
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.
|
|
6
|
+
|
|
7
|
+
**[Live Demo →](https://convex-files-control-example.pages.dev)**
|
|
5
8
|
|
|
6
9
|
## Features
|
|
7
10
|
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
Tenant IDs).
|
|
12
|
-
- **Secure Downloads**: Generate temporary, single-use, or limited-use download
|
|
11
|
+
- Two-step uploads (presigned URL) with access keys and optional expiration.
|
|
12
|
+
- Optional HTTP upload/download routes with auth hooks.
|
|
13
|
+
- Download grants with max uses, expiration, optional password, and shareable
|
|
13
14
|
links.
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
-
|
|
15
|
+
- Access-key based authorization (user IDs, tenant IDs, etc.).
|
|
16
|
+
- Built-in cleanup for expired uploads, grants, and files.
|
|
17
|
+
- Transfer files between Convex and R2.
|
|
18
|
+
- React hook for presigned or HTTP uploads.
|
|
17
19
|
|
|
18
|
-
##
|
|
20
|
+
## Install
|
|
19
21
|
|
|
20
22
|
```bash
|
|
21
23
|
npm install @gilhrpenner/convex-files-control
|
|
22
24
|
```
|
|
23
25
|
|
|
24
|
-
##
|
|
25
|
-
|
|
26
|
-
### 1. Configure Component
|
|
26
|
+
## Quick start
|
|
27
27
|
|
|
28
|
-
Add the component
|
|
28
|
+
### 1) Add the component
|
|
29
29
|
|
|
30
|
-
```
|
|
30
|
+
```ts
|
|
31
31
|
// convex.config.ts
|
|
32
32
|
import { defineApp } from "convex/server";
|
|
33
|
-
import
|
|
33
|
+
import convexFilesControl from "@gilhrpenner/convex-files-control/convex.config";
|
|
34
34
|
|
|
35
35
|
const app = defineApp();
|
|
36
|
-
app.use(
|
|
36
|
+
app.use(convexFilesControl);
|
|
37
37
|
|
|
38
38
|
export default app;
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
### 2
|
|
41
|
+
### 2) Create wrapper functions in your app
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
The component stores access control and download grants. Your app should store
|
|
44
|
+
its own file metadata (name, owner, etc.) and enforce auth. The wrappers below
|
|
45
|
+
mirror the example app in `example/convex/files.ts`.
|
|
45
46
|
|
|
46
|
-
```
|
|
47
|
+
```ts
|
|
47
48
|
// convex/files.ts
|
|
48
|
-
import { v } from "convex/values";
|
|
49
|
+
import { ConvexError, v } from "convex/values";
|
|
49
50
|
import { mutation } from "./_generated/server";
|
|
50
51
|
import { components } from "./_generated/api";
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
export const createDownloadGrant = mutation({
|
|
53
|
+
export const generateUploadUrl = mutation({
|
|
54
54
|
args: {
|
|
55
|
-
|
|
56
|
-
maxUses: v.optional(v.union(v.null(), v.number())),
|
|
57
|
-
expiresAt: v.optional(v.union(v.null(), v.number())),
|
|
55
|
+
provider: v.union(v.literal("convex"), v.literal("r2")),
|
|
58
56
|
},
|
|
59
57
|
handler: async (ctx, args) => {
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
59
|
+
if (!identity) throw new ConvexError("Unauthorized");
|
|
60
|
+
|
|
62
61
|
return await ctx.runMutation(
|
|
63
|
-
components.convexFilesControl.
|
|
64
|
-
|
|
62
|
+
components.convexFilesControl.upload.generateUploadUrl,
|
|
63
|
+
{
|
|
64
|
+
provider: args.provider,
|
|
65
|
+
// r2Config: { accountId, accessKeyId, secretAccessKey, bucketName },
|
|
66
|
+
},
|
|
65
67
|
);
|
|
66
68
|
},
|
|
67
69
|
});
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
{
|
|
71
|
+
export const finalizeUpload = mutation({
|
|
72
|
+
args: {
|
|
73
|
+
uploadToken: v.string(),
|
|
74
|
+
storageId: v.string(),
|
|
75
|
+
fileName: v.string(),
|
|
76
|
+
expiresAt: v.optional(v.union(v.null(), v.number())),
|
|
77
|
+
metadata: v.optional(
|
|
78
|
+
v.object({
|
|
79
|
+
size: v.number(),
|
|
80
|
+
sha256: v.string(),
|
|
81
|
+
contentType: v.union(v.string(), v.null()),
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
handler: async (ctx, args) => {
|
|
86
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
87
|
+
if (!identity) throw new ConvexError("Unauthorized");
|
|
88
|
+
|
|
89
|
+
const { fileName, ...componentArgs } = args;
|
|
90
|
+
const result = await ctx.runMutation(
|
|
91
|
+
components.convexFilesControl.upload.finalizeUpload,
|
|
92
|
+
{
|
|
93
|
+
...componentArgs,
|
|
94
|
+
accessKeys: [identity.subject],
|
|
95
|
+
},
|
|
77
96
|
);
|
|
97
|
+
|
|
98
|
+
// Store your own file record (name, owner, etc.) here.
|
|
99
|
+
// await ctx.db.insert("files", { ... });
|
|
100
|
+
|
|
101
|
+
return result;
|
|
78
102
|
},
|
|
79
103
|
});
|
|
80
104
|
```
|
|
81
105
|
|
|
82
|
-
### 3
|
|
106
|
+
### 3) Optional HTTP routes
|
|
83
107
|
|
|
84
|
-
If you want
|
|
85
|
-
|
|
108
|
+
If you want `/files/upload` and `/files/download`, register the router in
|
|
109
|
+
`convex/http.ts`. Access keys are provided by your hook (not via the form).
|
|
86
110
|
|
|
87
|
-
```
|
|
111
|
+
```ts
|
|
88
112
|
// convex/http.ts
|
|
89
113
|
import { httpRouter } from "convex/server";
|
|
90
114
|
import { registerRoutes } from "@gilhrpenner/convex-files-control";
|
|
@@ -93,236 +117,272 @@ import { components } from "./_generated/api";
|
|
|
93
117
|
const http = httpRouter();
|
|
94
118
|
|
|
95
119
|
registerRoutes(http, components.convexFilesControl, {
|
|
96
|
-
pathPrefix: "
|
|
97
|
-
|
|
98
|
-
|
|
120
|
+
pathPrefix: "files",
|
|
121
|
+
enableUploadRoute: true,
|
|
122
|
+
|
|
123
|
+
// Required when enableUploadRoute is true
|
|
124
|
+
checkUploadRequest: async (ctx) => {
|
|
125
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
126
|
+
if (!identity) {
|
|
127
|
+
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
|
128
|
+
status: 401,
|
|
129
|
+
headers: { "Content-Type": "application/json" },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { accessKeys: [identity.subject] };
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
// Optional: persist file metadata after a successful HTTP upload
|
|
137
|
+
onUploadComplete: async (ctx, { result, file, formData }) => {
|
|
138
|
+
const fileNameFromForm = formData.get("fileName");
|
|
139
|
+
const fileName =
|
|
140
|
+
typeof fileNameFromForm === "string"
|
|
141
|
+
? fileNameFromForm
|
|
142
|
+
: (file as File).name ?? "untitled";
|
|
143
|
+
// await ctx.runMutation(api.files.recordUpload, { ...result, fileName });
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
// Optional: provide accessKey for downloads
|
|
147
|
+
checkDownloadRequest: async (ctx) => {
|
|
148
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
149
|
+
if (identity) return { accessKey: identity.subject };
|
|
150
|
+
},
|
|
99
151
|
});
|
|
100
152
|
|
|
101
153
|
export default http;
|
|
102
154
|
```
|
|
103
155
|
|
|
104
|
-
|
|
156
|
+
HTTP upload requires `multipart/form-data` with fields:
|
|
157
|
+
|
|
158
|
+
- `file` (required)
|
|
159
|
+
- `provider` (optional, "convex" | "r2")
|
|
160
|
+
- `expiresAt` (optional, timestamp or `null`)
|
|
161
|
+
|
|
162
|
+
Access keys are not accepted via the form; they must come from
|
|
163
|
+
`checkUploadRequest`. Additional form fields are available on
|
|
164
|
+
`onUploadComplete` via `formData`.
|
|
105
165
|
|
|
106
|
-
|
|
166
|
+
Useful route options:
|
|
107
167
|
|
|
108
|
-
|
|
168
|
+
- `pathPrefix` (default: `/files`)
|
|
169
|
+
- `defaultUploadProvider` (`\"convex\"` or `\"r2\"`)
|
|
170
|
+
- `enableDownloadRoute` (default: `true`)
|
|
171
|
+
- `requireAccessKey` (force `checkDownloadRequest` to return an access key)
|
|
172
|
+
- `passwordHeader` / `passwordQueryParam` (override or disable password inputs)
|
|
109
173
|
|
|
110
|
-
|
|
111
|
-
storage.
|
|
174
|
+
## Uploading files
|
|
112
175
|
|
|
113
|
-
|
|
114
|
-
2. **Upload File**: POST the file to the returned `uploadUrl`.
|
|
115
|
-
3. **Finalize**: Call `finalizeUpload` with the `uploadToken` and `storageId`.
|
|
176
|
+
### Presigned URL flow
|
|
116
177
|
|
|
117
|
-
```
|
|
118
|
-
// Client-side
|
|
119
|
-
const { uploadUrl, uploadToken } = await generateUploadUrl(
|
|
178
|
+
```ts
|
|
179
|
+
// Client-side
|
|
180
|
+
const { uploadUrl, uploadToken } = await generateUploadUrl({
|
|
181
|
+
provider: "convex",
|
|
182
|
+
});
|
|
120
183
|
|
|
121
|
-
const
|
|
184
|
+
const uploadResponse = await fetch(uploadUrl, {
|
|
122
185
|
method: "POST",
|
|
123
|
-
body:
|
|
124
|
-
headers: { "Content-Type":
|
|
186
|
+
body: file,
|
|
187
|
+
headers: { "Content-Type": file.type || "application/octet-stream" },
|
|
125
188
|
});
|
|
126
|
-
const { storageId } = await result.json();
|
|
127
189
|
|
|
128
|
-
const
|
|
190
|
+
const { storageId } = await uploadResponse.json();
|
|
191
|
+
|
|
192
|
+
const result = await finalizeUpload({
|
|
129
193
|
uploadToken,
|
|
130
194
|
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,
|
|
195
|
+
fileName: file.name,
|
|
196
|
+
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
152
197
|
});
|
|
153
198
|
```
|
|
154
199
|
|
|
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`.
|
|
200
|
+
### React hook
|
|
160
201
|
|
|
161
202
|
```tsx
|
|
162
203
|
import { useUploadFile } from "@gilhrpenner/convex-files-control/react";
|
|
163
204
|
import { api } from "../convex/_generated/api";
|
|
164
205
|
|
|
165
|
-
const convexSiteUrl = import.meta.env.VITE_CONVEX_URL.replace(
|
|
206
|
+
const convexSiteUrl = import.meta.env.VITE_CONVEX_URL.replace(
|
|
207
|
+
".cloud",
|
|
208
|
+
".site",
|
|
209
|
+
);
|
|
166
210
|
|
|
167
211
|
const { uploadFile } = useUploadFile(api.files, {
|
|
212
|
+
method: "presigned",
|
|
168
213
|
http: { baseUrl: convexSiteUrl },
|
|
169
214
|
});
|
|
170
215
|
|
|
171
|
-
// Presigned
|
|
172
|
-
await uploadFile({
|
|
173
|
-
file,
|
|
174
|
-
accessKeys: ["user_123"],
|
|
175
|
-
expiresAt: Date.now() + 60 * 60 * 1000,
|
|
176
|
-
method: "presigned",
|
|
177
|
-
});
|
|
216
|
+
// Presigned
|
|
217
|
+
await uploadFile({ file, provider: "convex" });
|
|
178
218
|
|
|
179
|
-
// HTTP
|
|
219
|
+
// HTTP route
|
|
180
220
|
await uploadFile({
|
|
181
221
|
file,
|
|
182
|
-
accessKeys: ["user_123"],
|
|
183
222
|
method: "http",
|
|
223
|
+
provider: "convex",
|
|
224
|
+
http: {
|
|
225
|
+
baseUrl: convexSiteUrl,
|
|
226
|
+
// authToken: useAuthToken() from @convex-dev/auth/react
|
|
227
|
+
},
|
|
184
228
|
});
|
|
185
229
|
```
|
|
186
230
|
|
|
187
|
-
|
|
188
|
-
you can persist it in your own tables if needed.
|
|
231
|
+
`uploadFile` accepts:
|
|
189
232
|
|
|
190
|
-
|
|
233
|
+
- `file` (required)
|
|
234
|
+
- `provider` ("convex" | "r2")
|
|
235
|
+
- `expiresAt` (timestamp or `null`)
|
|
236
|
+
- `method` ("presigned" | "http")
|
|
191
237
|
|
|
192
|
-
|
|
193
|
-
token that can be exchanged for the file content.
|
|
238
|
+
## Downloading files
|
|
194
239
|
|
|
195
|
-
|
|
196
|
-
2. **Build URL**: Use the helper to construct the download link.
|
|
240
|
+
### Create a grant + build a URL
|
|
197
241
|
|
|
198
|
-
```
|
|
242
|
+
```ts
|
|
199
243
|
import { buildDownloadUrl } from "@gilhrpenner/convex-files-control";
|
|
200
244
|
|
|
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
|
-
});
|
|
245
|
+
const grant = await ctx.runMutation(
|
|
246
|
+
components.convexFilesControl.download.createDownloadGrant,
|
|
247
|
+
{
|
|
248
|
+
storageId,
|
|
249
|
+
maxUses: 1,
|
|
250
|
+
expiresAt: Date.now() + 10 * 60 * 1000,
|
|
251
|
+
shareableLink: false,
|
|
220
252
|
},
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const url = buildDownloadUrl({
|
|
256
|
+
baseUrl: "https://<your-convex-site>",
|
|
257
|
+
downloadToken: grant.downloadToken,
|
|
258
|
+
filename: "report.pdf",
|
|
259
|
+
// pathPrefix: "/files", // Optional if you changed the HTTP route prefix
|
|
221
260
|
});
|
|
222
261
|
```
|
|
223
262
|
|
|
224
|
-
|
|
225
|
-
|
|
263
|
+
Access keys are not placed in the URL. For private grants, supply them via
|
|
264
|
+
`checkDownloadRequest` (HTTP route) or pass `accessKey` when calling
|
|
265
|
+
`consumeDownloadGrantForUrl`.
|
|
226
266
|
|
|
227
|
-
|
|
267
|
+
### Shareable links
|
|
228
268
|
|
|
229
|
-
|
|
230
|
-
|
|
269
|
+
Set `shareableLink: true` to allow unauthenticated downloads (no access key
|
|
270
|
+
required). This is how the example app generates public links. If you enable
|
|
271
|
+
`requireAccessKey` on the HTTP route, shareable links will still require
|
|
272
|
+
`checkDownloadRequest` to return an access key.
|
|
231
273
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
});
|
|
274
|
+
### Password-protected grants
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
const grant = await ctx.runMutation(
|
|
278
|
+
components.convexFilesControl.download.createDownloadGrant,
|
|
279
|
+
{ storageId, password: "secret-passphrase" },
|
|
280
|
+
);
|
|
249
281
|
```
|
|
250
282
|
|
|
251
|
-
To consume a password-protected grant, pass
|
|
252
|
-
`consumeDownloadGrantForUrl
|
|
283
|
+
To consume a password-protected grant, pass `password` to
|
|
284
|
+
`consumeDownloadGrantForUrl`, or send it to the HTTP route via the
|
|
285
|
+
`x-download-password` header (preferred) or the `password` query param. Query
|
|
286
|
+
params can leak into logs, so headers or POST flows are safer.
|
|
287
|
+
|
|
288
|
+
## Access control & queries
|
|
289
|
+
|
|
290
|
+
Access keys are normalized (trimmed) and must contain at least one non-empty
|
|
291
|
+
value.
|
|
292
|
+
|
|
293
|
+
- `accessControl.addAccessKey(storageId, accessKey)`
|
|
294
|
+
- `accessControl.removeAccessKey(storageId, accessKey)`
|
|
295
|
+
- `accessControl.updateFileExpiration(storageId, expiresAt)`
|
|
296
|
+
- `queries.hasAccessKey(storageId, accessKey)`
|
|
297
|
+
- `queries.listAccessKeysPage(storageId, paginationOpts)`
|
|
298
|
+
- `queries.listFilesPage(paginationOpts)`
|
|
299
|
+
- `queries.listFilesByAccessKeyPage(accessKey, paginationOpts)`
|
|
300
|
+
- `queries.listDownloadGrantsPage(paginationOpts)`
|
|
301
|
+
- `queries.getFile({ storageId })`
|
|
302
|
+
|
|
303
|
+
Pagination uses `{ numItems: number, cursor: string | null }`.
|
|
304
|
+
|
|
305
|
+
## Cleanup
|
|
306
|
+
|
|
307
|
+
Use `cleanUp.cleanupExpired` to delete expired uploads, grants, and files. The
|
|
308
|
+
example app wraps this in a mutation and runs it in a cron job.
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
// convex/crons.ts
|
|
312
|
+
import { cronJobs } from "convex/server";
|
|
313
|
+
import { internal } from "./_generated/api";
|
|
253
314
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
{
|
|
315
|
+
const crons = cronJobs();
|
|
316
|
+
crons.hourly(
|
|
317
|
+
"cleanup-expired-files",
|
|
318
|
+
{ minuteUTC: 0 },
|
|
319
|
+
internal.files.cleanupExpiredFiles,
|
|
320
|
+
{},
|
|
258
321
|
);
|
|
322
|
+
export default crons;
|
|
259
323
|
```
|
|
260
324
|
|
|
261
|
-
|
|
262
|
-
`x-download-password` header (preferred) or a `password` query param. Note that
|
|
263
|
-
query params can appear in logs and caches, so headers or POST flows are safer.
|
|
325
|
+
## Server-side helper (FilesControl)
|
|
264
326
|
|
|
265
|
-
|
|
327
|
+
If you prefer a class wrapper around component calls, use `FilesControl`:
|
|
266
328
|
|
|
267
|
-
|
|
268
|
-
|
|
329
|
+
```ts
|
|
330
|
+
import { FilesControl } from "@gilhrpenner/convex-files-control";
|
|
331
|
+
import { components } from "./_generated/api";
|
|
269
332
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
checkDownloadRequest: async (_ctx, { request }) => {
|
|
273
|
-
const ip =
|
|
274
|
-
request.headers.get("x-forwarded-for") ??
|
|
275
|
-
request.headers.get("cf-connecting-ip") ??
|
|
276
|
-
"unknown";
|
|
277
|
-
|
|
278
|
-
// Call your rate limiter here. Return a Response to short-circuit.
|
|
279
|
-
if (ip === "blocked") {
|
|
280
|
-
return new Response(JSON.stringify({ error: "Too many requests" }), {
|
|
281
|
-
status: 429,
|
|
282
|
-
headers: { "Content-Type": "application/json" },
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
},
|
|
333
|
+
const files = new FilesControl(components.convexFilesControl, {
|
|
334
|
+
// r2: { accountId, accessKeyId, secretAccessKey, bucketName },
|
|
286
335
|
});
|
|
336
|
+
|
|
337
|
+
await files.generateUploadUrl(ctx, { provider: "convex" });
|
|
287
338
|
```
|
|
288
339
|
|
|
289
|
-
|
|
340
|
+
`FilesControl.clientApi()` also returns a ready-to-export API surface with
|
|
341
|
+
optional hooks if you want the component to generate your Convex mutations and
|
|
342
|
+
queries for you.
|
|
290
343
|
|
|
291
|
-
|
|
344
|
+
## R2 configuration
|
|
292
345
|
|
|
293
|
-
|
|
294
|
-
|
|
346
|
+
Provide R2 credentials when you use R2 for uploads, downloads, deletes, or
|
|
347
|
+
transfers. You can pass `r2Config` to the component calls or supply env vars for
|
|
348
|
+
the HTTP routes:
|
|
295
349
|
|
|
296
|
-
- `
|
|
297
|
-
- `
|
|
298
|
-
- `
|
|
299
|
-
- `
|
|
350
|
+
- `R2_ACCOUNT_ID`
|
|
351
|
+
- `R2_ACCESS_KEY_ID`
|
|
352
|
+
- `R2_SECRET_ACCESS_KEY`
|
|
353
|
+
- `R2_BUCKET_NAME`
|
|
300
354
|
|
|
301
|
-
|
|
355
|
+
## Transfer between providers
|
|
302
356
|
|
|
303
|
-
|
|
304
|
-
|
|
357
|
+
```ts
|
|
358
|
+
const result = await ctx.runAction(
|
|
359
|
+
components.convexFilesControl.transfer.transferFile,
|
|
360
|
+
{ storageId, targetProvider: "r2", r2Config },
|
|
361
|
+
);
|
|
362
|
+
```
|
|
305
363
|
|
|
306
|
-
|
|
307
|
-
|
|
364
|
+
The transfer preserves access keys and download grants, updates the file record,
|
|
365
|
+
and deletes the original storage object.
|
|
308
366
|
|
|
309
|
-
|
|
367
|
+
## Testing helper
|
|
310
368
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
369
|
+
```ts
|
|
370
|
+
import { convexTest } from "convex-test";
|
|
371
|
+
import { register } from "@gilhrpenner/convex-files-control/test";
|
|
314
372
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
import { api } from "./_generated/api";
|
|
373
|
+
const t = convexTest(schema, modules);
|
|
374
|
+
register(t, "convexFilesControl");
|
|
375
|
+
```
|
|
319
376
|
|
|
320
|
-
|
|
377
|
+
## Example app
|
|
321
378
|
|
|
322
|
-
|
|
323
|
-
crons.hourly("cleanup-files", { minutes: 0 }, api.files.cleanupExpired, {
|
|
324
|
-
limit: 100,
|
|
325
|
-
});
|
|
379
|
+
**[Live Demo →](https://convex-files-control-example.pages.dev)**
|
|
326
380
|
|
|
327
|
-
|
|
328
|
-
|
|
381
|
+
A full Convex + React + Convex Auth implementation lives in `example/`. It
|
|
382
|
+
demonstrates:
|
|
383
|
+
|
|
384
|
+
- presigned and HTTP uploads
|
|
385
|
+
- authenticated downloads and shareable links
|
|
386
|
+
- access key management
|
|
387
|
+
- transfer between Convex and R2
|
|
388
|
+
- 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"}
|