From 7261b769358c762ba26368ff94f31ab5d1d84fb8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 29 Dec 2021 16:52:59 +0100 Subject: [PATCH] app-backend: added static assets middleware + header constants Signed-off-by: Patrik Oldsberg --- .../createStaticAssetsStoreMiddleware.test.ts | 109 ++++++++++++++++++ .../createStaticAssetsStoreMiddleware.ts | 58 ++++++++++ plugins/app-backend/src/lib/assets/index.ts | 1 + plugins/app-backend/src/lib/headers.ts | 18 +++ 4 files changed, 186 insertions(+) create mode 100644 plugins/app-backend/src/lib/assets/createStaticAssetsStoreMiddleware.test.ts create mode 100644 plugins/app-backend/src/lib/assets/createStaticAssetsStoreMiddleware.ts create mode 100644 plugins/app-backend/src/lib/headers.ts diff --git a/plugins/app-backend/src/lib/assets/createStaticAssetsStoreMiddleware.test.ts b/plugins/app-backend/src/lib/assets/createStaticAssetsStoreMiddleware.test.ts new file mode 100644 index 0000000000..9a936a76cb --- /dev/null +++ b/plugins/app-backend/src/lib/assets/createStaticAssetsStoreMiddleware.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import request from 'supertest'; +import { createStaticAssetsStoreMiddleware } from './createStaticAssetsStoreMiddleware'; +import { StaticAssetsStore } from './StaticAssetsStore'; + +const mockStore = { + getAsset: jest.fn(), +} as unknown as jest.Mocked; + +describe('createStaticAssetsStoreMiddleware', () => { + const app = express(); + app.use(createStaticAssetsStoreMiddleware(mockStore)); + app.use((_req, res) => { + res.status(404).end('Not Found'); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should respond with an asset', async () => { + const now = new Date(); + mockStore.getAsset.mockResolvedValueOnce({ + path: 'foo.js', + lastModifiedAt: now, + content: Buffer.from('foo'), + }); + + const res = await request(app).get('/foo.js'); + + expect(res.status).toBe(200); + expect(res.text).toBe('foo'); + expect(res.get('Content-Type')).toBe( + 'application/javascript; charset=utf-8', + ); + expect(res.get('Content-Length')).toBe('3'); + expect(res.get('Cache-Control')).toBe('public, max-age=1209600'); + expect(res.get('Last-Modified')).toBe(now.toUTCString()); + + expect(mockStore.getAsset).toHaveBeenCalledTimes(1); + expect(mockStore.getAsset).toHaveBeenCalledWith('foo.js'); + }); + + mockStore.getAsset.mockResolvedValueOnce(undefined); + + it('should respond with not found', async () => { + const res = await request(app).get('/foo.js'); + + expect(res.status).toBe(404); + expect(res.text).toBe('Not Found'); + + expect(mockStore.getAsset).toHaveBeenCalledTimes(1); + expect(mockStore.getAsset).toHaveBeenCalledWith('foo.js'); + }); + + it('should handle other content type', async () => { + mockStore.getAsset.mockResolvedValueOnce({ + path: 'foo.css', + lastModifiedAt: new Date(), + content: Buffer.from('foo'), + }); + + const res = await request(app).get('/foo.css'); + + expect(res.status).toBe(200); + expect(res.text).toBe('foo'); + expect(res.get('Content-Type')).toBe('text/css; charset=utf-8'); + expect(res.get('Content-Length')).toBe('3'); + + expect(mockStore.getAsset).toHaveBeenCalledTimes(1); + expect(mockStore.getAsset).toHaveBeenLastCalledWith('foo.css'); + }); + + it('should handle unknown content types', async () => { + mockStore.getAsset.mockResolvedValueOnce({ + path: 'foo.notavalidextension', + lastModifiedAt: new Date(), + content: Buffer.from('foo'), + }); + + const res = await request(app).get('/foo.notavalidextension'); + + expect(res.status).toBe(200); + expect(res.body).toEqual(Buffer.from('foo')); + expect(res.get('Content-Type')).toBe('application/octet-stream'); + expect(res.get('Content-Length')).toBe('3'); + + expect(mockStore.getAsset).toHaveBeenCalledTimes(1); + expect(mockStore.getAsset).toHaveBeenLastCalledWith( + 'foo.notavalidextension', + ); + }); +}); diff --git a/plugins/app-backend/src/lib/assets/createStaticAssetsStoreMiddleware.ts b/plugins/app-backend/src/lib/assets/createStaticAssetsStoreMiddleware.ts new file mode 100644 index 0000000000..55fe30cbc3 --- /dev/null +++ b/plugins/app-backend/src/lib/assets/createStaticAssetsStoreMiddleware.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { extname } from 'path'; +import { RequestHandler } from 'express'; +import { StaticAssetsStore } from './StaticAssetsStore'; +import { CACHE_CONTROL_MAX_CACHE } from '../headers'; + +export function createStaticAssetsStoreMiddleware( + store: StaticAssetsStore, +): RequestHandler { + return (req, res, next) => { + if (req.method !== 'GET' && req.method !== 'HEAD') { + next(); + return; + } + + Promise.resolve( + (async () => { + // Drop leading slashes from the incoming path + const path = req.path.startsWith('/') ? req.path.slice(1) : req.path; + + const asset = await store.getAsset(path); + if (!asset) { + next(); + return; + } + + // Set the Content-Type header, falling back to octet-stream + const ext = extname(asset.path); + if (ext) { + res.type(ext); + } else { + res.type('bin'); + } + + // Same as our express.static override + res.setHeader('Cache-Control', CACHE_CONTROL_MAX_CACHE); + res.setHeader('Last-Modified', asset.lastModifiedAt.toUTCString()); + + res.send(asset.content); + })(), + ).catch(next); + }; +} diff --git a/plugins/app-backend/src/lib/assets/index.ts b/plugins/app-backend/src/lib/assets/index.ts index c8cb838dd6..cb83f153dd 100644 --- a/plugins/app-backend/src/lib/assets/index.ts +++ b/plugins/app-backend/src/lib/assets/index.ts @@ -17,3 +17,4 @@ export { StaticAssetsStore } from './StaticAssetsStore'; export type { StaticAsset, StaticAssetInput } from './types'; export { findStaticAssets } from './findStaticAssets'; +export { createStaticAssetsStoreMiddleware } from './createStaticAssetsStoreMiddleware'; diff --git a/plugins/app-backend/src/lib/headers.ts b/plugins/app-backend/src/lib/headers.ts new file mode 100644 index 0000000000..1bee2bbb37 --- /dev/null +++ b/plugins/app-backend/src/lib/headers.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const CACHE_CONTROL_NO_CACHE = 'no-store, max-age=0'; +export const CACHE_CONTROL_MAX_CACHE = 'public, max-age=1209600'; // 14 days