chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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 });
}