diff --git a/frontend/src/resources/previews/dashboard.png b/frontend/src/resources/previews/dashboard.png new file mode 100644 index 000000000..f2f20b9d8 Binary files /dev/null and b/frontend/src/resources/previews/dashboard.png differ diff --git a/frontend/src/resources/previews/lightning.png b/frontend/src/resources/previews/lightning.png new file mode 100644 index 000000000..f214dc1f9 Binary files /dev/null and b/frontend/src/resources/previews/lightning.png differ diff --git a/frontend/src/resources/previews/mining.png b/frontend/src/resources/previews/mining.png new file mode 100644 index 000000000..37c3873c1 Binary files /dev/null and b/frontend/src/resources/previews/mining.png differ diff --git a/unfurler/package-lock.json b/unfurler/package-lock.json index 3da33c69f..40520d413 100644 --- a/unfurler/package-lock.json +++ b/unfurler/package-lock.json @@ -1,12 +1,12 @@ { "name": "mempool-unfurl", - "version": "0.0.1", + "version": "0.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mempool-unfurl", - "version": "0.0.1", + "version": "0.1.0", "dependencies": { "@types/node": "^16.11.41", "express": "^4.18.0", diff --git a/unfurler/package.json b/unfurler/package.json index ca60201b3..59d48aa50 100644 --- a/unfurler/package.json +++ b/unfurler/package.json @@ -1,6 +1,6 @@ { "name": "mempool-unfurl", - "version": "0.0.2", + "version": "0.1.0", "description": "Renderer for mempool open graph link preview images", "repository": { "type": "git", diff --git a/unfurler/src/index.ts b/unfurler/src/index.ts index 08dff3964..e4c1f7fc5 100644 --- a/unfurler/src/index.ts +++ b/unfurler/src/index.ts @@ -1,10 +1,12 @@ import express from "express"; import { Application, Request, Response, NextFunction } from 'express'; import * as http from 'http'; +import * as https from 'https'; import config from './config'; import { Cluster } from 'puppeteer-cluster'; import ReusablePage from './concurrency/ReusablePage'; import { parseLanguageUrl } from './language/lang'; +import { matchRoute } from './routes'; const puppeteerConfig = require('../puppeteer.config.json'); if (config.PUPPETEER.EXEC_PATH) { @@ -17,13 +19,13 @@ class Server { cluster?: Cluster; mempoolHost: string; network: string; - defaultImageUrl: string; + secureHost = true; constructor() { this.app = express(); this.mempoolHost = config.MEMPOOL.HTTP_HOST + (config.MEMPOOL.HTTP_PORT ? ':' + config.MEMPOOL.HTTP_PORT : ''); + this.secureHost = this.mempoolHost.startsWith('https'); this.network = config.MEMPOOL.NETWORK || 'bitcoin'; - this.defaultImageUrl = this.getDefaultImageUrl(); this.startServer(); } @@ -113,11 +115,25 @@ class Server { async renderPreview(req, res) { try { - const path = req.params[0] - const img = await this.cluster?.execute({ url: this.mempoolHost + path, path: path, action: 'screenshot' }); + const rawPath = req.params[0]; + + let img = null; + + const { lang, path } = parseLanguageUrl(rawPath); + const matchedRoute = matchRoute(this.network, path); + + // don't bother unless the route is definitely renderable + if (rawPath.includes('/preview/') && matchedRoute.render) { + img = await this.cluster?.execute({ url: this.mempoolHost + rawPath, path: rawPath, action: 'screenshot' }); + } if (!img) { - res.status(500).send('failed to render page preview'); + // proxy fallback image from the frontend + if (this.secureHost) { + https.get(this.mempoolHost + matchedRoute.fallbackImg, (got) => got.pipe(res)); + } else { + http.get(this.mempoolHost + matchedRoute.fallbackImg, (got) => got.pipe(res)); + } } else { res.contentType('image/png'); res.send(img); @@ -137,50 +153,14 @@ class Server { return; } - let previewSupported = true; - let mode = 'mainnet' - let ogImageUrl = this.defaultImageUrl; - let ogTitle; const { lang, path } = parseLanguageUrl(rawPath); - const parts = path.slice(1).split('/'); + const matchedRoute = matchRoute(this.network, path); + let ogImageUrl = this.mempoolHost + (matchedRoute.staticImg || matchedRoute.fallbackImg); + let ogTitle = 'The Mempool Open Source Projectâ„¢'; - // handle network mode modifiers - if (['testnet', 'signet'].includes(parts[0])) { - mode = parts.shift(); - } - - // handle supported preview routes - switch (parts[0]) { - case 'block': - ogTitle = `Block: ${parts[1]}`; - break; - case 'address': - ogTitle = `Address: ${parts[1]}`; - break; - case 'tx': - ogTitle = `Transaction: ${parts[1]}`; - break; - case 'lightning': - switch (parts[1]) { - case 'node': - ogTitle = `Lightning Node: ${parts[2]}`; - break; - case 'channel': - ogTitle = `Lightning Channel: ${parts[2]}`; - break; - default: - previewSupported = false; - } - break; - default: - previewSupported = false; - } - - if (previewSupported) { + if (matchedRoute.render) { ogImageUrl = `${config.SERVER.HOST}/render/${lang || 'en'}/preview${path}`; - ogTitle = `${this.network ? capitalize(this.network) + ' ' : ''}${mode !== 'mainnet' ? capitalize(mode) + ' ' : ''}${ogTitle}`; - } else { - ogTitle = 'The Mempool Open Source Projectâ„¢'; + ogTitle = `${this.network ? capitalize(this.network) + ' ' : ''}${matchedRoute.networkMode !== 'mainnet' ? capitalize(matchedRoute.networkMode) + ' ' : ''}${matchedRoute.title}`; } res.send(` @@ -192,8 +172,8 @@ class Server { - - + + @@ -206,17 +186,6 @@ class Server { `); } - - getDefaultImageUrl() { - switch (this.network) { - case 'liquid': - return this.mempoolHost + '/resources/liquid/liquid-network-preview.png'; - case 'bisq': - return this.mempoolHost + '/resources/bisq/bisq-markets-preview.png'; - default: - return this.mempoolHost + '/resources/mempool-space-preview.png'; - } - } } const server = new Server(); diff --git a/unfurler/src/routes.ts b/unfurler/src/routes.ts new file mode 100644 index 000000000..24922c85d --- /dev/null +++ b/unfurler/src/routes.ts @@ -0,0 +1,124 @@ +interface Match { + render: boolean; + title: string; + fallbackImg: string; + staticImg?: string; + networkMode: string; +} + +const routes = { + block: { + render: true, + params: 1, + getTitle(path) { + return `Block: ${path[0]}`; + } + }, + address: { + render: true, + params: 1, + getTitle(path) { + return `Address: ${path[0]}`; + } + }, + tx: { + render: true, + params: 1, + getTitle(path) { + return `Transaction: ${path[0]}`; + } + }, + lightning: { + title: "Lightning", + fallbackImg: '/resources/previews/lightning.png', + routes: { + node: { + render: true, + params: 1, + getTitle(path) { + return `Lightning Node: ${path[0]}`; + } + }, + channel: { + render: true, + params: 1, + getTitle(path) { + return `Lightning Channel: ${path[0]}`; + } + }, + } + }, + mining: { + title: "Mining", + fallbackImg: '/resources/previews/mining.png' + } +}; + +const networks = { + bitcoin: { + fallbackImg: '/resources/mempool-space-preview.png', + staticImg: '/resources/previews/dashboard.png', + routes: { + ...routes // all routes supported + } + }, + liquid: { + fallbackImg: '/resources/liquid/liquid-network-preview.png', + routes: { // only block, address & tx routes supported + block: routes.block, + address: routes.address, + tx: routes.tx + } + }, + bisq: { + fallbackImg: '/resources/bisq/bisq-markets-preview.png', + routes: {} // no routes supported + } +}; + +export function matchRoute(network: string, path: string): Match { + const match: Match = { + render: false, + title: '', + fallbackImg: '', + networkMode: 'mainnet' + } + + const parts = path.slice(1).split('/').filter(p => p.length); + + if (parts[0] === 'preview') { + parts.shift(); + } + if (['testnet', 'signet'].includes(parts[0])) { + match.networkMode = parts.shift() || 'mainnet'; + } + + let route = networks[network] || networks.bitcoin; + match.fallbackImg = route.fallbackImg; + + // traverse the route tree until we run out of route or tree, or hit a renderable match + while (!route.render && route.routes && parts.length && route.routes[parts[0]]) { + route = route.routes[parts[0]]; + parts.shift(); + if (route.fallbackImg) { + match.fallbackImg = route.fallbackImg; + } + } + + // enough route parts left for title & rendering + if (route.render && parts.length >= route.params) { + match.render = true; + } + // only use set a static image for exact matches + if (!parts.length && route.staticImg) { + match.staticImg = route.staticImg; + } + // apply the title function if present + if (route.getTitle && typeof route.getTitle === 'function') { + match.title = route.getTitle(parts); + } else { + match.title = route.title; + } + + return match; +} \ No newline at end of file