Skip to content
Merged
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
34 changes: 14 additions & 20 deletions apps/blog/src/components/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,7 @@ import { SearchIcon } from "lucide-react";
import { Badge, Spinner } from "@prisma/eclipse";
import { BlogSearchResult } from "../lib/search-types";

export function CustomSearchDialogIcon({
isLoading,
}: {
isLoading: boolean;
}) {
export function CustomSearchDialogIcon({ isLoading }: { isLoading: boolean }) {
return (
<>
{isLoading ? (
Expand All @@ -45,13 +41,12 @@ type SearchResultItemProps = Parameters<
function isBlogSearchResult(value: unknown): value is BlogSearchResult {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<BlogSearchResult>;
return typeof candidate.url === "string" && typeof candidate.content === "string";
return (
typeof candidate.url === "string" && typeof candidate.content === "string"
);
}

function SearchResultItem({
item,
onClick,
}: SearchResultItemProps): ReactNode {
function SearchResultItem({ item, onClick }: SearchResultItemProps): ReactNode {
if (!isBlogSearchResult(item)) return null;
const post = item;

Expand Down Expand Up @@ -98,20 +93,20 @@ function SearchResultItem({
);
}




export default function CustomSearchDialog(props: SharedProps) {
const { search, setSearch, query } = useDocsSearch({
type: 'fetch',
api: withBlogBasePath('/api/search'),
type: "fetch",
api: withBlogBasePath("/api/search"),
delayMs: 500,
});



return (
<SearchDialog search={search} onSearchChange={setSearch} isLoading={query.isLoading} {...props}>
<SearchDialog
search={search}
onSearchChange={setSearch}
isLoading={query.isLoading}
{...props}
>
<SearchDialogOverlay />
<SearchDialogContent>
<SearchDialogHeader>
Expand All @@ -123,8 +118,7 @@ export default function CustomSearchDialog(props: SharedProps) {
items={query.data !== "empty" ? query.data : null}
Item={SearchResultItem}
/>
<SearchDialogFooter className="border-t border-fd-border p-2">
</SearchDialogFooter>
<SearchDialogFooter className="border-t border-fd-border p-2"></SearchDialogFooter>
</SearchDialogContent>
</SearchDialog>
);
Expand Down
13 changes: 13 additions & 0 deletions apps/site/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Mixedbread API Configuration
# Required for the search functionality to work
# Get your API key from: https://www.mixedbread.ai/
MIXEDBREAD_API_KEY=your_mixedbread_api_key_here

# Base URL for the site (optional, defaults based on environment)
# NEXT_PUBLIC_PRISMA_URL=https://www.prisma.io

# Vercel URL (automatically set in Vercel deployments)
# VERCEL_URL=

# Allowed development origins (optional, defaults to localhost,127.0.0.1,192.168.1.48)
# ALLOWED_DEV_ORIGINS=localhost,127.0.0.1
4 changes: 3 additions & 1 deletion apps/site/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const ContentSecurityPolicy = `
http://localhost:3002 http://127.0.0.1:3002
https://www.prisma.io https://prisma.io
https://cdn.sanity.io
https://prisma.io
https://prismalens.vercel.app
https://api.producthunt.com
https://www.google.com
Expand All @@ -74,7 +75,8 @@ const ContentSecurityPolicy = `
https://vercel.live https://vercel.com data: blob:
https://td.doubleclick.net
https://raw.githubusercontent.com
https://*.meetupstatic.com;
https://*.meetupstatic.com
https://www.prisma.io;

connect-src 'self'
https://api.github.com
Expand Down
4 changes: 4 additions & 0 deletions apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@
},
"dependencies": {
"@base-ui/react": "catalog:",
"@fumadocs/base-ui": "catalog:",
"@mixedbread/sdk": "catalog:",
"@prisma-docs/ui": "workspace:*",
"@prisma/eclipse": "workspace:^",
"@react-three/fiber": "^9.5.0",
"cors": "^2.8.6",
"html-react-parser": "^5.2.17",
"fumadocs-core": "catalog:",
"fumadocs-ui": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"npm-to-yarn": "catalog:",
Expand Down
Binary file added apps/site/public/og/og-support.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
164 changes: 157 additions & 7 deletions apps/site/src/app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,158 @@
export async function GET() {
return Response.json(
{
error: "Search is not configured in the site host zone.",
},
{ status: 404 },
);
import { SiteSearchResult } from "@/components/support/search-types";
import Mixedbread from "@mixedbread/sdk";
import { NextRequest, NextResponse } from "next/server";

type GeneratedMetadata = {
title?: string;
slug?: string;
metaTitle?: string;
metaDescription?: string;
metaImagePath?: string;
heroImagePath?: string;
tags?: string[];
excerpt?: string;
url?: string;
};

export const dynamic = "force-dynamic";

const mixedbreadApiKey = process.env.MIXEDBREAD_API_KEY;
const storeIdentifiers = ["blog-search", "web-search"] as const;
const client = mixedbreadApiKey
? new Mixedbread({ apiKey: mixedbreadApiKey })
: null;
const websiteBaseUrl = "https://prisma.io";
const blogPrefix = "/blog";
const docsPrefix = "/docs";

type MixedbreadSearchChunk = {
id?: string;
file_id?: string;
chunk_index?: number;
text?: string;
generated_metadata?: GeneratedMetadata;
};

function withPrefixedPath(
path: string | undefined,
prefix: string,
): string {
const normalizedPath = (path ?? "")
.replace(/^\/+/, "")
.replace(new RegExp(`^${prefix.replace("/", "")}/`), "");

return normalizedPath ? `${prefix}/${normalizedPath}` : "#";
}

function withBaseAndPrefixedPath(
path: string | undefined,
prefix: string,
): string {
const prefixedPath = withPrefixedPath(path, prefix);
return prefixedPath === "#"
? prefixedPath
: new URL(prefixedPath, websiteBaseUrl).toString();
}

function normalizeBlogUrl(slug?: string): string {
return withBaseAndPrefixedPath(slug, blogPrefix);
}

function normalizeBlogImagePath(imagePath?: string): string {
if (!imagePath) return "";
if (/^https?:\/\//.test(imagePath)) return imagePath;
const blogPath = withBaseAndPrefixedPath(imagePath, blogPrefix);
return blogPath === "#" ? "" : blogPath;
}

function normalizeDocsUrl(url?: string): string {
return withBaseAndPrefixedPath(url, docsPrefix);
}

function getDocsImagePath(normalizedDocsUrl: string): string {
if (!normalizedDocsUrl || normalizedDocsUrl === "#") return "";
const docsUrl = new URL(normalizedDocsUrl, websiteBaseUrl);
const docsPath = docsUrl.pathname.replace(/^\/docs\//, "").replace(/^\/+/, "");
return `https://www.prisma.io/docs/og/${docsPath}/image.png`;
}

function transformResult(item: MixedbreadSearchChunk): SiteSearchResult | null {
const metadata = item.generated_metadata;
if (!metadata) return null;

const source: SiteSearchResult["source"] | null = metadata.slug
? "blog"
: metadata.url
? "docs"
: null;
if (!source) return null;

const base =
item.id ?? `${item.file_id ?? "unknown"}-${item.chunk_index ?? "0"}`;

const normalizedUrl =
source === "blog"
? normalizeBlogUrl(metadata.slug)
: normalizeDocsUrl(metadata.url);

return {
id: `${base}-page`,
type: "page",
source,
content:
source === "blog"
? metadata.metaTitle ?? metadata.title ?? item.text ?? "Untitled"
: metadata.title ?? item.text ?? "Untitled",
url: normalizedUrl,
description:
source === "blog"
? metadata.metaDescription ?? metadata.excerpt ?? item.text ?? ""
: metadata.metaDescription ?? item.text ?? "",
heroImagePath:
source === "blog"
? normalizeBlogImagePath(
metadata.heroImagePath ?? metadata.metaImagePath,
)
: getDocsImagePath(normalizedUrl),
tags: source === "blog" ? metadata.tags ?? [] : [],
};
}

function transformResults(results: MixedbreadSearchChunk[]): SiteSearchResult[] {
return results
.map(transformResult)
.filter((item): item is SiteSearchResult => item !== null);
}

export async function GET(request: NextRequest) {
if (!client) {
console.error("Search API called but Mixedbread is not configured");
console.error("Please set MIXEDBREAD_API_KEY environment variable");
return NextResponse.json([]);
}

const query = request.nextUrl.searchParams.get("query")?.trim() ?? "";

if (!query) {
return NextResponse.json([]);
}

try {
const response = await client.stores.search({
query,
store_identifiers: [...storeIdentifiers],
top_k: 20,
search_options: {
rerank: true,
return_metadata: true,
},
});
Comment on lines +127 to +149
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Put abuse protection in front of the Mixedbread call.

This is a public GET endpoint and every non-empty query is forwarded straight to client.stores.search(). A trivial script can burn search quota or create an avoidable vendor spike. Please add rate limiting/cache/bot protection here or at the edge, and consider rejecting very short queries.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/site/src/app/api/search/route.ts` around lines 127 - 149, The GET
handler forwards every non-empty query to client.stores.search(), so add abuse
protection before that call: validate and reject very short queries (e.g.,
require trimmed query length >= 3), implement per-client rate limiting (IP or
authenticated key) that returns 429 when limits are exceeded, and add a
short-lived cache for identical queries to avoid repeated backend hits; ensure
these checks occur before invoking client.stores.search() in the GET function
(use the request.nextUrl.searchParams.get("query") and client.stores.search
references) and return early on rejection or a cached response.


return NextResponse.json(
transformResults(response.data as MixedbreadSearchChunk[]),
);
} catch (error) {
console.error("Mixedbread search failed:", error);
return NextResponse.json([]);
}
}
Loading
Loading