Add plannotator extension v0.19.10
This commit is contained in:
358
extensions/plannotator/server/reference.ts
Normal file
358
extensions/plannotator/server/reference.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Document and reference handlers (Node.js equivalents of packages/server/reference-handlers.ts).
|
||||
* VaultNode, buildFileTree, walkMarkdownFiles, handleDocRequest,
|
||||
* detectObsidianVaults, handleObsidian*, handleFileBrowserRequest
|
||||
*/
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
type Dirent,
|
||||
} from "node:fs";
|
||||
import type { ServerResponse } from "node:http";
|
||||
import { join, resolve as resolvePath } from "node:path";
|
||||
|
||||
import { json, parseBody } from "./helpers";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
|
||||
import {
|
||||
type VaultNode,
|
||||
buildFileTree,
|
||||
FILE_BROWSER_EXCLUDED,
|
||||
} from "../generated/reference-common.js";
|
||||
import { detectObsidianVaults } from "../generated/integrations-common.js";
|
||||
import {
|
||||
isAbsoluteUserPath,
|
||||
isCodeFilePath,
|
||||
resolveCodeFile,
|
||||
resolveMarkdownFile,
|
||||
resolveUserPath,
|
||||
isWithinProjectRoot,
|
||||
warmFileListCache,
|
||||
} from "../generated/resolve-file.js";
|
||||
import { htmlToMarkdown } from "../generated/html-to-markdown.js";
|
||||
import { preloadFile } from "@pierre/diffs/ssr";
|
||||
|
||||
type Res = ServerResponse;
|
||||
|
||||
/** Recursively walk a directory collecting files by extension, skipping ignored dirs. */
|
||||
function walkMarkdownFiles(dir: string, root: string, results: string[], extensions: RegExp = /\.(mdx?|html?)$/i): void {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true }) as Dirent[];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (FILE_BROWSER_EXCLUDED.includes(entry.name + "/")) continue;
|
||||
walkMarkdownFiles(join(dir, entry.name), root, results, extensions);
|
||||
} else if (entry.isFile() && extensions.test(entry.name)) {
|
||||
const relative = join(dir, entry.name)
|
||||
.slice(root.length + 1)
|
||||
.replace(/\\/g, "/");
|
||||
results.push(relative);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Serve a linked markdown document. Uses shared resolveMarkdownFile for parity with Bun server. */
|
||||
export async function handleDocRequest(res: Res, url: URL): Promise<void> {
|
||||
const requestedPath = url.searchParams.get("path");
|
||||
if (!requestedPath) {
|
||||
json(res, { error: "Missing path parameter" }, 400);
|
||||
return;
|
||||
}
|
||||
|
||||
// Side-channel: warm the code-file walk so /api/doc/exists POSTs land warm.
|
||||
void warmFileListCache(process.cwd(), "code");
|
||||
|
||||
// Try resolving relative to base directory first (used by annotate mode).
|
||||
// No isWithinProjectRoot check here — intentional, matches pre-existing
|
||||
// markdown behavior. The base param is set server-side by the annotate
|
||||
// server (see serverAnnotate.ts /api/doc route). The standalone HTML
|
||||
// block below (no base) retains its cwd-based containment check.
|
||||
const base = url.searchParams.get("base");
|
||||
const resolvedBase = base ? resolveUserPath(base) : null;
|
||||
if (
|
||||
resolvedBase &&
|
||||
!isAbsoluteUserPath(requestedPath) &&
|
||||
/\.(mdx?|html?)$/i.test(requestedPath)
|
||||
) {
|
||||
const fromBase = resolveUserPath(requestedPath, resolvedBase);
|
||||
try {
|
||||
if (existsSync(fromBase)) {
|
||||
const raw = readFileSync(fromBase, "utf-8");
|
||||
const isHtml = /\.html?$/i.test(requestedPath);
|
||||
const markdown = isHtml ? htmlToMarkdown(raw) : raw;
|
||||
json(res, { markdown, filepath: fromBase, isConverted: isHtml });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to standard resolution */
|
||||
}
|
||||
}
|
||||
|
||||
// HTML files: resolve directly (not via resolveMarkdownFile which only handles .md/.mdx)
|
||||
const projectRoot = process.cwd();
|
||||
if (/\.html?$/i.test(requestedPath)) {
|
||||
const resolvedHtml = resolveUserPath(requestedPath, resolvedBase || projectRoot);
|
||||
if (!isWithinProjectRoot(resolvedHtml, projectRoot)) {
|
||||
json(res, { error: "Access denied: path is outside project root" }, 403);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (existsSync(resolvedHtml)) {
|
||||
const html = readFileSync(resolvedHtml, "utf-8");
|
||||
json(res, { markdown: htmlToMarkdown(html), filepath: resolvedHtml, isConverted: true });
|
||||
return;
|
||||
}
|
||||
} catch { /* fall through to 404 */ }
|
||||
json(res, { error: `File not found: ${requestedPath}` }, 404);
|
||||
return;
|
||||
}
|
||||
|
||||
// Code files: try literal resolve first; on miss, fall back to smart resolver.
|
||||
if (isCodeFilePath(requestedPath)) {
|
||||
const literalPath = resolveUserPath(requestedPath, resolvedBase || projectRoot);
|
||||
const literalAllowed = resolvedBase || isWithinProjectRoot(literalPath, projectRoot);
|
||||
|
||||
let resolvedCode: string | null = null;
|
||||
if (literalAllowed && existsSync(literalPath)) {
|
||||
resolvedCode = literalPath;
|
||||
}
|
||||
|
||||
if (!resolvedCode) {
|
||||
const result = await resolveCodeFile(requestedPath, projectRoot);
|
||||
if (result.kind === "found") {
|
||||
resolvedCode = result.path;
|
||||
} else if (result.kind === "ambiguous") {
|
||||
const prefix = `${projectRoot}/`;
|
||||
const relative = result.matches.map((m: string) =>
|
||||
m.startsWith(prefix) ? m.slice(prefix.length) : m,
|
||||
);
|
||||
json(res, { error: `Ambiguous path '${requestedPath}'`, matches: relative }, 400);
|
||||
return;
|
||||
} else {
|
||||
json(res, { error: `File not found: ${requestedPath}` }, 404);
|
||||
return;
|
||||
}
|
||||
if (!isWithinProjectRoot(resolvedCode, projectRoot)) {
|
||||
json(res, { error: "Access denied: path is outside project root" }, 403);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(resolvedCode);
|
||||
if (stat.size > 2 * 1024 * 1024) {
|
||||
json(res, { error: "File too large (max 2MB)" }, 413);
|
||||
return;
|
||||
}
|
||||
const contents = readFileSync(resolvedCode, "utf-8");
|
||||
const displayName = resolvedCode.split("/").pop() || resolvedCode;
|
||||
let prerenderedHTML: string | undefined;
|
||||
try {
|
||||
const result = await preloadFile({
|
||||
file: { name: displayName, contents },
|
||||
options: { disableFileHeader: true },
|
||||
});
|
||||
prerenderedHTML = result.prerenderedHTML;
|
||||
} catch {
|
||||
// Fall back to client-side rendering
|
||||
}
|
||||
json(res, { codeFile: true, contents, filepath: resolvedCode, prerenderedHTML });
|
||||
return;
|
||||
} catch {
|
||||
json(res, { error: `File not found: ${requestedPath}` }, 404);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = resolveMarkdownFile(requestedPath, projectRoot);
|
||||
|
||||
if (result.kind === "ambiguous") {
|
||||
json(
|
||||
res,
|
||||
{
|
||||
error: `Ambiguous filename '${result.input}': found ${result.matches.length} matches`,
|
||||
matches: result.matches,
|
||||
},
|
||||
400,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.kind === "not_found" || result.kind === "unavailable") {
|
||||
json(res, { error: `File not found: ${result.input}` }, 404);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const markdown = readFileSync(result.path, "utf-8");
|
||||
json(res, { markdown, filepath: result.path });
|
||||
} catch {
|
||||
json(res, { error: "Failed to read file" }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch existence check for code-file paths the renderer wants to linkify.
|
||||
* POST /api/doc/exists with { paths: string[] }.
|
||||
*
|
||||
* TODO(security): see packages/server/reference-handlers.ts handleDocExists —
|
||||
* both absolute paths in `paths[]` AND the `base` field are honored verbatim
|
||||
* with no project-root containment check, leaking file existence back to the
|
||||
* caller. Fix in lockstep with the Bun handler.
|
||||
*/
|
||||
export async function handleDocExistsRequest(res: Res, req: IncomingMessage): Promise<void> {
|
||||
const body = await parseBody(req);
|
||||
const paths = (body as { paths?: unknown }).paths;
|
||||
if (!Array.isArray(paths) || !paths.every((p) => typeof p === "string")) {
|
||||
json(res, { error: "Expected { paths: string[] }" }, 400);
|
||||
return;
|
||||
}
|
||||
if (paths.length > 500) {
|
||||
json(res, { error: "Too many paths (max 500)" }, 400);
|
||||
return;
|
||||
}
|
||||
const baseRaw = (body as { base?: unknown }).base;
|
||||
const baseDir = typeof baseRaw === "string" && baseRaw.length > 0
|
||||
? resolveUserPath(baseRaw)
|
||||
: undefined;
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const results: Record<
|
||||
string,
|
||||
| { status: "found"; resolved: string }
|
||||
| { status: "ambiguous"; matches: string[] }
|
||||
| { status: "missing" }
|
||||
| { status: "unavailable" }
|
||||
> = {};
|
||||
|
||||
await Promise.all(
|
||||
(paths as string[]).map(async (p) => {
|
||||
const r = await resolveCodeFile(p, projectRoot, baseDir);
|
||||
if (r.kind === "found") {
|
||||
results[p] = { status: "found", resolved: r.path };
|
||||
} else if (r.kind === "ambiguous") {
|
||||
const prefix = `${projectRoot}/`;
|
||||
results[p] = {
|
||||
status: "ambiguous",
|
||||
matches: r.matches.map((m: string) => (m.startsWith(prefix) ? m.slice(prefix.length) : m)),
|
||||
};
|
||||
} else if (r.kind === "unavailable") {
|
||||
results[p] = { status: "unavailable" };
|
||||
} else {
|
||||
results[p] = { status: "missing" };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
json(res, { results });
|
||||
}
|
||||
|
||||
export function handleObsidianVaultsRequest(res: Res): void {
|
||||
json(res, { vaults: detectObsidianVaults() });
|
||||
}
|
||||
|
||||
export function handleObsidianFilesRequest(res: Res, url: URL): void {
|
||||
const vaultPath = url.searchParams.get("vaultPath");
|
||||
if (!vaultPath) {
|
||||
json(res, { error: "Missing vaultPath parameter" }, 400);
|
||||
return;
|
||||
}
|
||||
const resolvedVault = resolveUserPath(vaultPath);
|
||||
if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) {
|
||||
json(res, { error: "Invalid vault path" }, 400);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const files: string[] = [];
|
||||
walkMarkdownFiles(resolvedVault, resolvedVault, files, /\.mdx?$/i);
|
||||
files.sort();
|
||||
json(res, { tree: buildFileTree(files) });
|
||||
} catch {
|
||||
json(res, { error: "Failed to list vault files" }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleObsidianDocRequest(res: Res, url: URL): void {
|
||||
const vaultPath = url.searchParams.get("vaultPath");
|
||||
const filePath = url.searchParams.get("path");
|
||||
if (!vaultPath || !filePath) {
|
||||
json(res, { error: "Missing vaultPath or path parameter" }, 400);
|
||||
return;
|
||||
}
|
||||
if (!/\.mdx?$/i.test(filePath)) {
|
||||
json(res, { error: "Only markdown files are supported" }, 400);
|
||||
return;
|
||||
}
|
||||
const resolvedVault = resolveUserPath(vaultPath);
|
||||
let resolvedFile = resolvePath(resolvedVault, filePath);
|
||||
|
||||
// Bare filename search within vault
|
||||
if (!existsSync(resolvedFile) && !filePath.includes("/")) {
|
||||
const files: string[] = [];
|
||||
walkMarkdownFiles(resolvedVault, resolvedVault, files, /\.mdx?$/i);
|
||||
const matches = files.filter(
|
||||
(f) => f.split("/").pop()!.toLowerCase() === filePath.toLowerCase(),
|
||||
);
|
||||
if (matches.length === 1) {
|
||||
resolvedFile = resolvePath(resolvedVault, matches[0]);
|
||||
} else if (matches.length > 1) {
|
||||
json(
|
||||
res,
|
||||
{
|
||||
error: `Ambiguous filename '${filePath}': found ${matches.length} matches`,
|
||||
matches,
|
||||
},
|
||||
400,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Security: must be within vault
|
||||
if (
|
||||
!resolvedFile.startsWith(resolvedVault + "/") &&
|
||||
resolvedFile !== resolvedVault
|
||||
) {
|
||||
json(res, { error: "Access denied: path is outside vault" }, 403);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existsSync(resolvedFile)) {
|
||||
json(res, { error: `File not found: ${filePath}` }, 404);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const markdown = readFileSync(resolvedFile, "utf-8");
|
||||
json(res, { markdown, filepath: resolvedFile });
|
||||
} catch {
|
||||
json(res, { error: "Failed to read file" }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleFileBrowserRequest(res: Res, url: URL): void {
|
||||
const dirPath = url.searchParams.get("dirPath");
|
||||
if (!dirPath) {
|
||||
json(res, { error: "Missing dirPath parameter" }, 400);
|
||||
return;
|
||||
}
|
||||
const resolvedDir = resolveUserPath(dirPath);
|
||||
if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) {
|
||||
json(res, { error: "Invalid directory path" }, 400);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const files: string[] = [];
|
||||
walkMarkdownFiles(resolvedDir, resolvedDir, files);
|
||||
files.sort();
|
||||
json(res, { tree: buildFileTree(files) });
|
||||
} catch {
|
||||
json(res, { error: "Failed to list directory files" }, 500);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user