[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

Select the types of activity you want to include in your feed.

Support svg elements from plugins

Grace Kind (Jul 21, 2026, 4:51 PM -0500) 031ce4fa b1ae8e2d

+398 -25
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.18.43", 3 + "version": "0.18.44", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+208 -24
src/js/plugins/pluginRendering.js
··· 1 1 import { ExternalLinkWarningModal } from "/js/modals/externalLinkWarning.modal.js"; 2 - import { assertSafeInlineStyleValue } from "/js/plugins/pluginStylesLoader.js"; 2 + import { 3 + assertSafeInlineStyleValue, 4 + assertSafeSvgValue, 5 + } from "/js/plugins/pluginStylesLoader.js"; 3 6 import "/js/components/toggle-switch.js"; 4 7 import "/js/components/plugin-profiles-list.js"; 5 8 import "/js/components/plugin-posts-feed.js"; ··· 49 52 50 53 const ALLOWED_EVENTS = ["click", "change", "input"]; 51 54 55 + const HTML_NS = "http://www.w3.org/1999/xhtml"; 56 + const SVG_NS = "http://www.w3.org/2000/svg"; 57 + 58 + // Case-sensitive 59 + const SVG_ALLOWED_TAGS = new Set([ 60 + "svg", 61 + "g", 62 + "path", 63 + "rect", 64 + "circle", 65 + "ellipse", 66 + "line", 67 + "polyline", 68 + "polygon", 69 + "title", 70 + "desc", 71 + ]); 72 + 73 + const SVG_ALLOWED_ATTRS = new Set([ 74 + "viewBox", 75 + "preserveAspectRatio", 76 + "width", 77 + "height", 78 + "x", 79 + "y", 80 + "x1", 81 + "y1", 82 + "x2", 83 + "y2", 84 + "cx", 85 + "cy", 86 + "r", 87 + "rx", 88 + "ry", 89 + "d", 90 + "points", 91 + "transform", 92 + "class", 93 + "role", 94 + "fill", 95 + "fill-rule", 96 + "fill-opacity", 97 + "stroke", 98 + "stroke-width", 99 + "stroke-linecap", 100 + "stroke-linejoin", 101 + "stroke-dasharray", 102 + "stroke-opacity", 103 + "opacity", 104 + "color", 105 + ]); 106 + 107 + const SVG_MAX_PATH_LENGTH = 32 * 1024; 108 + 52 109 function isAllowedTag(tag) { 53 110 return ALLOWED_TAGS.includes(tag); 111 + } 112 + 113 + function isAllowedSvgAttr(name) { 114 + if (SVG_ALLOWED_ATTRS.has(name)) return true; 115 + if (name.startsWith("data-") || name.startsWith("aria-")) return true; 116 + return false; 54 117 } 55 118 56 119 const ALLOWED_ATTRS = [ ··· 288 351 return tag; 289 352 } 290 353 354 + function resolveSvgTag(node, pluginId) { 355 + const tag = typeof node.tag === "string" ? node.tag : ""; 356 + if (SVG_ALLOWED_TAGS.has(tag)) return tag; 357 + if (pluginId !== undefined) { 358 + console.warn( 359 + `[plugins] "${pluginId}" tried to render disallowed SVG tag <${tag}>`, 360 + ); 361 + } 362 + return "g"; 363 + } 364 + 365 + // Namespace is HTML by default, SVG once we enter an <svg> 366 + function resolveChildNamespace(parentNs, node) { 367 + if (parentNs === SVG_NS) return SVG_NS; 368 + if (typeof node.tag === "string" && node.tag.toLowerCase() === "svg") { 369 + return SVG_NS; 370 + } 371 + return HTML_NS; 372 + } 373 + 291 374 // Render a serialized VirtualNode (text or element) into a DOM node. 292 375 export class PluginRenderer { 293 376 constructor(pluginBridge, pluginId, renderContext) { ··· 303 386 el: null, 304 387 render(rawNode) { 305 388 const node = renderer._normalize(rawNode); 306 - if (this.el && renderer._sameKind(this.tree, node)) { 307 - renderer._patch(this.el, this.tree, node); 389 + if (this.el && renderer._sameKind(this.tree, node, HTML_NS)) { 390 + renderer._patch(this.el, this.tree, node, HTML_NS); 308 391 } else { 309 - this.el = renderer._create(node); 392 + this.el = renderer._create(node, HTML_NS); 310 393 } 311 394 this.tree = node; 312 395 return this.el; ··· 340 423 console.warn(`[plugins] "${this.pluginId}" had ${summary}`); 341 424 } 342 425 343 - _sameKind(oldNode, newNode) { 426 + _sameKind(oldNode, newNode, parentNs = HTML_NS) { 344 427 if (!oldNode || !newNode) return false; 345 428 if (oldNode.type === "text" && newNode.type === "text") return true; 346 429 if (oldNode.type === "element" && newNode.type === "element") { 430 + const oldNs = resolveChildNamespace(parentNs, oldNode); 431 + const newNs = resolveChildNamespace(parentNs, newNode); 432 + if (oldNs !== newNs) return false; 433 + if (oldNs === SVG_NS) { 434 + return resolveSvgTag(oldNode) === resolveSvgTag(newNode); 435 + } 347 436 return resolveTag(oldNode) === resolveTag(newNode); 348 437 } 349 438 return false; 350 439 } 351 440 352 - _create(node) { 441 + _create(node, parentNs = HTML_NS) { 353 442 if (node.type === "text") { 354 443 return document.createTextNode(node.value); 355 444 } 356 - const pluginId = this.pluginId; 357 - const tag = resolveTag(node, pluginId); 445 + const ns = resolveChildNamespace(parentNs, node); 446 + if (ns === SVG_NS) return this._createSvg(node); 447 + const tag = resolveTag(node, this.pluginId); 358 448 const element = document.createElement(tag); 359 449 if (tag === "a") { 360 450 element.setAttribute("target", "_blank"); ··· 393 483 for (const [name, value] of Object.entries(node.attrs)) { 394 484 if (!isAllowedAttr(name, tag)) { 395 485 console.warn( 396 - `[plugins] "${pluginId}" tried to set disallowed attribute "${name}" on <${tag}>`, 486 + `[plugins] "${this.pluginId}" tried to set disallowed attribute "${name}" on <${tag}>`, 397 487 ); 398 488 continue; 399 489 } 400 490 if (name === "href" && !isSafeHref(value)) { 401 491 console.warn( 402 - `[plugins] "${pluginId}" tried to set unsafe href "${value}"`, 492 + `[plugins] "${this.pluginId}" tried to set unsafe href "${value}"`, 403 493 ); 404 494 continue; 405 495 } ··· 413 503 } 414 504 this._patchEvents(element, null, node.events); 415 505 for (const child of node.children) { 416 - element.appendChild(this._create(child)); 506 + element.appendChild(this._create(child, HTML_NS)); 417 507 } 418 508 return element; 419 509 } 420 510 421 - _patch(node, oldNode, newNode) { 511 + _createSvg(node) { 512 + const tag = resolveSvgTag(node, this.pluginId); 513 + const element = document.createElementNS(SVG_NS, tag); 514 + if (node.attrs) { 515 + for (const [name, value] of Object.entries(node.attrs)) { 516 + this._setSvgAttr(element, tag, name, value); 517 + } 518 + } 519 + if (node.styles) { 520 + for (const [name, value] of Object.entries(node.styles)) { 521 + applyStyle(element, name, value); 522 + } 523 + } 524 + for (const child of node.children) { 525 + if (child.type === "text") { 526 + element.appendChild(document.createTextNode(child.value)); 527 + continue; 528 + } 529 + element.appendChild(this._create(child, SVG_NS)); 530 + } 531 + return element; 532 + } 533 + 534 + _setSvgAttr(element, tag, name, value) { 535 + const pluginId = this.pluginId; 536 + if (!isAllowedSvgAttr(name)) { 537 + if (pluginId !== undefined) { 538 + console.warn( 539 + `[plugins] "${pluginId}" tried to set disallowed SVG attribute "${name}" on <${tag}>`, 540 + ); 541 + } 542 + return; 543 + } 544 + const stringValue = String(value); 545 + if ( 546 + (name === "d" || name === "points") && 547 + stringValue.length > SVG_MAX_PATH_LENGTH 548 + ) { 549 + if (pluginId !== undefined) { 550 + console.warn( 551 + `[plugins] "${pluginId}" SVG "${name}" attribute exceeds max length; dropped`, 552 + ); 553 + } 554 + return; 555 + } 556 + try { 557 + assertSafeSvgValue(name, stringValue); 558 + } catch { 559 + if (pluginId !== undefined) { 560 + console.warn( 561 + `[plugins] "${pluginId}" tried to set unsafe SVG "${name}" value`, 562 + ); 563 + } 564 + return; 565 + } 566 + element.setAttribute(name, stringValue); 567 + } 568 + 569 + _patch(node, oldNode, newNode, parentNs = HTML_NS) { 422 570 if (newNode.type === "text") { 423 571 if (node.nodeValue !== newNode.value) node.nodeValue = newNode.value; 424 572 return; 425 573 } 426 - const pluginId = this.pluginId; 574 + const ns = resolveChildNamespace(parentNs, newNode); 575 + if (ns === SVG_NS) { 576 + this._patchSvg(node, oldNode, newNode); 577 + return; 578 + } 427 579 const element = node; 428 580 const oldAttrs = oldNode.attrs ?? {}; 429 581 const newAttrs = newNode.attrs ?? {}; ··· 431 583 const tag = element.localName; 432 584 433 585 for (const name of Object.keys(oldAttrs)) { 434 - if (!(name in newAttrs) && isAllowedAttr(name, tag)) { 435 - element.removeAttribute(name); 436 - } 586 + if (!(name in newAttrs)) element.removeAttribute(name); 437 587 } 438 588 for (const [name, value] of Object.entries(newAttrs)) { 439 589 if (!isAllowedAttr(name, tag)) { 440 590 console.warn( 441 - `[plugins] "${pluginId}" tried to set disallowed attribute "${name}"`, 591 + `[plugins] "${this.pluginId}" tried to set disallowed attribute "${name}"`, 442 592 ); 443 593 continue; 444 594 } ··· 446 596 if (isFocused && (name === "value" || name === "checked")) continue; 447 597 if (name === "href" && !isSafeHref(value)) { 448 598 console.warn( 449 - `[plugins] "${pluginId}" tried to set unsafe href "${value}"`, 599 + `[plugins] "${this.pluginId}" tried to set unsafe href "${value}"`, 450 600 ); 451 601 element.removeAttribute("href"); 452 602 continue; ··· 472 622 element.refresh(); 473 623 } 474 624 475 - const oldChildren = oldNode.children ?? []; 476 - const newChildren = newNode.children ?? []; 625 + this._patchChildren( 626 + element, 627 + oldNode.children ?? [], 628 + newNode.children ?? [], 629 + HTML_NS, 630 + ); 631 + } 632 + 633 + _patchChildren(element, oldChildren, newChildren, parentNs) { 477 634 const domChildren = Array.from(element.childNodes); 478 635 const max = Math.max(oldChildren.length, newChildren.length); 479 636 for (let index = 0; index < max; index++) { ··· 481 638 const newChild = newChildren[index]; 482 639 const domChild = domChildren[index]; 483 640 if (!oldChild && newChild) { 484 - element.appendChild(this._create(newChild)); 641 + element.appendChild(this._create(newChild, parentNs)); 485 642 } else if (oldChild && !newChild) { 486 643 if (domChild) element.removeChild(domChild); 487 - } else if (this._sameKind(oldChild, newChild)) { 488 - this._patch(domChild, oldChild, newChild); 644 + } else if (this._sameKind(oldChild, newChild, parentNs)) { 645 + this._patch(domChild, oldChild, newChild, parentNs); 489 646 } else { 490 - element.replaceChild(this._create(newChild), domChild); 647 + element.replaceChild(this._create(newChild, parentNs), domChild); 491 648 } 492 649 } 650 + } 651 + 652 + _patchSvg(element, oldNode, newNode) { 653 + const tag = element.localName; 654 + const oldAttrs = oldNode.attrs ?? {}; 655 + const newAttrs = newNode.attrs ?? {}; 656 + for (const name of Object.keys(oldAttrs)) { 657 + if (!(name in newAttrs)) element.removeAttribute(name); 658 + } 659 + for (const [name, value] of Object.entries(newAttrs)) { 660 + if (oldAttrs[name] === value) continue; 661 + this._setSvgAttr(element, tag, name, value); 662 + } 663 + const oldStyles = oldNode.styles ?? {}; 664 + const newStyles = newNode.styles ?? {}; 665 + for (const name of Object.keys(oldStyles)) { 666 + if (!(name in newStyles)) element.style.removeProperty(name); 667 + } 668 + for (const [name, value] of Object.entries(newStyles)) { 669 + if (oldStyles[name] !== value) applyStyle(element, name, value); 670 + } 671 + this._patchChildren( 672 + element, 673 + oldNode.children ?? [], 674 + newNode.children ?? [], 675 + SVG_NS, 676 + ); 493 677 } 494 678 495 679 _patchEvents(element, oldEvents, newEvents) {
+11
src/js/plugins/pluginStylesLoader.js
··· 7 7 } 8 8 } 9 9 10 + // Rejects all url()/image-set()/etc, matching the stylesheet rule 11 + export function assertSafeSvgValue(attribute, value) { 12 + if (typeof value !== "string") return; 13 + if (URL_FUNC_RE.test(value)) { 14 + throw new Error(`disallowed url() in ${attribute}`); 15 + } 16 + if (value.includes("\\")) { 17 + throw new Error(`disallowed escape in ${attribute}`); 18 + } 19 + } 20 + 10 21 export function assertSafeInlineStyleValue(property, value) { 11 22 // Reject any backslash so a CSS-escape-encoded identifier (e.g. `\75rl(...)`) 12 23 // can't slip past URL_FUNC_RE
+178
tests/unit/specs/plugins/pluginRendering.test.js
··· 1119 1119 assert.deepEqual(warn.mock.callCount(), 0); 1120 1120 }); 1121 1121 }); 1122 + 1123 + const SVG_NS = "http://www.w3.org/2000/svg"; 1124 + const HTML_NS = "http://www.w3.org/1999/xhtml"; 1125 + 1126 + function svgTree(pathAttrs = { d: "M0 0 L10 10" }) { 1127 + return { 1128 + type: "element", 1129 + tag: "svg", 1130 + attrs: { viewBox: "0 0 10 10", width: "10", height: "10" }, 1131 + children: [ 1132 + { type: "element", tag: "path", attrs: pathAttrs, children: [] }, 1133 + ], 1134 + }; 1135 + } 1136 + 1137 + describe("PluginRenderer:svg", () => { 1138 + it("renders <svg><path> with the SVG namespace", () => { 1139 + const { bridge } = makeBridge(); 1140 + const renderer = new PluginRenderer(bridge, "demo"); 1141 + const element = renderer.createRoot().render(svgTree()); 1142 + assert.deepEqual(element.namespaceURI, SVG_NS); 1143 + assert.deepEqual(element.tagName, "svg"); 1144 + const path = element.firstChild; 1145 + assert.deepEqual(path.namespaceURI, SVG_NS); 1146 + assert.deepEqual(path.getAttribute("d"), "M0 0 L10 10"); 1147 + }); 1148 + 1149 + it("preserves camelCase attributes like viewBox", () => { 1150 + const { bridge } = makeBridge(); 1151 + const renderer = new PluginRenderer(bridge, "demo"); 1152 + const element = renderer.createRoot().render(svgTree()); 1153 + assert.deepEqual(element.getAttribute("viewBox"), "0 0 10 10"); 1154 + assert(!element.hasAttribute("viewbox")); 1155 + }); 1156 + 1157 + it("replaces disallowed SVG tags with an inert <g>", () => { 1158 + const { bridge } = makeBridge(); 1159 + const renderer = new PluginRenderer(bridge, "demo"); 1160 + const element = renderer.createRoot().render({ 1161 + type: "element", 1162 + tag: "svg", 1163 + attrs: {}, 1164 + children: [ 1165 + { type: "element", tag: "script", attrs: {}, children: [] }, 1166 + { type: "element", tag: "foreignObject", attrs: {}, children: [] }, 1167 + { type: "element", tag: "path", attrs: { d: "M0 0" }, children: [] }, 1168 + ], 1169 + }); 1170 + assert.deepEqual(element.childNodes.length, 3); 1171 + const [first, second, third] = element.childNodes; 1172 + assert.deepEqual(first.namespaceURI, SVG_NS); 1173 + assert.deepEqual(first.tagName, "g"); 1174 + assert.deepEqual(second.tagName, "g"); 1175 + assert.deepEqual(third.tagName, "path"); 1176 + }); 1177 + 1178 + it("drops disallowed SVG attributes and on* handlers", () => { 1179 + const { bridge } = makeBridge(); 1180 + const renderer = new PluginRenderer(bridge, "demo"); 1181 + const element = renderer.createRoot().render({ 1182 + type: "element", 1183 + tag: "svg", 1184 + attrs: { onclick: "alert(1)", xmlns: "http://evil", id: "grad" }, 1185 + children: [], 1186 + }); 1187 + assert(!element.hasAttribute("onclick")); 1188 + assert(!element.hasAttribute("xmlns")); 1189 + assert(!element.hasAttribute("id")); 1190 + }); 1191 + 1192 + it("rejects url() in SVG attribute values", () => { 1193 + const { bridge } = makeBridge(); 1194 + const renderer = new PluginRenderer(bridge, "demo"); 1195 + const element = renderer.createRoot().render({ 1196 + type: "element", 1197 + tag: "svg", 1198 + attrs: {}, 1199 + children: [ 1200 + { 1201 + type: "element", 1202 + tag: "path", 1203 + attrs: { d: "M0 0", fill: "url(https://evil.example/x.svg)" }, 1204 + children: [], 1205 + }, 1206 + ], 1207 + }); 1208 + const path = element.firstChild; 1209 + assert(!path.hasAttribute("fill")); 1210 + assert.deepEqual(path.getAttribute("d"), "M0 0"); 1211 + }); 1212 + 1213 + it("caps oversized d and points attributes", () => { 1214 + const { bridge } = makeBridge(); 1215 + const renderer = new PluginRenderer(bridge, "demo"); 1216 + const huge = "M" + "0 ".repeat(20000); 1217 + const element = renderer.createRoot().render({ 1218 + type: "element", 1219 + tag: "svg", 1220 + attrs: {}, 1221 + children: [ 1222 + { type: "element", tag: "path", attrs: { d: huge }, children: [] }, 1223 + ], 1224 + }); 1225 + assert(!element.firstChild.hasAttribute("d")); 1226 + }); 1227 + 1228 + it("patches attribute updates on an SVG subtree", () => { 1229 + const { bridge } = makeBridge(); 1230 + const renderer = new PluginRenderer(bridge, "demo"); 1231 + const root = renderer.createRoot(); 1232 + const first = root.render(svgTree({ d: "M0 0" })); 1233 + const second = root.render(svgTree({ d: "M1 1" })); 1234 + assert(first === second); 1235 + assert.deepEqual(first.firstChild.getAttribute("d"), "M1 1"); 1236 + }); 1237 + 1238 + it("removes attributes that are no longer present on patch", () => { 1239 + const { bridge } = makeBridge(); 1240 + const renderer = new PluginRenderer(bridge, "demo"); 1241 + const root = renderer.createRoot(); 1242 + const first = root.render({ 1243 + type: "element", 1244 + tag: "svg", 1245 + attrs: { viewBox: "0 0 10 10", width: "10" }, 1246 + children: [], 1247 + }); 1248 + root.render({ 1249 + type: "element", 1250 + tag: "svg", 1251 + attrs: { viewBox: "0 0 10 10" }, 1252 + children: [], 1253 + }); 1254 + assert(!first.hasAttribute("width")); 1255 + }); 1256 + 1257 + it("allows <title>, <desc>, and role/aria attributes for a11y", () => { 1258 + const { bridge } = makeBridge(); 1259 + const renderer = new PluginRenderer(bridge, "demo"); 1260 + const element = renderer.createRoot().render({ 1261 + type: "element", 1262 + tag: "svg", 1263 + attrs: { role: "img", "aria-label": "square root" }, 1264 + children: [ 1265 + { 1266 + type: "element", 1267 + tag: "title", 1268 + attrs: {}, 1269 + children: [{ type: "text", value: "sqrt" }], 1270 + }, 1271 + { 1272 + type: "element", 1273 + tag: "desc", 1274 + attrs: {}, 1275 + children: [{ type: "text", value: "a radical" }], 1276 + }, 1277 + ], 1278 + }); 1279 + assert.deepEqual(element.getAttribute("role"), "img"); 1280 + assert.deepEqual(element.getAttribute("aria-label"), "square root"); 1281 + const [title, desc] = element.childNodes; 1282 + assert.deepEqual(title.namespaceURI, SVG_NS); 1283 + assert.deepEqual(title.tagName, "title"); 1284 + assert.deepEqual(title.textContent, "sqrt"); 1285 + assert.deepEqual(desc.textContent, "a radical"); 1286 + }); 1287 + 1288 + it("leaves existing HTML rendering unaffected", () => { 1289 + const { bridge } = makeBridge(); 1290 + const renderer = new PluginRenderer(bridge, "demo"); 1291 + const element = renderer.createRoot().render({ 1292 + tag: "div", 1293 + attrs: { class: "x" }, 1294 + children: [{ type: "text", value: "hello" }], 1295 + }); 1296 + assert.deepEqual(element.namespaceURI, HTML_NS); 1297 + assert.deepEqual(element.textContent, "hello"); 1298 + }); 1299 + });