Skip to content

Commit c19ded6

Browse files
authored
Merge pull request #739 from devforth/feature/AdminForth/2032/fix-upload-plugin-and-storage-
Feature/admin forth/2032/fix upload plugin and storage
2 parents a1fa458 + b8631f5 commit c19ded6

6 files changed

Lines changed: 38 additions & 11 deletions

File tree

‎adminforth/types/adapters/StorageAdapter.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ export interface StorageAdapter {
1515
* @param key - The key of the file to be uploaded e.g. "uploads/file.txt"
1616
* @param expiresIn - The expiration time in seconds for the presigned URL
1717
* @param contentType - The content type of the file to be uploaded, e.g. "image/png"
18+
* @param contentLength - Optional exact size of the file in bytes. When passed, the upload should be rejected if the body has another size.
1819
*
1920
* @returns A promise that resolves to an object containing the upload URL and any extra parameters which should be sent with PUT multipart form data
2021
*/
21-
getUploadSignedUrl(key: string, contentType: string, expiresIn?: number): Promise<{
22+
getUploadSignedUrl(key: string, contentType: string, expiresIn?: number, contentLength?: number): Promise<{
2223
uploadUrl: string;
2324
uploadExtraParams?: Record<string, string>;
2425
}>;

‎dev-demo/resources/cars_description_image.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export default {
5151
region: process.env.AWS_REGION as string,
5252
accessKeyId: process.env.AWS_ACCESS_KEY_ID as string,
5353
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY as string,
54+
strictContentType: true,
5455
}),
5556

5657
allowedFileExtensions: [

‎dev-demo/resources/cars_resources/carsResourseTemplate.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ export default function carsResourseTemplate(resourceId: string, dataSource: Car
215215
region: process.env.AWS_REGION as string,
216216
accessKeyId: process.env.AWS_ACCESS_KEY_ID as string,
217217
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY as string,
218+
strictContentType: true,
218219
s3ACL: "public-read"
219220
}),
220221
pathColumnName: 'photos',
@@ -236,6 +237,7 @@ export default function carsResourseTemplate(resourceId: string, dataSource: Car
236237
region: process.env.AWS_REGION as string,
237238
accessKeyId: process.env.AWS_ACCESS_KEY_ID as string,
238239
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY as string,
240+
strictContentType: true,
239241
s3ACL: "public-read"
240242
}),
241243
pathColumnName: 'promo_picture',
@@ -349,6 +351,7 @@ export default function carsResourseTemplate(resourceId: string, dataSource: Car
349351
region: process.env.AWS_REGION as string,
350352
accessKeyId: process.env.AWS_ACCESS_KEY_ID as string,
351353
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY as string,
354+
strictContentType: true,
352355
s3ACL: "public-read"
353356
}),
354357

‎dev-demo/utils.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const s3StorageAdapter = process.env.AWS_ACCESS_KEY_ID ? new AdminForthAd
2323
region: process.env.AWS_REGION as string,
2424
accessKeyId: process.env.AWS_ACCESS_KEY_ID as string,
2525
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY as string,
26+
strictContentType: true,
2627
s3ACL: "public-read"
2728
}) : null;
2829

‎documentation/docs/tutorial/06-Adapters/04-storage-adapters.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,31 @@ pnpm add @adminforth/storage-adapter-amazon-s3
1616

