Skip to content
This repository was archived by the owner on Apr 15, 2025. It is now read-only.

Commit 21fd653

Browse files
committed
Added new functionality: If extension isn't set in remote image url attempt to determine file type via content-type response header.
1 parent cc04c53 commit 21fd653

File tree

4 files changed

+150
-25
lines changed

4 files changed

+150
-25
lines changed

lib/FileSystem.js

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,10 @@ export class FileSystem {
134134
* Creates a SHA1 hash filename from a url and normalizes extension.
135135
*
136136
* @param url {String} - An absolute url.
137-
* @throws error on invalid (non jpg, png, gif) url file extension. NOTE file extension does not guarantee file mime type. We are trusting that it is set correctly on the server side.
138-
* @returns fileName {string} - A SHA1 filename that is unique to the resource located at passed in URL and includes the same extension.
137+
* @throws error on invalid (non jpg, png, gif, bmp) url file type. NOTE file extension or content-type header does not guarantee file mime type. We are trusting that it is set correctly on the server side.
138+
* @returns fileName {string} - A SHA1 filename that is unique to the resource located at passed in URL and includes an appropriate extension.
139139
*/
140-
getFileNameFromUrl(url) {
140+
async getFileNameFromUrl(url) {
141141

142142
const urlParts = new URL(url);
143143
const urlExt = urlParts.pathname.split('.').pop();
@@ -163,13 +163,69 @@ export class FileSystem {
163163
extension = 'bmp';
164164
break;
165165
default:
166-
throw new Error('url has invalid file extension.');
166+
extension = await this.getExtensionFromContentTypeHeader(url);
167+
}
168+
169+
if (!extension) {
170+
throw new Error('Unable to determine remote image filetype.');
167171
}
168172

169173
return sha1(url).toString() + '.' + extension;
170174

171175
}
172176

177+
/**
178+
*
179+
* Used to determine appropriate file extension for a remote file that doesn't include
180+
* an extension in the url. This method will attempt to determine extension using server
181+
* "Content-Type" response header.
182+
*
183+
* @param url {String} - An absolute url.
184+
* @returns extension {string} - A file extension appropriate for remote file.
185+
*/
186+
async getExtensionFromContentTypeHeader(url) {
187+
188+
let extension = null;
189+
let contentType = null;
190+
191+
// Request "Content-type" header from server.
192+
try{
193+
194+
const response = await fetch(url, {
195+
method: 'HEAD'
196+
});
197+
198+
if (response.headers.get('content-type')) {
199+
const rawContentType = response.headers.get('content-type'); // headers are case-insensitive, fetch standard is all lower case.
200+
contentType = rawContentType.toLowerCase();
201+
}
202+
203+
} catch (error) {
204+
console.error(error); // eslint-disable-line no-console
205+
}
206+
207+
// Use content type header to determine extension.
208+
switch(contentType) {
209+
case 'image/png':
210+
extension = 'png';
211+
break;
212+
case 'image/gif':
213+
extension = 'gif';
214+
break;
215+
case 'image/jpeg':
216+
extension = 'jpg';
217+
break;
218+
case 'image/bmp':
219+
extension = 'bmp';
220+
break;
221+
default:
222+
extension = null;
223+
}
224+
225+
return extension;
226+
227+
}
228+
173229
/**
174230
*
175231
* Convenience method used to get the associated local file path of a web image that has been written to disk.
@@ -183,7 +239,7 @@ export class FileSystem {
183239

184240
let filePath = null;
185241

186-
let fileName = this.getFileNameFromUrl(url);
242+
let fileName = await this.getFileNameFromUrl(url);
187243

188244
let permanentFileExists = this.exists('permanent/' + fileName);
189245
let cacheFileExists = this.exists('cache/' + fileName);
@@ -221,7 +277,7 @@ export class FileSystem {
221277
*/
222278
async fetchFile(url, permanent = false, fileName = null, clobber = false) {
223279

224-
fileName = fileName || this.getFileNameFromUrl(url);
280+
fileName = fileName || await this.getFileNameFromUrl(url);
225281
let path = this.baseFilePath + (permanent ? 'permanent' : 'cache') + '/' + fileName;
226282
this._validatePath(path, true);
227283

lib/imageCacheHoc.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,10 @@ export default function imageCacheHoc(Image, options = {}) {
138138

139139
// Async calls to local FS or network should occur here.
140140
// See: https://reactjs.org/docs/react-component.html#componentdidmount
141-
componentDidMount() {
141+
async componentDidMount() {
142142

143143
// Add a cache lock to file with this name (prevents concurrent <CacheableImage> components from pruning a file with this name from cache).
144-
let fileName = this.fileSystem.getFileNameFromUrl(traverse(this.props).get(['source', 'uri']));
144+
let fileName = await this.fileSystem.getFileNameFromUrl(traverse(this.props).get(['source', 'uri']));
145145
FileSystem.lockCacheFile(fileName, this.componentId);
146146

147147
// Check local fs for file, fallback to network and write file to disk if local file not found.
@@ -156,15 +156,15 @@ export default function imageCacheHoc(Image, options = {}) {
156156

157157
}
158158

159-
componentWillUnmount() {
160-
161-
// Remove component cache lock on associated image file on component teardown.
162-
let fileName = this.fileSystem.getFileNameFromUrl(traverse(this.props).get(['source', 'uri']));
163-
FileSystem.unlockCacheFile(fileName, this.componentId);
159+
async componentWillUnmount() {
164160

165161
// Cancel pending setState() actions.
166162
this.cancelLocalFilePathRequest();
167163

164+
// Remove component cache lock on associated image file on component teardown.
165+
let fileName = await this.fileSystem.getFileNameFromUrl(traverse(this.props).get(['source', 'uri']));
166+
FileSystem.unlockCacheFile(fileName, this.componentId);
167+
168168
}
169169

170170
render() {

tests/CacheableImage.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ describe('CacheableImage', function() {
262262

263263
});
264264

265-
it('setState() actions on component mount should create cancelable functions and use on unmount.', () => {
265+
it('setState() actions on component mount should create cancelable functions and use on unmount.', async () => {
266266

267267
// Set up mocks
268268
const FileSystem = require('../lib/FileSystem').default;
@@ -275,10 +275,10 @@ describe('CacheableImage', function() {
275275
const cacheableImage = new CacheableImage(mockData.mockCacheableImageProps);
276276

277277
// Mount and unmount
278-
cacheableImage.componentDidMount();
278+
await cacheableImage.componentDidMount();
279279
cacheableImage.cancelLocalFilePathRequest(); // Call it once manually to actually cancel local file path request
280280
cacheableImage.cancelLocalFilePathRequest = sinon.spy(); // Set up a spy to make sure it gets called in componentWillUnmount().
281-
cacheableImage.componentWillUnmount();
281+
await cacheableImage.componentWillUnmount();
282282
cacheableImage.cancelLocalFilePathRequest.should.be.calledOnce();
283283

284284
});

tests/FileSystem.test.js

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11

22
// Define globals for eslint.
3-
/* global describe it require */
3+
/* global describe it require jest */
44

55
// Load dependencies
66
import should from 'should'; // eslint-disable-line no-unused-vars
@@ -84,42 +84,111 @@ describe('lib/FileSystem', function() {
8484

8585
});
8686

87-
it('#getFileNameFromUrl should create a sha1 filename from a PNG/JPG/GIF/BMP url.', () => {
87+
it('#getFileNameFromUrl should create a sha1 filename from a PNG/JPG/GIF/BMP url.', async () => {
8888

8989
const fileSystem = FileSystemFactory();
9090

91-
let pngFilename = fileSystem.getFileNameFromUrl('https://img.wennermedia.com/5333a62d-07db-432a-92e2-198cafa38a14-326adb1a-d8ed-4a5d-b37e-5c88883e1989.png');
91+
let pngFilename = await fileSystem.getFileNameFromUrl('https://img.wennermedia.com/5333a62d-07db-432a-92e2-198cafa38a14-326adb1a-d8ed-4a5d-b37e-5c88883e1989.png');
9292

9393
pngFilename.should.equal('cd7d2199cd8e088cdfd9c99fc6359666adc36289.png');
9494

95-
let gifFilename = fileSystem.getFileNameFromUrl('https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif');
95+
let gifFilename = await fileSystem.getFileNameFromUrl('https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif');
9696

9797
gifFilename.should.equal('c048132247cd28c7879ab36d78a8f45194640006.gif');
9898

99-
let jpgFilename = fileSystem.getFileNameFromUrl('https://cdn2.hubspot.net/hub/42284/file-14233687-jpg/images/test_in_red.jpg');
99+
let jpgFilename = await fileSystem.getFileNameFromUrl('https://cdn2.hubspot.net/hub/42284/file-14233687-jpg/images/test_in_red.jpg');
100100

101101
jpgFilename.should.equal('6adf4569ecc3bf8c378bb4d47b1995cd85c5a13c.jpg');
102102

103-
let bmpFilename = fileSystem.getFileNameFromUrl('https://cdn-learn.adafruit.com/assets/assets/000/010/147/original/tiger.bmp');
103+
let bmpFilename = await fileSystem.getFileNameFromUrl('https://cdn-learn.adafruit.com/assets/assets/000/010/147/original/tiger.bmp');
104104

105105
bmpFilename.should.equal('282fb62d2caff367aff828ce21e79575733605c8.bmp');
106106

107107
});
108108

109-
it('#getFileNameFromUrl should handle urls with same pathname but different query strings or fragments as individual files.', () => {
109+
it('#getFileNameFromUrl should handle urls with same pathname but different query strings or fragments as individual files.', async () => {
110110

111111
const fileSystem = FileSystemFactory();
112112

113-
const pngFilename = fileSystem.getFileNameFromUrl('https://img.wennermedia.com/5333a62d-07db-432a-92e2-198cafa38a14-326adb1a-d8ed-4a5d-b37e-5c88883e1989.png?exampleparam=one&anotherparam=2#this-is-a-fragment');
113+
const pngFilename = await fileSystem.getFileNameFromUrl('https://img.wennermedia.com/5333a62d-07db-432a-92e2-198cafa38a14-326adb1a-d8ed-4a5d-b37e-5c88883e1989.png?exampleparam=one&anotherparam=2#this-is-a-fragment');
114114

115115
pngFilename.should.equal('9eea25bf871c2333648080180f6b616a91ce1b09.png');
116116

117-
const pngFilenameTwo = fileSystem.getFileNameFromUrl('https://img.wennermedia.com/5333a62d-07db-432a-92e2-198cafa38a14-326adb1a-d8ed-4a5d-b37e-5c88883e1989.png?exampleparam=DIFFERENT&anotherparam=2#this-is-a-fragment-two');
117+
const pngFilenameTwo = await fileSystem.getFileNameFromUrl('https://img.wennermedia.com/5333a62d-07db-432a-92e2-198cafa38a14-326adb1a-d8ed-4a5d-b37e-5c88883e1989.png?exampleparam=DIFFERENT&anotherparam=2#this-is-a-fragment-two');
118118

119119
pngFilenameTwo.should.equal('09091b8880ddb982968a0fe28abed5034f9a43b8.png');
120120

121121
});
122122

123+
it('#getFileNameFromUrl should handle PNG/JPG/GIF/BMP urls without file extensions by using content-type header.', async () => {
124+
125+
const fileSystem = FileSystemFactory();
126+
127+
// Mock fetch
128+
fetch = jest.fn(); // eslint-disable-line no-global-assign
129+
130+
// Test PNG
131+
fetch.mockReturnValueOnce(Promise.resolve({
132+
headers: {
133+
get: (headerName) => {
134+
135+
headerName.should.equals('content-type');
136+
137+
return 'image/png';
138+
}
139+
}
140+
}));
141+
142+
const pngFilename = await fileSystem.getFileNameFromUrl('https://cdn2.hubspot.net/hub/42284/file-14233687-jpg/images/test_in_red');
143+
pngFilename.should.equal('831eb245a3d9032cdce450f8760d2b8ddb442a3d.png');
144+
145+
// Test JPG
146+
fetch.mockReturnValueOnce(Promise.resolve({
147+
headers: {
148+
get: (headerName) => {
149+
150+
headerName.should.equals('content-type');
151+
152+
return 'image/jpeg';
153+
}
154+
}
155+
}));
156+
157+
const jpgFilename = await fileSystem.getFileNameFromUrl('https://cdn2.hubspot.net/hub/42284/file-14233687-jpg/images/test_in_red');
158+
jpgFilename.should.equal('831eb245a3d9032cdce450f8760d2b8ddb442a3d.jpg');
159+
160+
// Test GIF
161+
fetch.mockReturnValueOnce(Promise.resolve({
162+
headers: {
163+
get: (headerName) => {
164+
165+
headerName.should.equals('content-type');
166+
167+
return 'image/gif';
168+
}
169+
}
170+
}));
171+
172+
const gifFilename = await fileSystem.getFileNameFromUrl('https://cdn2.hubspot.net/hub/42284/file-14233687-jpg/images/test_in_red');
173+
gifFilename.should.equal('831eb245a3d9032cdce450f8760d2b8ddb442a3d.gif');
174+
175+
// Test BMP
176+
fetch.mockReturnValueOnce(Promise.resolve({
177+
headers: {
178+
get: (headerName) => {
179+
180+
headerName.should.equals('content-type');
181+
182+
return 'image/bmp';
183+
}
184+
}
185+
}));
186+
187+
const bmpFilename = await fileSystem.getFileNameFromUrl('https://cdn2.hubspot.net/hub/42284/file-14233687-jpg/images/test_in_red');
188+
bmpFilename.should.equal('831eb245a3d9032cdce450f8760d2b8ddb442a3d.bmp');
189+
190+
});
191+
123192
it('#getLocalFilePathFromUrl should return local filepath if it exists on local fs in permanent dir.', () => {
124193

125194
const RNFetchBlob = require('react-native-fetch-blob');

0 commit comments

Comments
 (0)