Skip to content

Commit 0f5e997

Browse files
authored
fix(storage): add correct contentType (#14536)
1 parent 23a3293 commit 0f5e997

File tree

7 files changed

+219
-4
lines changed

7 files changed

+219
-4
lines changed

packages/aws-amplify/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -529,7 +529,7 @@
529529
"name": "[Storage] uploadData (S3)",
530530
"path": "./dist/esm/storage/index.mjs",
531531
"import": "{ uploadData }",
532-
"limit": "24 kB"
532+
"limit": "24.4 kB"
533533
}
534534
]
535535
}

packages/storage/__tests__/providers/s3/apis/internal/uploadData/putObjectJob.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,5 +453,71 @@ describe('putObjectJob with path', () => {
453453
},
454454
);
455455
});
456+
457+
it('should detect content type from file extension', async () => {
458+
const abortController = new AbortController();
459+
const testData = 'data';
460+
const job = putObjectJob(
461+
{
462+
key: 'image.jpg',
463+
data: testData,
464+
},
465+
abortController.signal,
466+
testData.length,
467+
);
468+
await job();
469+
470+
expect(mockPutObject).toHaveBeenCalledWith(
471+
expect.any(Object),
472+
expect.objectContaining({
473+
ContentType: 'image/jpeg',
474+
}),
475+
);
476+
});
477+
478+
it('should detect content type from File object', async () => {
479+
const abortController = new AbortController();
480+
const file = new File(['content'], 'test.png', { type: 'image/png' });
481+
const job = putObjectJob(
482+
{
483+
key: 'test.jpg', // Different extension to test File.type takes precedence
484+
data: file,
485+
},
486+
abortController.signal,
487+
file.size,
488+
);
489+
await job();
490+
491+
expect(mockPutObject).toHaveBeenCalledWith(
492+
expect.any(Object),
493+
expect.objectContaining({
494+
ContentType: 'image/png',
495+
}),
496+
);
497+
});
498+
499+
it('should use explicit contentType when provided', async () => {
500+
const abortController = new AbortController();
501+
const testData = 'data';
502+
const job = putObjectJob(
503+
{
504+
key: 'image.jpg',
505+
data: testData,
506+
options: {
507+
contentType: 'custom/type',
508+
},
509+
},
510+
abortController.signal,
511+
testData.length,
512+
);
513+
await job();
514+
515+
expect(mockPutObject).toHaveBeenCalledWith(
516+
expect.any(Object),
517+
expect.objectContaining({
518+
ContentType: 'custom/type',
519+
}),
520+
);
521+
});
456522
});
457523
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { getContentType } from '../../src/utils/contentType';
5+
6+
describe('getContentType', () => {
7+
it('should return File.type when data is a File with type', () => {
8+
const file = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
9+
expect(getContentType(file, 'test.jpg')).toBe('image/jpeg');
10+
});
11+
12+
it('should return MIME type based on file extension', () => {
13+
expect(getContentType('data', 'image.png')).toBe('image/png');
14+
expect(getContentType('data', 'video.mp4')).toBe('video/mp4');
15+
expect(getContentType('data', 'document.pdf')).toBe('application/pdf');
16+
});
17+
18+
it('should handle files with multiple dots by using last extension', () => {
19+
expect(getContentType('data', 'myfile.tar.gz')).toBe('application/gzip');
20+
expect(getContentType('data', 'archive.tar.bz')).toBe('application/x-bzip');
21+
expect(getContentType('data', 'data.json.zip')).toBe('application/zip');
22+
});
23+
24+
it('should handle case insensitive extensions', () => {
25+
expect(getContentType('data', 'IMAGE.PNG')).toBe('image/png');
26+
expect(getContentType('data', 'Video.MP4')).toBe('video/mp4');
27+
});
28+
29+
it('should return undefined for unknown extensions', () => {
30+
expect(getContentType('data', 'file.unknown')).toBeUndefined();
31+
expect(getContentType('data', 'noextension')).toBeUndefined();
32+
});
33+
34+
it('should prefer File.type over extension detection', () => {
35+
const file = new File(['content'], 'test.png', { type: 'image/jpeg' });
36+
expect(getContentType(file, 'test.png')).toBe('image/jpeg');
37+
});
38+
39+
it('should fallback to extension when File has no type', () => {
40+
const file = new File(['content'], 'test.png', { type: '' });
41+
expect(getContentType(file, 'test.png')).toBe('image/png');
42+
});
43+
});

packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadCache.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Part, listParts } from '../../../../utils/client/s3data';
1212
import { logger } from '../../../../../../utils';
1313
// TODO: Remove this interface when we move to public advanced APIs.
1414
import { UploadDataInput as UploadDataWithPathInputWithAdvancedOptions } from '../../../../../../internals/types/inputs';
15+
import { getContentType } from '../../../../../../utils/contentType';
1516

1617
const ONE_HOUR = 1000 * 60 * 60;
1718

