mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-08 18:51:24 +03:00
279 lines
7.2 KiB
TypeScript
279 lines
7.2 KiB
TypeScript
// A pull request's "### Visual" SVG, rebuilt from an allowlist for the What's new in beta dialog, which shows it only
|
|
// as an <img>. The input is parsed as XML and serialized again from scratch: exactly one <svg> root, known shape,
|
|
// paint and animation elements and attributes only, references to the document itself only, and no CSS escapes.
|
|
export * as BetaVisual from "./beta-visual"
|
|
|
|
import { DomUtils, parseDocument } from "htmlparser2"
|
|
|
|
/** The most bytes a visual may take once rebuilt. */
|
|
const LIMIT = 8 * 1024
|
|
|
|
const ELEMENTS = new Set([
|
|
"svg",
|
|
"g",
|
|
"defs",
|
|
"symbol",
|
|
"use",
|
|
"rect",
|
|
"circle",
|
|
"ellipse",
|
|
"line",
|
|
"polyline",
|
|
"polygon",
|
|
"path",
|
|
"text",
|
|
"tspan",
|
|
"title",
|
|
"desc",
|
|
"clipPath",
|
|
"mask",
|
|
"linearGradient",
|
|
"radialGradient",
|
|
"stop",
|
|
"style",
|
|
"animate",
|
|
"animateTransform",
|
|
"animateMotion",
|
|
"set",
|
|
])
|
|
|
|
const SMIL = new Set(["animate", "animateTransform", "animateMotion", "set"])
|
|
|
|
const ATTRIBUTES = new Set([
|
|
"id",
|
|
"class",
|
|
"style",
|
|
"viewBox",
|
|
"preserveAspectRatio",
|
|
"width",
|
|
"height",
|
|
"x",
|
|
"y",
|
|
"x1",
|
|
"y1",
|
|
"x2",
|
|
"y2",
|
|
"cx",
|
|
"cy",
|
|
"r",
|
|
"rx",
|
|
"ry",
|
|
"fx",
|
|
"fy",
|
|
"d",
|
|
"points",
|
|
"pathLength",
|
|
"transform",
|
|
"transform-origin",
|
|
"href",
|
|
"color",
|
|
"fill",
|
|
"fill-opacity",
|
|
"fill-rule",
|
|
"stroke",
|
|
"stroke-width",
|
|
"stroke-opacity",
|
|
"stroke-linecap",
|
|
"stroke-linejoin",
|
|
"stroke-dasharray",
|
|
"stroke-dashoffset",
|
|
"stroke-miterlimit",
|
|
"opacity",
|
|
"visibility",
|
|
"display",
|
|
"vector-effect",
|
|
"paint-order",
|
|
"clip-path",
|
|
"clip-rule",
|
|
"clipPathUnits",
|
|
"mask",
|
|
"maskUnits",
|
|
"maskContentUnits",
|
|
"offset",
|
|
"stop-color",
|
|
"stop-opacity",
|
|
"gradientUnits",
|
|
"gradientTransform",
|
|
"spreadMethod",
|
|
"font-size",
|
|
"font-weight",
|
|
"font-family",
|
|
"letter-spacing",
|
|
"text-anchor",
|
|
"dominant-baseline",
|
|
"attributeName",
|
|
"attributeType",
|
|
"values",
|
|
"from",
|
|
"to",
|
|
"by",
|
|
"dur",
|
|
"begin",
|
|
"end",
|
|
"repeatCount",
|
|
"repeatDur",
|
|
"keyTimes",
|
|
"keySplines",
|
|
"keyPoints",
|
|
"calcMode",
|
|
"additive",
|
|
"accumulate",
|
|
"restart",
|
|
"type",
|
|
"rotate",
|
|
"path",
|
|
])
|
|
|
|
/** Where text may appear; elsewhere it is formatting whitespace. */
|
|
const TEXT = new Set(["text", "tspan", "title", "desc", "style"])
|
|
|
|
type Node = ReturnType<typeof parseDocument>["children"][number]
|
|
|
|
/**
|
|
* The visual rebuilt twice: `animated` as drawn, and `still` with its SMIL elements, keyframes and animation and
|
|
* transition declarations removed, for reduced motion. None when it is not one <svg> root, uses anything outside the
|
|
* allowlist that can reach outside the document (a CSS escape, `@import`, a `url()` or `href` that is not `#id`, a
|
|
* doctype), or is over 8 KB.
|
|
*/
|
|
export function sanitize(input: string) {
|
|
if (input.length > LIMIT * 4 || /<!(doctype|entity|element|attlist)/i.test(input)) return undefined
|
|
|
|
const document = parseDocument(input.replace(/^\s*<\?xml[^>]*\?>/, ""), {
|
|
xmlMode: true,
|
|
recognizeCDATA: true,
|
|
recognizeSelfClosing: true,
|
|
})
|
|
|
|
const roots = document.children.filter(
|
|
(node) => !DomUtils.isComment(node) && !(DomUtils.isText(node) && !node.data.trim()),
|
|
)
|
|
|
|
const root = roots[0]
|
|
|
|
if (roots.length !== 1 || !root || !DomUtils.isTag(root) || root.name !== "svg") return undefined
|
|
|
|
const animated = serialize(root, false)
|
|
const still = serialize(root, true)
|
|
|
|
if (animated === undefined || still === undefined || new TextEncoder().encode(animated).length > LIMIT)
|
|
return undefined
|
|
|
|
return { animated, still }
|
|
}
|
|
|
|
/** One element and its allowed children as markup; undefined anywhere means the whole visual is refused. */
|
|
function serialize(node: Node, still: boolean): string | undefined {
|
|
if (!DomUtils.isTag(node) || !ELEMENTS.has(node.name)) return ""
|
|
|
|
const name = node.name
|
|
|
|
if (SMIL.has(name) && (still || /href$/i.test(node.attribs.attributeName ?? ""))) return ""
|
|
|
|
const attributes = Object.entries(node.attribs).flatMap(([key, value]): [string, string | undefined][] => {
|
|
const attribute = key === "xlink:href" ? "href" : key
|
|
|
|
if (!ATTRIBUTES.has(attribute)) return []
|
|
|
|
if (attribute === "href" && !/^#[\w-]+$/.test(value)) return []
|
|
|
|
if (attribute === "style") return [[attribute, declarations(value, still)]]
|
|
|
|
return [[attribute, safe(value) ? value : undefined]]
|
|
})
|
|
|
|
// A CSS escape or an outside reference in any value refuses the whole visual, rather than drawing part of it.
|
|
if (attributes.some(([, value]) => value === undefined)) return undefined
|
|
|
|
const children = node.children.map((child) => {
|
|
if (DomUtils.isTag(child)) return serialize(child, still)
|
|
|
|
if (DomUtils.isCDATA(child) || DomUtils.isText(child)) return text(name, DomUtils.textContent(child), still)
|
|
|
|
return ""
|
|
})
|
|
|
|
if (children.includes(undefined)) return undefined
|
|
|
|
const open = [
|
|
name,
|
|
...(name === "svg" ? [`xmlns="http://www.w3.org/2000/svg"`] : []),
|
|
...attributes.map(([key, value]) => `${key}="${escape(value)}"`),
|
|
].join(" ")
|
|
|
|
const body = children.join("")
|
|
|
|
return body ? `<${open}>${body}</${name}>` : `<${open}/>`
|
|
}
|
|
|
|
/** An element's text, kept only where text means something; a stylesheet must pass `stylesheet`. */
|
|
function text(element: string, value: string, still: boolean) {
|
|
if (!TEXT.has(element)) return ""
|
|
|
|
if (element !== "style") return escape(value)
|
|
|
|
const css = stylesheet(value, still)
|
|
|
|
return css === undefined ? undefined : escape(css)
|
|
}
|
|
|
|
/** A stylesheet that only styles this document: rules, `@keyframes` and `@media`, no escapes or outside urls. */
|
|
function stylesheet(value: string, still: boolean) {
|
|
const css = value.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
|
|
if (!safe(css) || /@(?!(-webkit-)?keyframes\b|media\b)/i.test(css)) return undefined
|
|
|
|
if (!still) return css.trim()
|
|
|
|
return withoutKeyframes(css)
|
|
.replace(/\{([^{}]*)\}/g, (_, body: string) => `{${declarations(body, true)}}`)
|
|
.trim()
|
|
}
|
|
|
|
/** A declaration list, without its animations and transitions for the still. */
|
|
function declarations(value: string, still: boolean) {
|
|
const css = value.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
|
|
if (!safe(css)) return undefined
|
|
|
|
if (!still) return css.trim()
|
|
|
|
return css
|
|
.split(";")
|
|
.filter((declaration) => !/^\s*(-webkit-)?(animation|transition)(-[\w-]+)?\s*:/i.test(declaration))
|
|
.join(";")
|
|
.trim()
|
|
}
|
|
|
|
/** The stylesheet without its `@keyframes` blocks, braces balanced. */
|
|
function withoutKeyframes(css: string) {
|
|
const start = css.search(/@(-webkit-)?keyframes\b/i)
|
|
|
|
if (start === -1) return css
|
|
|
|
const open = css.indexOf("{", start)
|
|
|
|
if (open === -1) return css.slice(0, start)
|
|
|
|
const end = Array.from(css.slice(open)).reduce(
|
|
(state, char, index) => {
|
|
if (state.end !== -1) return state
|
|
|
|
const depth = state.depth + ({ "{": 1, "}": -1 }[char] ?? 0)
|
|
|
|
return { depth, end: depth === 0 ? open + index + 1 : -1 }
|
|
},
|
|
{ depth: 0, end: -1 },
|
|
).end
|
|
|
|
return css.slice(0, start) + (end === -1 ? "" : withoutKeyframes(css.slice(end)))
|
|
}
|
|
|
|
/** No CSS escapes, no `@import`, and every `url()` points into the document. */
|
|
function safe(value: string) {
|
|
return !value.includes("\\") && !/@import/i.test(value) && !/url\(\s*(?!['"]?#)/i.test(value)
|
|
}
|
|
|
|
function escape(value: string) {
|
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
|
}
|