/**
* The note column: Markdown, sanitisation and wikilinks
* (weave-workspace §1.2, §5.2, §10, P2.4).
*
* "Notes are the product" (§1.1), so this is the column the workspace is
* actually for. It is also the only place in the client that turns content off
* the disk into DOM, which makes it the only place with a genuine threat
* model — so most of the length below is about that.
*
* ## The threat model, stated plainly
*
* The server is loopback-only and cookie-authenticated (§5.1), so this is not
* about a remote attacker reaching the page. It is about *content*: a vault
* note written by an agent, a `.okf` summary generated from a repository, or a
* README in a repo someone cloned to look at. Any of those can contain
* `
`, and a workspace whose whole premise is "point it at
* an unfamiliar repository and read the knowledge" cannot treat that as
* hypothetical. Rendering untrusted-ish local content into the DOM is exactly
* the case §0 V5 chose DOMPurify for.
*
* ## Three independent layers, in order
*
* Each would be sufficient against the obvious attacks; they are stacked
* because "sufficient against the attacks I thought of" is the assumption that
* fails.
*
* 1. **Raw HTML never becomes HTML.** {@link markdownRenderer} overrides
* marked's `html` renderer to escape its input, so ``
* in a note body reaches the DOM as the *text* ``.
* marked removed its own `sanitize` option in v5 precisely in favour of
* this shape — the renderer, not the parser, is where the decision belongs.
* 2. **Unsafe URL schemes are dropped at render.** {@link safeUrl} allows a
* short scheme allowlist and nothing else, so a `[a](javascript:alert(1))`
* link renders with no `href` at all rather than with a stripped one.
* 3. **DOMPurify is the final gate.** {@link SANITIZE_CONFIG} is an explicit
* tag/attribute *allowlist* — not a denylist of known-bad — so anything
* layers 1 and 2 failed to anticipate still has to be on a list somebody
* wrote down. No `on*` handler is on it, and `script`, `iframe`, `object`,
* `embed`, `form` and `style` are not either.
*
* ## What the tests can and cannot prove
*
* §10 forbids a DOM test environment, and DOMPurify needs one — it is a
* wrapper around `DOMParser` and refuses to run without it
* (`DOMPurify.isSupported === false` under bare Node). So layer 3 cannot be
* *executed* in this repository's test suite, and no amount of wanting it to
* changes that.
*
* What the suite does instead is prove the two layers that are pure string
* transforms, against hostile fixtures, and prove that layer 3 is wired
* correctly: {@link renderNote} takes its sanitiser as a parameter, so a test
* hands it a spy and asserts the HTML is passed through it with
* {@link SANITIZE_CONFIG}, and asserts the config's contents directly. The
* honest summary is: *the input to DOMPurify is already safe by construction
* and that is tested; DOMPurify is configured strictly and wired as the last
* step, and that is tested; DOMPurify's own behaviour is upstream's tested
* property, not ours.*
*
* ## Tier rules (§2)
*
* `src/web/client/**`. `marked` and `dompurify` are the two npm dependencies
* this phase adds, both listed in `tests/web/tiers.test.ts`' client allowlist
* and both already licence-cleared in `scripts/build-web.mjs` (§0.1). The
* view-models come through `../../shared/view`; core is never imported
* directly. No DOM *type* is named here either — {@link Purifier} and
* {@link ClosestElement} are structural ports — which is what lets the root
* `tsconfig.json` project compile the tests.
*/
import { Marked } from "marked";
import type { TokenizerAndRendererExtension, Tokens } from "marked";
import { relTime } from "../../shared/view";
import type { GraphPayload, ViewNote, WireGraphNode, WireNoteSource } from "../../shared/wire";
import { provenanceGlyph, provenanceTitle } from "../tree/tree.model";
// --- escaping ---------------------------------------------------------------------
/** The five HTML-significant characters. */
const HTML_ESCAPES = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
} as const;
type HtmlSpecial = keyof typeof HTML_ESCAPES;
/**
* The character class matching exactly {@link HTML_ESCAPES}' keys.
*
* Built from the keys rather than written out a second time, which is what
* justifies the cast in {@link escapeHtml}: the regex and the table cannot
* disagree about which characters are special, because one is derived from the
* other. All five are literal inside a character class, so no escaping is
* needed to assemble it.
*/
const HTML_SPECIAL_RE = new RegExp(`[${Object.keys(HTML_ESCAPES).join("")}]`, "g");
/**
* Escape a string for interpolation into HTML text or a quoted attribute.
*
* `src/web/server/page.ts` has a near-identical function and this is not
* shared with it, deliberately: that one is server-tier and the client may not
* import it, and promoting five characters into `src/web/shared/` to avoid
* five lines would be a module whose only content is a table everyone already
* knows. The duplication is bounded (it cannot grow — HTML will not acquire a
* sixth significant character) and both copies are tested.
*
* Both quote styles are escaped even though every attribute below is
* double-quoted, because the cost is nothing and the invariant "an escaped
* string is safe in *any* attribute position" is much easier to hold in your
* head than "safe in the positions I checked".
*
* The cast is sound and the usual `?? ch` fallback is deliberately absent:
* {@link HTML_SPECIAL_RE} is generated from {@link HTML_ESCAPES}' own keys, so
* a matched character is a key by construction. A fallback here would be a
* branch that cannot be taken and therefore cannot be covered — the same
* consideration as `tree.model.ts`' `idAt`, resolved by making the two
* definitions share a source instead of by adding dead defensive code.
*/
export function escapeHtml(value: string): string {
return value.replace(HTML_SPECIAL_RE, (ch) => HTML_ESCAPES[ch as HtmlSpecial]);
}
// --- URL safety -----------------------------------------------------------------------
/**
* URL schemes a link or image in a note may use.
*
* An allowlist, and a short one. `javascript:` is the attack everyone knows;
* `data:` is the one that gets forgotten, and `data:text/html,