import { Meteor } from 'meteor/meteor'; let fetch; let WebApp; if (Meteor.isServer) { WebApp = require('meteor/webapp').WebApp; } else { fetch = require('meteor/fetch').fetch; } const NoOp = () => {}; const urlRE = /\/___cookie___\/set/; const rootUrl = Meteor.isServer ? process.env.ROOT_URL : window.__meteor_runtime_config__.ROOT_URL || window.__meteor_runtime_config__.meteorEnv.ROOT_URL || false; const mobileRootUrl = Meteor.isServer ? process.env.MOBILE_ROOT_URL : window.__meteor_runtime_config__.MOBILE_ROOT_URL || window.__meteor_runtime_config__.meteorEnv.MOBILE_ROOT_URL || false; const helpers = { isUndefined(obj) { return obj === void 0; }, isArray(obj) { return Array.isArray(obj); }, clone(obj) { if (!this.isObject(obj)) return obj; return this.isArray(obj) ? obj.slice() : Object.assign({}, obj); }, }; const _helpers = ['Number', 'Object', 'Function']; for (let i = 0; i < _helpers.length; i++) { helpers['is' + _helpers[i]] = function (obj) { return Object.prototype.toString.call(obj) === '[object ' + _helpers[i] + ']'; }; } /** * @url https://github.com/jshttp/cookie/blob/master/index.js * @name cookie * @author jshttp * @license * (The MIT License) * * Copyright (c) 2012-2014 Roman Shtylman * Copyright (c) 2015 Douglas Christopher Wilson * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * 'Software'), to deal in the Software without restriction, including * without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ const decode = decodeURIComponent; const encode = encodeURIComponent; const pairSplitRegExp = /; */; /** * RegExp to match field-content in RFC 7230 sec 3.2 * * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] * field-vchar = VCHAR / obs-text * obs-text = %x80-FF */ const fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/; /** * @function * @name tryDecode * @param {String} str * @param {Function} d * @summary Try decoding a string using a decoding function. * @private */ const tryDecode = (str, d) => { try { return d(str); } catch (e) { return str; } }; /** * @function * @name parse * @param {String} str * @param {Object} [options] * @return {Object} * @summary * Parse a cookie header. * Parse the given cookie header string into an object * The object has the various cookies as keys(names) => values * @private */ const parse = (str, options) => { if (typeof str !== 'string') { throw new Meteor.Error(404, 'argument str must be a string'); } const obj = {}; const opt = options || {}; let val; let key; let eqIndx; str.split(pairSplitRegExp).forEach((pair) => { eqIndx = pair.indexOf('='); if (eqIndx < 0) { return; } key = pair.substr(0, eqIndx).trim(); key = tryDecode(unescape(key), opt.decode || decode); val = pair.substr(++eqIndx, pair.length).trim(); if (val[0] === '"') { val = val.slice(1, -1); } if (void 0 === obj[key]) { obj[key] = tryDecode(val, opt.decode || decode); } }); return obj; }; /** * @function * @name antiCircular * @param data {Object} - Circular or any other object which needs to be non-circular * @private */ const antiCircular = (_obj) => { const object = helpers.clone(_obj); const cache = new Map(); return JSON.stringify(object, (key, value) => { if (typeof value === 'object' && value !== null) { if (cache.get(value)) { return void 0; } cache.set(value, true); } return value; }); }; /** * @function * @name serialize * @param {String} name * @param {String} val * @param {Object} [options] * @return { cookieString: String, sanitizedValue: Mixed } * @summary * Serialize data into a cookie header. * Serialize the a name value pair into a cookie string suitable for * http headers. An optional options object specified cookie parameters. * serialize('foo', 'bar', { httpOnly: true }) => "foo=bar; httpOnly" * @private */ const serialize = (key, val, opt = {}) => { let name; if (!fieldContentRegExp.test(key)) { name = escape(key); } else { name = key; } let sanitizedValue = val; let value = val; if (!helpers.isUndefined(value)) { if (helpers.isObject(value) || helpers.isArray(value)) { const stringified = antiCircular(value); value = encode(`JSON.parse(${stringified})`); sanitizedValue = JSON.parse(stringified); } else { value = encode(value); if (value && !fieldContentRegExp.test(value)) { value = escape(value); } } } else { value = ''; } const pairs = [`${name}=${value}`]; if (helpers.isNumber(opt.maxAge)) { pairs.push(`Max-Age=${opt.maxAge}`); } if (opt.domain && typeof opt.domain === 'string') { if (!fieldContentRegExp.test(opt.domain)) { throw new Meteor.Error(404, 'option domain is invalid'); } pairs.push(`Domain=${opt.domain}`); } if (opt.path && typeof opt.path === 'string') { if (!fieldContentRegExp.test(opt.path)) { throw new Meteor.Error(404, 'option path is invalid'); } pairs.push(`Path=${opt.path}`); } else { pairs.push('Path=/'); } opt.expires = opt.expires || opt.expire || false; if (opt.expires === Infinity) { pairs.push('Expires=Fri, 31 Dec 9999 23:59:59 GMT'); } else if (opt.expires instanceof Date) { pairs.push(`Expires=${opt.expires.toUTCString()}`); } else if (opt.expires === 0) { pairs.push('Expires=0'); } else if (helpers.isNumber(opt.expires)) { pairs.push(`Expires=${new Date(opt.expires).toUTCString()}`); } if (opt.httpOnly) { pairs.push('HttpOnly'); } if (opt.secure) { pairs.push('Secure'); } if (opt.firstPartyOnly) { pairs.push('First-Party-Only'); } if (opt.sameSite) { pairs.push(opt.sameSite === true ? 'SameSite' : `SameSite=${opt.sameSite}`); } return { cookieString: pairs.join('; '), sanitizedValue }; }; const isStringifiedRegEx = /JSON\.parse\((.*)\)/; const isTypedRegEx = /false|true|null/; const deserialize = (string) => { if (typeof string !== 'string') { return string; } if (isStringifiedRegEx.test(string)) { let obj = string.match(isStringifiedRegEx)[1]; if (obj) { try { return JSON.parse(decode(obj)); } catch (e) { console.error('[ostrio:cookies] [.get()] [deserialize()] Exception:', e, string, obj); return string; } } return string; } else if (isTypedRegEx.test(string)) { try { return JSON.parse(string); } catch (e) { return string; } } return string; }; /** * @locus Anywhere * @class __cookies * @param opts {Object} - Options (configuration) object * @param opts._cookies {Object|String} - Current cookies as String or Object * @param opts.TTL {Number|Boolean} - Default cookies expiration time (max-age) in milliseconds, by default - session (false) * @param opts.runOnServer {Boolean} - Expose Cookies class to Server * @param opts.response {http.ServerResponse|Object} - This object is created internally by a HTTP server * @param opts.allowQueryStringCookies {Boolean} - Allow passing Cookies in a query string (in URL). Primary should be used only in Cordova environment * @param opts.allowedCordovaOrigins {Regex|Boolean} - [Server] Allow setting Cookies from that specific origin which in Meteor/Cordova is localhost:12XXX (^http://localhost:12[0-9]{3}$) * @summary Internal Class */ class __cookies { constructor(opts) { this.__pendingCookies = []; this.TTL = opts.TTL || false; this.response = opts.response || false; this.runOnServer = opts.runOnServer || false; this.allowQueryStringCookies = opts.allowQueryStringCookies || false; this.allowedCordovaOrigins = opts.allowedCordovaOrigins || false; if (this.allowedCordovaOrigins === true) { this.allowedCordovaOrigins = /^http:\/\/localhost:12[0-9]{3}$/; } this.originRE = new RegExp(`^https?:\/\/(${rootUrl ? rootUrl : ''}${mobileRootUrl ? '|' + mobileRootUrl : ''})$`); if (helpers.isObject(opts._cookies)) { this.cookies = opts._cookies; } else { this.cookies = parse(opts._cookies); } } /** * @locus Anywhere * @memberOf __cookies * @name get * @param {String} key - The name of the cookie to read * @param {String} _tmp - Unparsed string instead of user's cookies * @summary Read a cookie. If the cookie doesn't exist a null value will be returned. * @returns {String|void} */ get(key, _tmp) { const cookieString = _tmp ? parse(_tmp) : this.cookies; if (!key || !cookieString) { return void 0; } if (cookieString.hasOwnProperty(key)) { return deserialize(cookieString[key]); } return void 0; } /** * @locus Anywhere * @memberOf __cookies * @name set * @param {String} key - The name of the cookie to create/overwrite * @param {String} value - The value of the cookie * @param {Object} opts - [Optional] Cookie options (see readme docs) * @summary Create/overwrite a cookie. * @returns {Boolean} */ set(key, value, opts = {}) { if (key && !helpers.isUndefined(value)) { if (helpers.isNumber(this.TTL) && opts.expires === undefined) { opts.expires = new Date(+new Date() + this.TTL); } const { cookieString, sanitizedValue } = serialize(key, value, opts); this.cookies[key] = sanitizedValue; if (Meteor.isClient) { document.cookie = cookieString; } else if (this.response) { this.__pendingCookies.push(cookieString); this.response.setHeader('Set-Cookie', this.__pendingCookies); } return true; } return false; } /** * @locus Anywhere * @memberOf __cookies * @name remove * @param {String} key - The name of the cookie to create/overwrite * @param {String} path - [Optional] The path from where the cookie will be * readable. E.g., "/", "/mydir"; if not specified, defaults to the current * path of the current document location (string or null). The path must be * absolute (see RFC 2965). For more information on how to use relative paths * in this argument, see: https://developer.mozilla.org/en-US/docs/Web/API/document.cookie#Using_relative_URLs_in_the_path_parameter * @param {String} domain - [Optional] The domain from where the cookie will * be readable. E.g., "example.com", ".example.com" (includes all subdomains) * or "subdomain.example.com"; if not specified, defaults to the host portion * of the current document location (string or null). * @summary Remove a cookie(s). * @returns {Boolean} */ remove(key, path = '/', domain = '') { if (key && this.cookies.hasOwnProperty(key)) { const { cookieString } = serialize(key, '', { domain, path, expires: new Date(0), }); delete this.cookies[key]; if (Meteor.isClient) { document.cookie = cookieString; } else if (this.response) { this.response.setHeader('Set-Cookie', cookieString); } return true; } else if (!key && this.keys().length > 0 && this.keys()[0] !== '') { const keys = Object.keys(this.cookies); for (let i = 0; i < keys.length; i++) { this.remove(keys[i]); } return true; } return false; } /** * @locus Anywhere * @memberOf __cookies * @name has * @param {String} key - The name of the cookie to create/overwrite * @param {String} _tmp - Unparsed string instead of user's cookies * @summary Check whether a cookie exists in the current position. * @returns {Boolean} */ has(key, _tmp) { const cookieString = _tmp ? parse(_tmp) : this.cookies; if (!key || !cookieString) { return false; } return cookieString.hasOwnProperty(key); } /** * @locus Anywhere * @memberOf __cookies * @name keys * @summary Returns an array of all readable cookies from this location. * @returns {[String]} */ keys() { if (this.cookies) { return Object.keys(this.cookies); } return []; } /** * @locus Client * @memberOf __cookies * @name send * @param cb {Function} - Callback * @summary Send all cookies over XHR to server. * @returns {void} */ send(cb = NoOp) { if (Meteor.isServer) { cb(new Meteor.Error(400, "Can't run `.send()` on server, it's Client only method!")); } if (this.runOnServer) { let path = `${ window.__meteor_runtime_config__.ROOT_URL_PATH_PREFIX || window.__meteor_runtime_config__.meteorEnv.ROOT_URL_PATH_PREFIX || '' }/___cookie___/set`; let query = ''; if (Meteor.isCordova && this.allowQueryStringCookies) { const cookiesKeys = this.keys(); const cookiesArray = []; for (let i = 0; i < cookiesKeys.length; i++) { const { sanitizedValue } = serialize(cookiesKeys[i], this.get(cookiesKeys[i])); const pair = `${cookiesKeys[i]}=${sanitizedValue}`; if (!cookiesArray.includes(pair)) { cookiesArray.push(pair); } } if (cookiesArray.length) { path = Meteor.absoluteUrl('___cookie___/set'); query = `?___cookies___=${encodeURIComponent(cookiesArray.join('; '))}`; } } fetch(`${path}${query}`, { credentials: 'include', type: 'cors', }) .then((response) => { cb(void 0, response); }) .catch(cb); } else { cb(new Meteor.Error(400, "Can't send cookies on server when `runOnServer` is false.")); } return void 0; } } /** * @function * @locus Server * @summary Middleware handler * @private */ const __middlewareHandler = (request, response, opts) => { let _cookies = {}; if (opts.runOnServer) { if (request.headers && request.headers.cookie) { _cookies = parse(request.headers.cookie); } return new __cookies({ _cookies, TTL: opts.TTL, runOnServer: opts.runOnServer, response, allowQueryStringCookies: opts.allowQueryStringCookies, }); } throw new Meteor.Error(400, "Can't use middleware when `runOnServer` is false."); }; /** * @locus Anywhere * @class Cookies * @param opts {Object} * @param opts.TTL {Number} - Default cookies expiration time (max-age) in milliseconds, by default - session (false) * @param opts.auto {Boolean} - [Server] Auto-bind in middleware as `req.Cookies`, by default `true` * @param opts.handler {Function} - [Server] Middleware handler * @param opts.runOnServer {Boolean} - Expose Cookies class to Server * @param opts.allowQueryStringCookies {Boolean} - Allow passing Cookies in a query string (in URL). Primary should be used only in Cordova environment * @param opts.allowedCordovaOrigins {Regex|Boolean} - [Server] Allow setting Cookies from that specific origin which in Meteor/Cordova is localhost:12XXX (^http://localhost:12[0-9]{3}$) * @summary Main Cookie class */ class Cookies extends __cookies { constructor(opts = {}) { opts.TTL = helpers.isNumber(opts.TTL) ? opts.TTL : false; opts.runOnServer = opts.runOnServer !== false ? true : false; opts.allowQueryStringCookies = opts.allowQueryStringCookies !== true ? false : true; if (Meteor.isClient) { opts._cookies = document.cookie; super(opts); } else { opts._cookies = {}; super(opts); opts.auto = opts.auto !== false ? true : false; this.opts = opts; this.handler = helpers.isFunction(opts.handler) ? opts.handler : false; this.onCookies = helpers.isFunction(opts.onCookies) ? opts.onCookies : false; if (opts.runOnServer && !Cookies.isLoadedOnServer) { Cookies.isLoadedOnServer = true; if (opts.auto) { WebApp.connectHandlers.use((req, res, next) => { if (urlRE.test(req._parsedUrl.path)) { const matchedCordovaOrigin = !!req.headers.origin && this.allowedCordovaOrigins && this.allowedCordovaOrigins.test(req.headers.origin); const matchedOrigin = matchedCordovaOrigin || (!!req.headers.origin && this.originRE.test(req.headers.origin)); if (matchedOrigin) { res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Access-Control-Allow-Origin', req.headers.origin); } const cookiesArray = []; let cookiesObject = {}; if (matchedCordovaOrigin && opts.allowQueryStringCookies && req.query.___cookies___) { cookiesObject = parse(decodeURIComponent(req.query.___cookies___)); } else if (req.headers.cookie) { cookiesObject = parse(req.headers.cookie); } const cookiesKeys = Object.keys(cookiesObject); if (cookiesKeys.length) { for (let i = 0; i < cookiesKeys.length; i++) { const { cookieString } = serialize(cookiesKeys[i], cookiesObject[cookiesKeys[i]]); if (!cookiesArray.includes(cookieString)) { cookiesArray.push(cookieString); } } if (cookiesArray.length) { res.setHeader('Set-Cookie', cookiesArray); } } helpers.isFunction(this.onCookies) && this.onCookies(__middlewareHandler(req, res, opts)); res.writeHead(200); res.end(''); } else { req.Cookies = __middlewareHandler(req, res, opts); helpers.isFunction(this.handler) && this.handler(req.Cookies); next(); } }); } } } } /** * @locus Server * @memberOf Cookies * @name middleware * @summary Get Cookies instance into callback * @returns {void} */ middleware() { if (!Meteor.isServer) { throw new Meteor.Error(500, "[ostrio:cookies] Can't use `.middleware()` on Client, it's Server only!"); } return (req, res, next) => { helpers.isFunction(this.handler) && this.handler(__middlewareHandler(req, res, this.opts)); next(); }; } } if (Meteor.isServer) { Cookies.isLoadedOnServer = false; } /* Export the Cookies class */ export { Cookies };