@@ -166,7 +167,7 @@ export const getUploadsCacheKey = ({
166167
}: UploadsCacheKeyOptions) => {
167168
let levelStr;
168169
const resolvedContentType =
169-
contentType ?? file?.type ?? 'application/octet-stream';
170+
contentType ?? getContentType(file, key) ?? 'application/octet-stream';
170171

171172
// If no access level is defined, we're using custom gen2 access rules
172173
if (accessLevel === undefined) {

packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadHandlers.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { logger } from '../../../../../../utils';
3838
import { calculateContentCRC32 } from '../../../../utils/crc32';
3939
import { StorageOperationOptionsInput } from '../../../../../../types/inputs';
4040
import { IntegrityError } from '../../../../../../errors/IntegrityError';
41+
import { getContentType } from '../../../../../../utils/contentType';
4142

4243
import { uploadPartExecutor } from './uploadPartExecutor';
4344
import {
@@ -137,7 +138,9 @@ export const getMultipartUploadHandlers = (
137138
const {
138139
contentDisposition,
139140
contentEncoding,
140-
contentType = 'application/octet-stream',
141+
contentType = uploadDataOptions?.contentType ??
142+
getContentType(data, objectKey) ??
143+
'application/octet-stream',
141144
metadata,
142145
preventOverwrite,
143146
onProgress,

packages/storage/src/providers/s3/apis/internal/uploadData/putObjectJob.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from '../../../utils/constants';
2323
import { calculateContentCRC32 } from '../../../utils/crc32';
2424
import { constructContentDisposition } from '../../../utils/constructContentDisposition';
25+
import { getContentType } from '../../../../../utils/contentType';
2526

2627
/**
2728
* The input interface for UploadData API with only the options needed for single part upload.
@@ -60,7 +61,9 @@ export const putObjectJob =
6061
const {
6162
contentDisposition,
6263
contentEncoding,
63-
contentType = 'application/octet-stream',
64+
contentType = uploadDataOptions?.contentType ??
65+
getContentType(data, objectKey) ??
66+
'application/octet-stream',
6467
preventOverwrite,
6568
metadata,
6669
checksumAlgorithm,
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
const MIME_TYPES: Record<string, string> = {
5+
// Audio
6+
aac: 'audio/aac',
7+
mid: 'audio/midi',
8+
midi: 'audio/x-midi',
9+
mp3: 'audio/mpeg',
10+
oga: 'audio/ogg',
11+
opus: 'audio/ogg',
12+
wav: 'audio/wav',
13+
weba: 'audio/webm',
14+
// Video
15+
avi: 'video/x-msvideo',
16+
mp4: 'video/mp4',
17+
mpeg: 'video/mpeg',
18+
ogv: 'video/ogg',
19+
ts: 'video/mp2t',
20+
webm: 'video/webm',
21+
// Images
22+
apng: 'image/apng',
23+
avif: 'image/avif',
24+
bmp: 'image/bmp',
25+
gif: 'image/gif',
26+
ico: 'image/vnd.microsoft.icon',
27+
jpeg: 'image/jpeg',
28+
jpg: 'image/jpeg',
29+
png: 'image/png',
30+
svg: 'image/svg+xml',
31+
tif: 'image/tiff',
32+
tiff: 'image/tiff',
33+
webp: 'image/webp',
34+
// Text
35+
css: 'text/css',
36+
csv: 'text/csv',
37+
htm: 'text/html',
38+
html: 'text/html',
39+
ics: 'text/calendar',
40+
js: 'text/javascript',
41+
md: 'text/markdown',
42+
mjs: 'text/javascript',
43+
txt: 'text/plain',
44+
// Application
45+
abw: 'application/x-abiword',
46+
arc: 'application/x-freearc',
47+
azw: 'application/vnd.amazon.ebook',
48+
bin: 'application/octet-stream',
49+
bz: 'application/x-bzip',
50+
bz2: 'application/x-bzip2',
51+
cda: 'application/x-cdf',
52+
csh: 'application/x-csh',
53+
doc: 'application/msword',
54+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
55+
eot: 'application/vnd.ms-fontobject',
56+
epub: 'application/epub+zip',
57+
gz: 'application/gzip',
58+
jar: 'application/java-archive',
59+
json: 'application/json',
60+
jsonld: 'application/ld+json',
61+
mpkg: 'application/vnd.apple.installer+xml',
62+
odp: 'application/vnd.oasis.opendocument.presentation',
63+
ods: 'application/vnd.oasis.opendocument.spreadsheet',
64+
odt: 'application/vnd.oasis.opendocument.text',
65+
ogx: 'application/ogg',
66+
pdf: 'application/pdf',
67+
php: 'application/x-httpd-php',
68+
ppt: 'application/vnd.ms-powerpoint',
69+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
70+
rar: 'application/vnd.rar',
71+
rtf: 'application/rtf',
72+
sh: 'application/x-sh',
73+
tar: 'application/x-tar',
74+
vsd: 'application/vnd.visio',
75+
webmanifest: 'application/manifest+json',
76+
xhtml: 'application/xhtml+xml',
77+
xls: 'application/vnd.ms-excel',
78+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
79+
xml: 'application/xml',
80+
zip: 'application/zip',
81+
// Fonts
82+
otf: 'font/otf',
83+
ttf: 'font/ttf',
84+
woff: 'font/woff',
85+
woff2: 'font/woff2',
86+
};
87+
88+
/**
89+
* Detect content type from file data or filename extension
90+
*/
91+
export const getContentType = (data: any, key: string): string | undefined => {
92+
if (data instanceof File && data.type) {
93+
return data.type;
94+
}
95+
96+
const ext = key.split('.').pop()?.toLowerCase();
97+
98+
return ext ? MIME_TYPES[ext] : undefined;
99+
};

0 commit comments

Comments
 (0)