diff --git a/src/app/[locale]/projects/[id]/page.tsx b/src/app/[locale]/projects/[id]/page.tsx index 3b298c2..6abde94 100644 --- a/src/app/[locale]/projects/[id]/page.tsx +++ b/src/app/[locale]/projects/[id]/page.tsx @@ -16,18 +16,16 @@ export default async function ProjectDetailPage({ const { locale, id } = resolvedParams const tProject = await getTranslations('project') - // Get project by slug - const project = await getProjectBySlug(id) + // 并行获取项目和相关项目数据 + const [project, relatedProjectsResult] = await Promise.all([ + getProjectBySlug(id), + getProjects({ limit: 3 }), + ]) if (!project) { notFound() } - // Get related projects (same tags, excluding current project) - const relatedProjectsResult = await getProjects({ - limit: 3, - }) - const relatedProjects = relatedProjectsResult.projects .filter(p => p.id !== project.id) .filter(p => p.tags.some(t => project.tags.some(pt => pt.id === t.id))) @@ -72,12 +70,13 @@ export default async function ProjectDetailPage({ export async function generateMetadata({ params }: ProjectDetailPageProps) { const resolvedParams = await params const { id, locale } = resolvedParams + const tProject = await getTranslations('project') const project = await getProjectBySlug(id) if (!project) { return { - title: 'Project Not Found', + title: tProject('notFound'), } } diff --git a/src/app/api/webhook/projects/route.ts b/src/app/api/webhook/projects/route.ts index 285459a..9c0ea63 100644 --- a/src/app/api/webhook/projects/route.ts +++ b/src/app/api/webhook/projects/route.ts @@ -1,11 +1,14 @@ import { NextRequest, NextResponse } from 'next/server' +import crypto from 'crypto' import { prisma } from '@/lib/prisma' +import type { ProjectStatus, LinkType } from '@prisma/client' import { WebhookPayloadSchema, ProjectInputSchema, type WebhookPayload, type ProjectInput, } from '@/lib/validations' +import { generateSlug } from '@/lib/slug' /** * 多级去重策略:查找已存在的项目 @@ -72,9 +75,7 @@ async function findExistingProject(projectData: ProjectInput) { } // 优先级3: 通过 slug 匹配(兜底) - const slug = - projectData.nameEn?.toLowerCase().replace(/\s+/g, '-') || - projectData.name.toLowerCase().replace(/\s+/g, '-') + const slug = generateSlug(projectData.name, projectData.nameEn) const existingBySlug = await prisma.project.findUnique({ where: { slug }, @@ -114,9 +115,15 @@ export async function POST(request: NextRequest) { const payload = validationResult.data as WebhookPayload - // Verify API Key + // Verify API Key using timing-safe comparison to prevent timing attacks const apiKey = process.env.WEBHOOK_API_KEY - if (payload.apiKey !== apiKey) { + if ( + !apiKey || + !crypto.timingSafeEqual( + Buffer.from(payload.apiKey), + Buffer.from(apiKey) + ) + ) { return NextResponse.json( { success: false, @@ -165,38 +172,38 @@ export async function POST(request: NextRequest) { // 多级去重:查找已存在的项目 const existingProject = await findExistingProject(validProject) - // Upsert tags with better error handling for name uniqueness + // 优化:批量查询所有已存在的标签,避免 N+1 问题 + const allTagNames = validProject.tags.map((t) => t.name) + const existingTags = await prisma.tag.findMany({ + where: { name: { in: allTagNames } }, + }) + const existingTagNames = new Set(existingTags.map((t) => t.name)) + + // Upsert tags - 优化后只查询不存在的标签 const tagConnections = await Promise.all( validProject.tags.map(async (tag) => { - const slug = - tag.nameEn?.toLowerCase().replace(/\s+/g, '-') || - tag.name.toLowerCase().replace(/\s+/g, '-') + const tagSlug = generateSlug(tag.name, tag.nameEn) - // First, try to find by name (handle name uniqueness constraint) - const existingByName = await prisma.tag.findUnique({ - where: { name: tag.name }, - }) - - if (existingByName) { - // Tag with this name already exists, use it - return existingByName + // 首先从批量查询结果中查找 + if (existingTagNames.has(tag.name)) { + return existingTags.find((t) => t.name === tag.name)! } - // Try upsert by slug (safe now since name doesn't exist) + // 只有标签不存在时才尝试 upsert try { return await prisma.tag.upsert({ - where: { slug }, + where: { slug: tagSlug }, update: {}, create: { name: tag.name, nameEn: tag.nameEn || null, - slug, + slug: tagSlug, }, }) } catch (error) { - // If slug conflicts with existing tag, find and use that one + // 如果 slug 冲突,查找并使用已存在的标签 const existingBySlug = await prisma.tag.findUnique({ - where: { slug }, + where: { slug: tagSlug }, }) if (existingBySlug) { return existingBySlug @@ -207,9 +214,7 @@ export async function POST(request: NextRequest) { ) // Generate slug for project - const slug = - validProject.nameEn?.toLowerCase().replace(/\s+/g, '-') || - validProject.name.toLowerCase().replace(/\s+/g, '-') + const slug = generateSlug(validProject.name, validProject.nameEn) if (existingProject) { // Update existing project @@ -240,7 +245,7 @@ export async function POST(request: NextRequest) { descriptionEn: validProject.descriptionEn || null, content: validProject.content || null, contentEn: validProject.contentEn || null, - status: validProject.status as any, + status: validProject.status as ProjectStatus, source: validProject.source || null, tags: { create: tagConnections.map((t) => ({ @@ -257,7 +262,7 @@ export async function POST(request: NextRequest) { await prisma.externalLink.createMany({ data: validProject.links.map((link) => ({ - type: link.type as any, + type: link.type as LinkType, url: link.url, title: link.title || null, projectId: existingProject.id, @@ -280,7 +285,7 @@ export async function POST(request: NextRequest) { descriptionEn: validProject.descriptionEn || null, content: validProject.content || null, contentEn: validProject.contentEn || null, - status: validProject.status as any, + status: validProject.status as ProjectStatus, source: validProject.source || null, tags: { create: tagConnections.map((t) => ({ @@ -289,7 +294,7 @@ export async function POST(request: NextRequest) { }, links: { create: validProject.links.map((link) => ({ - type: link.type as any, + type: link.type as LinkType, url: link.url, title: link.title || null, })), @@ -300,12 +305,13 @@ export async function POST(request: NextRequest) { results.created++ } } catch (error) { + console.error(`[Webhook] Error processing project at index ${i}:`, error) results.failed++ results.errors.push({ index: i, field: 'general', - message: error instanceof Error ? error.message : 'Unknown error', - value: projectData, + message: 'Failed to process project. Please check the server logs.', + value: process.env.NODE_ENV === 'development' ? projectData : undefined, }) } } diff --git a/src/components/project/MarkdownContent.tsx b/src/components/project/MarkdownContent.tsx index 9ca261a..0484410 100644 --- a/src/components/project/MarkdownContent.tsx +++ b/src/components/project/MarkdownContent.tsx @@ -1,7 +1,6 @@ -import React from 'react' +import React, { type PropsWithChildren } from 'react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' -import rehypeRaw from 'rehype-raw' import rehypeSanitize from 'rehype-sanitize' import type { Components } from 'react-markdown' @@ -34,8 +33,8 @@ function escapeHtml(code: string): string { // Custom GitHub-style components const components: Components = { // Headings with anchor links - h1: (({ children, ...props }: any) => { - const id = generateHeadingId(children?.toString() || '') + h1: ({ children, ...props }: PropsWithChildren) => { + const id = generateHeadingId(typeof children === 'string' ? children : '') return (

) - }) as any, - h2: (({ children, ...props }: any) => { - const id = generateHeadingId(children?.toString() || '') + }, + h2: ({ children, ...props }: PropsWithChildren) => { + const id = generateHeadingId(typeof children === 'string' ? children : '') return (

) - }) as any, - h3: (({ children, ...props }: any) => { - const id = generateHeadingId(children?.toString() || '') + }, + h3: ({ children, ...props }: PropsWithChildren) => { + const id = generateHeadingId(typeof children === 'string' ? children : '') return (

@@ -80,7 +79,7 @@ const components: Components = {

) - }) as any, + }, h4: ({ children, ...props }) => (

{children} @@ -256,7 +255,7 @@ export function MarkdownContent({ content, className = '' }: MarkdownContentProp
{content} diff --git a/src/hooks/useProjects.ts b/src/hooks/useProjects.ts index 3116c56..b124099 100644 --- a/src/hooks/useProjects.ts +++ b/src/hooks/useProjects.ts @@ -1,4 +1,25 @@ import { prisma } from '@/lib/prisma' +import type { Prisma } from '@prisma/client' + +// 定义带有标签和链接的项目类型 +export type ProjectWithTagsAndLinks = Prisma.ProjectGetPayload<{ + include: { + tags: { include: { tag: true } } + links: true + } +}> + +// 定义扁平化标签的项目类型 +export type ProjectWithFlatTags = Omit & { + tags: Prisma.TagGetPayload<{}>[] +} + +// 定义标签计数类型 +export type TagWithProjectCount = Prisma.TagGetPayload<{ + include: { + _count: { select: { projects: true } } + } +}> export async function getProjects(options?: { search?: string @@ -6,7 +27,15 @@ export async function getProjects(options?: { status?: 'ACTIVE' | 'ARCHIVED' page?: number limit?: number -}) { +}): Promise<{ + projects: ProjectWithFlatTags[] + pagination: { + page: number + limit: number + total: number + totalPages: number + } +}> { const { search, tag, @@ -15,11 +44,12 @@ export async function getProjects(options?: { limit = 20, } = options || {} - const where: any = { + const where: Prisma.ProjectWhereInput = { status, } - if (search) { + // 添加搜索字符串长度验证 + if (search && search.length >= 2 && search.length <= 100) { where.OR = [ { name: { contains: search, mode: 'insensitive' } }, { nameEn: { contains: search, mode: 'insensitive' } }, @@ -31,7 +61,9 @@ export async function getProjects(options?: { if (tag) { where.tags = { some: { - slug: tag, + tag: { + slug: tag, + }, }, } } @@ -73,7 +105,7 @@ export async function getProjects(options?: { } } -export async function getProjectBySlug(slug: string) { +export async function getProjectBySlug(slug: string): Promise { const project = await prisma.project.findUnique({ where: { slug }, include: { @@ -97,7 +129,7 @@ export async function getProjectBySlug(slug: string) { } } -export async function getAllTags() { +export async function getAllTags(): Promise { return prisma.tag.findMany({ include: { _count: { @@ -110,7 +142,7 @@ export async function getAllTags() { }) } -export async function getTagsWithProjectCounts() { +export async function getTagsWithProjectCounts(): Promise { const tags = await prisma.tag.findMany({ include: { _count: { diff --git a/src/lib/slug.ts b/src/lib/slug.ts new file mode 100644 index 0000000..e03f9b2 --- /dev/null +++ b/src/lib/slug.ts @@ -0,0 +1,39 @@ +/** + * Slug 生成工具函数 + * 将项目名称转换为 URL 友好的 slug 格式 + */ + +/** + * 生成 URL 友好的 slug + * @param name - 中文名称 + * @param nameEn - 英文名称(可选) + * @returns slug 字符串 + * + * @example + * generateSlug('Hello World', '你好世界') // '你好世界' + * generateSlug('Hello World') // 'hello-world' + * generateSlug('Hello World') // 'hello-world' (多个空格合并为一个) + * generateSlug('Hello @#$ World') // 'hello-world' (移除特殊字符) + */ +export function generateSlug(name: string, nameEn?: string | null): string { + // 优先使用英文名称,如果没有则使用中文名称 + const baseName = nameEn?.trim() || name.trim() + + // 转换为小写 + const lowercase = baseName.toLowerCase() + + // 移除特殊字符(保留字母、数字、空格、连字符和中文) + const cleaned = lowercase.replace(/[^\w\s\u4e00-\u9fa5-]/g, '') + + // 将空格替换为连字符 + const dashed = cleaned.replace(/\s+/g, '-') + + // 合并多个连续的连字符 + const normalized = dashed.replace(/-+/g, '-') + + // 移除首尾的连字符 + const trimmed = normalized.replace(/^-+|-+$/g, '') + + // 限制长度为 100 字符 + return trimmed.substring(0, 100) || 'untitled' +} diff --git a/src/lib/validations.ts b/src/lib/validations.ts index 3535832..bac8ca7 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -13,7 +13,17 @@ export const LinkTypeEnum = z.enum(['WEBSITE', 'GITHUB', 'HUGGINGFACE', 'PAPER'] export const ExternalLinkSchema = z.object({ type: LinkTypeEnum, - url: z.string().url('Invalid URL format'), + url: z.string() + .min(1, 'URL is required') + .max(2000, 'URL is too long') + .refine((url) => { + try { + const parsed = new URL(url) + return ['http:', 'https:'].includes(parsed.protocol) + } catch { + return false + } + }, 'URL must use http or https protocol'), title: z.string().max(200).optional() }) @@ -59,7 +69,7 @@ export const WebhookPayloadSchema = WebhookAuthSchema.extend({ // ================================ export const ProjectQuerySchema = z.object({ - search: z.string().max(100).optional(), + search: z.string().min(2).max(100).optional(), tags: z.array(z.string()).optional(), status: ProjectStatusEnum.optional(), page: z.coerce.number().int().positive().default(1),