2020-12-08 03:20:59 -05:00
|
|
|
'use strict';
|
|
|
|
|
2011-05-28 21:05:43 +01:00
|
|
|
/**
|
2016-09-09 12:32:24 -07:00
|
|
|
* This Module manages all /minified/* requests. It controls the
|
|
|
|
* minification && compression of Javascript and CSS.
|
|
|
|
*/
|
2011-05-30 15:53:11 +01:00
|
|
|
|
|
|
|
/*
|
2011-08-11 15:26:41 +01:00
|
|
|
* 2011 Peter 'Pita' Martischka (Primary Technology Ltd)
|
2011-05-28 21:05:43 +01:00
|
|
|
*
|
|
|
|
* 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.
|
|
|
|
*/
|
|
|
|
|
2020-11-23 13:24:19 -05:00
|
|
|
const settings = require('./Settings');
|
2021-02-11 18:19:31 -05:00
|
|
|
const fs = require('fs').promises;
|
2020-11-23 13:24:19 -05:00
|
|
|
const path = require('path');
|
2020-12-08 03:20:59 -05:00
|
|
|
const plugins = require('../../static/js/pluginfw/plugin_defs');
|
2020-11-23 13:24:19 -05:00
|
|
|
const RequireKernel = require('etherpad-require-kernel');
|
|
|
|
const mime = require('mime-types');
|
|
|
|
const Threads = require('threads');
|
|
|
|
const log4js = require('log4js');
|
2021-05-08 17:40:36 -04:00
|
|
|
const sanitizePathname = require('./sanitizePathname');
|
2020-11-23 13:24:19 -05:00
|
|
|
|
|
|
|
const logger = log4js.getLogger('Minify');
|
|
|
|
|
2021-02-10 01:21:29 -05:00
|
|
|
const ROOT_DIR = path.join(settings.root, 'src/static/');
|
2020-11-23 13:24:19 -05:00
|
|
|
|
2020-12-08 03:20:59 -05:00
|
|
|
const threadsPool = new Threads.Pool(() => Threads.spawn(new Threads.Worker('./MinifyWorker')), 2);
|
2020-11-23 13:24:19 -05:00
|
|
|
|
|
|
|
const LIBRARY_WHITELIST = [
|
2020-10-02 18:43:12 -04:00
|
|
|
'async',
|
|
|
|
'js-cookie',
|
|
|
|
'security',
|
2021-06-05 03:34:55 -04:00
|
|
|
'split-grid',
|
2020-10-02 18:43:12 -04:00
|
|
|
'tinycon',
|
|
|
|
'underscore',
|
|
|
|
'unorm',
|
|
|
|
];
|
2012-05-11 18:01:10 -07:00
|
|
|
|
2012-09-03 14:37:10 -07:00
|
|
|
// What follows is a terrible hack to avoid loop-back within the server.
|
|
|
|
// TODO: Serve files from another service, or directly from the file system.
|
2021-04-21 16:24:27 -04:00
|
|
|
const requestURI = async (url, method, headers) => {
|
2021-02-11 19:44:43 -05:00
|
|
|
const parsedUrl = new URL(url);
|
2021-02-11 19:31:42 -05:00
|
|
|
let status = 500;
|
|
|
|
const content = [];
|
|
|
|
const mockRequest = {
|
|
|
|
url,
|
|
|
|
method,
|
2021-02-11 19:44:43 -05:00
|
|
|
params: {filename: (parsedUrl.pathname + parsedUrl.search).replace(/^\/static\//, '')},
|
2021-02-11 19:31:42 -05:00
|
|
|
headers,
|
|
|
|
};
|
2021-04-21 16:24:27 -04:00
|
|
|
let mockResponse;
|
|
|
|
const p = new Promise((resolve) => {
|
|
|
|
mockResponse = {
|
|
|
|
writeHead: (_status, _headers) => {
|
|
|
|
status = _status;
|
|
|
|
for (const header in _headers) {
|
|
|
|
if (Object.prototype.hasOwnProperty.call(_headers, header)) {
|
|
|
|
headers[header] = _headers[header];
|
|
|
|
}
|
2012-09-03 14:37:10 -07:00
|
|
|
}
|
2021-04-21 16:24:27 -04:00
|
|
|
},
|
|
|
|
setHeader: (header, value) => {
|
|
|
|
headers[header.toLowerCase()] = value.toString();
|
|
|
|
},
|
|
|
|
header: (header, value) => {
|
|
|
|
headers[header.toLowerCase()] = value.toString();
|
|
|
|
},
|
|
|
|
write: (_content) => {
|
|
|
|
_content && content.push(_content);
|
|
|
|
},
|
|
|
|
end: (_content) => {
|
|
|
|
_content && content.push(_content);
|
|
|
|
resolve([status, headers, content.join('')]);
|
|
|
|
},
|
|
|
|
};
|
|
|
|
});
|
|
|
|
await minify(mockRequest, mockResponse);
|
|
|
|
return await p;
|
|
|
|
};
|
2020-12-08 03:20:59 -05:00
|
|
|
|
|
|
|
const requestURIs = (locations, method, headers, callback) => {
|
2021-04-21 16:29:55 -04:00
|
|
|
Promise.all(locations.map(async (loc) => {
|
|
|
|
try {
|
|
|
|
return await requestURI(loc, method, headers);
|
|
|
|
} catch (err) {
|
|
|
|
logger.debug(`requestURI(${JSON.stringify(loc)}, ${JSON.stringify(method)}, ` +
|
|
|
|
`${JSON.stringify(headers)}) failed: ${err.stack || err}`);
|
|
|
|
return [500, headers, ''];
|
|
|
|
}
|
|
|
|
})).then((responses) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
const statuss = responses.map((x) => x[0]);
|
|
|
|
const headerss = responses.map((x) => x[1]);
|
|
|
|
const contentss = responses.map((x) => x[2]);
|
2012-09-03 14:37:10 -07:00
|
|
|
callback(statuss, headerss, contentss);
|
2021-02-11 17:48:48 -05:00
|
|
|
});
|
2020-12-08 03:20:59 -05:00
|
|
|
};
|
2012-09-03 14:37:10 -07:00
|
|
|
|
2021-02-28 05:21:45 -05:00
|
|
|
const compatPaths = {
|
|
|
|
'js/browser.js': 'js/vendors/browser.js',
|
|
|
|
'js/farbtastic.js': 'js/vendors/farbtastic.js',
|
|
|
|
'js/gritter.js': 'js/vendors/gritter.js',
|
|
|
|
'js/html10n.js': 'js/vendors/html10n.js',
|
|
|
|
'js/jquery.js': 'js/vendors/jquery.js',
|
|
|
|
'js/nice-select.js': 'js/vendors/nice-select.js',
|
|
|
|
};
|
|
|
|
|
2011-05-28 18:09:17 +01:00
|
|
|
/**
|
2011-07-26 17:39:25 +01:00
|
|
|
* creates the minifed javascript for the given minified name
|
2011-05-30 15:53:11 +01:00
|
|
|
* @param req the Express request
|
|
|
|
* @param res the Express response
|
2011-05-28 18:09:17 +01:00
|
|
|
*/
|
2021-02-11 17:15:31 -05:00
|
|
|
const minify = async (req, res) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
let filename = req.params.filename;
|
2021-02-25 00:38:14 -05:00
|
|
|
try {
|
|
|
|
filename = sanitizePathname(filename);
|
|
|
|
} catch (err) {
|
|
|
|
logger.error(`sanitization of pathname "${filename}" failed: ${err.stack || err}`);
|
2012-03-04 23:45:33 +01:00
|
|
|
res.writeHead(404, {});
|
|
|
|
res.end();
|
2016-09-09 12:32:24 -07:00
|
|
|
return;
|
2011-07-26 17:39:25 +01:00
|
|
|
}
|
2012-01-05 11:31:39 +01:00
|
|
|
|
2021-02-28 05:21:45 -05:00
|
|
|
// Backward compatibility for plugins that require() files from old paths.
|
|
|
|
const newLocation = compatPaths[filename.replace(/^plugins\/ep_etherpad-lite\/static\//, '')];
|
|
|
|
if (newLocation != null) {
|
|
|
|
logger.warn(`request for deprecated path "${filename}", replacing with "${newLocation}"`);
|
|
|
|
filename = newLocation;
|
2021-02-24 18:58:27 -05:00
|
|
|
}
|
|
|
|
|
2012-05-11 18:01:10 -07:00
|
|
|
/* Handle static files for plugins/libraries:
|
2012-03-10 14:03:29 -08:00
|
|
|
paths like "plugins/ep_myplugin/static/js/test.js"
|
|
|
|
are rewritten into ROOT_PATH_OF_MYPLUGIN/static/js/test.js,
|
|
|
|
commonly ETHERPAD_ROOT/node_modules/ep_myplugin/static/js/test.js
|
|
|
|
*/
|
2020-12-08 03:20:59 -05:00
|
|
|
const match = filename.match(/^plugins\/([^/]+)(\/(?:(static\/.*)|.*))?$/);
|
2012-03-10 14:03:29 -08:00
|
|
|
if (match) {
|
2020-11-23 13:24:19 -05:00
|
|
|
const library = match[1];
|
|
|
|
const libraryPath = match[2] || '';
|
2012-05-11 18:01:10 -07:00
|
|
|
|
|
|
|
if (plugins.plugins[library] && match[3]) {
|
2020-11-23 13:24:19 -05:00
|
|
|
const plugin = plugins.plugins[library];
|
|
|
|
const pluginPath = plugin.package.realPath;
|
2021-03-01 17:00:05 -05:00
|
|
|
filename = path.join(pluginPath, libraryPath);
|
2021-02-25 00:38:14 -05:00
|
|
|
// On Windows, path.relative converts forward slashes to backslashes. Convert them back
|
|
|
|
// because some of the code below assumes forward slashes. Node.js treats both the backlash
|
|
|
|
// and the forward slash characters as pathname component separators on Windows so this does
|
|
|
|
// not change the meaning of the pathname. This conversion does not introduce a directory
|
|
|
|
// traversal vulnerability because all '..\\' substrings have already been removed by
|
|
|
|
// sanitizePathname.
|
|
|
|
filename = filename.replace(/\\/g, '/');
|
2020-12-08 03:20:59 -05:00
|
|
|
} else if (LIBRARY_WHITELIST.indexOf(library) !== -1) {
|
2012-05-11 18:01:10 -07:00
|
|
|
// Go straight into node_modules
|
|
|
|
// Avoid `require.resolve()`, since 'mustache' and 'mustache/index.js'
|
|
|
|
// would end up resolving to logically distinct resources.
|
2021-02-25 00:20:31 -05:00
|
|
|
filename = path.join('../node_modules/', library, libraryPath);
|
2012-03-10 14:03:29 -08:00
|
|
|
}
|
|
|
|
}
|
2021-11-23 21:06:16 -05:00
|
|
|
const [, testf] = /^plugins\/ep_etherpad-lite\/(tests\/frontend\/.*)/.exec(filename) || [];
|
|
|
|
if (testf != null) filename = `../${testf}`;
|
2012-03-10 14:03:29 -08:00
|
|
|
|
2020-11-23 13:24:19 -05:00
|
|
|
const contentType = mime.lookup(filename);
|
2012-02-26 22:01:52 +01:00
|
|
|
|
2021-02-11 19:11:50 -05:00
|
|
|
const [date, exists] = await statFile(filename, 3);
|
2021-02-11 17:15:31 -05:00
|
|
|
if (date) {
|
|
|
|
date.setMilliseconds(0);
|
|
|
|
res.setHeader('last-modified', date.toUTCString());
|
|
|
|
res.setHeader('date', (new Date()).toUTCString());
|
|
|
|
if (settings.maxAge !== undefined) {
|
|
|
|
const expiresDate = new Date(Date.now() + settings.maxAge * 1000);
|
|
|
|
res.setHeader('expires', expiresDate.toUTCString());
|
|
|
|
res.setHeader('cache-control', `max-age=${settings.maxAge}`);
|
2012-03-04 23:45:33 +01:00
|
|
|
}
|
2021-02-11 17:15:31 -05:00
|
|
|
}
|
2012-01-14 21:42:47 -08:00
|
|
|
|
2021-02-11 17:15:31 -05:00
|
|
|
if (!exists) {
|
|
|
|
res.writeHead(404, {});
|
|
|
|
res.end();
|
|
|
|
} else if (new Date(req.headers['if-modified-since']) >= date) {
|
|
|
|
res.writeHead(304, {});
|
|
|
|
res.end();
|
|
|
|
} else if (req.method === 'HEAD') {
|
|
|
|
res.header('Content-Type', contentType);
|
|
|
|
res.writeHead(200, {});
|
|
|
|
res.end();
|
|
|
|
} else if (req.method === 'GET') {
|
2021-02-11 19:11:50 -05:00
|
|
|
const content = await getFileCompressed(filename, contentType);
|
2021-02-11 17:15:31 -05:00
|
|
|
res.header('Content-Type', contentType);
|
|
|
|
res.writeHead(200, {});
|
|
|
|
res.write(content);
|
|
|
|
res.end();
|
|
|
|
} else {
|
|
|
|
res.writeHead(405, {allow: 'HEAD, GET'});
|
|
|
|
res.end();
|
|
|
|
}
|
2020-12-08 03:20:59 -05:00
|
|
|
};
|
2011-05-28 18:09:17 +01:00
|
|
|
|
2012-03-04 23:45:33 +01:00
|
|
|
// Check for the existance of the file and get the last modification date.
|
2021-02-11 16:54:25 -05:00
|
|
|
const statFile = async (filename, dirStatLimit) => {
|
2019-03-26 23:58:14 +01:00
|
|
|
/*
|
|
|
|
* The only external call to this function provides an explicit value for
|
|
|
|
* dirStatLimit: this check could be removed.
|
|
|
|
*/
|
2012-09-11 22:25:44 -07:00
|
|
|
if (typeof dirStatLimit === 'undefined') {
|
|
|
|
dirStatLimit = 3;
|
|
|
|
}
|
|
|
|
|
2020-12-08 03:20:59 -05:00
|
|
|
if (dirStatLimit < 1 || filename === '' || filename === '/') {
|
2021-02-11 16:54:25 -05:00
|
|
|
return [null, false];
|
2020-12-08 03:20:59 -05:00
|
|
|
} else if (filename === 'js/ace.js') {
|
2012-03-04 23:45:33 +01:00
|
|
|
// Sometimes static assets are inlined into this file, so we have to stat
|
|
|
|
// everything.
|
2021-02-11 16:54:25 -05:00
|
|
|
return [await lastModifiedDateOfEverything(), true];
|
2020-12-08 03:20:59 -05:00
|
|
|
} else if (filename === 'js/require-kernel.js') {
|
2021-02-11 18:42:27 -05:00
|
|
|
return [_requireLastModified, true];
|
2012-03-04 23:45:33 +01:00
|
|
|
} else {
|
2021-02-11 16:54:25 -05:00
|
|
|
let stats;
|
|
|
|
try {
|
2021-03-01 17:00:05 -05:00
|
|
|
stats = await fs.stat(path.resolve(ROOT_DIR, filename));
|
2021-02-11 16:54:25 -05:00
|
|
|
} catch (err) {
|
2021-04-21 16:26:24 -04:00
|
|
|
if (['ENOENT', 'ENOTDIR'].includes(err.code)) {
|
2021-02-11 16:54:25 -05:00
|
|
|
// Stat the directory instead.
|
|
|
|
const [date] = await statFile(path.dirname(filename), dirStatLimit - 1);
|
|
|
|
return [date, false];
|
2012-01-22 18:29:00 -08:00
|
|
|
}
|
2021-02-11 16:54:25 -05:00
|
|
|
throw err;
|
|
|
|
}
|
2021-02-11 18:42:27 -05:00
|
|
|
return [stats.mtime, stats.isFile()];
|
2012-03-04 23:45:33 +01:00
|
|
|
}
|
2020-12-08 03:20:59 -05:00
|
|
|
};
|
|
|
|
|
2021-02-11 16:26:03 -05:00
|
|
|
const lastModifiedDateOfEverything = async () => {
|
2021-02-25 00:20:31 -05:00
|
|
|
const folders2check = [path.join(ROOT_DIR, 'js/'), path.join(ROOT_DIR, 'css/')];
|
2021-02-11 18:42:27 -05:00
|
|
|
let latestModification = null;
|
2021-02-03 00:30:07 +01:00
|
|
|
// go through this two folders
|
2021-02-25 00:20:31 -05:00
|
|
|
await Promise.all(folders2check.map(async (dir) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
// read the files in the folder
|
2021-02-25 00:20:31 -05:00
|
|
|
const files = await fs.readdir(dir);
|
2020-11-23 13:24:19 -05:00
|
|
|
|
2021-02-11 15:16:45 -05:00
|
|
|
// we wanna check the directory itself for changes too
|
|
|
|
files.push('.');
|
2020-11-23 13:24:19 -05:00
|
|
|
|
2021-02-11 15:16:45 -05:00
|
|
|
// go through all files in this folder
|
|
|
|
await Promise.all(files.map(async (filename) => {
|
|
|
|
// get the stat data of this file
|
2021-02-25 00:20:31 -05:00
|
|
|
const stats = await fs.stat(path.join(dir, filename));
|
2020-11-23 13:24:19 -05:00
|
|
|
|
2021-02-11 15:16:45 -05:00
|
|
|
// compare the modification time to the highest found
|
2021-02-11 18:42:27 -05:00
|
|
|
if (latestModification == null || stats.mtime > latestModification) {
|
|
|
|
latestModification = stats.mtime;
|
2021-02-11 15:16:45 -05:00
|
|
|
}
|
|
|
|
}));
|
2021-02-11 16:26:03 -05:00
|
|
|
}));
|
|
|
|
return latestModification;
|
2020-12-08 03:20:59 -05:00
|
|
|
};
|
2012-01-22 18:29:00 -08:00
|
|
|
|
2012-03-04 23:45:33 +01:00
|
|
|
// This should be provided by the module, but until then, just use startup
|
|
|
|
// time.
|
2020-11-23 13:24:19 -05:00
|
|
|
const _requireLastModified = new Date();
|
2020-12-08 03:20:59 -05:00
|
|
|
const requireDefinition = () => `var require = ${RequireKernel.kernelSource};\n`;
|
2012-01-15 22:07:45 -08:00
|
|
|
|
2021-02-11 17:10:13 -05:00
|
|
|
const getFileCompressed = async (filename, contentType) => {
|
|
|
|
let content = await getFile(filename);
|
|
|
|
if (!content || !settings.minify) {
|
|
|
|
return content;
|
|
|
|
} else if (contentType === 'application/javascript') {
|
|
|
|
return await new Promise((resolve) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
threadsPool.queue(async ({compressJS}) => {
|
2020-06-04 15:00:50 +02:00
|
|
|
try {
|
2020-11-23 13:24:19 -05:00
|
|
|
logger.info('Compress JS file %s.', filename);
|
2020-06-04 15:00:50 +02:00
|
|
|
|
|
|
|
content = content.toString();
|
|
|
|
const compressResult = await compressJS(content);
|
|
|
|
|
|
|
|
if (compressResult.error) {
|
2020-06-07 20:09:10 +00:00
|
|
|
console.error(`Error compressing JS (${filename}) using terser`, compressResult.error);
|
2020-06-04 15:00:50 +02:00
|
|
|
} else {
|
|
|
|
content = compressResult.code.toString(); // Convert content obj code to string
|
|
|
|
}
|
|
|
|
} catch (error) {
|
2020-12-08 03:20:59 -05:00
|
|
|
console.error('getFile() returned an error in ' +
|
|
|
|
`getFileCompressed(${filename}, ${contentType}): ${error}`);
|
2020-04-03 00:05:15 +00:00
|
|
|
}
|
2021-02-11 17:10:13 -05:00
|
|
|
resolve(content);
|
2020-11-23 13:24:19 -05:00
|
|
|
});
|
2021-02-11 17:10:13 -05:00
|
|
|
});
|
|
|
|
} else if (contentType === 'text/css') {
|
|
|
|
return await new Promise((resolve) => {
|
2020-11-23 13:24:19 -05:00
|
|
|
threadsPool.queue(async ({compressCSS}) => {
|
2020-06-04 15:00:50 +02:00
|
|
|
try {
|
2020-11-23 13:24:19 -05:00
|
|
|
logger.info('Compress CSS file %s.', filename);
|
2020-06-04 15:00:50 +02:00
|
|
|
|
|
|
|
content = await compressCSS(filename, ROOT_DIR);
|
|
|
|
} catch (error) {
|
|
|
|
console.error(`CleanCSS.minify() returned an error on ${filename}: ${error}`);
|
|
|
|
}
|
2021-02-11 17:10:13 -05:00
|
|
|
resolve(content);
|
2020-11-23 13:24:19 -05:00
|
|
|
});
|
2021-02-11 17:10:13 -05:00
|
|
|
});
|
|
|
|
} else {
|
|
|
|
return content;
|
|
|
|
}
|
2020-12-08 03:20:59 -05:00
|
|
|
};
|
2012-01-15 17:23:48 -08:00
|
|
|
|
2021-02-11 16:32:29 -05:00
|
|
|
const getFile = async (filename) => {
|
|
|
|
if (filename === 'js/require-kernel.js') return requireDefinition();
|
2021-03-01 17:00:05 -05:00
|
|
|
return await fs.readFile(path.resolve(ROOT_DIR, filename));
|
2020-12-08 03:20:59 -05:00
|
|
|
};
|
2012-03-04 23:45:33 +01:00
|
|
|
|
2021-02-11 17:15:31 -05:00
|
|
|
exports.minify = (req, res, next) => minify(req, res).catch((err) => next(err || new Error(err)));
|
2012-09-03 14:37:10 -07:00
|
|
|
|
|
|
|
exports.requestURIs = requestURIs;
|
2020-09-21 00:42:29 -04:00
|
|
|
|
|
|
|
exports.shutdown = async (hookName, context) => {
|
|
|
|
await threadsPool.terminate();
|
|
|
|
};
|