diff --git a/package.json b/package.json index 88a5af1..3202ca1 100644 --- a/package.json +++ b/package.json @@ -97,16 +97,18 @@ "@fortawesome/fontawesome-svg-core": "^1.2.25", "@fortawesome/free-solid-svg-icons": "^5.11.2", "compression": "^1.7.4", + "etag": "^1.8.1", "express": "^4.17.1", "express-winston": "^4.0.5", "file-type": "^12.3.0", + "fresh": "^0.5.2", "fs-extra": "^9.0.1", "helmet": "^4.1.0", "json5": "^2.1.3", "lodash": "^4.17.20", "node-pty": "^0.9.0", + "parseurl": "^1.3.3", "sass": "^1.26.10", - "serve-favicon": "^2.5.0", "socket.io": "^2.3.0", "socket.io-client": "^2.3.0", "toastify-js": "^1.9.1", @@ -118,7 +120,9 @@ "devDependencies": { "@types/chai": "^4.2.12", "@types/compression": "^1.7.0", + "@types/etag": "^1.8.0", "@types/express": "^4.17.8", + "@types/fresh": "^0.5.0", "@types/fs-extra": "^9.0.1", "@types/helmet": "^0.0.48", "@types/jsdom": "^12.2.4", @@ -126,7 +130,7 @@ "@types/mocha": "^8.0.3", "@types/morgan": "^1.7.37", "@types/node": "^14.6.3", - "@types/serve-favicon": "^2.5.0", + "@types/parseurl": "^1.3.1", "@types/sinon": "^7.5.1", "@types/socket.io": "^2.1.11", "@types/socket.io-client": "^1.4.33", diff --git a/src/server/socketServer.ts b/src/server/socketServer.ts index e5fb112..748d885 100644 --- a/src/server/socketServer.ts +++ b/src/server/socketServer.ts @@ -32,7 +32,7 @@ export async function server( .use(`${basePath}/client`, serveStatic('client')) .use(winston.logger(logger)) .use(compression()) - .use(favicon) + .use(favicon(basePath)) .use(redirect) .use(policies(allowIframe)) .get(basePath, client) diff --git a/src/server/socketServer/html.ts b/src/server/socketServer/html.ts index 47785ed..ccdf72d 100644 --- a/src/server/socketServer/html.ts +++ b/src/server/socketServer/html.ts @@ -1,4 +1,4 @@ -import type express from 'express'; +import type { Request, Response, RequestHandler } from 'express'; import { isDev } from '../../shared/env.js'; const jsFiles = isDev ? ['dev', 'wetty'] : ['wetty']; @@ -6,6 +6,7 @@ const cssFiles = ['styles', 'options', 'overlay', 'terminal']; const render = ( title: string, + favicon: string, css: string[], js: string[], ): string => ` @@ -14,6 +15,7 @@ const render = ( + ${title} ${css.map(file => ``).join('\n')} @@ -38,14 +40,16 @@ const render = ( `; -export const html = (base: string, title: string) => ( - _req: express.Request, - res: express.Response, -) => +export const html = (base: string, title: string): RequestHandler => ( + _req: Request, + res: Response, +): void => { res.send( render( title, + `${base}/favicon.ico`, cssFiles.map(css => `${base}/assets/css/${css}.css`), jsFiles.map(js => `${base}/client/${js}.js`), ), ); +}; diff --git a/src/server/socketServer/middleware.ts b/src/server/socketServer/middleware.ts index ddef3e8..9d88494 100644 --- a/src/server/socketServer/middleware.ts +++ b/src/server/socketServer/middleware.ts @@ -1,15 +1,99 @@ -import type express from 'express'; -import { join } from 'path'; -import { default as _favicon } from 'serve-favicon'; +import type { Request, Response, NextFunction, RequestHandler } from 'express'; +import etag from 'etag'; +import fresh from 'fresh'; +import parseUrl from 'parseurl'; +import fs from 'fs'; +import { join, resolve } from 'path'; -export const favicon = _favicon(join('build', 'assets', 'favicon.ico')); +const ONE_YEAR_MS = 60 * 60 * 24 * 365 * 1000; // 1 year +/** + * Determine if the cached representation is fresh. + * @param req - server request + * @param res - server response + * @returns if the cache is fresh or not + */ +const isFresh = (req: Request, res: Response): boolean => + fresh(req.headers, { + etag: res.getHeader('ETag'), + 'last-modified': res.getHeader('Last-Modified'), + }); + +/** + * redirect requests with trailing / to remove it + * + * @param req - server request + * @param res - server response + * @param next - next middleware to call on finish + */ export function redirect( - req: express.Request, - res: express.Response, - next: Function, -) { + req: Request, + res: Response, + next: NextFunction, +): void { if (req.path.substr(-1) === '/' && req.path.length > 1) res.redirect(301, req.path.slice(0, -1) + req.url.slice(req.path.length)); else next(); } + +/** + * Serves the favicon located by the given `path`. + * + * @param basePath - server base path + * @returns middleware + */ +export function favicon(basePath: string): RequestHandler { + const path = resolve(join('build', 'assets', 'favicon.ico')); + return (req: Request, res: Response, next: NextFunction): void => { + if (getPathName(req) !== `${basePath}/favicon.ico`) { + next(); + return; + } + + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.statusCode = req.method === 'OPTIONS' ? 200 : 405; + res.setHeader('Allow', 'GET, HEAD, OPTIONS'); + res.setHeader('Content-Length', '0'); + res.end(); + return; + } + + fs.readFile(path, (err: Error | null, buf: Buffer) => { + if (err) return next(err); + Object.entries({ + 'Cache-Control': `public, max-age=${Math.floor(ONE_YEAR_MS / 1000)}`, + ETag: etag(buf), + }).forEach(([key, value]) => { + res.setHeader(key, value); + }); + + // Validate freshness + if (isFresh(req, res)) { + res.statusCode = 304; + return res.end(); + } + + // Send icon + res.statusCode = 200; + res.setHeader('Content-Length', buf.length); + res.setHeader('Content-Type', 'image/x-icon'); + return res.end(buf); + }); + }; +} + +/** + * Get the request pathname. + * + * @param requests + * @returns path name or undefined + */ + +function getPathName(req: Request): string | undefined { + try { + const url = parseUrl(req); + return url?.pathname ? url.pathname : undefined; + } catch (e) { + return undefined; + } +} diff --git a/yarn.lock b/yarn.lock index 5ed54bb..549b297 100644 --- a/yarn.lock +++ b/yarn.lock @@ -299,6 +299,13 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== +"@types/etag@^1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@types/etag/-/etag-1.8.0.tgz#37f0b1f3ea46da7ae319bbedb607e375b4c99f7e" + integrity sha512-EdSN0x+Y0/lBv7YAb8IU4Jgm6DWM+Bqtz7o5qozl96fzaqdqbdfHS5qjdpFeIv7xQ8jSLyjMMNShgYtMajEHyQ== + dependencies: + "@types/node" "*" + "@types/express-serve-static-core@*": version "4.17.12" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.12.tgz#9a487da757425e4f267e7d1c5720226af7f89591" @@ -318,6 +325,11 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/fresh@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@types/fresh/-/fresh-0.5.0.tgz#4d09231027d69c4369cfb01a9af5ef083d0d285f" + integrity sha512-eGPzuyc6wZM3sSHJdF7NM2jW6B/xsB014Rqg/iDa6xY02mlfy1w/TE2sYhR8vbHxkzJOXiGo6NuIk3xk35vsgQ== + "@types/fs-extra@^9.0.1": version "9.0.1" resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.1.tgz#91c8fc4c51f6d5dbe44c2ca9ab09310bd00c7918" @@ -405,6 +417,13 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/parseurl@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@types/parseurl/-/parseurl-1.3.1.tgz#e3cb1102160e48efa59f497c4ec22dee4f3b5b27" + integrity sha1-48sRAhYOSO+ln0l8TsIt7k87Wyc= + dependencies: + "@types/node" "*" + "@types/qs@*": version "6.9.4" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.4.tgz#a59e851c1ba16c0513ea123830dd639a0a15cb6a" @@ -429,13 +448,6 @@ dependencies: "@types/node" "*" -"@types/serve-favicon@^2.5.0": - version "2.5.0" - resolved "https://registry.yarnpkg.com/@types/serve-favicon/-/serve-favicon-2.5.0.tgz#21164e61290d577d75e22de1b3119fad70bf52b6" - integrity sha512-APK6i1tJp8XBYCZyU4HqtNZBiwipIBQvpQVLYZezTm4TaKKl0KrsGokQK9k3Ll2CaEGNuehppKhXp/Ki9oWT/w== - dependencies: - "@types/express" "*" - "@types/serve-static@*": version "1.13.5" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.5.tgz#3d25d941a18415d3ab092def846e135a08bbcf53" @@ -2398,7 +2410,7 @@ forwarded@~0.1.2: resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ= -fresh@0.5.2: +fresh@0.5.2, fresh@^0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= @@ -4111,7 +4123,7 @@ parseuri@0.0.5: dependencies: better-assert "~1.0.0" -parseurl@~1.3.2, parseurl@~1.3.3: +parseurl@^1.3.3, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== @@ -4703,11 +4715,6 @@ rxjs@^6.5.2, rxjs@^6.6.0, rxjs@^6.6.2: dependencies: tslib "^1.9.0" -safe-buffer@5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" - integrity sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg== - safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" @@ -4795,17 +4802,6 @@ serialize-javascript@4.0.0: dependencies: randombytes "^2.1.0" -serve-favicon@^2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/serve-favicon/-/serve-favicon-2.5.0.tgz#935d240cdfe0f5805307fdfe967d88942a2cbcf0" - integrity sha1-k10kDN/g9YBTB/3+ln2IlCosvPA= - dependencies: - etag "~1.8.1" - fresh "0.5.2" - ms "2.1.1" - parseurl "~1.3.2" - safe-buffer "5.1.1" - serve-static@1.14.1: version "1.14.1" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"