Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 125 additions & 11 deletions web/src/domain/validation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { Note, NoteDocument, RichNode } from "./contracts";
import { validateNoteTarget } from "../links/reference";
export type DocumentPolicy = "plain" | "notes";
export type DocumentInspection = {
document: NoteDocument;
nodeVisits: number;
jsonUnits: number;
};
const blocks = new Set([
"paragraph",
"heading",
Expand All @@ -13,26 +20,89 @@ const blocks = new Set([
"hardBreak",
"text",
]);
function referencePath(
parent: string,
child: RichNode,
index: number,
): boolean {
if (parent === "paragraph" || parent === "heading")
return ["text", "hardBreak", "noteLink"].includes(child.type);
if (parent === "bulletList" || parent === "orderedList")
return child.type === "listItem";
if (parent === "taskList") return child.type === "taskItem";
if (parent === "listItem" || parent === "taskItem")
return index === 0
? child.type === "paragraph"
: blocks.has(child.type) &&
!["text", "hardBreak", "listItem", "taskItem"].includes(child.type);
if (parent === "doc" || parent === "blockquote")
return (
blocks.has(child.type) &&
!["text", "hardBreak", "listItem", "taskItem"].includes(child.type)
);
return false;
}
export function safeHref(value: unknown): value is string {
return (
typeof value === "string" &&
/^(https?:\/\/|mailto:)/i.test(value) &&
!/[\u0000-\u0020\u007f]/.test(value)
);
}
export function validateDocument(value: unknown): NoteDocument {
export function inspectNoteDocument(
value: unknown,
policy: DocumentPolicy,
): DocumentInspection {
if (policy !== "plain" && policy !== "notes")
throw new Error("invalid_document: Unsupported document policy.");
let count = 0;
function node(v: unknown, depth: number): RichNode {
let references = 0;
function node(
v: unknown,
depth: number,
parent?: string,
validReferencePath = true,
): RichNode {
if (++count > 50000 || depth > 32 || !v || typeof v !== "object")
throw new Error(
"invalid_document: The document is too large or malformed.",
);
const n = v as RichNode;
if (
typeof n.type !== "string" ||
(depth === 0 ? n.type !== "doc" : !blocks.has(n.type))
(depth === 0
? n.type !== "doc"
: !blocks.has(n.type) && !(policy === "notes" && n.type === "noteLink"))
)
throw new Error("invalid_document: Unsupported document node.");
if (n.type === "noteLink") {
if (
!validReferencePath ||
(parent !== "paragraph" && parent !== "heading")
)
throw new Error(
"invalid_document: A note reference must be inline text content.",
);
if (++references > 1000)
throw new Error("invalid_document: Too many note references.");
// A reference is an identity atom, never cached visible text, HTML, or
// another kind of link. PM attribute containers may have a null prototype.
if (
Object.keys(n).length !== 2 ||
!Object.hasOwn(n, "type") ||
!Object.hasOwn(n, "attrs") ||
!n.attrs ||
typeof n.attrs !== "object" ||
Array.isArray(n.attrs) ||
Object.keys(n.attrs).length !== 2 ||
!Object.hasOwn(n.attrs, "version") ||
!Object.hasOwn(n.attrs, "target") ||
n.attrs.version !== 1
)
throw new Error("invalid_document: Invalid note reference.");
validateNoteTarget(n.attrs.target);
return n;
}
if (
n.text !== undefined &&
(typeof n.text !== "string" || n.text.length > 1000000)
Expand All @@ -58,22 +128,42 @@ export function validateDocument(value: unknown): NoteDocument {
if (n.content !== undefined) {
if (!Array.isArray(n.content))
throw new Error("invalid_document: Invalid content.");
n.content.forEach((c) => node(c, depth + 1));
n.content.forEach((c, index) =>
node(
c,
depth + 1,
n.type,
validReferencePath && referencePath(n.type, c, index),
),
);
}
return n;
}
node(value, 0);
if (!Array.isArray((value as NoteDocument).content))
throw new Error("invalid_document: Missing content.");
if (JSON.stringify(value).length > 2000000)
const jsonUnits = JSON.stringify(value).length;
if (jsonUnits > 2000000)
throw new Error("invalid_document: Document exceeds 2 MB.");
return structuredClone(value as NoteDocument);
return {
document: structuredClone(value as NoteDocument),
nodeVisits: count,
jsonUnits,
};
}
export function validateInput(input: {
/** Generic documents, including Journal and legacy Notes, never admit references. */
export function validateDocument(value: unknown): NoteDocument {
return inspectNoteDocument(value, "plain").document;
}
export function validateNoteDocument(value: unknown): NoteDocument {
return inspectNoteDocument(value, "notes").document;
}
type NoteInput = {
title: string;
document: NoteDocument;
tags: string[];
}): typeof input {
};
function validateInputMetadata(input: NoteInput): void {
if (
typeof input.title !== "string" ||
input.title.length > 500 ||
Expand All @@ -82,13 +172,25 @@ export function validateInput(input: {
input.tags.some((t) => typeof t !== "string" || t.length > 100)
)
throw new Error("invalid_note: Invalid title or tags.");
}
export function validateInput(input: NoteInput): NoteInput {
return inputForPolicy(input, "plain");
}
export function validateNoteInput(input: NoteInput): NoteInput {
return inputForPolicy(input, "notes");
}
function inputForPolicy(input: NoteInput, policy: DocumentPolicy): NoteInput {
validateInputMetadata(input);
return {
title: input.title,
document: validateDocument(input.document),
document: inspectNoteDocument(input.document, policy).document,
tags: [...new Set(input.tags)],
};
}
export function validateNotes(value: unknown): Note[] {
export function inspectNotes(
value: unknown,
policy: DocumentPolicy = "plain",
): Array<{ note: Note; nodeVisits: number; jsonUnits: number }> {
if (!Array.isArray(value) || value.length > 10000)
throw new Error("invalid_library: Invalid library.");
const ids = new Set<string>();
Expand All @@ -108,6 +210,18 @@ export function validateNotes(value: unknown): Note[] {
)
throw new Error("invalid_library: Invalid note metadata.");
ids.add(v.id);
return { ...v, ...validateInput(v) };
validateInputMetadata(v);
const inspected = inspectNoteDocument(v.document, policy);
return {
note: { ...v, document: inspected.document, tags: [...new Set(v.tags)] },
nodeVisits: inspected.nodeVisits,
jsonUnits: inspected.jsonUnits,
};
});
}
export function validateNotes(
value: unknown,
policy: DocumentPolicy = "plain",
): Note[] {
return inspectNotes(value, policy).map(({ note }) => note);
}
55 changes: 55 additions & 0 deletions web/src/editor/noteLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Node, type NodeViewRenderer } from "@tiptap/core";
import { validateNoteTarget } from "../links/reference";

export type NoteLinkOptions = { nodeView?: NodeViewRenderer };

/** Shared identity-only schema. The optional view changes presentation, not data. */
const NoteLink = Node.create<NoteLinkOptions>({
name: "noteLink",
group: "inline",
inline: true,
atom: true,
selectable: true,
marks: "",
addOptions() {
return {};
},
addAttributes() {
return {
version: {
default: 1,
rendered: false,
validate(value: unknown) {
if (value !== 1) throw new Error("Invalid note reference version.");
},
},
target: {
isRequired: true,
rendered: false,
validate(value: unknown) {
validateNoteTarget(value);
},
},
};
},
// Untrusted HTML/attributes never confer a typed reference. Notes-only paste
// uses the separate bounded reference codec and actual editor admission.
parseHTML() {
return [];
},
renderHTML() {
return ["span", { "data-type": "note-link" }, "Note reference"];
},
renderText() {
return "Note reference";
},
addNodeView() {
return this.options.nodeView ?? null;
},
});

export function createNoteLinkExtension(
options: NoteLinkOptions = {},
): Node<NoteLinkOptions> {
return NoteLink.configure(options);
}
38 changes: 38 additions & 0 deletions web/src/editor/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
getSchema,
type Extensions,
type NodeViewRenderer,
} from "@tiptap/core";
import type { Schema } from "@tiptap/pm/model";
import StarterKit from "@tiptap/starter-kit";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import { safeHref } from "../domain/validation";
import { createNoteLinkExtension } from "./noteLink";

function allowedUri(value: string): boolean {
if (!safeHref(value)) return false;
try {
return ["http:", "https:", "mailto:"].includes(new URL(value).protocol);
} catch {
return false;
}
}

/** Identical configuration for the real editor and headless persisted-step verification. */
export function createNoteExtensions(
options: { noteLinkNodeView?: NodeViewRenderer } = {},
): Extensions {
return [
StarterKit.configure({
trailingNode: false,
link: { openOnClick: false, autolink: false, isAllowedUri: allowedUri },
}),
TaskList.configure({}),
TaskItem.configure({ nested: true }),
createNoteLinkExtension({ nodeView: options.noteLinkNodeView }),
];
}
export function createNoteSchema(): Schema {
return getSchema(createNoteExtensions());
}
Loading
Loading