Files
meteor/packages/webapp/webapp_server.js
Welkin Wong fcb317a31f fix(webapp): prevent truncated agent string in lookupUserAgent (#13686)
* fix(webapp): prevent truncated agent string in lookupUserAgent

In useragent-ng's lookup function implementation, there are security validations and truncation checks for useragent length.  For certain useragent use cases where the length exceeds 150 characters, premature truncation before being passed to the lookup function results in incorrect agent identification.

* test(webapp): add tests for agent identification including special user agent
2025-04-10 08:57:00 -03:00

1559 lines
51 KiB
JavaScript

import assert from 'assert';
import { readFileSync, chmodSync, chownSync } from 'fs';
import { createServer } from 'http';
import { userInfo } from 'os';
import { join as pathJoin, dirname as pathDirname } from 'path';
import { parse as parseUrl } from 'url';
import { createHash } from 'crypto';
import express from 'express';
import compress from 'compression';
import cookieParser from 'cookie-parser';
import qs from 'qs';
import parseRequest from 'parseurl';
import { lookup as lookupUserAgent } from 'useragent-ng';
import { isModern } from 'meteor/modern-browsers';
import send from 'send';
import {
removeExistingSocketFile,
registerSocketFileCleanup,
} from './socket_file.js';
import cluster from 'cluster';
import { execSync } from 'child_process';
var SHORT_SOCKET_TIMEOUT = 5 * 1000;
var LONG_SOCKET_TIMEOUT = 120 * 1000;
const createExpressApp = () => {
const app = express();
// Security and performace headers
// these headers come from these docs: https://expressjs.com/en/api.html#app.settings.table
app.set('x-powered-by', false);
app.set('etag', false);
app.set('query parser', qs.parse);
return app;
}
export const WebApp = {};
export const WebAppInternals = {};
const hasOwn = Object.prototype.hasOwnProperty;
WebAppInternals.NpmModules = {
express : {
version: Npm.require('express/package.json').version,
module: express,
}
};
// More of a convenience for the end user
WebApp.express = express;
// Though we might prefer to use web.browser (modern) as the default
// architecture, safety requires a more compatible defaultArch.
WebApp.defaultArch = 'web.browser.legacy';
// XXX maps archs to manifests
WebApp.clientPrograms = {};
// XXX maps archs to program path on filesystem
var archPath = {};
var bundledJsCssUrlRewriteHook = function(url) {
var bundledPrefix = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || '';
return bundledPrefix + url;
};
var sha1 = function(contents) {
var hash = createHash('sha1');
hash.update(contents);
return hash.digest('hex');
};
function shouldCompress(req, res) {
if (req.headers['x-no-compression']) {
// don't compress responses with this request header
return false;
}
// fallback to standard filter function
return compress.filter(req, res);
}
// #BrowserIdentification
//
// We have multiple places that want to identify the browser: the
// unsupported browser page, the appcache package, and, eventually
// delivering browser polyfills only as needed.
//
// To avoid detecting the browser in multiple places ad-hoc, we create a
// Meteor "browser" object. It uses but does not expose the npm
// useragent module (we could choose a different mechanism to identify
// the browser in the future if we wanted to). The browser object
// contains
//
// * `name`: the name of the browser in camel case
// * `major`, `minor`, `patch`: integers describing the browser version
//
// Also here is an early version of a Meteor `request` object, intended
// to be a high-level description of the request without exposing
// details of Express's low-level `req`. Currently it contains:
//
// * `browser`: browser identification object described above
// * `url`: parsed url, including parsed query params
//
// As a temporary hack there is a `categorizeRequest` function on WebApp which
// converts a Express `req` to a Meteor `request`. This can go away once smart
// packages such as appcache are being passed a `request` object directly when
// they serve content.
//
// This allows `request` to be used uniformly: it is passed to the html
// attributes hook, and the appcache package can use it when deciding
// whether to generate a 404 for the manifest.
//
// Real routing / server side rendering will probably refactor this
// heavily.
// e.g. "Mobile Safari" => "mobileSafari"
var camelCase = function(name) {
var parts = name.split(' ');
parts[0] = parts[0].toLowerCase();
for (var i = 1; i < parts.length; ++i) {
parts[i] = parts[i].charAt(0).toUpperCase() + parts[i].substring(1);
}
return parts.join('');
};
var identifyBrowser = function(userAgentString) {
if (!userAgentString) {
return {
name: 'unknown',
major: 0,
minor: 0,
patch: 0
};
}
var userAgent = lookupUserAgent(userAgentString);
return {
name: camelCase(userAgent.family),
major: +userAgent.major,
minor: +userAgent.minor,
patch: +userAgent.patch,
};
};
// XXX Refactor as part of implementing real routing.
WebAppInternals.identifyBrowser = identifyBrowser;
WebApp.categorizeRequest = function(req) {
if (req.browser && req.arch && typeof req.modern === 'boolean') {
// Already categorized.
return req;
}
const browser = identifyBrowser(req.headers['user-agent']);
const modern = isModern(browser);
const path =
typeof req.pathname === 'string'
? req.pathname
: parseRequest(req).pathname;
const categorized = {
browser,
modern,
path,
arch: WebApp.defaultArch,
url: parseUrl(req.url, true),
dynamicHead: req.dynamicHead,
dynamicBody: req.dynamicBody,
headers: req.headers,
cookies: req.cookies,
};
const pathParts = path.split('/');
const archKey = pathParts[1];
if (archKey.startsWith('__')) {
const archCleaned = 'web.' + archKey.slice(2);
if (hasOwn.call(WebApp.clientPrograms, archCleaned)) {
pathParts.splice(1, 1); // Remove the archKey part.
return Object.assign(categorized, {
arch: archCleaned,
path: pathParts.join('/'),
});
}
}
// TODO Perhaps one day we could infer Cordova clients here, so that we
// wouldn't have to use prefixed "/__cordova/..." URLs.
const preferredArchOrder = isModern(browser)
? ['web.browser', 'web.browser.legacy']
: ['web.browser.legacy', 'web.browser'];
for (const arch of preferredArchOrder) {
// If our preferred arch is not available, it's better to use another
// client arch that is available than to guarantee the site won't work
// by returning an unknown arch. For example, if web.browser.legacy is
// excluded using the --exclude-archs command-line option, legacy
// clients are better off receiving web.browser (which might actually
// work) than receiving an HTTP 404 response. If none of the archs in
// preferredArchOrder are defined, only then should we send a 404.
if (hasOwn.call(WebApp.clientPrograms, arch)) {
return Object.assign(categorized, { arch });
}
}
return categorized;
};
// HTML attribute hooks: functions to be called to determine any attributes to
// be added to the '<html>' tag. Each function is passed a 'request' object (see
// #BrowserIdentification) and should return null or object.
var htmlAttributeHooks = [];
var getHtmlAttributes = function(request) {
var combinedAttributes = {};
(htmlAttributeHooks || []).forEach(function(hook) {
var attributes = hook(request);
if (attributes === null) return;
if (typeof attributes !== 'object')
throw Error('HTML attribute hook must return null or object');
Object.assign(combinedAttributes, attributes);
});
return combinedAttributes;
};
WebApp.addHtmlAttributeHook = function(hook) {
htmlAttributeHooks.push(hook);
};
// Serve app HTML for this URL?
var appUrl = function(url) {
if (url === '/favicon.ico' || url === '/robots.txt') return false;
// NOTE: app.manifest is not a web standard like favicon.ico and
// robots.txt. It is a file name we have chosen to use for HTML5
// appcache URLs. It is included here to prevent using an appcache
// then removing it from poisoning an app permanently. Eventually,
// once we have server side routing, this won't be needed as
// unknown URLs with return a 404 automatically.
if (url === '/app.manifest') return false;
// Avoid serving app HTML for declared routes such as /sockjs/.
if (RoutePolicy.classify(url)) return false;
// we currently return app HTML on all URLs by default
return true;
};
// We need to calculate the client hash after all packages have loaded
// to give them a chance to populate __meteor_runtime_config__.
//
// Calculating the hash during startup means that packages can only
// populate __meteor_runtime_config__ during load, not during startup.
//
// Calculating instead it at the beginning of main after all startup
// hooks had run would allow packages to also populate
// __meteor_runtime_config__ during startup, but that's too late for
// autoupdate because it needs to have the client hash at startup to
// insert the auto update version itself into
// __meteor_runtime_config__ to get it to the client.
//
// An alternative would be to give autoupdate a "post-start,
// pre-listen" hook to allow it to insert the auto update version at
// the right moment.
Meteor.startup(function() {
function getter(key) {
return function(arch) {
arch = arch || WebApp.defaultArch;
const program = WebApp.clientPrograms[arch];
const value = program && program[key];
// If this is the first time we have calculated this hash,
// program[key] will be a thunk (lazy function with no parameters)
// that we should call to do the actual computation.
return typeof value === 'function' ? (program[key] = value()) : value;
};
}
WebApp.calculateClientHash = WebApp.clientHash = getter('version');
WebApp.calculateClientHashRefreshable = getter('versionRefreshable');
WebApp.calculateClientHashNonRefreshable = getter('versionNonRefreshable');
WebApp.calculateClientHashReplaceable = getter('versionReplaceable');
WebApp.getRefreshableAssets = getter('refreshableAssets');
});
// When we have a request pending, we want the socket timeout to be long, to
// give ourselves a while to serve it, and to allow sockjs long polls to
// complete. On the other hand, we want to close idle sockets relatively
// quickly, so that we can shut down relatively promptly but cleanly, without
// cutting off anyone's response.
WebApp._timeoutAdjustmentRequestCallback = function(req, res) {
// this is really just req.socket.setTimeout(LONG_SOCKET_TIMEOUT);
req.setTimeout(LONG_SOCKET_TIMEOUT);
// Insert our new finish listener to run BEFORE the existing one which removes
// the response from the socket.
var finishListeners = res.listeners('finish');
// XXX Apparently in Node 0.12 this event was called 'prefinish'.
// https://github.com/joyent/node/commit/7c9b6070
// But it has switched back to 'finish' in Node v4:
// https://github.com/nodejs/node/pull/1411
res.removeAllListeners('finish');
res.on('finish', function() {
res.setTimeout(SHORT_SOCKET_TIMEOUT);
});
Object.values(finishListeners).forEach(function(l) {
res.on('finish', l);
});
};
// Will be updated by main before we listen.
// Map from client arch to boilerplate object.
// Boilerplate object has:
// - func: XXX
// - baseData: XXX
var boilerplateByArch = {};
// Register a callback function that can selectively modify boilerplate
// data given arguments (request, data, arch). The key should be a unique
// identifier, to prevent accumulating duplicate callbacks from the same
// call site over time. Callbacks will be called in the order they were
// registered. A callback should return false if it did not make any
// changes affecting the boilerplate. Passing null deletes the callback.
// Any previous callback registered for this key will be returned.
const boilerplateDataCallbacks = Object.create(null);
WebAppInternals.registerBoilerplateDataCallback = function(key, callback) {
const previousCallback = boilerplateDataCallbacks[key];
if (typeof callback === 'function') {
boilerplateDataCallbacks[key] = callback;
} else {
assert.strictEqual(callback, null);
delete boilerplateDataCallbacks[key];
}
// Return the previous callback in case the new callback needs to call
// it; for example, when the new callback is a wrapper for the old.
return previousCallback || null;
};
// Given a request (as returned from `categorizeRequest`), return the
// boilerplate HTML to serve for that request.
//
// If a previous Express middleware has rendered content for the head or body,
// returns the boilerplate with that content patched in otherwise
// memoizes on HTML attributes (used by, eg, appcache) and whether inline
// scripts are currently allowed.
// XXX so far this function is always called with arch === 'web.browser'
function getBoilerplate(request, arch) {
return getBoilerplateAsync(request, arch);
}
/**
* @summary Takes a runtime configuration object and
* returns an encoded runtime string.
* @locus Server
* @param {Object} rtimeConfig
* @returns {String}
*/
WebApp.encodeRuntimeConfig = function(rtimeConfig) {
return JSON.stringify(encodeURIComponent(JSON.stringify(rtimeConfig)));
};
/**
* @summary Takes an encoded runtime string and returns
* a runtime configuration object.
* @locus Server
* @param {String} rtimeConfigString
* @returns {Object}
*/
WebApp.decodeRuntimeConfig = function(rtimeConfigStr) {
return JSON.parse(decodeURIComponent(JSON.parse(rtimeConfigStr)));
};
const runtimeConfig = {
// hooks will contain the callback functions
// set by the caller to addRuntimeConfigHook
hooks: new Hook(),
// updateHooks will contain the callback functions
// set by the caller to addUpdatedNotifyHook
updateHooks: new Hook(),
// isUpdatedByArch is an object containing fields for each arch
// that this server supports.
// - Each field will be true when the server updates the runtimeConfig for that arch.
// - When the hook callback is called the update field in the callback object will be
// set to isUpdatedByArch[arch].
// = isUpdatedyByArch[arch] is reset to false after the callback.
// This enables the caller to cache data efficiently so they do not need to
// decode & update data on every callback when the runtimeConfig is not changing.
isUpdatedByArch: {},
};
/**
* @name addRuntimeConfigHookCallback(options)
* @locus Server
* @isprototype true
* @summary Callback for `addRuntimeConfigHook`.
*
* If the handler returns a _falsy_ value the hook will not
* modify the runtime configuration.
*
* If the handler returns a _String_ the hook will substitute
* the string for the encoded configuration string.
*
* **Warning:** the hook does not check the return value at all it is
* the responsibility of the caller to get the formatting correct using
* the helper functions.
*
* `addRuntimeConfigHookCallback` takes only one `Object` argument
* with the following fields:
* @param {Object} options
* @param {String} options.arch The architecture of the client
* requesting a new runtime configuration. This can be one of
* `web.browser`, `web.browser.legacy` or `web.cordova`.
* @param {Object} options.request
* A NodeJs [IncomingMessage](https://nodejs.org/api/http.html#http_class_http_incomingmessage)
* https://nodejs.org/api/http.html#http_class_http_incomingmessage
* `Object` that can be used to get information about the incoming request.
* @param {String} options.encodedCurrentConfig The current configuration object
* encoded as a string for inclusion in the root html.
* @param {Boolean} options.updated `true` if the config for this architecture
* has been updated since last called, otherwise `false`. This flag can be used
* to cache the decoding/encoding for each architecture.
*/
/**
* @summary Hook that calls back when the meteor runtime configuration,
* `__meteor_runtime_config__` is being sent to any client.
*
* **returns**: <small>_Object_</small> `{ stop: function, callback: function }`
* - `stop` <small>_Function_</small> Call `stop()` to stop getting callbacks.
* - `callback` <small>_Function_</small> The passed in `callback`.
* @locus Server
* @param {addRuntimeConfigHookCallback} callback
* See `addRuntimeConfigHookCallback` description.
* @returns {Object} {{ stop: function, callback: function }}
* Call the returned `stop()` to stop getting callbacks.
* The passed in `callback` is returned also.
*/
WebApp.addRuntimeConfigHook = function(callback) {
return runtimeConfig.hooks.register(callback);
};
async function getBoilerplateAsync(request, arch) {
let boilerplate = boilerplateByArch[arch];
await runtimeConfig.hooks.forEachAsync(async hook => {
const meteorRuntimeConfig = await hook({
arch,
request,
encodedCurrentConfig: boilerplate.baseData.meteorRuntimeConfig,
updated: runtimeConfig.isUpdatedByArch[arch],
});
if (!meteorRuntimeConfig) return true;
boilerplate.baseData = Object.assign({}, boilerplate.baseData, {
meteorRuntimeConfig,
});
return true;
});
runtimeConfig.isUpdatedByArch[arch] = false;
const { dynamicHead, dynamicBody } = request;
const data = Object.assign(
{},
boilerplate.baseData,
{
htmlAttributes: getHtmlAttributes(request),
},
{ dynamicHead, dynamicBody }
);
let madeChanges = false;
let promise = Promise.resolve();
Object.keys(boilerplateDataCallbacks).forEach(key => {
promise = promise
.then(() => {
const callback = boilerplateDataCallbacks[key];
return callback(request, data, arch);
})
.then(result => {
// Callbacks should return false if they did not make any changes.
if (result !== false) {
madeChanges = true;
}
});
});
return promise.then(() => ({
stream: boilerplate.toHTMLStream(data),
statusCode: data.statusCode,
headers: data.headers,
}));
}
/**
* @name addUpdatedNotifyHookCallback(options)
* @summary callback handler for `addupdatedNotifyHook`
* @isprototype true
* @locus Server
* @param {Object} options
* @param {String} options.arch The architecture that is being updated.
* This can be one of `web.browser`, `web.browser.legacy` or `web.cordova`.
* @param {Object} options.manifest The new updated manifest object for
* this `arch`.
* @param {Object} options.runtimeConfig The new updated configuration
* object for this `arch`.
*/
/**
* @summary Hook that runs when the meteor runtime configuration
* is updated. Typically the configuration only changes during development mode.
* @locus Server
* @param {addUpdatedNotifyHookCallback} handler
* The `handler` is called on every change to an `arch` runtime configuration.
* See `addUpdatedNotifyHookCallback`.
* @returns {Object} {{ stop: function, callback: function }}
*/
WebApp.addUpdatedNotifyHook = function(handler) {
return runtimeConfig.updateHooks.register(handler);
};
WebAppInternals.generateBoilerplateInstance = function(
arch,
manifest,
additionalOptions
) {
additionalOptions = additionalOptions || {};
runtimeConfig.isUpdatedByArch[arch] = true;
const rtimeConfig = {
...__meteor_runtime_config__,
...(additionalOptions.runtimeConfigOverrides || {}),
};
runtimeConfig.updateHooks.forEach(cb => {
cb({ arch, manifest, runtimeConfig: rtimeConfig });
return true;
});
const meteorRuntimeConfig = JSON.stringify(
encodeURIComponent(JSON.stringify(rtimeConfig))
);
return new Boilerplate(
arch,
manifest,
Object.assign(
{
pathMapper(itemPath) {
return pathJoin(archPath[arch], itemPath);
},
baseDataExtension: {
additionalStaticJs: (Object.entries(additionalStaticJs) || []).map(function(
[pathname, contents]
) {
return {
pathname: pathname,
contents: contents,
};
}),
// Convert to a JSON string, then get rid of most weird characters, then
// wrap in double quotes. (The outermost JSON.stringify really ought to
// just be "wrap in double quotes" but we use it to be safe.) This might
// end up inside a <script> tag so we need to be careful to not include
// "</script>", but normal {{spacebars}} escaping escapes too much! See
// https://github.com/meteor/meteor/issues/3730
meteorRuntimeConfig,
meteorRuntimeHash: sha1(meteorRuntimeConfig),
rootUrlPathPrefix:
__meteor_runtime_config__.ROOT_URL_PATH_PREFIX || '',
bundledJsCssUrlRewriteHook: bundledJsCssUrlRewriteHook,
sriMode: sriMode,
inlineScriptsAllowed: WebAppInternals.inlineScriptsAllowed(),
inline: additionalOptions.inline,
},
},
additionalOptions
)
);
};
// A mapping from url path to architecture (e.g. "web.browser") to static
// file information with the following fields:
// - type: the type of file to be served
// - cacheable: optionally, whether the file should be cached or not
// - sourceMapUrl: optionally, the url of the source map
//
// Info also contains one of the following:
// - content: the stringified content that should be served at this path
// - absolutePath: the absolute path on disk to the file
// Serve static files from the manifest or added with
// `addStaticJs`. Exported for tests.
WebAppInternals.staticFilesMiddleware = async function(
staticFilesByArch,
req,
res,
next
) {
var pathname = parseRequest(req).pathname;
try {
pathname = decodeURIComponent(pathname);
} catch (e) {
next();
return;
}
var serveStaticJs = function(s) {
if (
req.method === 'GET' ||
req.method === 'HEAD' ||
Meteor.settings.packages?.webapp?.alwaysReturnContent
) {
res.writeHead(200, {
'Content-type': 'application/javascript; charset=UTF-8',
'Content-Length': Buffer.byteLength(s),
});
res.write(s);
res.end();
} else {
const status = req.method === 'OPTIONS' ? 200 : 405;
res.writeHead(status, {
Allow: 'OPTIONS, GET, HEAD',
'Content-Length': '0',
});
res.end();
}
};
if (
pathname in additionalStaticJs &&
!WebAppInternals.inlineScriptsAllowed()
) {
serveStaticJs(additionalStaticJs[pathname]);
return;
}
const { arch, path } = WebApp.categorizeRequest(req);
if (!hasOwn.call(WebApp.clientPrograms, arch)) {
// We could come here in case we run with some architectures excluded
next();
return;
}
// If pauseClient(arch) has been called, program.paused will be a
// Promise that will be resolved when the program is unpaused.
const program = WebApp.clientPrograms[arch];
await program.paused;
if (
path === '/meteor_runtime_config.js' &&
!WebAppInternals.inlineScriptsAllowed()
) {
serveStaticJs(
`__meteor_runtime_config__ = ${program.meteorRuntimeConfig};`
);
return;
}
const info = getStaticFileInfo(staticFilesByArch, pathname, path, arch);
if (!info) {
next();
return;
}
// "send" will handle HEAD & GET requests
if (
req.method !== 'HEAD' &&
req.method !== 'GET' &&
!Meteor.settings.packages?.webapp?.alwaysReturnContent
) {
const status = req.method === 'OPTIONS' ? 200 : 405;
res.writeHead(status, {
Allow: 'OPTIONS, GET, HEAD',
'Content-Length': '0',
});
res.end();
return;
}
// We don't need to call pause because, unlike 'static', once we call into
// 'send' and yield to the event loop, we never call another handler with
// 'next'.
// Cacheable files are files that should never change. Typically
// named by their hash (eg meteor bundled js and css files).
// We cache them ~forever (1yr).
const maxAge = info.cacheable ? 1000 * 60 * 60 * 24 * 365 : 0;
if (info.cacheable) {
// Since we use req.headers["user-agent"] to determine whether the
// client should receive modern or legacy resources, tell the client
// to invalidate cached resources when/if its user agent string
// changes in the future.
res.setHeader('Vary', 'User-Agent');
}
// Set the X-SourceMap header, which current Chrome, FireFox, and Safari
// understand. (The SourceMap header is slightly more spec-correct but FF
// doesn't understand it.)
//
// You may also need to enable source maps in Chrome: open dev tools, click
// the gear in the bottom right corner, and select "enable source maps".
if (info.sourceMapUrl) {
res.setHeader(
'X-SourceMap',
__meteor_runtime_config__.ROOT_URL_PATH_PREFIX + info.sourceMapUrl
);
}
if (info.type === 'js' || info.type === 'dynamic js') {
res.setHeader('Content-Type', 'application/javascript; charset=UTF-8');
} else if (info.type === 'css') {
res.setHeader('Content-Type', 'text/css; charset=UTF-8');
} else if (info.type === 'json') {
res.setHeader('Content-Type', 'application/json; charset=UTF-8');
}
if (info.hash) {
res.setHeader('ETag', '"' + info.hash + '"');
}
if (info.content) {
res.setHeader('Content-Length', Buffer.byteLength(info.content));
res.write(info.content);
res.end();
} else {
send(req, info.absolutePath, {
maxage: maxAge,
dotfiles: 'allow', // if we specified a dotfile in the manifest, serve it
lastModified: false, // don't set last-modified based on the file date
})
.on('error', function(err) {
Log.error('Error serving static file ' + err);
res.writeHead(500);
res.end();
})
.on('directory', function() {
Log.error('Unexpected directory ' + info.absolutePath);
res.writeHead(500);
res.end();
})
.pipe(res);
}
};
function getStaticFileInfo(staticFilesByArch, originalPath, path, arch) {
if (!hasOwn.call(WebApp.clientPrograms, arch)) {
return null;
}
// Get a list of all available static file architectures, with arch
// first in the list if it exists.
const staticArchList = Object.keys(staticFilesByArch);
const archIndex = staticArchList.indexOf(arch);
if (archIndex > 0) {
staticArchList.unshift(staticArchList.splice(archIndex, 1)[0]);
}
let info = null;
staticArchList.some(arch => {
const staticFiles = staticFilesByArch[arch];
function finalize(path) {
info = staticFiles[path];
// Sometimes we register a lazy function instead of actual data in
// the staticFiles manifest.
if (typeof info === 'function') {
info = staticFiles[path] = info();
}
return info;
}
// If staticFiles contains originalPath with the arch inferred above,
// use that information.
if (hasOwn.call(staticFiles, originalPath)) {
return finalize(originalPath);
}
// If categorizeRequest returned an alternate path, try that instead.
if (path !== originalPath && hasOwn.call(staticFiles, path)) {
return finalize(path);
}
});
return info;
}
// Parse the passed in port value. Return the port as-is if it's a String
// (e.g. a Windows Server style named pipe), otherwise return the port as an
// integer.
//
// DEPRECATED: Direct use of this function is not recommended; it is no
// longer used internally, and will be removed in a future release.
WebAppInternals.parsePort = port => {
let parsedPort = parseInt(port);
if (Number.isNaN(parsedPort)) {
parsedPort = port;
}
return parsedPort;
};
import { onMessage } from 'meteor/inter-process-messaging';
onMessage('webapp-pause-client', async ({ arch }) => {
await WebAppInternals.pauseClient(arch);
});
onMessage('webapp-reload-client', async ({ arch }) => {
await WebAppInternals.generateClientProgram(arch);
});
async function runWebAppServer() {
var shuttingDown = false;
var syncQueue = new Meteor._AsynchronousQueue();
var getItemPathname = function(itemUrl) {
return decodeURIComponent(parseUrl(itemUrl).pathname);
};
WebAppInternals.reloadClientPrograms = async function() {
await syncQueue.runTask(function() {
const staticFilesByArch = Object.create(null);
const { configJson } = __meteor_bootstrap__;
const clientArchs =
configJson.clientArchs || Object.keys(configJson.clientPaths);
try {
clientArchs.forEach(arch => {
generateClientProgram(arch, staticFilesByArch);
});
WebAppInternals.staticFilesByArch = staticFilesByArch;
} catch (e) {
Log.error('Error reloading the client program: ' + e.stack);
process.exit(1);
}
});
};
// Pause any incoming requests and make them wait for the program to be
// unpaused the next time generateClientProgram(arch) is called.
WebAppInternals.pauseClient = async function(arch) {
await syncQueue.runTask(() => {
const program = WebApp.clientPrograms[arch];
const { unpause } = program;
program.paused = new Promise(resolve => {
if (typeof unpause === 'function') {
// If there happens to be an existing program.unpause function,
// compose it with the resolve function.
program.unpause = function() {
unpause();
resolve();
};
} else {
program.unpause = resolve;
}
});
});
};
WebAppInternals.generateClientProgram = async function(arch) {
await syncQueue.runTask(() => generateClientProgram(arch));
};
function generateClientProgram(
arch,
staticFilesByArch = WebAppInternals.staticFilesByArch
) {
const clientDir = pathJoin(
pathDirname(__meteor_bootstrap__.serverDir),
arch
);
// read the control for the client we'll be serving up
const programJsonPath = pathJoin(clientDir, 'program.json');
let programJson;
try {
programJson = JSON.parse(readFileSync(programJsonPath));
} catch (e) {
if (e.code === 'ENOENT') return;
throw e;
}
if (programJson.format !== 'web-program-pre1') {
throw new Error(
'Unsupported format for client assets: ' +
JSON.stringify(programJson.format)
);
}
if (!programJsonPath || !clientDir || !programJson) {
throw new Error('Client config file not parsed.');
}
archPath[arch] = clientDir;
const staticFiles = (staticFilesByArch[arch] = Object.create(null));
const { manifest } = programJson;
manifest.forEach(item => {
if (item.url && item.where === 'client') {
staticFiles[getItemPathname(item.url)] = {
absolutePath: pathJoin(clientDir, item.path),
cacheable: item.cacheable,
hash: item.hash,
// Link from source to its map
sourceMapUrl: item.sourceMapUrl,
type: item.type,
};
if (item.sourceMap) {
// Serve the source map too, under the specified URL. We assume
// all source maps are cacheable.
staticFiles[getItemPathname(item.sourceMapUrl)] = {
absolutePath: pathJoin(clientDir, item.sourceMap),
cacheable: true,
};
}
}
});
const { PUBLIC_SETTINGS } = __meteor_runtime_config__;
const configOverrides = {
PUBLIC_SETTINGS,
};
const oldProgram = WebApp.clientPrograms[arch];
const newProgram = (WebApp.clientPrograms[arch] = {
format: 'web-program-pre1',
manifest: manifest,
// Use arrow functions so that these versions can be lazily
// calculated later, and so that they will not be included in the
// staticFiles[manifestUrl].content string below.
//
// Note: these version calculations must be kept in agreement with
// CordovaBuilder#appendVersion in tools/cordova/builder.js, or hot
// code push will reload Cordova apps unnecessarily.
version: () =>
WebAppHashing.calculateClientHash(manifest, null, configOverrides),
versionRefreshable: () =>
WebAppHashing.calculateClientHash(
manifest,
type => type === 'css',
configOverrides
),
versionNonRefreshable: () =>
WebAppHashing.calculateClientHash(
manifest,
(type, replaceable) => type !== 'css' && !replaceable,
configOverrides
),
versionReplaceable: () =>
WebAppHashing.calculateClientHash(
manifest,
(_type, replaceable) => replaceable,
configOverrides
),
cordovaCompatibilityVersions: programJson.cordovaCompatibilityVersions,
PUBLIC_SETTINGS,
hmrVersion: programJson.hmrVersion,
});
// Expose program details as a string reachable via the following URL.
const manifestUrlPrefix = '/__' + arch.replace(/^web\./, '');
const manifestUrl = manifestUrlPrefix + getItemPathname('/manifest.json');
staticFiles[manifestUrl] = () => {
if (Package.autoupdate) {
const {
AUTOUPDATE_VERSION = Package.autoupdate.Autoupdate.autoupdateVersion,
} = process.env;
if (AUTOUPDATE_VERSION) {
newProgram.version = AUTOUPDATE_VERSION;
}
}
if (typeof newProgram.version === 'function') {
newProgram.version = newProgram.version();
}
return {
content: JSON.stringify(newProgram),
cacheable: false,
hash: newProgram.version,
type: 'json',
};
};
generateBoilerplateForArch(arch);
// If there are any requests waiting on oldProgram.paused, let them
// continue now (using the new program).
if (oldProgram && oldProgram.paused) {
oldProgram.unpause();
}
}
const defaultOptionsForArch = {
'web.cordova': {
runtimeConfigOverrides: {
// XXX We use absoluteUrl() here so that we serve https://
// URLs to cordova clients if force-ssl is in use. If we were
// to use __meteor_runtime_config__.ROOT_URL instead of
// absoluteUrl(), then Cordova clients would immediately get a
// HCP setting their DDP_DEFAULT_CONNECTION_URL to
// http://example.meteor.com. This breaks the app, because
// force-ssl doesn't serve CORS headers on 302
// redirects. (Plus it's undesirable to have clients
// connecting to http://example.meteor.com when force-ssl is
// in use.)
DDP_DEFAULT_CONNECTION_URL:
process.env.MOBILE_DDP_URL || Meteor.absoluteUrl(),
ROOT_URL: process.env.MOBILE_ROOT_URL || Meteor.absoluteUrl(),
},
},
'web.browser': {
runtimeConfigOverrides: {
isModern: true,
},
},
'web.browser.legacy': {
runtimeConfigOverrides: {
isModern: false,
},
},
};
WebAppInternals.generateBoilerplate = async function() {
// This boilerplate will be served to the mobile devices when used with
// Meteor/Cordova for the Hot-Code Push and since the file will be served by
// the device's server, it is important to set the DDP url to the actual
// Meteor server accepting DDP connections and not the device's file server.
await syncQueue.runTask(function() {
Object.keys(WebApp.clientPrograms).forEach(generateBoilerplateForArch);
});
};
function generateBoilerplateForArch(arch) {
const program = WebApp.clientPrograms[arch];
const additionalOptions = defaultOptionsForArch[arch] || {};
const { baseData } = (boilerplateByArch[
arch
] = WebAppInternals.generateBoilerplateInstance(
arch,
program.manifest,
additionalOptions
));
// We need the runtime config with overrides for meteor_runtime_config.js:
program.meteorRuntimeConfig = JSON.stringify({
...__meteor_runtime_config__,
...(additionalOptions.runtimeConfigOverrides || null),
});
program.refreshableAssets = baseData.css.map(file => ({
url: bundledJsCssUrlRewriteHook(file.url),
}));
}
await WebAppInternals.reloadClientPrograms();
// webserver
var app = createExpressApp()
// Packages and apps can add handlers that run before any other Meteor
// handlers via WebApp.rawExpressHandlers.
var rawExpressHandlers = createExpressApp()
app.use(rawExpressHandlers);
// Auto-compress any json, javascript, or text.
app.use(compress({ filter: shouldCompress }));
// parse cookies into an object
app.use(cookieParser());
// We're not a proxy; reject (without crashing) attempts to treat us like
// one. (See #1212.)
app.use(function(req, res, next) {
if (RoutePolicy.isValidUrl(req.url)) {
next();
return;
}
res.writeHead(400);
res.write('Not a proxy');
res.end();
});
function getPathParts(path) {
const parts = path.split('/');
while (parts[0] === '') parts.shift();
return parts;
}
function isPrefixOf(prefix, array) {
return (
prefix.length <= array.length &&
prefix.every((part, i) => part === array[i])
);
}
// Strip off the path prefix, if it exists.
app.use(function(request, response, next) {
const pathPrefix = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX;
const { pathname, search } = parseUrl(request.url);
// check if the path in the url starts with the path prefix
if (pathPrefix) {
const prefixParts = getPathParts(pathPrefix);
const pathParts = getPathParts(pathname);
if (isPrefixOf(prefixParts, pathParts)) {
request.url = '/' + pathParts.slice(prefixParts.length).join('/');
if (search) {
request.url += search;
}
return next();
}
}
if (pathname === '/favicon.ico' || pathname === '/robots.txt') {
return next();
}
if (pathPrefix) {
response.writeHead(404);
response.write('Unknown path');
response.end();
return;
}
next();
});
// Serve static files from the manifest.
// This is inspired by the 'static' middleware.
app.use(function(req, res, next) {
// console.log(String(arguments.callee));
WebAppInternals.staticFilesMiddleware(
WebAppInternals.staticFilesByArch,
req,
res,
next
);
});
// Core Meteor packages like dynamic-import can add handlers before
// other handlers added by package and application code.
app.use((WebAppInternals.meteorInternalHandlers = createExpressApp()));
/**
* @name expressHandlersCallback(req, res, next)
* @locus Server
* @isprototype true
* @summary callback handler for `WebApp.expressHandlers`
* @param {Object} req
* a Node.js
* [IncomingMessage](https://nodejs.org/api/http.html#class-httpincomingmessage)
* object with some extra properties. This argument can be used
* to get information about the incoming request.
* @param {Object} res
* a Node.js
* [ServerResponse](https://nodejs.org/api/http.html#class-httpserverresponse)
* object. Use this to write data that should be sent in response to the
* request, and call `res.end()` when you are done.
* @param {Function} next
* Calling this function will pass on the handling of
* this request to the next relevant handler.
*
*/
/**
* @method handlers
* @memberof WebApp
* @locus Server
* @summary Register a handler for all HTTP requests.
* @param {String} [path]
* This handler will only be called on paths that match
* this string. The match has to border on a `/` or a `.`.
*
* For example, `/hello` will match `/hello/world` and
* `/hello.world`, but not `/hello_world`.
* @param {expressHandlersCallback} handler
* A handler function that will be called on HTTP requests.
* See `expressHandlersCallback`
*
*/
// Packages and apps can add handlers to this via WebApp.expressHandlers.
// They are inserted before our default handler.
var packageAndAppHandlers = createExpressApp()
app.use(packageAndAppHandlers);
let suppressExpressErrors = false;
// Express knows it is an error handler because it has 4 arguments instead of
// 3. go figure. (It is not smart enough to find such a thing if it's hidden
// inside packageAndAppHandlers.)
app.use(function(err, req, res, next) {
if (!err || !suppressExpressErrors || !req.headers['x-suppress-error']) {
next(err);
return;
}
res.writeHead(err.status, { 'Content-Type': 'text/plain' });
res.end('An error message');
});
app.use(async function(req, res, next) {
if (!appUrl(req.url)) {
return next();
} else if (
req.method !== 'HEAD' &&
req.method !== 'GET' &&
!Meteor.settings.packages?.webapp?.alwaysReturnContent
) {
const status = req.method === 'OPTIONS' ? 200 : 405;
res.writeHead(status, {
Allow: 'OPTIONS, GET, HEAD',
'Content-Length': '0',
});
res.end();
} else {
var headers = {
'Content-Type': 'text/html; charset=utf-8',
};
if (shuttingDown) {
headers['Connection'] = 'Close';
}
var request = WebApp.categorizeRequest(req);
if (request.url.query && request.url.query['meteor_css_resource']) {
// In this case, we're requesting a CSS resource in the meteor-specific
// way, but we don't have it. Serve a static css file that indicates that
// we didn't have it, so we can detect that and refresh. Make sure
// that any proxies or CDNs don't cache this error! (Normally proxies
// or CDNs are smart enough not to cache error pages, but in order to
// make this hack work, we need to return the CSS file as a 200, which
// would otherwise be cached.)
headers['Content-Type'] = 'text/css; charset=utf-8';
headers['Cache-Control'] = 'no-cache';
res.writeHead(200, headers);
res.write('.meteor-css-not-found-error { width: 0px;}');
res.end();
return;
}
if (request.url.query && request.url.query['meteor_js_resource']) {
// Similarly, we're requesting a JS resource that we don't have.
// Serve an uncached 404. (We can't use the same hack we use for CSS,
// because actually acting on that hack requires us to have the JS
// already!)
headers['Cache-Control'] = 'no-cache';
res.writeHead(404, headers);
res.end('404 Not Found');
return;
}
if (request.url.query && request.url.query['meteor_dont_serve_index']) {
// When downloading files during a Cordova hot code push, we need
// to detect if a file is not available instead of inadvertently
// downloading the default index page.
// So similar to the situation above, we serve an uncached 404.
headers['Cache-Control'] = 'no-cache';
res.writeHead(404, headers);
res.end('404 Not Found');
return;
}
const { arch } = request;
assert.strictEqual(typeof arch, 'string', { arch });
if (!hasOwn.call(WebApp.clientPrograms, arch)) {
// We could come here in case we run with some architectures excluded
headers['Cache-Control'] = 'no-cache';
res.writeHead(404, headers);
if (Meteor.isDevelopment) {
res.end(`No client program found for the ${arch} architecture.`);
} else {
// Safety net, but this branch should not be possible.
res.end('404 Not Found');
}
return;
}
// If pauseClient(arch) has been called, program.paused will be a
// Promise that will be resolved when the program is unpaused.
await WebApp.clientPrograms[arch].paused;
return getBoilerplateAsync(request, arch)
.then(({ stream, statusCode, headers: newHeaders }) => {
if (!statusCode) {
statusCode = res.statusCode ? res.statusCode : 200;
}
if (newHeaders) {
Object.assign(headers, newHeaders);
}
res.writeHead(statusCode, headers);
stream.pipe(res, {
// End the response when the stream ends.
end: true,
});
})
.catch(error => {
Log.error('Error running template: ' + error.stack);
res.writeHead(500, headers);
res.end();
});
}
});
// Return 404 by default, if no other handlers serve this URL.
app.use(function(req, res) {
res.writeHead(404);
res.end();
});
var httpServer = createServer(app);
var onListeningCallbacks = [];
// After 5 seconds w/o data on a socket, kill it. On the other hand, if
// there's an outstanding request, give it a higher timeout instead (to avoid
// killing long-polling requests)
httpServer.setTimeout(SHORT_SOCKET_TIMEOUT);
// Do this here, and then also in livedata/stream_server.js, because
// stream_server.js kills all the current request handlers when installing its
// own.
httpServer.on('request', WebApp._timeoutAdjustmentRequestCallback);
// If the client gave us a bad request, tell it instead of just closing the
// socket. This lets load balancers in front of us differentiate between "a
// server is randomly closing sockets for no reason" and "client sent a bad
// request".
//
// This will only work on Node 6; Node 4 destroys the socket before calling
// this event. See https://github.com/nodejs/node/pull/4557/ for details.
httpServer.on('clientError', (err, socket) => {
// Pre-Node-6, do nothing.
if (socket.destroyed) {
return;
}
if (err.message === 'Parse Error') {
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
} else {
// For other errors, use the default behavior as if we had no clientError
// handler.
socket.destroy(err);
}
});
const suppressErrors = function() {
suppressExpressErrors = true;
};
let warnedAboutConnectUsage = false;
// start up app
Object.assign(WebApp, {
connectHandlers: packageAndAppHandlers,
handlers: packageAndAppHandlers,
rawConnectHandlers: rawExpressHandlers,
rawHandlers: rawExpressHandlers,
httpServer: httpServer,
expressApp: app,
// For testing.
suppressConnectErrors: () => {
if (! warnedAboutConnectUsage) {
Meteor._debug("WebApp.suppressConnectErrors has been renamed to Meteor._suppressExpressErrors and it should be used only in tests.");
warnedAboutConnectUsage = true;
}
suppressErrors();
},
_suppressExpressErrors: suppressErrors,
onListening: function(f) {
if (onListeningCallbacks) onListeningCallbacks.push(f);
else f();
},
// This can be overridden by users who want to modify how listening works
// (eg, to run a proxy like Apollo Engine Proxy in front of the server).
startListening: function(httpServer, listenOptions, cb) {
httpServer.listen(listenOptions, cb);
},
});
/**
* @name main
* @locus Server
* @summary Starts the HTTP server.
* If `UNIX_SOCKET_PATH` is present Meteor's HTTP server will use that socket file for inter-process communication, instead of TCP.
* If you choose to not include webapp package in your application this method still must be defined for your Meteor application to work.
*/
// Let the rest of the packages (and Meteor.startup hooks) insert Express
// middlewares and update __meteor_runtime_config__, then keep going to set up
// actually serving HTML.
exports.main = async argv => {
await WebAppInternals.generateBoilerplate();
const startHttpServer = listenOptions => {
WebApp.startListening(
argv?.httpServer || httpServer,
listenOptions,
Meteor.bindEnvironment(
() => {
if (process.env.METEOR_PRINT_ON_LISTEN) {
console.log('LISTENING');
}
const callbacks = onListeningCallbacks;
onListeningCallbacks = null;
callbacks?.forEach(callback => {
callback();
});
},
e => {
console.error('Error listening:', e);
console.error(e && e.stack);
}
)
);
};
let localPort = process.env.PORT || 0;
let unixSocketPath = process.env.UNIX_SOCKET_PATH;
if (unixSocketPath) {
if (cluster.isWorker) {
const workerName = cluster.worker.process.env.name || cluster.worker.id;
unixSocketPath += '.' + workerName + '.sock';
}
// Start the HTTP server using a socket file.
removeExistingSocketFile(unixSocketPath);
startHttpServer({ path: unixSocketPath });
const unixSocketPermissions = (
process.env.UNIX_SOCKET_PERMISSIONS || ''
).trim();
if (unixSocketPermissions) {
if (/^[0-7]{3}$/.test(unixSocketPermissions)) {
chmodSync(unixSocketPath, parseInt(unixSocketPermissions, 8));
} else {
throw new Error('Invalid UNIX_SOCKET_PERMISSIONS specified');
}
}
const unixSocketGroup = (process.env.UNIX_SOCKET_GROUP || '').trim();
if (unixSocketGroup) {
const unixSocketGroupInfo = getGroupInfo(unixSocketGroup);
if (unixSocketGroupInfo === null) {
throw new Error('Invalid UNIX_SOCKET_GROUP name specified');
}
chownSync(unixSocketPath, userInfo().uid, unixSocketGroupInfo.gid);
}
registerSocketFileCleanup(unixSocketPath);
} else {
localPort = isNaN(Number(localPort)) ? localPort : Number(localPort);
if (/\\\\?.+\\pipe\\?.+/.test(localPort)) {
// Start the HTTP server using Windows Server style named pipe.
startHttpServer({ path: localPort });
} else if (typeof localPort === 'number') {
// Start the HTTP server using TCP.
startHttpServer({
port: localPort,
host: process.env.BIND_IP || '0.0.0.0',
});
} else {
throw new Error('Invalid PORT specified');
}
}
return 'DAEMON';
};
}
const isGetentAvailable = () => {
try {
execSync('which getent');
return true;
} catch {
return false;
}
};
const getGroupInfoUsingGetent = (groupName) => {
try {
const stdout = execSync(`getent group ${groupName}`, { encoding: 'utf8' });
if (!stdout) return null;
const [name, , gid] = stdout.trim().split(':');
if (name == null || gid == null) return null;
return { name, gid: Number(gid) };
} catch (error) {
return null;
}
};
const getGroupInfoFromFile = (groupName) => {
try {
const data = readFileSync('/etc/group', 'utf8');
const groupLine = data.trim().split('\n').find(line => line.startsWith(`${groupName}:`));
if (!groupLine) return null;
const [name, , gid] = groupLine.trim().split(':');
if (name == null || gid == null) return null;
return { name, gid: Number(gid) };
} catch (error) {
return null;
}
};
export const getGroupInfo = (groupName) => {
let groupInfo = getGroupInfoFromFile(groupName);
if (!groupInfo && isGetentAvailable()) {
groupInfo = getGroupInfoUsingGetent(groupName);
}
return groupInfo;
};
var inlineScriptsAllowed = true;
WebAppInternals.inlineScriptsAllowed = function() {
return inlineScriptsAllowed;
};
WebAppInternals.setInlineScriptsAllowed = async function(value) {
inlineScriptsAllowed = value;
await WebAppInternals.generateBoilerplate();
};
var sriMode;
WebAppInternals.enableSubresourceIntegrity = async function(use_credentials = false) {
sriMode = use_credentials ? 'use-credentials' : 'anonymous';
await WebAppInternals.generateBoilerplate();
};
WebAppInternals.setBundledJsCssUrlRewriteHook = async function(hookFn) {
bundledJsCssUrlRewriteHook = hookFn;
await WebAppInternals.generateBoilerplate();
};
WebAppInternals.setBundledJsCssPrefix = async function(prefix) {
var self = this;
await self.setBundledJsCssUrlRewriteHook(function(url) {
return prefix + url;
});
};
// Packages can call `WebAppInternals.addStaticJs` to specify static
// JavaScript to be included in the app. This static JS will be inlined,
// unless inline scripts have been disabled, in which case it will be
// served under `/<sha1 of contents>`.
var additionalStaticJs = {};
WebAppInternals.addStaticJs = function(contents) {
additionalStaticJs['/' + sha1(contents) + '.js'] = contents;
};
// Exported for tests
WebAppInternals.getBoilerplate = getBoilerplate;
WebAppInternals.additionalStaticJs = additionalStaticJs;
await runWebAppServer();