1717
Stores uploaded files in [Amazon S3](https://aws.amazon.com/s3/), providing scalable cloud storage.
1818

19+
### Strict content type
20+
21+
The upload plugin picks the content type of a file by its extension, e.g. `image/png` for `.png`. By default S3 does not check it on upload, so a client can store a file with any `Content-Type`, e.g. `text/html` under a `.png` key, and the browser will open it as a page. Set `strictContentType: true` to sign the content type into the upload URL, then an upload with another type is rejected:
22+
23+
```ts
24+
new AdminForthAdapterS3Storage({
25+
bucket: process.env.AWS_BUCKET_NAME as string,
26+
region: process.env.AWS_REGION as string,
27+
accessKeyId: process.env.AWS_ACCESS_KEY_ID as string,
28+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY as string,
29+
strictContentType: true,
30+
}),
31+
```
32+
33+
Before turning it on, update `@adminforth/bulk-ai-flow`, `@adminforth/markdown`, `@adminforth/rich-editor` and `@adminforth/oauth`: their older versions send the type reported by the browser, and the upload fails when it differs from the one picked by the extension. Your own code that uploads files to the URL from the [upload plugin API](../09-Plugins/05-1-upload-api.md) has to send the `contentType` returned with the URL as well. While the option is off, the adapter logs a warning on start. It will be on by default in the next major version.
34+
1935
## S3 compatible adapter
2036
```bash
2137
pnpm add @adminforth/storage-adapter-s3-compatible
2238
```
2339

2440
Provides S3 compatible interface for object storage services such as MinIO, Wasabi, Cloudflare R2 or other third-party S3 providers.
41+
42+
It supports the same `strictContentType` option as the Amazon S3 adapter, see [Strict content type](#strict-content-type).
43+
2544
### How the adapter works
2645

2746
The Amazon S3 adapter uses tagging to mark objects with the special tag `adminforth-candidate-for-cleanup`, and then creates a lifecycle rule that automatically expires objects with that tag.

‎documentation/docs/tutorial/09-Plugins/05-1-upload-api.md‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -115,14 +115,14 @@ app.post(
115115

116116
const plugin = admin.getPluginById('my_reports_plugin');
117117

118-
const { uploadUrl, filePath, uploadExtraParams, pathColumnName } = await plugin.getUploadUrl({
118+
const { uploadUrl, contentType: uploadContentType, filePath, uploadExtraParams, pathColumnName } = await plugin.getUploadUrl({
119119
recordId, // can be undefined for new records
120120
filename, // e.g. file.name
121121
contentType, // e.g. file.type
122-
size, // optional, will be validated against maxFileSize to drop error earlier
122+
size, // e.g. file.size: validated against maxFileSize, the upload URL then accepts only a file of exactly this size
123123
});
124124

125-
res.json({ uploadUrl, filePath, uploadExtraParams, pathColumnName });
125+
res.json({ uploadUrl, contentType: uploadContentType, filePath, uploadExtraParams, pathColumnName });
126126
}),
127127
);
128128
```
@@ -133,7 +133,7 @@ On the frontend:
133133
const file = input.files[0];
134134

135135
// 1) Ask your backend to generate upload URL
136-
const { uploadUrl, filePath, uploadExtraParams } = await fetch('/api/uploads/get-url-existing', {
136+
const { uploadUrl, contentType, filePath, uploadExtraParams } = await fetch('/api/uploads/get-url-existing', {
137137
method: 'POST',
138138
headers: { 'Content-Type': 'application/json' },
139139
body: JSON.stringify({
@@ -166,7 +166,8 @@ await new Promise<void>((resolve, reject) => {
166166
});
167167

168168
xhr.open('PUT', uploadUrl, true);
169-
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
169+
// the type picked by the plugin from the file extension, the storage may reject another one
170+
xhr.setRequestHeader('Content-Type', contentType);
170171

171172
if (uploadExtraParams) {
172173
Object.entries(uploadExtraParams).forEach(([key, value]) => {
@@ -321,7 +322,7 @@ async function onFileChange(e: Event) {
321322
if (!file) return;
322323
323324
// 1) Ask backend for presigned URL
324-
const { uploadUrl, filePath, uploadExtraParams, pathColumnName } = await fetch(
325+
const { uploadUrl, contentType, filePath, uploadExtraParams, pathColumnName } = await fetch(
325326
'/api/uploads/avatar/get-url',
326327
{
327328
method: 'POST',
@@ -357,7 +358,8 @@ async function onFileChange(e: Event) {
357358
});
358359
359360
xhr.open('PUT', uploadUrl, true);
360-
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
361+
// the type picked by the plugin from the file extension, the storage may reject another one
362+
xhr.setRequestHeader('Content-Type', contentType);
361363
362364
if (uploadExtraParams) {
363365
Object.entries(uploadExtraParams).forEach(([key, value]) => {
@@ -395,14 +397,14 @@ app.post(
395397
396398
const plugin = admin.getPluginById('my_reports_plugin');
397399
398-
const { uploadUrl, filePath, uploadExtraParams, pathColumnName } = await plugin.getUploadUrl({
400+
const { uploadUrl, contentType: uploadContentType, filePath, uploadExtraParams, pathColumnName } = await plugin.getUploadUrl({
399401
recordId, // can be undefined for create pages
400402
filename,
401403
contentType,
402-
size, // optional
404+
size, // exact size of the file, the upload URL accepts only this size
403405
});
404406
405-
res.json({ uploadUrl, filePath, uploadExtraParams, pathColumnName });
407+
res.json({ uploadUrl, contentType: uploadContentType, filePath, uploadExtraParams, pathColumnName });
406408
}),
407409
);
408410
```

0 commit comments

Comments
 (0)