~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
190 lines · 7.0 KB Raw History
// Rendering a repository's markdown.
//
// The text comes out of somebody's repository and the result is put into this
// page, so nothing in it may become markup of its own choosing. Two rules
// hold that line:
//
//   HTML the document writes is read and written again from an allowlist,
//   never passed through. `#/lib/html.js` holds that list and explains it.
//
//   A link or image address must name a scheme this page is willing to
//   follow. `javascript:` is the one that matters, and an allowlist refuses
//   it along with everything else nobody expected.
//
// What is left is markup these two files generated from a fixed set of tags,
// which is a surface small enough to read.

import { Marked } from 'marked';
import { highlightBlock, languageFor } from '#/lib/highlight.js';
import { escapeAttribute, sanitizeHtml } from '#/lib/html.js';

/** Schemes a rendered link or image may point at. */
const SAFE_SCHEMES = new Set(['http:', 'https:', 'mailto:']);

/**
 * Whether an address is one this page will follow. A relative address names
 * no scheme and stays inside the repository, so it is allowed and resolved
 * by the caller.
 * @param {string} href
 * @returns {boolean}
 */
export function isSafeHref(href) {
  const value = String(href ?? '').trim();
  if (value === '') return false;
  // A control character can hide a scheme from a careless reader, and the
  // URL parser strips some of them before deciding what the scheme is.
  // Refuse any address carrying one rather than reason about which.
  for (let i = 0; i < value.length; i++) {
    const code = value.charCodeAt(i);
    if (code <= 0x1f || code === 0x7f) return false;
  }
  if (value.startsWith('#')) return true;
  try {
    // A base is needed to parse a relative address at all. One that parses
    // relative to it has no scheme of its own, so it is repository-relative.
    const parsed = new URL(value, 'https://repo.invalid/');
    if (!/^[a-z][a-z0-9+.-]*:/i.test(value)) return true;
    return SAFE_SCHEMES.has(parsed.protocol);
  } catch {
    return false;
  }
}

/** @param {string} href */
const isRelative = (href) =>
  !/^[a-z][a-z0-9+.-]*:/i.test(href) &&
  !href.startsWith('/') &&
  !href.startsWith('#');

/**
 * Resolve a repository-relative address against the directory the document
 * sits in, so `../img.png` in `docs/guide.md` names `img.png`.
 * @param {string} basePath - directory of the document, '' at the root
 * @param {string} href
 * @returns {string}
 */
export function resolveRepoPath(basePath, href) {
  const segments = basePath ? basePath.split('/') : [];
  for (const part of href.split('/')) {
    if (part === '' || part === '.') continue;
    if (part === '..') segments.pop();
    else segments.push(part);
  }
  return segments.join('/');
}

/**
 * @typedef {Object} MarkdownContext
 * @property {string} [repo]
 * @property {string} [refName]
 * @property {string} [basePath] - directory the document sits in
 */

/**
 * The address of a repository file in this app.
 * @param {MarkdownContext} ctx
 * @param {string} path
 * @returns {string}
 */
const blobHref = (ctx, path) =>
  `/${encodeURIComponent(String(ctx.repo))}/blob/${encodeURI(String(ctx.refName))}/${encodeURI(path)}`;

/**
 * Render markdown to HTML for the page.
 *
 * The context is what a document in a tree has and prose about the work does
 * not: a repository and a ref to resolve a relative address against. Without
 * it a relative link or image keeps its path and finds nothing, which is the
 * honest answer for a review that names no tree.
 * @param {string} text
 * @param {MarkdownContext} [ctx]
 * @returns {string} HTML
 */
export function renderMarkdown(text, ctx = {}) {
  const basePath = ctx.basePath ?? '';
  // Whether a relative address has anywhere to resolve to. Prose about the
  // work names no tree, so a path beside it points at nothing and is left as
  // the words it was written as.
  const inTree = Boolean(ctx.repo && ctx.refName);
  const marked = new Marked({
    gfm: true,
    breaks: false,
    // Between an inline `<code>` and its closing tag, marked marks the text
    // escaped and prints it as it stands. An unterminated tag in there would
    // then become a tag of the document's own. Clearing the mark sends the
    // text through the escape every other piece of text takes.
    walkTokens(token) {
      if (token.type === 'text' && token.escaped) token.escaped = false;
    },
  });

  /**
   * Addresses for the tags the document writes itself. A relative one with
   * no tree to resolve against is dropped rather than pointed at nothing,
   * which is the answer the renderer gives its own links and images.
   */
  const htmlContext = {
    /** @param {string} href */
    link(href) {
      if (!isSafeHref(href)) return null;
      if (!isRelative(href)) return { href, external: true };
      if (!inTree) return null;
      return {
        href: blobHref(ctx, resolveRepoPath(basePath, href)),
        external: false,
      };
    },
    /** @param {string} src @returns {Record<string, string>|null} */
    image(src) {
      if (!isSafeHref(src)) return null;
      if (!isRelative(src)) return { src };
      if (!inTree) return null;
      return { 'data-repo-path': resolveRepoPath(basePath, src) };
    },
  };

  marked.use({
    renderer: {
      /** Read the document's own tags and write back the ones allowed here. */
      html({ text: raw }) {
        return sanitizeHtml(raw, htmlContext);
      },

      /** Fenced code reads the same as a file does elsewhere in the app. */
      code({ text: source, lang }) {
        const language = lang ? languageFor(`x.${lang.split(/\s+/)[0]}`) : '';
        return `<pre class="md-code"><code>${highlightBlock(source, language)}</code></pre>`;
      },

      link({ href, title, tokens }) {
        const label = this.parser.parseInline(tokens);
        if (!isSafeHref(href)) return label;
        if (isRelative(href) && !inTree) return label;
        const target = isRelative(href)
          ? blobHref(ctx, resolveRepoPath(basePath, href))
          : href;
        const attrs = title ? ` title="${escapeAttribute(title)}"` : '';
        // An address outside the repository opens away from this page and is
        // told not to carry it along.
        const away = isRelative(href)
          ? ''
          : ' target="_blank" rel="noopener noreferrer"';
        return `<a href="${escapeAttribute(target)}"${attrs}${away}>${label}</a>`;
      },

      image({ href, title, text: alt }) {
        if (!isSafeHref(href)) return escapeAttribute(alt ?? '');
        const attrs = title ? ` title="${escapeAttribute(title)}"` : '';
        if (!isRelative(href)) {
          return `<img src="${escapeAttribute(href)}" alt="${escapeAttribute(alt ?? '')}"${attrs}>`;
        }
        // The file is in the repository, whose objects are already in memory.
        // The path travels on the tag and the view fills in the bytes.
        const path = resolveRepoPath(basePath, href);
        return `<img data-repo-path="${escapeAttribute(path)}" alt="${escapeAttribute(alt ?? '')}"${attrs}>`;
      },
    },
  });

  return marked.parse(text, { async: false });
}