diff --git a/apps/web/app/api/docs/search/route.ts b/apps/web/app/api/docs/search/route.ts index 9259cc6f..886d8d88 100644 --- a/apps/web/app/api/docs/search/route.ts +++ b/apps/web/app/api/docs/search/route.ts @@ -44,16 +44,29 @@ function getSnippet(content: string, query: string, length = 160): string { return snippet } -export async function GET(request: NextRequest) { - const query = request.nextUrl.searchParams.get('q')?.trim() ?? '' +type IndexedDoc = { + title: string + href: string + section: string + plainContent: string + lowerTitle: string + lowerContent: string +} - if (query.length < 2) { - return NextResponse.json([] as SearchResult[]) - } +/** + * The docs corpus, parsed once per process. + * + * Every request used to `existsSync` + `readFileSync` + `gray-matter` + regex + * strip every doc file, on a route with no rate limit that the search UI calls + * on a 200ms debounce. The content is build-time static - it cannot change + * while the server is running - so there is no reason to redo any of it. + */ +let corpus: IndexedDoc[] | null = null - const lowerQuery = query.toLowerCase() - const results: (SearchResult & { score: number })[] = [] +function getCorpus(): IndexedDoc[] { + if (corpus) return corpus + const docs: IndexedDoc[] = [] for (const section of docSections) { for (const item of section.items) { const slug = item.href.replace('/docs/', '').split('/') @@ -63,26 +76,59 @@ export async function GET(request: NextRequest) { const raw = fs.readFileSync(filePath, 'utf-8') const { data: fm, content } = matter(raw) const title = (fm.title as string) || item.title - - const titleMatch = title.toLowerCase().includes(lowerQuery) const plainContent = stripMarkdown(content) - const contentMatch = plainContent.toLowerCase().includes(lowerQuery) - - if (!titleMatch && !contentMatch) continue - const snippet = contentMatch ? getSnippet(plainContent, query) : plainContent.slice(0, 140).trim() + '…' - - results.push({ + docs.push({ title, href: item.href, section: section.title, - snippet, - matchInTitle: titleMatch, - score: titleMatch ? 10 : 1, + plainContent, + lowerTitle: title.toLowerCase(), + lowerContent: plainContent.toLowerCase(), }) } } + corpus = docs + return corpus +} + +/** + * Longest query we will scan the corpus for. Past this a query cannot match + * anything meaningful, and the length is attacker-controlled. + */ +const MAX_QUERY_LENGTH = 128 + +export async function GET(request: NextRequest) { + const query = request.nextUrl.searchParams.get('q')?.trim().slice(0, MAX_QUERY_LENGTH) ?? '' + + if (query.length < 2) { + return NextResponse.json([] as SearchResult[]) + } + + const lowerQuery = query.toLowerCase() + const results: (SearchResult & { score: number })[] = [] + + for (const doc of getCorpus()) { + const titleMatch = doc.lowerTitle.includes(lowerQuery) + const contentMatch = doc.lowerContent.includes(lowerQuery) + + if (!titleMatch && !contentMatch) continue + + const snippet = contentMatch + ? getSnippet(doc.plainContent, query) + : doc.plainContent.slice(0, 140).trim() + '…' + + results.push({ + title: doc.title, + href: doc.href, + section: doc.section, + snippet, + matchInTitle: titleMatch, + score: titleMatch ? 10 : 1, + }) + } + results.sort((a, b) => b.score - a.score) return NextResponse.json( diff --git a/apps/web/app/api/webhook-sample/route.ts b/apps/web/app/api/webhook-sample/route.ts index 94d4554d..17e006dc 100644 --- a/apps/web/app/api/webhook-sample/route.ts +++ b/apps/web/app/api/webhook-sample/route.ts @@ -37,18 +37,39 @@ export async function POST(req: Request) { }); } + // Nothing this endpoint accepts is large. Reading an unbounded body - and + // then HMAC-ing over an unbounded caller-supplied secret - is free CPU for + // anyone who asks, on a route that exists only to show what a payload looks + // like. Both are capped well above any legitimate input. + const MAX_BODY_BYTES = 4_096; + const MAX_SECRET_LENGTH = 256; + const MAX_ADDRESS_LENGTH = 56; + let body: Body = {}; try { if (req.headers.get("content-type")?.includes("application/json")) { - body = (await req.json()) as Body; + const raw = await req.text(); + if (Buffer.byteLength(raw, "utf8") > MAX_BODY_BYTES) { + return Response.json( + { + error: "payload_too_large", + message: `Request body is capped at ${MAX_BODY_BYTES} bytes.`, + }, + { status: 413 }, + ); + } + body = raw ? (JSON.parse(raw) as Body) : {}; } } catch { - /* allow empty body */ + /* allow empty or malformed body */ } - const secret = body.secret?.trim() || `whsec_demo_${randomBytes(16).toString("hex")}`; - const generatedSecret = !body.secret?.trim(); - const address = body.address?.trim() || "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUV"; + const callerSecret = body.secret?.trim().slice(0, MAX_SECRET_LENGTH); + const secret = callerSecret || `whsec_demo_${randomBytes(16).toString("hex")}`; + const generatedSecret = !callerSecret; + const address = + body.address?.trim().slice(0, MAX_ADDRESS_LENGTH) || + "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUV"; const event = generateSamplePayment(address); const payload = JSON.stringify(event); diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 4ea47859..22916c6d 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -13,7 +13,7 @@ const instrumentSerif = Instrument_Serif({ }); export const metadata: Metadata = { - title: "Orbit Stellar - Real-time event infrastructure for Stellar developers", + title: "Orbital - Real-time event infrastructure for Stellar developers", description: "Watch any Stellar address. Register webhooks. React hooks for on-chain events. The missing event layer for Stellar developers.", }; diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx index bc81852a..c2a5344c 100644 --- a/apps/web/components/Footer.tsx +++ b/apps/web/components/Footer.tsx @@ -2,6 +2,14 @@ import Link from 'next/link' +import { + FOOTER_COMMUNITY_LINKS, + FOOTER_PRODUCT_LINKS, + NPM_PACKAGES, + npmUrl, + type NavLink, +} from '@/lib/links' + const labelStyle: React.CSSProperties = { fontFamily: 'var(--font-sans)', fontSize: '11px', @@ -22,6 +30,20 @@ const linkStyle: React.CSSProperties = { transition: 'color 0.15s', } +function FooterLink({ label, href, external }: NavLink) { + return ( + (e.currentTarget.style.color = '#fff')} + onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--muted2)')} + > + {label} + + ) +} + export default function Footer() { return (
MIT License
-- ● - All systems operational -
+ {/* + A hardcoded "● All systems operational" badge used to sit here, + next to a "Status" link that went nowhere. There is no uptime + monitor behind it, so it was green even while the demo endpoint + was returning 503. Removed rather than faked - put it back only + alongside a real status source. + */} {/* Product */}( + (e.currentTarget.style.color = '#fff')} + onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--muted2)')} > - {cmd} -
+ @orbital-stellar/{pkg} + ))}- {starter.repo} -
+ examples/{starter.dir} → + {/* Description */}
{/* Deploy button */}
-
Suggested questions
- {SUGGESTED.map((q) => ( - - ))} -- AI can make mistakes. Verify important info. +
Try searching for
+ {SUGGESTED.map((q) => ( + + ))} + > + ) : loading ? ( +Searching…
+ ) : results.length === 0 ? ( + searched && ( ++ No docs match “{query.trim()}”. Try a different term, or{' '} + + browse the API reference + + . +
+ ) + ) : ( + results.map((result) => ( + ++ {result.section} +
+{result.title}
+ {result.snippet && ( ++ {result.snippet} +
+ )} + + )) + )} ++ Results come from this site's documentation.