From 7ab4a3d28ca45b899a4167c4fa2d7a3f036c30be Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Mon, 24 Aug 2026 03:16:02 +0800 Subject: [PATCH] [chore] fix sonar issues: replaceAll, globalThis, a11y, Readonly props, ReDoS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeps the 70 SonarQube issues open on pathorsAI_pensieve plus the 6 security hotspots. No behaviour change intended. - S7781 replace(/…/g) -> replaceAll (mcp, search, github, extract, markdown, auth, route) - S7764 window -> globalThis (consent-form, login-panel) - S7758 charCodeAt/fromCharCode -> codePointAt/fromCodePoint - S6594 String.match -> RegExp.exec (markdown, extract, graph-view) - S6759 props typed Readonly<…> (10 components) - S6479 array-index keys -> stable keys (members table, skeleton rows) - S6478 Folder/DocLink/RelLink lifted out of GraphView to module scope, deps passed as props - S6848/S6844/S1082 clickable spans and a bare become + ); + } else { + action = ( + + ); + } + return (
@@ -17,17 +37,7 @@ export function AcceptClient({ id }: { id: string }) { Join workspace - {isPending ? : !session ? ( - - ) : ( - - )} + {action}

{msg}

diff --git a/app/accept/[id]/page.tsx b/app/accept/[id]/page.tsx index 4785a21..b4e3ea5 100644 --- a/app/accept/[id]/page.tsx +++ b/app/accept/[id]/page.tsx @@ -1,5 +1,5 @@ import { AcceptClient } from "./accept-client"; -export default async function Accept({ params }: { params: Promise<{ id: string }> }) { +export default async function Accept({ params }: Readonly<{ params: Promise<{ id: string }> }>) { const { id } = await params; return ; } diff --git a/app/consent/consent-form.tsx b/app/consent/consent-form.tsx index db186dc..4fb76ef 100644 --- a/app/consent/consent-form.tsx +++ b/app/consent/consent-form.tsx @@ -4,7 +4,7 @@ import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; /** The signed authorization query must go back to the server byte-for-byte. */ -const oauthQuery = () => (typeof window === "undefined" ? "" : window.location.search.slice(1)); +const oauthQuery = () => (typeof globalThis.window === "undefined" ? "" : globalThis.location.search.slice(1)); export function ConsentForm() { const [busy, setBusy] = useState<"accept" | "deny" | null>(null); @@ -21,7 +21,7 @@ export function ConsentForm() { }); const data = (await res.json()) as { url?: string; redirect?: boolean }; if (data?.url) { - window.location.href = data.url; + globalThis.location.href = data.url; return; } throw new Error("no redirect url"); @@ -49,7 +49,7 @@ export function ConsentForm() { /** No session on the consent page means the flow lost its login; send it back. */ export function ContinueToLogin() { return ( - ); diff --git a/app/layout.tsx b/app/layout.tsx index 007cd83..5cab875 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,7 +3,7 @@ export const metadata = { title: "Pensieve", description: "A knowledge graph for HTML documents — sync your repos, fly between your notes.", }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( {children} diff --git a/app/login/login-panel.tsx b/app/login/login-panel.tsx index 72f7727..b2cc078 100644 --- a/app/login/login-panel.tsx +++ b/app/login/login-panel.tsx @@ -14,7 +14,7 @@ export function LoginPanel() { // query string. Handing it back verbatim as `oauth_query` is what lets // better-auth resume /oauth2/authorize once Google returns — reserialising it // would break the signature. - const oauthQuery = window.location.search.slice(1); + const oauthQuery = globalThis.location.search.slice(1); try { const res = await fetch("/api/auth/sign-in/social", { method: "POST", @@ -27,7 +27,7 @@ export function LoginPanel() { }); const data = (await res.json()) as { url?: string }; if (data?.url) { - window.location.href = data.url; + globalThis.location.href = data.url; return; } throw new Error("no redirect url"); diff --git a/app/o/[slug]/d/[...path]/route.ts b/app/o/[slug]/d/[...path]/route.ts index 681af64..0232462 100644 --- a/app/o/[slug]/d/[...path]/route.ts +++ b/app/o/[slug]/d/[...path]/route.ts @@ -16,7 +16,7 @@ export async function GET(req: Request, ctx: { params: Promise<{ slug: string; p const assetRows = await db.select().from(schema.asset).where(and( eq(schema.asset.organizationId, access.org.id), eq(schema.asset.path, rawPath))).limit(1); if (assetRows.length) { - const bytes = Uint8Array.from(atob(assetRows[0].data), (c) => c.charCodeAt(0)); + const bytes = Uint8Array.from(atob(assetRows[0].data), (c) => c.codePointAt(0)!); return new Response(bytes, { headers: { "content-type": assetRows[0].contentType, "cache-control": "private, max-age=300" } }); } @@ -27,7 +27,7 @@ export async function GET(req: Request, ctx: { params: Promise<{ slug: string; p const nav = ``; let html = rows[0].html; // in-workspace links resolve through the org prefix - html = html.replace(/href="(\/[^"#?]+?)(?:\.(?:html|md))?"/g, (_m, p) => `href="/o/${slug}/d${p}"`); + html = html.replaceAll(/href="(\/[^"#?]+?)(?:\.(?:html|md))?"/g, (_m, p) => `href="/o/${slug}/d${p}"`); html = html.includes("") ? html.replace("", nav + "") : html + nav; return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }); } diff --git a/app/o/[slug]/graph-view.tsx b/app/o/[slug]/graph-view.tsx index 484c1eb..b243ced 100644 --- a/app/o/[slug]/graph-view.tsx +++ b/app/o/[slug]/graph-view.tsx @@ -1,5 +1,5 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react"; import { ExternalLink, PanelLeftClose, PanelLeftOpen, Search, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -51,6 +51,7 @@ function runSim(cv: HTMLCanvasElement, nodes: Node[], edges: Edge[], opts: { cv.width = W * dpr; cv.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; resize(); addEventListener("resize", resize); const small = !opts.pannable; + const maxLabel = small ? 16 : 24; const loop = () => { for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) { @@ -58,7 +59,7 @@ function runSim(cv: HTMLCanvasElement, nodes: Node[], edges: Edge[], opts: { if (d2 < 90000) { const f = (small ? 700 : 1600) / d2; const d = Math.sqrt(d2); dx /= d; dy /= d; a.vx! -= dx * f; a.vy! -= dy * f; b.vx! += dx * f; b.vy! += dy * f; } } edges.forEach((e) => { const a = byId[e.from], b = byId[e.to]; if (!a || !b) return; - const dx = b.x! - a.x!, dy = b.y! - a.y!, d = Math.sqrt(dx * dx + dy * dy) || 1; + const dx = b.x! - a.x!, dy = b.y! - a.y!, d = Math.hypot(dx, dy) || 1; const f = (d - (small ? 70 : 110)) * 0.004; a.vx! += (dx / d) * f * d; a.vy! += (dy / d) * f * d; b.vx! -= (dx / d) * f * d; b.vy! -= (dy / d) * f * d; }); nodes.forEach((n) => { if (opts.center === n.id) { n.x = 0; n.y = 0; return; } @@ -69,20 +70,22 @@ function runSim(cv: HTMLCanvasElement, nodes: Node[], edges: Edge[], opts: { ctx.clearRect(0, 0, W, H); ctx.save(); ctx.translate(W / 2 + panX, H / 2 + panY); ctx.strokeStyle = css("--rule"); ctx.lineWidth = 1; edges.forEach((e) => { const a = byId[e.from], b = byId[e.to]; if (!a || !b) return; - ctx.globalAlpha = hot && (hot === a || hot === b) ? 1 : hot ? 0.25 : 0.8; + let alpha = 0.8; + if (hot) alpha = hot === a || hot === b ? 1 : 0.25; + ctx.globalAlpha = alpha; ctx.beginPath(); ctx.moveTo(a.x!, a.y!); ctx.lineTo(b.x!, b.y!); ctx.stroke(); }); nodes.forEach((n) => { const r = (small ? 4 : 5) + Math.min(9, (deg[n.id] ?? 0) * 1.4); const isCenter = opts.center === n.id; const linked = hot && edges.some((e) => (e.from === hot.id && e.to === n.id) || (e.to === hot.id && e.from === n.id)); ctx.globalAlpha = !hot || n === hot || linked ? 1 : 0.3; - ctx.fillStyle = isCenter ? css("--accent-2") : n === hot ? css("--accent-2") : css("--accent"); + ctx.fillStyle = isCenter || n === hot ? css("--accent-2") : css("--accent"); ctx.beginPath(); ctx.arc(n.x!, n.y!, r, 0, 7); ctx.fill(); if (isCenter) { ctx.strokeStyle = css("--accent-2"); ctx.globalAlpha = 0.35; ctx.beginPath(); ctx.arc(n.x!, n.y!, r + 4, 0, 7); ctx.stroke(); ctx.strokeStyle = css("--rule"); } ctx.globalAlpha = !hot || n === hot || linked ? 0.95 : 0.25; ctx.fillStyle = css("--ink-2"); ctx.font = (small ? "10.5px " : "11.5px ") + css("--sans"); - const label = n.title.length > (small ? 16 : 24) ? n.title.slice(0, small ? 15 : 23) + "…" : n.title; + const label = n.title.length > maxLabel ? n.title.slice(0, maxLabel - 1) + "…" : n.title; ctx.fillText(label, n.x! + r + 5, n.y! + 4); }); ctx.restore(); ctx.globalAlpha = 1; raf = requestAnimationFrame(loop); @@ -105,13 +108,17 @@ function runSim(cv: HTMLCanvasElement, nodes: Node[], edges: Edge[], opts: { cv.removeEventListener("pointerdown", down); removeEventListener("pointermove", move); removeEventListener("pointerup", up); }; } -function LocalGraph({ center, graph, onOpen }: { center: string; graph: { nodes: Node[]; edges: Edge[] }; onOpen: (n: Node) => void }) { +function LocalGraph({ center, graph, onOpen }: Readonly<{ center: string; graph: { nodes: Node[]; edges: Edge[] }; onOpen: (n: Node) => void }>) { const ref = useRef(null); const hotRef = useRef(null); useEffect(() => { if (!ref.current) return; const near = new Set([center]); - graph.edges.forEach((e) => { if (e.from === center) near.add(e.to); if (e.to === center) near.add(e.from); }); + // an edge may touch `center` at either end — both checks are independent + graph.edges.forEach((e) => { + if (e.from === center) near.add(e.to); + if (e.to === center) near.add(e.from); + }); const nodes = graph.nodes.filter((n) => near.has(n.id)).map((n) => ({ ...n })); const edges = graph.edges.filter((e) => near.has(e.from) && near.has(e.to)); return runSim(ref.current, nodes, edges, { center, onOpen, hotRef }); @@ -119,7 +126,79 @@ function LocalGraph({ center, graph, onOpen }: { center: string; graph: { nodes: return ; } -export function GraphView({ slug, orgName, role }: { slug: string; orgName: string; role: string }) { +/** `/o//d/` — the shape of an in-workspace document link. */ +const DOC_HREF = /^\/o\/[^/]+\/d(\/.+)$/; + +type OpenDoc = (path: string, title: string) => void; + +function DocLink({ slug, n, depth, active, onOpen }: Readonly<{ + slug: string; n: Node; depth: number; active: boolean; onOpen: OpenDoc; +}>) { + return ( +
{ e.preventDefault(); onOpen(n.id, n.title); }} + style={{ display: "block", padding: `6px 18px 6px ${20 + depth * 14}px`, textDecoration: "none", + color: active ? "var(--accent-2)" : "var(--ink-2)", fontSize: 13.5, lineHeight: 1.35, + background: active ? "var(--accent-wash)" : undefined }}> + {n.title} + {n.date} + + ); +} + +function RelLink({ slug, n, onOpen }: Readonly<{ slug: string; n: Node; onOpen: OpenDoc }>) { + return ( + { e.preventDefault(); onOpen(n.id, n.title); }} + style={{ display: "block", padding: "4px 0", textDecoration: "none", color: "var(--ink-2)", fontSize: 13 }}> + {n.title} + + ); +} + +/** Collapse arrow and folder name are two separate actions; both are buttons so + * they carry a role and answer to the keyboard, not just to the mouse. */ +const bareButton = { background: "none", border: 0, padding: 0, cursor: "pointer", textAlign: "left" } as const; + +function Folder({ t, depth, slug, filter, setFilter, closed, setClosed, openPath, onOpen }: Readonly<{ + t: Tree; depth: number; slug: string; + filter: string | null; setFilter: Dispatch>; + closed: Record; setClosed: Dispatch>>; + openPath: string | null; onOpen: OpenDoc; +}>) { + const isClosed = !!closed[t.path]; + return ( +
+
+ + + {t.count} +
+ {!isClosed && ( +
+ {t.folders.map((f) => ( + + ))} + {t.docs.map((n) => ( + + ))} +
+ )} +
+ ); +} + +export function GraphView({ slug, orgName, role }: Readonly<{ slug: string; orgName: string; role: string }>) { const [graph, setGraph] = useState<{ nodes: Node[]; edges: Edge[] } | null>(null); const [q, setQ] = useState(""); const [hits, setHits] = useState(null); @@ -164,7 +243,7 @@ export function GraphView({ slug, orgName, role }: { slug: string; orgName: stri if ((e.data as { pnsvPalette?: boolean })?.pnsvPalette) { setPaletteOpen(true); return; } const href = (e.data as { pnsvOpen?: string })?.pnsvOpen; if (!href) return; - const m = href.match(/^\/o\/[^/]+\/d(\/.+)$/); + const m = DOC_HREF.exec(href); if (!m) return; const path = m[1]; const title = graph?.nodes.find((n) => n.id === path)?.title ?? path; @@ -216,52 +295,14 @@ export function GraphView({ slug, orgName, role }: { slug: string; orgName: stri const ins = openDoc && graph ? graph.edges.filter((e) => e.to === openDoc.path) .map((e) => graph.nodes.find((n) => n.id === e.from)).filter(Boolean) as Node[] : []; - const Folder = ({ t, depth }: { t: Tree; depth: number }) => ( -
-
- setClosed((c) => ({ ...c, [t.path]: !c[t.path] }))}> - {closed[t.path] ? "▸" : "▾"} - - setFilter(filter === t.path ? null : t.path)}> - {t.name} - - {t.count} -
- {!closed[t.path] && ( -
- {t.folders.map((f) => )} - {t.docs.map((n) => )} -
- )} -
- ); - - const DocLink = ({ n, depth }: { n: Node; depth: number }) => ( - { e.preventDefault(); openRef.current(n.id, n.title); }} - style={{ display: "block", padding: `6px 18px 6px ${20 + depth * 14}px`, textDecoration: "none", - color: openDoc?.path === n.id ? "var(--accent-2)" : "var(--ink-2)", fontSize: 13.5, lineHeight: 1.35, - background: openDoc?.path === n.id ? "var(--accent-wash)" : undefined }}> - {n.title} - {n.date} - - ); - - const RelLink = ({ n }: { n: Node }) => ( - { e.preventDefault(); openRef.current(n.id, n.title); }} - style={{ display: "block", padding: "4px 0", textDecoration: "none", color: "var(--ink-2)", fontSize: 13 }}> - {n.title} - - ); + const open: OpenDoc = (path, title) => openRef.current(path, title); + const openPath = openDoc?.path ?? null; return (
openRef.current(p, t)} /> + recent={recent} onOpen={open} />
)} @@ -362,7 +409,7 @@ export function GraphView({ slug, orgName, role }: { slug: string; orgName: stri
-