chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// The HTML a repository's markdown is allowed to keep.
//
// A README writes tags of its own for what markdown has no syntax for: a row
// of screenshots, a centred banner, a collapsed section. That text belongs to
// whoever wrote the repository, so none of it may reach the page as markup of
// its own choosing.
//
// This module reads the tags and writes them again. A tag in the result
// carries a name from the list below, attributes from the list below, and
// values this file escaped. Every other tag is dropped, and for a few of them
// the text between the tags is dropped as well. Rewriting is what makes the
// result small enough to read: a mistake in the reader can lose a tag, but it
// cannot invent one.
/** Tags a document may open, each with the attributes it may carry. */
const TAGS = {
a: ['href'],
abbr: [],
b: [],
big: [],
blockquote: [],
br: [],
caption: [],
center: [],
cite: [],
code: [],
col: ['span', 'width'],
colgroup: ['span'],
dd: [],
del: [],
details: ['open'],
dfn: [],
div: [],
dl: [],
dt: [],
em: [],
figcaption: [],
figure: [],
h1: [],
h2: [],
h3: [],
h4: [],
h5: [],
h6: [],
hr: [],
i: [],
img: ['src', 'alt', 'width', 'height'],
ins: [],
kbd: [],
li: [],
mark: [],
ol: ['start', 'type'],
p: [],
picture: [],
pre: [],
q: [],
s: [],
samp: [],
small: [],
span: [],
strike: [],
strong: [],
sub: [],
summary: [],
sup: [],
table: [],
tbody: [],
td: ['colspan', 'rowspan', 'valign'],
tfoot: [],
th: ['colspan', 'rowspan', 'valign'],
thead: [],
time: [],
tr: [],
tt: [],
u: [],
ul: [],
var: [],
wbr: [],
};
/** Attributes any tag on the list may carry. */
const GLOBAL = ['align', 'title'];
/** Tags that take no closing tag. */
const VOID = new Set(['br', 'col', 'hr', 'img', 'wbr']);
/** Attributes whose presence is the whole value. */
const BOOLEAN = new Set(['open']);
/**
* Tags whose text is dropped along with the tag. A `<script>` reads as words
* if only its tags go, and those words are not what the page is for.
*/
const OPAQUE = new Set([
'embed',
'iframe',
'math',
'noembed',
'noframes',
'noscript',
'object',
'script',
'style',
'svg',
'template',
'textarea',
'title',
'xmp',
]);
const ALIGNMENTS = new Set(['left', 'right', 'center', 'justify']);
const VERTICAL = new Set(['top', 'middle', 'bottom', 'baseline']);
/** A count of rows, columns or list steps. */
const count = (/** @type {string} */ value) =>
/^\d{1,4}$/.test(value) ? value : null;
/** A length in pixels or in percent of the line. */
const size = (/** @type {string} */ value) =>
/^\d{1,5}%?$/.test(value) ? value : null;
/** @type {Record<string, (value: string) => string | null>} */
const VALUES = {
align: (value) =>
ALIGNMENTS.has(value.toLowerCase()) ? value.toLowerCase() : null,
valign: (value) =>
VERTICAL.has(value.toLowerCase()) ? value.toLowerCase() : null,
colspan: count,
rowspan: count,
span: count,
start: count,
width: size,
height: size,
type: (value) => (/^[1aAiI]$/.test(value) ? value : null),
};
const ESCAPES = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
};
/**
* Escape a value on its way into an attribute.
* @param {string} value
* @returns {string}
*/
export function escapeAttribute(value) {
return String(value).replace(
/[&<>"']/g,
(character) => ESCAPES[/** @type {keyof typeof ESCAPES} */ (character)],
);
}
/**
* Escape text on its way into the document. An entity the text already
* carries is left alone, so `&` reads as one ampersand rather than as
* five characters. A decoded entity in a text node is a character and never
* a tag.
* @param {string} text
* @returns {string}
*/
export function escapeText(text) {
return String(text).replace(
/[<>"']|&(?!(?:#\d{1,7}|#[Xx][0-9A-Fa-f]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});)/g,
(character) => ESCAPES[/** @type {keyof typeof ESCAPES} */ (character)],
);
}
/**
* Entities an attribute value may carry. The browser decodes these before it
* reads an address, so this file decodes them before it judges one. `colon`
* and `Tab` are here because they are the ones that hide a scheme.
*/
const ENTITIES = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
colon: ':',
sol: '/',
num: '#',
semi: ';',
lpar: '(',
rpar: ')',
period: '.',
comma: ',',
Tab: '\t',
NewLine: '\n',
};
/**
* Read an attribute value the way the browser reads one.
* @param {string} value
* @returns {string}
*/
function decodeEntities(value) {
return value.replace(
/&(#\d{1,7}|#[Xx][0-9A-Fa-f]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});?/g,
(whole, body) => {
if (body[0] === '#') {
const code =
body[1] === 'x' || body[1] === 'X'
? Number.parseInt(body.slice(2), 16)
: Number.parseInt(body.slice(1), 10);
return code > 0 && code <= 0x10ffff
? String.fromCodePoint(code)
: whole;
}
return ENTITIES[/** @type {keyof typeof ENTITIES} */ (body)] ?? whole;
},
);
}
/**
* @typedef {Object} HtmlTag
* @property {'tag'} type
* @property {string} name - lower case
* @property {boolean} closing
* @property {boolean} selfClosing
* @property {Map<string, string>} attributes - lower case names, decoded values
*/
/**
* @typedef {Object} HtmlText
* @property {'text'} type
* @property {string} text
*/
/**
* Read a fragment of HTML into its tags and the text between them. A comment,
* a doctype and a processing instruction each read as nothing at all.
* @param {string} raw
* @returns {Array<HtmlTag | HtmlText>}
*/
export function scanHtml(raw) {
/** @type {Array<HtmlTag | HtmlText>} */
const tokens = [];
/** @param {string} text */
const addText = (text) => {
if (text !== '') tokens.push({ type: 'text', text });
};
let at = 0;
while (at < raw.length) {
const angle = raw.indexOf('<', at);
if (angle === -1) {
addText(raw.slice(at));
break;
}
addText(raw.slice(at, angle));
if (raw.startsWith('<!--', angle)) {
const end = raw.indexOf('-->', angle + 4);
at = end === -1 ? raw.length : end + 3;
continue;
}
if (raw[angle + 1] === '!' || raw[angle + 1] === '?') {
const end = raw.indexOf('>', angle + 1);
at = end === -1 ? raw.length : end + 1;
continue;
}
const opening = /^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/.exec(raw.slice(angle));
if (!opening) {
// A bare angle bracket. It is text, and the caller escapes it.
addText('<');
at = angle + 1;
continue;
}
const read = readAttributes(raw, angle + opening[0].length);
tokens.push({
type: 'tag',
name: opening[2].toLowerCase(),
closing: opening[1] === '/',
selfClosing: read.selfClosing,
attributes: read.attributes,
});
at = read.end;
}
return tokens;
}
const SPACE = /\s/;
const NAME_END = /[\s/>=]/;
/**
* Read the attributes of one tag, from just after its name to just after the
* bracket that ends it.
* @param {string} raw
* @param {number} start
* @returns {{ attributes: Map<string, string>, end: number, selfClosing: boolean }}
*/
function readAttributes(raw, start) {
/** @type {Map<string, string>} */
const attributes = new Map();
let at = start;
let selfClosing = false;
while (at < raw.length) {
while (at < raw.length && SPACE.test(raw[at])) at++;
if (at >= raw.length) break;
if (raw[at] === '>') {
at++;
break;
}
if (raw[at] === '/') {
selfClosing = raw[at + 1] === '>';
at++;
continue;
}
const nameStart = at;
while (at < raw.length && !NAME_END.test(raw[at])) at++;
if (at === nameStart) {
// An `=` with no name before it. One step keeps the loop moving.
at++;
continue;
}
const name = raw.slice(nameStart, at).toLowerCase();
while (at < raw.length && SPACE.test(raw[at])) at++;
let value = '';
if (raw[at] === '=') {
at++;
while (at < raw.length && SPACE.test(raw[at])) at++;
const quote = raw[at];
if (quote === '"' || quote === "'") {
const end = raw.indexOf(quote, at + 1);
value = end === -1 ? raw.slice(at + 1) : raw.slice(at + 1, end);
at = end === -1 ? raw.length : end + 1;
} else {
const valueStart = at;
while (at < raw.length && !/[\s>]/.test(raw[at])) at++;
value = raw.slice(valueStart, at);
}
}
if (!attributes.has(name)) attributes.set(name, decodeEntities(value));
}
return { attributes, end: at, selfClosing };
}
/**
* @typedef {Object} HtmlContext
* @property {(href: string) => { href: string, external: boolean } | null} link
* - the address a link may point at, or null to drop the attribute
* @property {(src: string) => Record<string, string> | null} image - the
* attributes that fetch an image, or null to drop the source
*/
/**
* Write the attributes of one tag.
* @param {string} tag
* @param {Map<string, string>} attributes
* @param {HtmlContext} ctx
* @returns {string}
*/
function renderAttributes(tag, attributes, ctx) {
const allowed = /** @type {readonly string[]} */ (
TAGS[/** @type {keyof typeof TAGS} */ (tag)]
);
let out = '';
for (const [name, value] of attributes) {
if (tag === 'a' && name === 'href') {
const link = ctx.link(value);
if (!link) continue;
out += ` href="${escapeAttribute(link.href)}"`;
// An address outside the repository opens away from this page and is
// told not to carry it along.
if (link.external) out += ' target="_blank" rel="noopener noreferrer"';
continue;
}
if (tag === 'img' && name === 'src') {
const image = ctx.image(value);
if (!image) continue;
for (const [key, address] of Object.entries(image)) {
out += ` ${key}="${escapeAttribute(address)}"`;
}
continue;
}
if (!allowed.includes(name) && !GLOBAL.includes(name)) continue;
if (BOOLEAN.has(name)) {
out += ` ${name}`;
continue;
}
const clean = (VALUES[name] ?? ((given) => given))(value);
if (clean === null) continue;
out += ` ${name}="${escapeAttribute(clean)}"`;
}
return out;
}
/**
* Rewrite a fragment of a document's own HTML as the tags this page allows.
* @param {string} raw
* @param {HtmlContext} ctx
* @returns {string} HTML
*/
export function sanitizeHtml(raw, ctx) {
let out = '';
/** The opaque tag whose text is being dropped, if there is one. */
let opaque = '';
for (const token of scanHtml(raw)) {
if (opaque) {
if (token.type === 'tag' && token.closing && token.name === opaque) {
opaque = '';
}
continue;
}
if (token.type === 'text') {
out += escapeText(token.text);
continue;
}
const { name, closing, selfClosing, attributes } = token;
if (OPAQUE.has(name)) {
if (!closing && !selfClosing) opaque = name;
continue;
}
if (!Object.hasOwn(TAGS, name)) continue;
if (closing) {
if (!VOID.has(name)) out += `</${name}>`;
continue;
}
out += `<${name}${renderAttributes(name, attributes, ctx)}>`;
}
return out;
}