├── .gitattributes ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── lib └── controllers.js ├── library.js ├── package.json ├── plugin.json ├── public ├── scripts │ ├── admin.js │ └── client.js ├── style.scss └── templates │ ├── admin │ └── plugins │ │ └── iframely.tpl │ └── partials │ ├── iframely-link-title.tpl │ ├── iframely-widget-card.tpl │ └── iframely-widget-wrapper.tpl └── screenshot.png /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | *.sln merge=union 7 | *.csproj merge=union 8 | *.vbproj merge=union 9 | *.fsproj merge=union 10 | *.dbproj merge=union 11 | 12 | # Standard to msysgit 13 | *.doc diff=astextplain 14 | *.DOC diff=astextplain 15 | *.docx diff=astextplain 16 | *.DOCX diff=astextplain 17 | *.dot diff=astextplain 18 | *.DOT diff=astextplain 19 | *.pdf diff=astextplain 20 | *.PDF diff=astextplain 21 | *.rtf diff=astextplain 22 | *.RTF diff=astextplain 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## Eclipse 3 | ################# 4 | 5 | *.pydevproject 6 | .project 7 | .metadata 8 | bin/ 9 | tmp/ 10 | *.tmp 11 | *.bak 12 | *.swp 13 | *~.nib 14 | local.properties 15 | .classpath 16 | .settings/ 17 | .loadpath 18 | 19 | # External tool builders 20 | .externalToolBuilders/ 21 | 22 | # Locally stored "Eclipse launch configurations" 23 | *.launch 24 | 25 | # CDT-specific 26 | .cproject 27 | 28 | # PDT-specific 29 | .buildpath 30 | 31 | 32 | ################# 33 | ## Visual Studio 34 | ################# 35 | 36 | ## Ignore Visual Studio temporary files, build results, and 37 | ## files generated by popular Visual Studio add-ons. 38 | 39 | # User-specific files 40 | *.suo 41 | *.user 42 | *.sln.docstates 43 | 44 | # Build results 45 | 46 | [Dd]ebug/ 47 | [Rr]elease/ 48 | x64/ 49 | build/ 50 | [Bb]in/ 51 | [Oo]bj/ 52 | 53 | # MSTest test Results 54 | [Tt]est[Rr]esult*/ 55 | [Bb]uild[Ll]og.* 56 | 57 | *_i.c 58 | *_p.c 59 | *.ilk 60 | *.meta 61 | *.obj 62 | *.pch 63 | *.pdb 64 | *.pgc 65 | *.pgd 66 | *.rsp 67 | *.sbr 68 | *.tlb 69 | *.tli 70 | *.tlh 71 | *.tmp 72 | *.tmp_proj 73 | *.log 74 | *.vspscc 75 | *.vssscc 76 | .builds 77 | *.pidb 78 | *.log 79 | *.scc 80 | 81 | # Visual C++ cache files 82 | ipch/ 83 | *.aps 84 | *.ncb 85 | *.opensdf 86 | *.sdf 87 | *.cachefile 88 | 89 | # Visual Studio profiler 90 | *.psess 91 | *.vsp 92 | *.vspx 93 | 94 | # Guidance Automation Toolkit 95 | *.gpState 96 | 97 | # ReSharper is a .NET coding add-in 98 | _ReSharper*/ 99 | *.[Rr]e[Ss]harper 100 | 101 | # TeamCity is a build add-in 102 | _TeamCity* 103 | 104 | # DotCover is a Code Coverage Tool 105 | *.dotCover 106 | 107 | # NCrunch 108 | *.ncrunch* 109 | .*crunch*.local.xml 110 | 111 | # Installshield output folder 112 | [Ee]xpress/ 113 | 114 | # DocProject is a documentation generator add-in 115 | DocProject/buildhelp/ 116 | DocProject/Help/*.HxT 117 | DocProject/Help/*.HxC 118 | DocProject/Help/*.hhc 119 | DocProject/Help/*.hhk 120 | DocProject/Help/*.hhp 121 | DocProject/Help/Html2 122 | DocProject/Help/html 123 | 124 | # Click-Once directory 125 | publish/ 126 | 127 | # Publish Web Output 128 | *.Publish.xml 129 | *.pubxml 130 | 131 | # NuGet Packages Directory 132 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 133 | #packages/ 134 | 135 | # Windows Azure Build Output 136 | csx 137 | *.build.csdef 138 | 139 | # Windows Store app package directory 140 | AppPackages/ 141 | 142 | # Others 143 | sql/ 144 | *.Cache 145 | ClientBin/ 146 | [Ss]tyle[Cc]op.* 147 | ~$* 148 | *~ 149 | *.dbmdl 150 | *.[Pp]ublish.xml 151 | *.pfx 152 | *.publishsettings 153 | 154 | # RIA/Silverlight projects 155 | Generated_Code/ 156 | 157 | # Backup & report files from converting an old project file to a newer 158 | # Visual Studio version. Backup files are not needed, because we have git ;-) 159 | _UpgradeReport_Files/ 160 | Backup*/ 161 | UpgradeLog*.XML 162 | UpgradeLog*.htm 163 | 164 | # SQL Server files 165 | App_Data/*.mdf 166 | App_Data/*.ldf 167 | 168 | ############# 169 | ## Windows detritus 170 | ############# 171 | 172 | # Windows image file caches 173 | Thumbs.db 174 | ehthumbs.db 175 | 176 | # Folder config file 177 | Desktop.ini 178 | 179 | # Recycle Bin used on file shares 180 | $RECYCLE.BIN/ 181 | 182 | # Mac crap 183 | .DS_Store 184 | 185 | 186 | ############# 187 | ## Python 188 | ############# 189 | 190 | *.py[co] 191 | 192 | # Packages 193 | *.egg 194 | *.egg-info 195 | dist/ 196 | build/ 197 | eggs/ 198 | parts/ 199 | var/ 200 | sdist/ 201 | develop-eggs/ 202 | .installed.cfg 203 | 204 | # Installer logs 205 | pip-log.txt 206 | 207 | # Unit test / coverage reports 208 | .coverage 209 | .tox 210 | 211 | #Translations 212 | *.mo 213 | 214 | #Mr Developer 215 | .mr.developer.cfg 216 | 217 | sftp-config.json 218 | node_modules/ 219 | 220 | .idea -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | sftp-config.json 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 NodeBB Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Iframely for NodeBB 2 | 3 | Iframely will give you embeds from over 1700 domains, plus previews for the rest of the web. Powered by remote API. 4 | 5 | Here's how it looks: 6 | 7 | ![Screenshot](./screenshot.png) 8 | 9 | Try your URLs at [iframely.com/debug](http://iframely.com/debug) to see how it is supported. 10 | 11 | ## Features 12 | 13 | * YouTube, Vimeo, SoundCloud, Gists, Twitter, Facebook, Instagrams, Google Maps, Imgur, Giphy, GfyCat, Flickr - you name it. Plus, articles and general links. 14 | * URL previews automatically collapse if post gets downvoted a lot. 15 | * Embed codes will be responsive and will look great on any device. 16 | * You can use it with API key via [cloud service](https://iframely.com), or self-host the [open-source version](https://github.com/itteco/iframely) 17 | * Optionally use [Camo Image Proxy](https://github.com/atmos/camo) to avoid image hot-linking and mixed-content warnings in SSL. 18 | 19 | 20 | ## Contribute 21 | 22 | PRs are welcome. 23 | 24 | If you see a problem - please submit an issue. 25 | 26 | The plugin is maintained by joint effort of Iframely and NodeBB. -------------------------------------------------------------------------------- /lib/controllers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Controllers = {}; 4 | 5 | Controllers.renderAdminPage = function (req, res, next) { 6 | /* 7 | Make sure the route matches your path to template exactly. 8 | 9 | If your route was: 10 | myforum.com/some/complex/route/ 11 | your template should be: 12 | templates/some/complex/route.tpl 13 | and you would render it like so: 14 | res.render('some/complex/route'); 15 | */ 16 | 17 | res.render('admin/plugins/iframely', { 18 | title: 'Iframely', 19 | }); 20 | }; 21 | 22 | module.exports = Controllers; -------------------------------------------------------------------------------- /library.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var controllers = require('./lib/controllers'); 4 | var async = require.main.require('async'); 5 | var nconf = require.main.require('nconf'); 6 | var winston = require.main.require('winston'); 7 | var validator = require.main.require('validator'); 8 | var meta = require.main.require('./src/meta'); 9 | 10 | var postCache = require.main.require('./src/posts/cache'); 11 | var LRU = require('lru-cache'); 12 | var url = require('url'); 13 | var moment = require('moment'); 14 | var crypto = require('crypto'); 15 | 16 | var ONE_DAY_MS = 1000*60*60*24; 17 | var DEFAULT_CACHE_MAX_AGE_DAYS = 1; 18 | 19 | var iframely = { 20 | config: undefined, 21 | apiBase: 'http://iframe.ly/api/iframely?origin=nodebb&align=left', 22 | htmlRegex: /(?:]*>||^)(.*?)<\/a>(?:|<\/p>)?/gm 23 | }; 24 | var app; 25 | 26 | iframely.init = function(params, callback) { 27 | var router = params.router, 28 | hostMiddleware = params.middleware; 29 | 30 | app = params.app; 31 | 32 | router.get('/admin/plugins/iframely', hostMiddleware.admin.buildHeader, controllers.renderAdminPage); 33 | router.get('/api/admin/plugins/iframely', controllers.renderAdminPage); 34 | 35 | meta.settings.get('iframely', function(err, config) { 36 | 37 | config.blacklist = (config.blacklist && config.blacklist.split(',')) || []; 38 | 39 | iframely.config = config; 40 | 41 | var cacheMaxAgeDays = getIntValue(config.cacheMaxAgeDays, DEFAULT_CACHE_MAX_AGE_DAYS); 42 | 43 | if (cacheMaxAgeDays < DEFAULT_CACHE_MAX_AGE_DAYS) { 44 | cacheMaxAgeDays = DEFAULT_CACHE_MAX_AGE_DAYS; 45 | } 46 | 47 | iframely.cache= LRU({ 48 | maxAge: cacheMaxAgeDays * ONE_DAY_MS 49 | }); 50 | 51 | callback(); 52 | }); 53 | }; 54 | 55 | iframely.updateConfig = function(data) { 56 | if (data.plugin === 'iframely') { 57 | winston.verbose('[plugin/iframely] Config updated'); 58 | postCache.reset(); 59 | data.settings.blacklist = data.settings.blacklist.split(','); 60 | iframely.config = data.settings; 61 | } 62 | }; 63 | 64 | iframely.addAdminNavigation = function(header, callback) { 65 | header.plugins.push({ 66 | route: '/plugins/iframely', 67 | icon: 'fa-link', 68 | name: 'Iframely' 69 | }); 70 | 71 | callback(null, header); 72 | }; 73 | 74 | iframely.replace = function(raw, options, callback) { 75 | if (typeof options === 'function') { 76 | callback = options; 77 | } 78 | 79 | if (raw && typeof raw !== 'string' && raw.hasOwnProperty('postData') && raw.postData.hasOwnProperty('content')) { 80 | /** 81 | * If a post object is received (`filter:post.parse`), 82 | * instead of a plain string, call self. 83 | */ 84 | 85 | iframely.replace(raw.postData.content, { 86 | votes: parseInt(raw.postData.votes), 87 | isPost: true 88 | }, function(err, html) { 89 | raw.postData.content = html; 90 | return callback(err, raw); 91 | }); 92 | 93 | } else { 94 | var isPreview = !options || !options.isPost; 95 | // Skip parsing post with negative votes. 96 | if (options && options.isPost) { 97 | var votes = (options && typeof options.votes === 'number') ? options.votes : 0; 98 | if (votes < getIntValue(iframely.config.doNoteParseIfVotesLessThen, -10)) { 99 | return callback(null, raw); 100 | } 101 | } 102 | 103 | var urls = []; 104 | var urlsDict = {}; 105 | var match; 106 | 107 | // Isolate matches 108 | while(match = iframely.htmlRegex.exec(raw)) { 109 | // Eliminate trailing slashes for comparison purposes 110 | [1, 2].forEach(key => { 111 | if (match[key].endsWith('/')) { 112 | match[key] = match[key].slice(0, -1); 113 | } 114 | }); 115 | 116 | // Only match if it is a naked link (no anchor text) 117 | var target; 118 | try { 119 | target = url.parse(match[1]); 120 | } catch (err) { 121 | target = ''; 122 | } 123 | 124 | if (( 125 | (match[1] === match[2]) || 126 | (match[1] === encodeURI(match[2])) || 127 | (target.host + target.path === match[2]) 128 | 129 | ) && !hostInBlacklist(target.host)) { 130 | 131 | var uri = match[1]; 132 | 133 | // Eliminate duplicates and internal links 134 | if (!(uri in urlsDict) && !isInternalLink(target)) { 135 | urlsDict[uri] = true; 136 | urls.push({ 137 | match: match[0], 138 | url: uri 139 | }); 140 | } 141 | } 142 | } 143 | 144 | async.waterfall([ 145 | // Query urls from Iframely, in batches of 10 146 | async.apply(async.mapLimit, urls, 10, iframely.query), 147 | 148 | function(embeds, next) { 149 | async.reduce(embeds.filter(Boolean), raw, function(html, data, next) { 150 | var embed = data.embed; 151 | var match = data.match; 152 | var url = data.url; 153 | var fromCache = data.fromCache; 154 | var embedHtml = embed.html; 155 | 156 | var hideWidgetForPreview = isPreview && fromCache; 157 | 158 | var generateCardWithImage = false; 159 | 160 | var icon = getIcon(embed); 161 | var image = getImage(embed); 162 | var scriptSrc = getScriptSrc(embedHtml); 163 | // Allow only `iframe.ly/embed.js` script. 164 | var isIframelyWidget = scriptSrc && ( 165 | /^(?:https:)?\/\/(?:\w+\.)iframe\.ly\/embed\.js/.test(scriptSrc) 166 | || /^(?:https:)?\/\/if-cdn\.com\/embed\.js/.test(scriptSrc) 167 | || /^(?:https:)?\/\/iframely\.net\/embed\.js/.test(scriptSrc) 168 | ); 169 | 170 | var isSanitized = !scriptSrc || isIframelyWidget; 171 | 172 | if (embedHtml && isSanitized) { 173 | // Render embedHtml. 174 | } else if (image) { 175 | // Render card with image. 176 | generateCardWithImage = image; 177 | } else { 178 | // No embed code, no image. Show link with title only. 179 | app.render('partials/iframely-link-title', { 180 | title: embed.meta.title || url, 181 | embed: embed, 182 | icon: icon, 183 | url: url 184 | }, function (err, parsed) { 185 | if (err) { 186 | winston.error('[plugin/iframely] Could not parse embed: ' + err.message + '. Url: ' + url); 187 | return next(null, html); 188 | } 189 | 190 | next(null, html.replace(match, parsed)); 191 | }); 192 | return; 193 | } 194 | 195 | // Format meta info. 196 | var metaInfo = []; 197 | 198 | if (generateCardWithImage) { 199 | if (embed.meta.author) { 200 | metaInfo.push(embed.meta.author); 201 | } 202 | 203 | var date = getDate(embed.meta.date); 204 | if (date) { 205 | metaInfo.push(date); 206 | } 207 | 208 | var currency = embed.meta.currency_code || embed.meta.currency; 209 | var price = embed.meta.price ? (embed.meta.price + (currency ? (' ' + currency) : '')) : null; 210 | if (price) { 211 | metaInfo.push(price); 212 | } 213 | 214 | var duration = getDuration(embed.meta.duration); 215 | if (duration) { 216 | metaInfo.push(duration); 217 | } 218 | 219 | var views = getViews(embed.meta.views); 220 | if (views) { 221 | metaInfo.push(views); 222 | } 223 | 224 | if (embed.meta.category) { 225 | metaInfo.push(embed.meta.category); 226 | } 227 | } 228 | 229 | // END Format meta info. 230 | 231 | embedHtml = wrapHtmlImages(embedHtml); 232 | var title = validator.escape(shortenText(embed.meta.title, 200)); 233 | 234 | var context = { 235 | show_title: false, 236 | domain: getDomain(embed), 237 | title: title && title || false, 238 | description: validator.escape(shortenText(embed.meta.description, 300)), 239 | favicon: wrapImage(icon), 240 | embed: embed, 241 | url: url, 242 | metaString: metaInfo.length ? metaInfo.join('  /  ') : false, 243 | embedHtml: embedHtml, 244 | embedIsImg: /^]+>$/.test(embedHtml), 245 | image: generateCardWithImage, 246 | hideWidgetForPreview: hideWidgetForPreview 247 | }; 248 | 249 | if (context.title && embed.rel.indexOf('player') > -1 && embed.rel.indexOf('gifv') === -1) { 250 | context.show_title = true; 251 | } 252 | 253 | if (embed.rel.indexOf('file') > -1 && embed.rel.indexOf('reader') > -1) { 254 | context.title = embed.meta.canonical; 255 | context.show_title = true; 256 | } 257 | 258 | function renderWidgetWrapper(err, embed_widget) { 259 | if (err) { 260 | winston.error('[plugin/iframely] Could not parse embed: ' + err.message + '. Url: ' + url); 261 | return next(null, html); 262 | } 263 | 264 | embed_widget = embed_widget ? embed_widget : false; 265 | 266 | context.widget_html = embed_widget; 267 | 268 | if (hideWidgetForPreview && embed_widget) { 269 | context.embedHtmlEscaped = validator.escape(embed_widget); 270 | } 271 | 272 | app.render('partials/iframely-widget-wrapper', context, function (err, parsed) { 273 | if (err) { 274 | winston.error('[plugin/iframely] Could not parse embed! ' + err.message + '. Url: ' + url); 275 | return next(null, html); 276 | } 277 | 278 | next(null, html.replace(match, parsed)); 279 | }); 280 | } 281 | 282 | if (generateCardWithImage) { 283 | app.render('partials/iframely-widget-card', context, renderWidgetWrapper); 284 | } else { 285 | renderWidgetWrapper(null, context.embedHtml); 286 | } 287 | 288 | }, next); 289 | } 290 | 291 | ], function(error, html) { 292 | if (error) { 293 | winston.error('[plugin/iframely] Could not parse embed! ' + err.message + '. Urls: ' + urls); 294 | } 295 | 296 | callback(null, html); 297 | }); 298 | } 299 | }; 300 | 301 | iframely.query = function(data, callback) { 302 | if (iframely.cache.has(data.url)) { 303 | winston.verbose('[plugin/iframely] Retrieving \'' + data.url + '\' from cache...'); 304 | setImmediate(function() { 305 | try { 306 | callback(null, { 307 | url: data.url, 308 | match: data.match, 309 | embed: iframely.cache.get(data.url), 310 | fromCache: true 311 | }); 312 | } catch(ex) { 313 | winston.error('[plugin/iframely] Could not parse embed! ' + ex + '. Url: ' + data.url); 314 | } 315 | }); 316 | } else { 317 | winston.verbose('[plugin/iframely] Querying \'' + data.url + '\' via Iframely...') 318 | 319 | if (iframely.config.endpoint) { 320 | 321 | var custom_endpoint = /^https?:\/\//i.test(iframely.config.endpoint); 322 | 323 | var iframelyAPI = custom_endpoint ? iframely.config.endpoint : iframely['apiBase'] + '&api_key=' + iframely.config.endpoint; 324 | iframelyAPI += (iframelyAPI.indexOf('?') > -1 ? '&' : '?') + 'url=' + encodeURIComponent(data.url); 325 | 326 | if (custom_endpoint) { 327 | iframelyAPI += '&group=true'; 328 | } 329 | 330 | fetch(iframelyAPI).catch(err => { 331 | winston.error('[plugin/iframely] Encountered error querying Iframely API: ' + err.message + '. Url: ' + data.url + '. Api call: ' + iframelyAPI); 332 | callback(); 333 | }).then(async (res) => { 334 | if (!res.ok) { 335 | winston.error('[plugin/iframely] Encountered error querying Iframely API: ' + res.status + '. Url: ' + data.url + '. Api call: ' + iframelyAPI); 336 | return callback(); 337 | } 338 | try { 339 | if (res.status === 404) { 340 | winston.verbose('[plugin/iframely] not found: ' + data.url); 341 | return callback(); 342 | } 343 | let body = await res.json() 344 | 345 | if (res.status !== 200 || !body) { 346 | winston.verbose('[plugin/iframely] iframely responded with error: ' + JSON.stringify(body) + '. Url: ' + data.url + '. Api call: ' + iframelyAPI); 347 | return callback(); 348 | } 349 | if (!body.meta || !body.links) { 350 | winston.error('[plugin/iframely] Invalid Iframely API response. Url: ' + data.url + '. Api call: ' + iframelyAPI + '. Body: ' + JSON.stringify(body)); 351 | return callback(); 352 | } 353 | 354 | iframely.cache.set(data.url, body); 355 | 356 | callback(null, { 357 | url: data.url, 358 | match: data.match, 359 | embed: body, 360 | fromCache: false 361 | }); 362 | } catch (ex) { 363 | winston.error('[plugin/iframely] Could not parse embed! ' + ex.stack + '. Url: ' + data.url + '. Api call: ' + iframelyAPI); 364 | callback(); 365 | } 366 | }); 367 | } else { 368 | winston.error('[plugin/iframely] No API key or endpoint configured, skipping Iframely'); 369 | callback(); 370 | } 371 | } 372 | }; 373 | 374 | function hostInBlacklist(host) { 375 | return iframely.config.blacklist && iframely.config.blacklist.indexOf(host) > -1; 376 | } 377 | 378 | function wrapHtmlImages(html) { 379 | 380 | if (html && iframely.config.camoProxyKey && iframely.config.camoProxyHost) { 381 | return html.replace(/]+src=["'][^'"]+["']/gi, function(item) { 382 | var m = item.match(/(]+src=["'])([^'"]+)(["'])/i); 383 | var url = wrapImage(m[2]); 384 | return m[1] + url + m[3]; 385 | }); 386 | 387 | } else { 388 | return html; 389 | } 390 | } 391 | 392 | function wrapImage(url) { 393 | 394 | if (url && iframely.config.camoProxyKey && iframely.config.camoProxyHost && url.indexOf(iframely.config.camoProxyHost) === -1) { 395 | 396 | var hexDigest, hexEncodedPath; 397 | 398 | hexDigest = crypto.createHmac('sha1', iframely.config.camoProxyKey).update(url).digest('hex'); 399 | hexEncodedPath = (new Buffer(url)).toString('hex'); 400 | 401 | return [ 402 | iframely.config.camoProxyHost.replace(/\/$/, ''), // Remove tail '/' 403 | hexDigest, 404 | hexEncodedPath 405 | ].join('/'); 406 | 407 | } else { 408 | return url; 409 | } 410 | } 411 | 412 | function getIntValue(value, defaultValue) { 413 | value = parseInt(value); 414 | if (typeof value === 'number' && !isNaN(value)) { 415 | return value; 416 | } else { 417 | return defaultValue; 418 | } 419 | } 420 | 421 | function shortenText(value, maxlength) { 422 | 423 | if (!value) { 424 | return ''; 425 | } 426 | 427 | maxlength = maxlength || 130; 428 | 429 | value = '' + value; 430 | 431 | if (value.length <= maxlength) { 432 | return value; 433 | } else { 434 | 435 | value = value.substr(0, maxlength); 436 | 437 | var m = value.match(/(.*)[\. ,\/-]/); 438 | 439 | if (m) { 440 | value = m[1] 441 | return m[1] + '...'; 442 | } 443 | 444 | return value + '...'; 445 | } 446 | } 447 | 448 | function getDuration(duration) { 449 | if (duration) { 450 | var seconds = duration % 60; 451 | var minutes = Math.floor((duration - seconds) / 60); 452 | var hours = Math.floor(minutes / 60); 453 | minutes = minutes % 60; 454 | 455 | if (seconds < 10) { 456 | seconds = '0' + seconds; 457 | } 458 | 459 | if (minutes < 10) { 460 | minutes = '0' + minutes; 461 | } 462 | 463 | return (hours ? (hours + ':') : '') + minutes + ':' + seconds; 464 | } 465 | } 466 | 467 | function numberWithCommas(x) { 468 | var parts = x.toString().split("."); 469 | parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, "'"); 470 | return parts.join("."); 471 | } 472 | 473 | function getViews(views) { 474 | if (views) { 475 | if (views > 1000000) { 476 | return numberWithCommas((views / 1000000).toFixed(1)) + 'Mln'; 477 | } else if (views > 1000) { 478 | return numberWithCommas((views / 1000).toFixed(1)) + 'K'; 479 | } else { 480 | return numberWithCommas(views); 481 | } 482 | } 483 | } 484 | 485 | function getDomain(embed) { 486 | var domain = embed.meta.site; 487 | if (!domain) { 488 | var url = embed.meta.canonical; 489 | var m = url.match(/(?:https?:\/\/)?(?:www\.)?([^\/]+)/i); 490 | if (m) { 491 | domain = m[1]; 492 | } else { 493 | domain = url; 494 | } 495 | } 496 | return domain; 497 | } 498 | 499 | function getDate(date) { 500 | 501 | var onDate = ''; 502 | if (date) { 503 | date = new Date(date); 504 | if (date && !isNaN(date.getTime())) { 505 | 506 | var language = meta.config.defaultLang || 'en_GB'; 507 | 508 | onDate = moment(date).locale(language).format('MMM D'); 509 | 510 | if (date.getFullYear() !== new Date().getFullYear()) { 511 | onDate = onDate + ', ' + date.getFullYear(); 512 | } 513 | } 514 | } 515 | 516 | return onDate; 517 | } 518 | 519 | function getImage(embed) { 520 | 521 | var image = 522 | embed 523 | && embed.links 524 | 525 | && ((embed.links.thumbnail 526 | && embed.links.thumbnail.length 527 | && embed.links.thumbnail[0]) 528 | 529 | || (embed.links.image 530 | && embed.links.image.length 531 | && embed.links.image[0])); 532 | 533 | return image && image.href; 534 | } 535 | 536 | function getIcon(embed) { 537 | 538 | var icon = 539 | embed 540 | && embed.links 541 | && embed.links.icon 542 | && embed.links.icon.length 543 | && embed.links.icon[0]; 544 | 545 | return icon && icon.href || false; 546 | } 547 | 548 | function getScriptSrc(html) { 549 | var scriptMatch = html && html.match(/]+src="([^"]+)"/); 550 | return scriptMatch && scriptMatch[1]; 551 | } 552 | 553 | var forumURL = url.parse(nconf.get('url')); 554 | var uploadsURL = url.parse(url.resolve(nconf.get('url'), nconf.get('upload_url'))); 555 | 556 | function isInternalLink(target) { 557 | if (target.host !== forumURL.host || target.path.indexOf(forumURL.path) !== 0) { 558 | return false; 559 | } 560 | if (target.host !== uploadsURL.host || target.path.indexOf(uploadsURL.path) !== 0) { 561 | return true; 562 | } 563 | return false; 564 | } 565 | 566 | module.exports = iframely; 567 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodebb-plugin-iframely", 3 | "version": "1.2.0", 4 | "description": "Iframely will give you embeds from over 1700 domains, plus previews for the rest of the web.", 5 | "main": "library.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/NodeBB/nodebb-plugin-iframely" 9 | }, 10 | "keywords": [ 11 | "nodebb", 12 | "plugin", 13 | "iframely", 14 | "embed", 15 | "oembed", 16 | "youtube", 17 | "gfycat", 18 | "giphy", 19 | "imgur", 20 | "unfurl", 21 | "onebox" 22 | ], 23 | "author": { 24 | "name": "Julian Lam", 25 | "email": "julian@nodebb.org" 26 | }, 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/NodeBB/nodebb-plugin-iframely/issues" 30 | }, 31 | "readmeFilename": "README.md", 32 | "nbbpm": { 33 | "compatibility": "^3.2.0" 34 | }, 35 | "dependencies": { 36 | "@iframely/embed.js": "^1.3.1", 37 | "lru-cache": "^2.7.0", 38 | "moment": "^2.20.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "nodebb-plugin-iframely", 3 | "url": "https://github.com/NodeBB/nodebb-plugin-iframely", 4 | "library": "./library.js", 5 | "hooks": [ 6 | { "hook": "static:app.load", "method": "init" }, 7 | { "hook": "filter:admin.header.build", "method": "addAdminNavigation" }, 8 | { "hook": "action:settings.set", "method": "updateConfig" }, 9 | { "hook": "filter:parse.raw", "method": "replace" }, 10 | { "hook": "filter:parse.post", "method": "replace" } 11 | ], 12 | "scripts": [ 13 | "node_modules/@iframely/embed.js/dist/embed.min.js", 14 | "public/scripts/client.js" 15 | ], 16 | "modules": { 17 | "../admin/plugins/iframely.js": "public/scripts/admin.js" 18 | }, 19 | "scss": [ 20 | "public/style.scss" 21 | ], 22 | "templates": "public/templates" 23 | } 24 | -------------------------------------------------------------------------------- /public/scripts/admin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | define('admin/plugins/iframely', ['settings', 'alerts'], function (Settings, alerts) { 4 | var admin = {}; 5 | 6 | admin.init = function () { 7 | Settings.load('iframely', $('.iframely-settings'), function() { 8 | function tagifyInput(selector) { 9 | var input = $(selector).tagsinput({ 10 | tagClass: 'badge bg-info', 11 | confirmKeys: [13, 44], 12 | trimValue: true, 13 | 14 | }); 15 | $(input[0]['$input']).addClass('form-control').parent().css('display', 'block'); 16 | 17 | } 18 | 19 | tagifyInput('#blacklist'); 20 | }); 21 | 22 | $('#save').on('click', function() { 23 | Settings.save('iframely', $('.iframely-settings'), function() { 24 | alerts.alert({ 25 | type: 'success', 26 | alert_id: 'iframely-saved', 27 | title: 'Settings Saved', 28 | message: 'Please reload your NodeBB to apply these settings', 29 | clickfn: function() { 30 | socket.emit('admin.reload'); 31 | } 32 | }); 33 | }); 34 | }); 35 | } 36 | 37 | return admin; 38 | }); -------------------------------------------------------------------------------- /public/scripts/client.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | $(window).on('action:ajaxify.end action:posts.loaded action:composer.preview', function() { 3 | /** 4 | * Iframely requires to call `iframely.load();` after widgets loaded to page. 5 | * `action:ajaxify.end` - triggered when posts rendered on page. 6 | * `action:posts.loaded` - triggered when you are on a topic page and a new post is sent to the client. 7 | * `action:composer.preview` - triggered when new preview rendered in composer. 8 | * In both cases Iframely need to initialize widgets. 9 | */ 10 | iframely.load(); 11 | }); 12 | 13 | $(window).on('action:composer.preview', function() { 14 | /** 15 | * This logic prevents widget flickering while editing post in composer. 16 | * When user opens post editor, widget will be collapsed and `click to prevew` button will be shown. 17 | * Click event on that button will show widget expanded. 18 | * Click button rendered in template: 19 | * `static/templates/partials/iframely-widget-wrapper.tpl` 20 | */ 21 | $('.iframely-container a[data-iframely-show-preview]').one('click', function(e) { 22 | e.stopPropagation(); 23 | var $parent = $(this).parent(); 24 | var html = $parent.attr('data-html'); 25 | $parent.html(html); 26 | return false; 27 | }); 28 | }); 29 | }); 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /public/style.scss: -------------------------------------------------------------------------------- 1 | .iframely-link { 2 | 3 | margin-bottom: 15px; 4 | 5 | .thumb { 6 | max-width: 21px !important; 7 | margin-right: 5px; 8 | } 9 | 10 | .iframely-container { 11 | 12 | margin-top: 15px; 13 | 14 | max-width: 600px; 15 | 16 | img, iframe { 17 | max-width: 100% !important; 18 | } 19 | 20 | img.thumb { 21 | max-width: 21px !important; 22 | margin-right: 5px; 23 | } 24 | 25 | .panel-iframely { 26 | 27 | .iframely-embed { 28 | 29 | .one-line { 30 | text-overflow: ellipsis; 31 | white-space: nowrap; 32 | overflow: hidden; 33 | display: inline-block; 34 | width: 100%; 35 | } 36 | 37 | .iframely-meta { 38 | margin-bottom: 10px; 39 | } 40 | 41 | .media iframe, .media img { 42 | max-width: 100%; 43 | } 44 | 45 | p.description { 46 | margin: 10px 0 0 0; 47 | } 48 | } 49 | } 50 | 51 | .iframely-embed { 52 | margin: auto; 53 | } 54 | .iframely-responsive { 55 | top: 0; 56 | left: 0; 57 | width: 100%; 58 | height: 0; 59 | position: relative; 60 | padding-bottom: 56.25%; 61 | } 62 | .iframely-responsive>* { 63 | top: 0; 64 | left: 0; 65 | width: 100%; 66 | height: 100%; 67 | position: absolute; 68 | border: 0; 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /public/templates/admin/plugins/iframely.tpl: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 |
6 |
7 |
8 |
API Settings
9 | 10 |
11 |
12 |

