💾 Archived View for iich.space › src › gateway › index.ts captured on 2022-03-01 at 15:59:55.

View Raw

More Information

⬅️ Previous capture (2021-12-03)

🚧 View Differences

-=-=-=-=-=-=-

import { readFileSync } from 'fs';
import { createServer as httpCreateServer } from 'http';
import { createServer as httpsCreateServer } from 'https';
import { connect } from 'tls';
import { URL } from 'url';

import { createLogger } from '@/log';

import { createBan } from '~/db/admin/queries';
import { hasBan } from '~/db/queries';
import { generateHash } from '~/util/hash';

const BLACKLIST = ['wp-includes', 'phpunit', 'microsoft', 'aspx', '.env'];

const log = createLogger();

const request = async (path: string): Promise<Buffer> =>
  new Promise<Buffer>((ok) => {
    let response = Buffer.from([]);
    const url =
      path.slice(0, 1) === '/' ? `gemini://localhost:1966${path}` : path;

    const socket = connect(
      { host: 'localhost', port: 1965, rejectUnauthorized: false },
      () => {
        socket.write(url + '\r\n');
      },
    )
      .on('data', (data) => (response = Buffer.concat([response, data])))
      .on('end', () => ok(response));
  });

const GEMINI = '♊︎';

const stylesheet = readFileSync('src/gateway/style.css');

const header = `
<html>
  <head>
    <title>IIch.space</title>
    <link href="https://fonts.googleapis.com/css?family=Nunito:200,400,600,800&display=swap" rel="stylesheet" />
    <link href="https://fonts.googleapis.com/css2?family=Fira+Mono&display=swap" rel="stylesheet"> 
    <meta name="viewport" content="initial-scale=1.0">
    <link href="/style.css" rel="stylesheet">
  </head>
  <body>
    <div id="header">
      <div class="container">
        <a href="/">${GEMINI} IIch.space</a>
      </div>
    </div>
    <div class="content container">
`
  .replaceAll(/ +/g, ' ')
  .replaceAll('\n', '');

const footer = `
    </div>
    <script>
      function replace(e) {
        e.preventDefault();
        if (this.nextSibling.tagName === "IMG") {
          this.nextSibling.remove();
        } else {
          const img = document.createElement('img');
          img.src = this.href;
          this.parentElement.insertBefore(img, this.nextSibling);
        }
      }
      document.querySelectorAll('a[href*=png]').forEach((node) => node.onclick = replace);
    </script>
  </body>
</html>
`
  .replaceAll(/ +/g, ' ')
  .replaceAll('\n', '');

const tags: Record<string, string> = {
  '"': '&quot;',
  '&': '&amp;',
  "'": '&#39;',
  '<': '&lt;',
  '>': '&gt;',
};

const escapeHTML = (str: string): string =>
  str.replace(/[&<>'"]/g, (tag) => tags[tag]);

const generateHTML = (page: string) => {
  const lines = page.split('\n');
  const parts = [];

  let pre = false;

  for (const line of lines) {
    const clean = escapeHTML(line);

    if (pre) {
      if (line.startsWith('```')) {
        parts.push('</pre>');
        pre = false;
      } else {
        parts.push(`<div>${clean}</div>`);
      }
    } else {
      if (line.startsWith('### ')) {
        parts.push(`<h3>${clean.slice(4)}</h3>`);
      } else if (line.startsWith('## ')) {
        parts.push(`<h2>${clean.slice(3)}</h2>`);
      } else if (line.startsWith('# ')) {
        parts.push(`<h1>${clean.slice(2)}</h1>`);
      } else if (line.startsWith('=> ')) {
        const [, href, ...rest] = clean.split(' ');
        let label = rest.join(' ');

        if (label === '') {
          label = href;
        }

        parts.push(`<a href="${href}">${label}</a>`);
      } else if (line.startsWith('```')) {
        parts.push('<pre>');
        pre = !pre;
      } else {
        parts.push(`<p>${clean}</p>`);
      }
    }
  }

  return parts.join('');
};

const redirectServer = httpCreateServer((req, res) => {
  const remote = generateHash(req.socket.remoteAddress!);
  log.info(remote, req.url);
  const url = new URL(req.url || '/', `http://${req.headers.host}`);
  url.protocol = 'https';
  res.writeHead(302, url.href);
  res.write(`
<head>
  <meta http-equiv="Refresh" content="0; URL=${url.href}">
</head>
`);
  res.end();
});

const server = httpsCreateServer(
  {
    cert: readFileSync(process.env.SSL_CERT_PATH!),
    key: readFileSync(process.env.SSL_KEY_PATH!),
  },
  async (req, res) => {
    try {
      const remote = generateHash(req.socket.remoteAddress!);

      if (hasBan(remote)) {
        res.writeHead(500);
        res.end('ip banned');
        return;
      }

      if (req.url) {
        if (req.url === '/style.css') {
          res.write(stylesheet);
          res.end();
          return;
        }

        if (req.url === '/favicon.ico') {
          res.writeHead(404);
          res.end('404');
          return;
        }

        log.info(remote, req.url);

        const urlSearch = req.url.toLowerCase();

        for (const term of BLACKLIST) {
          if (urlSearch.includes(term)) {
            createBan(remote);
            res.writeHead(500);
            res.end('blacklisted url - ip banned');
            log.info('ip banned', remote);
            return;
          }
        }

        const href = `https://${req.headers.host}${req.url}`;
        const url = new URL(href);
        url.search = '';
        const response = await request(url.href);
        const index = response.indexOf('\r\n');
        const status = response.slice(0, index);
        const body = response.slice(index + 2);
        const [code, meta] = status.toString().split(' ');

        if (code === '20') {
          if (meta === 'image/png') {
            res.writeHead(200, {
              'Content-length': body.length,
              'Content-type': 'image/png',
            });

            res.write(body);
          } else if (meta !== 'text/gemini') {
            res.writeHead(200, {
              'Content-type': `${meta}; charset=utf-8`,
            });

            res.write(body);
          } else {
            res.setHeader('Content-type', 'text/html; charset=utf-8');
            res.write(header);
            res.write(generateHTML(body.toString()));
            res.write(footer);
          }
        } else if (code.startsWith('1') || code.startsWith('3')) {
          const redirect = `gemini://${url.hostname}${url.pathname}`;
          res.writeHead(302, redirect);
          res.write(`
<head>
  <meta http-equiv="Refresh" content="0; URL=${redirect}">
</head>
`);
        } else {
          res.writeHead(404, { 'Content-Type': 'text/plain' });
          res.end('404');
          return;
        }
      }
    } catch (e) {
      console.error(e);
    }

    res.end();
  },
);

export const startGateway = (): void => {
  redirectServer.listen(1080, () => {
    log.info('http server started and listening on port 1080');
  });
  server.listen(1443, () => {
    log.info('https server started and listening on port 1443');
  });
};