Merge pull request #2425 from mononaut/unfurler-refactor
Unfurler fallback images & bisq support
This commit is contained in:
		
						commit
						0b129c907a
					
				
							
								
								
									
										
											BIN
										
									
								
								frontend/src/resources/previews/dashboard.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/src/resources/previews/dashboard.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 63 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								frontend/src/resources/previews/lightning.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/src/resources/previews/lightning.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 288 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								frontend/src/resources/previews/mining.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/src/resources/previews/mining.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 112 KiB  | 
							
								
								
									
										4
									
								
								unfurler/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								unfurler/package-lock.json
									
									
									
										generated
									
									
									
								
							@ -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",
 | 
			
		||||
 | 
			
		||||
@ -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",
 | 
			
		||||
 | 
			
		||||
@ -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();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -123,11 +125,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(config.SERVER.HOST + matchedRoute.fallbackImg, (got) => got.pipe(res));
 | 
			
		||||
        } else {
 | 
			
		||||
          http.get(config.SERVER.HOST + matchedRoute.fallbackImg, (got) => got.pipe(res));
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        res.contentType('image/png');
 | 
			
		||||
        res.send(img);
 | 
			
		||||
@ -147,50 +163,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 = config.SERVER.HOST + (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(`
 | 
			
		||||
@ -202,8 +182,8 @@ class Server {
 | 
			
		||||
        <meta name="description" content="The Mempool Open Source Project™ - Explore the full Bitcoin ecosystem with mempool.space™"/>
 | 
			
		||||
        <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">
 | 
			
		||||
@ -216,17 +196,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
									
								
							
							
						
						
									
										124
									
								
								unfurler/src/routes.ts
									
									
									
									
									
										Normal 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;
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user