UNPKG

apostrophe

Version:
246 lines (236 loc) • 9.44 kB
const cheerio = require('cheerio'); // The oembed module provides an oembed query service for embedding // third-party website content, such as YouTube videos. The service includes // enhancements and substitutes for several services that do not support // oembed or do not support it well, and it is possible to add more by // extending the `enhanceOembetter` method. The server-side // code provides a query route with caching. The browser-side code // provides methods to make a query, and also to make a query and then // immediately display the result. // // Also see the [oembetter](https://www.npmjs.com/package/oembetter) npm module and // the [oembed](http://oembed.com/) documentation. // // Sites to be embedded need to be allowlisted, to avoid XSS attacks. Many // widely trusted sites are already allowlisted. // // Your `allowlist` option is concatenated with `oembetter`'s standard // allowlist, plus wufoo.com, infogr.am, and slideshare.net. // // Your `endpoints` option is concatenated with `oembetter`'s standard // endpoints list. module.exports = { options: { alias: 'oembed', cacheLifetime: 60 * 60 }, init(self) { self.createOembetter(); self.enhanceOembetter(); self.enableBrowserData(); }, methods(self) { return { // Creates an instance of the `oembetter` module and adds the standard // allowlist. Called by `afterConstruct`. createOembetter() { self.oembetter = require('oembetter')(); // Don't permit oembed of untrusted sites, which could // lead to XSS attacks self.oembetter.allowlist(self.oembetter.suggestedAllowlist.concat( self.options.allowlist || [], [ 'wufoo.com', 'infogr.am', 'slideshare.net' ]) ); self.oembetter.endpoints( self.oembetter.suggestedEndpoints.concat(self.options.endpoints || []) ); }, // Enhances oembetter to support services better or to support services // that have no oembed support by default. Called by `afterConstruct`. // Extend this method to add additional `oembetter` filters. enhanceOembetter() { require('./lib/youtube.js')(self, self.oembetter); require('./lib/vimeo.js')(self, self.oembetter); require('./lib/wufoo.js')(self, self.oembetter); require('./lib/infogram.js')(self, self.oembetter); }, // This method fetches the specified URL, determines its best embedded // representation via `oembetter`, and on success returns an // object containing the oembed API response from the service provider. // // If `options.alwaysIframe` is true, the result is a simple // iframe of the URL. If `options.iframeHeight` is set, the iframe // has that height in pixels, otherwise it is left to CSS. These // iframe-related options are not used in core Apostrophe and remain // available to project-level application code for backwards compatibility // purposes only. // // The `options` object is passed on to `oembetter.fetch`. // // Responses are automatically cached, by default for one hour. See the // cacheLifetime option to the module. async query(req, url, options) { if (!options) { options = {}; } if (!options.headers) { options = { ...options, headers: { // Enables access to vimeo private videos locked to this domain Referer: req.baseUrlWithPrefix } }; } if (!options.headers.Referer) { options.headers = { ...options.headers, Referer: req.baseUrlWithPrefix }; } // Tolerant URL handling url = self.apos.launder.url(url, null, true); if (!url) { throw self.apos.error('invalid', req.t('apostrophe:oembedVideoUrlInvalid')); } const key = url + ':' + JSON.stringify(options); let response = await self.apos.cache.get('@apostrophecms/oembed', key); if (response !== undefined) { return response; } if (options.alwaysIframe) { response = await self.iframe(req, url, options); } else { try { response = await require('util').promisify(self.oembetter.fetch)(url, options); } catch (err) { throw self.apos.error('invalid', req.t('apostrophe:oembedVideoUrlInvalid')); } } // Make non-secure URLs protocol relative and // let the browser upgrade them to https if needed function makeProtocolRelative(s) { s = s.replace(/^http:\/\//, '//'); return s.replace(/(["'])http:\/\//g, '$1//'); } if (response.thumbnail_url) { response.thumbnail_url = makeProtocolRelative(response.thumbnail_url); } if (response.html) { response.html = makeProtocolRelative(response.html); } // cache oembed responses for one hour await self.apos.cache.set('@apostrophecms/oembed', key, response, self.options.cacheLifetime); return response; }, // Not currently used. Present for backwards compatibility // in the event that application code used it directly. // // Given a URL, return an oembed response for it // which just iframes the URL given. Fetches the page // first to get the title property. // // If options.iframeHeight is set, use that # of // pixels, otherwise do not specify & let CSS do it. // // Called by `self.query` if the `alwaysIframe` option // is true. async iframe(req, url, options) { const body = await self.apos.http.get(url); const $ = cheerio.load(body); let title = url; if ($) { title = $('meta[property="og:title"]').attr('content') || $('title').text(); if (!title) { // A common goof these days title = $('h1').text(); if (!title) { // Oh c'mon title = url; } } } let style = ''; let html = '<iframe STYLE src="' + self.escapeHtml(url) + '" class="apos-always-iframe"></iframe>'; if (options.iframeHeight) { style = 'style="height:' + options.iframeHeight + 'px"'; } html = html.replace('STYLE', style); return { title, type: 'rich', html }; }, // Returns browser-side javascript to load a given // cross-domain js file dynamically and then run // the javascript code in the `then` string argument. // `script` should be a URL pointing to the third-party // js file and may start with // to autoselect // http or https depending on how the page was loaded. // // You may supply an id attribute for the script tag. // Some services rely on these (infogr.am). // // You may also supply the ID of an element that the // script should be inserted immediately before. Some // services try to infer how they should behave from the // context the script tag is in (infogr.am). // // This code was inspired by the wufoo embed code and // is used to dynamically load wufoo and other services // that use js-based embed codes. See the oembetter // filter in `wufoo.js`. afterScriptLoads(script, beforeId, scriptId, then) { if (script.match(/^\/\//)) { script = '(\'https:\' == d.location.protocol ? \'https:\' : \'http:\') + \'' + script + '\''; } else { script = '\'' + script + '\''; } if (scriptId) { scriptId = 's.id = "' + scriptId + '"; '; } else { scriptId = ''; } let before; if (beforeId) { before = 'd.getElementById("' + beforeId + '")'; } else { before = 'd.getElementsByTagName(t)[0]'; } return '<script type="text/javascript">' + '(function(d, t) {' + 'var s = d.createElement(t);' + 's.src = ' + script + ';' + scriptId + 's.onload = s.onreadystatechange = function() {' + 'var rs = this.readyState; if (rs) if (rs != \'complete\') if (rs != \'loaded\') return;' + then + '};' + 'var scr = ' + before + ', par = scr.parentNode; par.insertBefore(s, scr);' + '})(document, \'script\');' + '</script>'; }, getBrowserData(req) { // api is public return { action: self.action }; } }; }, apiRoutes(self) { return { // Simple API to self.query, with caching. Accepts url and // alwaysIframe parameters; alwaysIframe is assumed false // if not provided. The response is a JSON object as returned // by apos.oembed. A GET request because it is cache-friendly. get: { async query(req) { const url = self.apos.launder.string(req.query.url); const options = { // This feature is no longer available via the // oembed proxy because it posed an SSRF risk and // was never used in core Apostrophe widgets alwaysIframe: false }; const result = await self.query(req, url, options); return result; } } }; } };