diff --git a/.eslintrc b/.eslintrc index 5ad8bcd..7e41429 100644 --- a/.eslintrc +++ b/.eslintrc @@ -6,6 +6,6 @@ "r": "readonly" }, "parserOptions": { - "ecmaVersion": 2018 + "ecmaVersion": 2020 } } diff --git a/lib/remote-storage.js b/lib/remote-storage.js index d69bbba..f5741ce 100644 --- a/lib/remote-storage.js +++ b/lib/remote-storage.js @@ -149,23 +149,39 @@ module.exports = class RemoteStorage { } const content = await fs.readFile(file) const mimeType = mime.lookup(path.extname(file)) + // first we will grab it from the global config: htmlCacheDuration, etc. const cacheControlString = this._getCacheControlConfig(mimeType, appConfig.app) const uploadParams = { Bucket: this.bucket, Key: urlJoin(prefix, path.basename(file)), - Body: content, - CacheControl: cacheControlString + Body: content + } + // if we found it in the global config, we will use it ( for now ) + if (cacheControlString) { + uploadParams.CacheControl = cacheControlString } // add response headers if specified in manifest - const responseHeaders = this.getResponseHeadersForFile(file, distRoot, appConfig) - if (responseHeaders) { + const responseHeaders = this.getResponseHeadersForFile(file, distRoot, appConfig) ?? {} + // here we allow overriding the cache control if specified in response headers + // this is considered more specific than the general cache control config + // ideally we deprecate cache control config in favor of response headers directly + if (responseHeaders?.['adp-cache-control']) { + uploadParams.CacheControl = responseHeaders['adp-cache-control'] + delete responseHeaders['adp-cache-control'] + } + + if (appConfig.auditUserId) { + responseHeaders['adp-AuditUserId'] = appConfig.auditUserId + } + // we only set metadata if we have added anything to responseHeaders object + // it is not null, but could be empty + if (Object.keys(responseHeaders).length > 0) { uploadParams.Metadata = responseHeaders } // s3 misses some mime types like for css files if (mimeType) { uploadParams.ContentType = mimeType } - // Note: putObject is recommended for files < 100MB and has a limit of 5GB, which is ok for our use case of storing static web assets // if we intend to store larger files, we should use multipart upload and https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/modules/_aws_sdk_lib_storage.html return this.s3.putObject(uploadParams) @@ -190,6 +206,13 @@ module.exports = class RemoteStorage { return responseHeaders } + /** + * Checks if a header can be added to a file based on the rule + * @param {string} file - file path + * @param {string} distRoot - distribution root + * @param {string} rule - rule to check + * @returns {boolean} true if header can be added, false otherwise + */ canAddHeader (file, distRoot, rule) { const filePath = path.parse(file) const normalisedRule = rule.replace(/\//g, path.sep) @@ -198,7 +221,6 @@ module.exports = class RemoteStorage { if (folderPathToMatch.endsWith(path.sep)) { folderPathToMatch = folderPathToMatch.substring(0, folderPathToMatch.length - 1) // remove any trailing path separator } - if (rule === '/*') { // all content return true } else if (rule.endsWith('/*')) { // all content in a folder ex. /test/* @@ -293,17 +315,19 @@ module.exports = class RemoteStorage { * @param {Object} appConfig - application config */ _getCacheControlConfig (mimeType, appConfig) { - const cacheControlStr = 's-maxage=0' + const cacheControlStr = 's-maxage=60' if (!mimeType) { - return cacheControlStr + return null } else if (mimeType === mime.lookup('html')) { - return cacheControlStr + ', max-age=' + appConfig.htmlCacheDuration + return `${cacheControlStr}, max-age=${appConfig.htmlCacheDuration}` } else if (mimeType === mime.lookup('js')) { - return cacheControlStr + ', max-age=' + appConfig.jsCacheDuration + return `${cacheControlStr}, max-age=${appConfig.jsCacheDuration}` } else if (mimeType === mime.lookup('css')) { - return cacheControlStr + ', max-age=' + appConfig.cssCacheDuration + return `${cacheControlStr}, max-age=${appConfig.cssCacheDuration}` } else if (mimeType.startsWith('image')) { - return cacheControlStr + ', max-age=' + appConfig.imageCacheDuration - } else { return cacheControlStr } + return `${cacheControlStr}, max-age=${appConfig.imageCacheDuration}` + } else { + return null + } } } diff --git a/package.json b/package.json index 01f6fab..5a1139d 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "klaw": "^4", "lodash.clonedeep": "^4.5.0", "mime-types": "^2.1.24", - "parcel": "^2.7.0", + "parcel": "^2.15.4", "proxy-agent": "^6.3.0", "regenerator-runtime": "^0.13.7" }, diff --git a/test/lib/remote-storage.test.js b/test/lib/remote-storage.test.js index abae930..2a72b72 100644 --- a/test/lib/remote-storage.test.js +++ b/test/lib/remote-storage.test.js @@ -300,31 +300,31 @@ describe('RemoteStorage', () => { test('cachecontrol string for html', async () => { const rs = new RemoteStorage(global.fakeTVMResponse) const response = rs._getCacheControlConfig('text/html', global.fakeConfig.app) - expect(response).toBe('s-maxage=0, max-age=60') + expect(response).toBe('s-maxage=60, max-age=60') }) test('cachecontrol string for JS', async () => { const rs = new RemoteStorage(global.fakeTVMResponse) const response = rs._getCacheControlConfig('application/javascript', global.fakeConfig.app) - expect(response).toBe('s-maxage=0, max-age=604800') + expect(response).toBe('s-maxage=60, max-age=604800') }) test('cachecontrol string for CSS', async () => { const rs = new RemoteStorage(global.fakeTVMResponse) const response = rs._getCacheControlConfig('text/css', global.fakeConfig.app) - expect(response).toBe('s-maxage=0, max-age=604800') + expect(response).toBe('s-maxage=60, max-age=604800') }) test('cachecontrol string for Image', async () => { const rs = new RemoteStorage(global.fakeTVMResponse) const response = rs._getCacheControlConfig('image/jpeg', global.fakeConfig.app) - expect(response).toBe('s-maxage=0, max-age=604800') + expect(response).toBe('s-maxage=60, max-age=604800') }) test('cachecontrol string for default', async () => { const rs = new RemoteStorage(global.fakeTVMResponse) const response = rs._getCacheControlConfig('application/pdf', global.fakeConfig.app) - expect(response).toBe('s-maxage=0') + expect(response).toBe(null) }) // response header tests @@ -573,4 +573,93 @@ describe('RemoteStorage', () => { } expect(mockS3.putObject).toHaveBeenCalledWith(expect.objectContaining(expected)) }) + + test('Cache control override from response headers', async () => { + global.addFakeFiles(vol, 'fakeDir', { 'index.html': 'fake content' }) + const rs = new RemoteStorage(global.fakeTVMResponse) + const files = await rs.walkDir('fakeDir') + const fakeDistRoot = path.parse(files[0]).dir + const filePath = files[0] // Use absolute path from walkDir + const newConfig = global.configWithModifiedWeb(global.fakeConfig, { + 'response-headers': { + '/*.html': { + 'cache-control': 'max-age=3600, s-maxage=7200', + testHeader: 'generic-header' + } + } + }) + await rs.uploadFile(filePath, 'fakeprefix', newConfig, fakeDistRoot) + const body = Buffer.from('fake content', 'utf8') + const expected = { + Bucket: 'fake-bucket', + Key: 'fakeprefix/index.html', + Body: body, + ContentType: 'text/html', + CacheControl: 'max-age=3600, s-maxage=7200', + Metadata: { + 'adp-testHeader': 'generic-header' + } + } + expect(mockS3.putObject).toHaveBeenCalledWith(expect.objectContaining(expected)) + // Verify that adp-cache-control was removed from metadata + const putObjectCall = mockS3.putObject.mock.calls[0][0] + expect(putObjectCall.Metadata).not.toHaveProperty('adp-cache-control') + }) + + test('uploadFile includes auditUserId in metadata when set', async () => { + global.addFakeFiles(vol, 'fakeDir', { 'index.js': 'fake content' }) + const rs = new RemoteStorage(global.fakeTVMResponse) + const fakeConfig = { ...global.fakeConfig, auditUserId: 'test-user-123' } + await rs.uploadFile('fakeDir/index.js', 'fakeprefix', fakeConfig, 'fakeDir') + const body = Buffer.from('fake content', 'utf8') + const expected = { + Bucket: 'fake-bucket', + Key: 'fakeprefix/index.js', + Body: body, + ContentType: 'application/javascript', + Metadata: expect.objectContaining({ + 'adp-AuditUserId': 'test-user-123' + }) + } + expect(mockS3.putObject).toHaveBeenCalledWith(expect.objectContaining(expected)) + }) + + test('uploadFile does not set Metadata when responseHeaders is empty and auditUserId is not set', async () => { + global.addFakeFiles(vol, 'fakeDir', { 'index.js': 'fake content' }) + const rs = new RemoteStorage(global.fakeTVMResponse) + const fakeConfig = { + app: global.fakeConfig.app + // No web.response-headers and no auditUserId + } + await rs.uploadFile('fakeDir/index.js', 'fakeprefix', fakeConfig, 'fakeDir') + const body = Buffer.from('fake content', 'utf8') + const putObjectCall = mockS3.putObject.mock.calls[0][0] + expect(putObjectCall).not.toHaveProperty('Metadata') + expect(putObjectCall).toMatchObject({ + Bucket: 'fake-bucket', + Key: 'fakeprefix/index.js', + Body: body, + ContentType: 'application/javascript' + }) + }) + + test('uploadFile sets CacheControl even when responseHeaders is empty and auditUserId is not set', async () => { + global.addFakeFiles(vol, 'fakeDir', { 'index.html': 'fake content' }) + const rs = new RemoteStorage(global.fakeTVMResponse) + const fakeConfig = { + app: global.fakeConfig.app + // No web.response-headers and no auditUserId + } + await rs.uploadFile('fakeDir/index.html', 'fakeprefix', fakeConfig, 'fakeDir') + const body = Buffer.from('fake content', 'utf8') + const putObjectCall = mockS3.putObject.mock.calls[0][0] + expect(putObjectCall).not.toHaveProperty('Metadata') + expect(putObjectCall).toMatchObject({ + Bucket: 'fake-bucket', + Key: 'fakeprefix/index.html', + Body: body, + ContentType: 'text/html', + CacheControl: 's-maxage=60, max-age=60' + }) + }) })