refactor unfurler routing & add fallback imgs

This commit is contained in:
Mononaut 2022-08-29 01:23:20 +00:00
parent 2979f286cf
commit a5db46240e
No known key found for this signature in database
GPG Key ID: 61B952CAF4838F94
7 changed files with 155 additions and 62 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

@ -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",

View File

@ -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",

View File

@ -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 {
<meta name="description" content="The Mempool Open Source Project™ - our self-hosted explorer for the ${capitalize(this.network)} community."/>
<meta property="og:image" content="${ogImageUrl}"/>
<meta property="og:image:type" content="image/png"/>
<meta property="og:image:width" content="${previewSupported ? 1200 : 1000}"/>
<meta property="og:image:height" content="${previewSupported ? 600 : 500}"/>
<meta property="og:image:width" content="${matchedRoute.render ? 1200 : 1000}"/>
<meta property="og:image:height" content="${matchedRoute.render ? 600 : 500}"/>
<meta property="og:title" content="${ogTitle}">
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:site" content="@mempool">
@ -206,17 +186,6 @@ class Server {
</html>
`);
}
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();

124
unfurler/src/routes.ts Normal file
View File

@ -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;
}