diff --git a/apps/client/src/widgets/highlights_list.ts b/apps/client/src/widgets/highlights_list.ts index 6557478e37..447c932c1b 100644 --- a/apps/client/src/widgets/highlights_list.ts +++ b/apps/client/src/widgets/highlights_list.ts @@ -9,17 +9,18 @@ import appContext, { type EventData } from "../components/app_context.js"; import type FNote from "../entities/fnote.js"; import attributeService from "../services/attributes.js"; import { t } from "../services/i18n.js"; +import katex from "../services/math.js"; import options from "../services/options.js"; import OnClickButtonWidget from "./buttons/onclick_button.js"; import RightPanelWidget from "./right_panel_widget.js"; -import DOMPurify from "dompurify"; +import DOMPurify, { type Config as DOMPurifyConfig } from "dompurify"; /** * DOMPurify configuration for highlight list items. Only allows inline * formatting tags that appear in highlighted text (bold, italic, underline, * colored/background-colored spans, KaTeX math output). */ -const HIGHLIGHT_PURIFY_CONFIG: DOMPurify.Config = { +const HIGHLIGHT_PURIFY_CONFIG: DOMPurifyConfig = { ALLOWED_TAGS: [ "b", "i", "em", "strong", "u", "s", "del", "sub", "sup", "code", "mark", "span", "abbr", "small", "a", @@ -145,6 +146,77 @@ export default class HighlightsListWidget extends RightPanelWidget { this.triggerCommand("reEvaluateRightPaneVisibility"); } + extractOuterTag(htmlStr: string | null) { + if (htmlStr === null) { + return null; + } + // Regular expressions that match only the outermost tag + const regex = /^<([a-zA-Z]+)([^>]*)>/; + const match = htmlStr.match(regex); + if (match) { + const tagName = match[1].toLowerCase(); // Extract tag name + const attributes = match[2].trim(); // Extract label attributes + return { tagName, attributes }; + } + return null; + } + + areOuterTagsConsistent(str1: string | null, str2: string | null) { + const tag1 = this.extractOuterTag(str1); + const tag2 = this.extractOuterTag(str2); + // If one of them has no label, returns false + if (!tag1 || !tag2) { + return false; + } + // Compare tag names and attributes to see if they are the same + return tag1.tagName === tag2.tagName && tag1.attributes === tag2.attributes; + } + + /** + * Rendering formulas in strings using katex + * + * @param html Note's html content + * @returns The HTML content with mathematical formulas rendered by KaTeX. + */ + async replaceMathTextWithKatax(html: string) { + const mathTextRegex = /\\\(([\s\S]*?)\\\)<\/span>/g; + const matches = [...html.matchAll(mathTextRegex)]; + let modifiedText = html; + + if (matches.length > 0) { + // Process all matches asynchronously + for (const match of matches) { + const latexCode = match[1]; + let rendered; + + try { + rendered = katex.renderToString(latexCode, { + throwOnError: false + }); + } catch (e) { + if (e instanceof ReferenceError && e.message.includes("katex is not defined")) { + // Load KaTeX if it is not already loaded + try { + rendered = katex.renderToString(latexCode, { + throwOnError: false + }); + } catch (renderError) { + console.error("KaTeX rendering error after loading library:", renderError); + rendered = match[0]; // Fall back to original if error persists + } + } else { + console.error("KaTeX rendering error:", e); + rendered = match[0]; // Fall back to original on error + } + } + + // Replace the matched formula in the modified text + modifiedText = modifiedText.replace(match[0], rendered); + } + } + return modifiedText; + } + async getHighlightList(content: string, optionsHighlightsList: string[]) { // matches a span containing background-color const regex1 = /]*style\s*=\s*[^>]*background-color:[^>]*?>[\s\S]*?<\/span>/gi; @@ -188,6 +260,9 @@ export default class HighlightsListWidget extends RightPanelWidget { const $highlightsList = $("
    "); let prevEndIndex = -1, hlLiCount = 0; + let prevSubHtml: string | null = null; + // Used to determine if a string is only a formula + const onlyMathRegex = /^\\\([^\)]*?\)<\/span>(?:\\\([^\)]*?\)<\/span>)*$/; for (let match: RegExpMatchArray | null = null, hltIndex = 0; (match = combinedRegex.exec(content)) !== null; hltIndex++) { const subHtml = match[0]; @@ -201,7 +276,7 @@ export default class HighlightsListWidget extends RightPanelWidget { if (prevEndIndex !== -1 && startIndex === prevEndIndex) { // If the previous element is connected to this element in HTML, then concatenate them into one. - $highlightsList.children().last().append(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG)); + $highlightsList.children().last().append(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG) as string); } else { const hasText = $(subHtml).text().trim(); @@ -210,12 +285,12 @@ export default class HighlightsListWidget extends RightPanelWidget { //If the two elements have the same style and there are only formulas in between, append the formulas and the current element to the end of the previous element. if (this.areOuterTagsConsistent(prevSubHtml, subHtml) && onlyMathRegex.test(substring)) { const $lastLi = $highlightsList.children("li").last(); - $lastLi.append(DOMPurify.sanitize(await this.replaceMathTextWithKatax(substring), HIGHLIGHT_PURIFY_CONFIG)); - $lastLi.append(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG)); + $lastLi.append(DOMPurify.sanitize(await this.replaceMathTextWithKatax(substring), HIGHLIGHT_PURIFY_CONFIG) as string); + $lastLi.append(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG) as string); } else { $highlightsList.append( $("
  1. ") - .html(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG)) + .html(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG) as string) .on("click", () => this.jumpToHighlightsList(findSubStr, hltIndex)) ); } @@ -227,6 +302,7 @@ export default class HighlightsListWidget extends RightPanelWidget { } } prevEndIndex = endIndex; + prevSubHtml = subHtml; } return { $highlightsList, diff --git a/apps/client/src/widgets/toc.ts b/apps/client/src/widgets/toc.ts index aa256b0f39..03c81d970a 100644 --- a/apps/client/src/widgets/toc.ts +++ b/apps/client/src/widgets/toc.ts @@ -21,14 +21,14 @@ import OnClickButtonWidget from "./buttons/onclick_button.js"; import appContext, { type EventData } from "../components/app_context.js"; import katex from "../services/math.js"; import type FNote from "../entities/fnote.js"; -import DOMPurify from "dompurify"; +import DOMPurify, { type Config as DOMPurifyConfig } from "dompurify"; /** * DOMPurify configuration for ToC headings. Only allows inline formatting * tags that legitimately appear in headings (bold, italic, KaTeX math output). * Blocks all event handlers, script tags, and dangerous attributes. */ -const TOC_PURIFY_CONFIG: DOMPurify.Config = { +const TOC_PURIFY_CONFIG: DOMPurifyConfig = { ALLOWED_TAGS: [ "b", "i", "em", "strong", "s", "del", "sub", "sup", "code", "mark", "span", "abbr", "small", @@ -358,7 +358,7 @@ export default class TocWidget extends RightPanelWidget { // const headingText = await this.replaceMathTextWithKatax(m[2]); - const $itemContent = $('
    ').html(DOMPurify.sanitize(headingText, TOC_PURIFY_CONFIG)); + const $itemContent = $('
    ').html(DOMPurify.sanitize(headingText, TOC_PURIFY_CONFIG) as string); const $li = $("
  2. ").append($itemContent) .on("click", () => this.jumpToHeading(headingIndex)); $ols[$ols.length - 1].append($li); diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts index ecab49c0c9..7e2bca00e4 100644 --- a/apps/server/src/routes/api/options.ts +++ b/apps/server/src/routes/api/options.ts @@ -117,7 +117,7 @@ const ALLOWED_OPTIONS = new Set([ // Options that contain secrets (API keys, tokens, etc.). // These can be written by the client but are never sent back in GET responses. -const WRITE_ONLY_OPTIONS = new Set([ +const WRITE_ONLY_OPTIONS = new Set([ "openaiApiKey", "anthropicApiKey" ]); diff --git a/apps/server/src/share/routes.ts b/apps/server/src/share/routes.ts index d7887d1a79..de39d81fd3 100644 --- a/apps/server/src/share/routes.ts +++ b/apps/server/src/share/routes.ts @@ -4,6 +4,7 @@ import safeCompare from "safe-compare"; import SearchContext from "../services/search/search_context.js"; import searchService from "../services/search/services/search.js"; import utils, { sanitizeSvg } from "../services/utils.js"; +import { setSvgHeaders } from "../services/svg_sanitizer.js"; import { getDefaultTemplatePath, renderNoteContent } from "./content_renderer.js"; import type SAttachment from "./shaca/entities/sattachment.js"; import type SNote from "./shaca/entities/snote.js";