13 | Use Iframely as a cloud or self-hosted open-source API. 14 |

15 |

16 | Get cloud API key here. 17 |

18 |
19 |
20 | 21 | 22 |
23 |
24 | 25 | 26 |
27 |
28 |
29 | 30 |
31 |
Disable parsing on negative votes
32 |
33 |
34 |

35 | Iframely uses +/- vote to check when to parse URL to show previews. 36 |

37 | 40 | 41 |
42 |
43 |
44 | 45 |
46 |
Or ignore some domains
47 |
48 |

49 | Iframely won't even try to parse URLs from these domains (e.g. your own domain makes sense here). 50 |

51 |
52 | 53 | 54 |
55 |
56 |
57 | 58 |
59 |
Image Proxy
60 |
61 |
62 |

63 | Optional (but recommended) Camo server settings to proxy images under SSL and avoid hot-linking. 64 |

65 | 66 | 67 |
68 |
69 | 70 | 71 |
72 |
73 |
74 |
75 |
76 | 77 | 78 |
79 |
80 | -------------------------------------------------------------------------------- /public/templates/partials/iframely-link-title.tpl: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /public/templates/partials/iframely-widget-card.tpl: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 |
7 | {metaString} 8 |
9 | 10 | 11 | 12 |

13 | 14 | 15 | 16 | 17 | {title} 18 | 19 |

20 | 21 | 22 | 23 |
24 | 25 | {title} 26 | 27 |
28 | 29 | 30 | 31 |

32 | {description} 33 |

34 | 35 |
36 |
37 |
38 | -------------------------------------------------------------------------------- /public/templates/partials/iframely-widget-wrapper.tpl: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NodeBB-Community/nodebb-plugin-iframely/fccce46810dd8e6f790505de4b00e7fcbdc8f97a/screenshot.png --------------------------------------------------------------------------------