fix: 修复代码审查发现的安全性和性能问题

主要修复:
- 安全性:API 密钥使用恒定时间比较防止时序攻击
- 安全性:URL 验证仅允许 http/https 协议,防止 XSS
- 安全性:移除 Markdown 的 rehypeRaw 插件增强 XSS 防护
- 安全性:Webhook 错误信息脱敏,生产环境不暴露敏感数据
- 性能:项目详情页使用并行数据获取
- 性能:Webhook 批量查询标签,避免 N+1 查询问题
- 类型安全:移除 any 类型,使用 Prisma 类型注解
- 类型安全:为 useProjects 函数添加返回类型
- 代码质量:创建 slug 工具函数统一 slug 生成逻辑
- 代码质量:搜索字符串添加最小长度限制(2字符)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-16 14:43:46 +08:00
co-authored by Claude
parent 5721206516
commit 2a37a83072
6 changed files with 145 additions and 60 deletions
+7 -8
View File
@@ -16,18 +16,16 @@ export default async function ProjectDetailPage({
const { locale, id } = resolvedParams const { locale, id } = resolvedParams
const tProject = await getTranslations('project') 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) { if (!project) {
notFound() notFound()
} }
// Get related projects (same tags, excluding current project)
const relatedProjectsResult = await getProjects({
limit: 3,
})
const relatedProjects = relatedProjectsResult.projects const relatedProjects = relatedProjectsResult.projects
.filter(p => p.id !== project.id) .filter(p => p.id !== project.id)
.filter(p => p.tags.some(t => project.tags.some(pt => pt.id === t.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) { export async function generateMetadata({ params }: ProjectDetailPageProps) {
const resolvedParams = await params const resolvedParams = await params
const { id, locale } = resolvedParams const { id, locale } = resolvedParams
const tProject = await getTranslations('project')
const project = await getProjectBySlug(id) const project = await getProjectBySlug(id)
if (!project) { if (!project) {
return { return {
title: 'Project Not Found', title: tProject('notFound'),
} }
} }
+37 -31
View File
@@ -1,11 +1,14 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import type { ProjectStatus, LinkType } from '@prisma/client'
import { import {
WebhookPayloadSchema, WebhookPayloadSchema,
ProjectInputSchema, ProjectInputSchema,
type WebhookPayload, type WebhookPayload,
type ProjectInput, type ProjectInput,
} from '@/lib/validations' } from '@/lib/validations'
import { generateSlug } from '@/lib/slug'
/** /**
* 多级去重策略:查找已存在的项目 * 多级去重策略:查找已存在的项目
@@ -72,9 +75,7 @@ async function findExistingProject(projectData: ProjectInput) {
} }
// 优先级3: 通过 slug 匹配(兜底) // 优先级3: 通过 slug 匹配(兜底)
const slug = const slug = generateSlug(projectData.name, projectData.nameEn)
projectData.nameEn?.toLowerCase().replace(/\s+/g, '-') ||
projectData.name.toLowerCase().replace(/\s+/g, '-')
const existingBySlug = await prisma.project.findUnique({ const existingBySlug = await prisma.project.findUnique({
where: { slug }, where: { slug },
@@ -114,9 +115,15 @@ export async function POST(request: NextRequest) {
const payload = validationResult.data as WebhookPayload 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 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( return NextResponse.json(
{ {
success: false, success: false,
@@ -165,38 +172,38 @@ export async function POST(request: NextRequest) {
// 多级去重:查找已存在的项目 // 多级去重:查找已存在的项目
const existingProject = await findExistingProject(validProject) 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( const tagConnections = await Promise.all(
validProject.tags.map(async (tag) => { validProject.tags.map(async (tag) => {
const slug = const tagSlug = generateSlug(tag.name, tag.nameEn)
tag.nameEn?.toLowerCase().replace(/\s+/g, '-') ||
tag.name.toLowerCase().replace(/\s+/g, '-')
// First, try to find by name (handle name uniqueness constraint) // 首先从批量查询结果中查找
const existingByName = await prisma.tag.findUnique({ if (existingTagNames.has(tag.name)) {
where: { name: tag.name }, return existingTags.find((t) => t.name === tag.name)!
})
if (existingByName) {
// Tag with this name already exists, use it
return existingByName
} }
// Try upsert by slug (safe now since name doesn't exist) // 只有标签不存在时才尝试 upsert
try { try {
return await prisma.tag.upsert({ return await prisma.tag.upsert({
where: { slug }, where: { slug: tagSlug },
update: {}, update: {},
create: { create: {
name: tag.name, name: tag.name,
nameEn: tag.nameEn || null, nameEn: tag.nameEn || null,
slug, slug: tagSlug,
}, },
}) })
} catch (error) { } catch (error) {
// If slug conflicts with existing tag, find and use that one // 如果 slug 冲突,查找并使用已存在的标签
const existingBySlug = await prisma.tag.findUnique({ const existingBySlug = await prisma.tag.findUnique({
where: { slug }, where: { slug: tagSlug },
}) })
if (existingBySlug) { if (existingBySlug) {
return existingBySlug return existingBySlug
@@ -207,9 +214,7 @@ export async function POST(request: NextRequest) {
) )
// Generate slug for project // Generate slug for project
const slug = const slug = generateSlug(validProject.name, validProject.nameEn)
validProject.nameEn?.toLowerCase().replace(/\s+/g, '-') ||
validProject.name.toLowerCase().replace(/\s+/g, '-')
if (existingProject) { if (existingProject) {
// Update existing project // Update existing project
@@ -240,7 +245,7 @@ export async function POST(request: NextRequest) {
descriptionEn: validProject.descriptionEn || null, descriptionEn: validProject.descriptionEn || null,
content: validProject.content || null, content: validProject.content || null,
contentEn: validProject.contentEn || null, contentEn: validProject.contentEn || null,
status: validProject.status as any, status: validProject.status as ProjectStatus,
source: validProject.source || null, source: validProject.source || null,
tags: { tags: {
create: tagConnections.map((t) => ({ create: tagConnections.map((t) => ({
@@ -257,7 +262,7 @@ export async function POST(request: NextRequest) {
await prisma.externalLink.createMany({ await prisma.externalLink.createMany({
data: validProject.links.map((link) => ({ data: validProject.links.map((link) => ({
type: link.type as any, type: link.type as LinkType,
url: link.url, url: link.url,
title: link.title || null, title: link.title || null,
projectId: existingProject.id, projectId: existingProject.id,
@@ -280,7 +285,7 @@ export async function POST(request: NextRequest) {
descriptionEn: validProject.descriptionEn || null, descriptionEn: validProject.descriptionEn || null,
content: validProject.content || null, content: validProject.content || null,
contentEn: validProject.contentEn || null, contentEn: validProject.contentEn || null,
status: validProject.status as any, status: validProject.status as ProjectStatus,
source: validProject.source || null, source: validProject.source || null,
tags: { tags: {
create: tagConnections.map((t) => ({ create: tagConnections.map((t) => ({
@@ -289,7 +294,7 @@ export async function POST(request: NextRequest) {
}, },
links: { links: {
create: validProject.links.map((link) => ({ create: validProject.links.map((link) => ({
type: link.type as any, type: link.type as LinkType,
url: link.url, url: link.url,
title: link.title || null, title: link.title || null,
})), })),
@@ -300,12 +305,13 @@ export async function POST(request: NextRequest) {
results.created++ results.created++
} }
} catch (error) { } catch (error) {
console.error(`[Webhook] Error processing project at index ${i}:`, error)
results.failed++ results.failed++
results.errors.push({ results.errors.push({
index: i, index: i,
field: 'general', field: 'general',
message: error instanceof Error ? error.message : 'Unknown error', message: 'Failed to process project. Please check the server logs.',
value: projectData, value: process.env.NODE_ENV === 'development' ? projectData : undefined,
}) })
} }
} }
+11 -12
View File
@@ -1,7 +1,6 @@
import React from 'react' import React, { type PropsWithChildren } from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize' import rehypeSanitize from 'rehype-sanitize'
import type { Components } from 'react-markdown' import type { Components } from 'react-markdown'
@@ -34,8 +33,8 @@ function escapeHtml(code: string): string {
// Custom GitHub-style components // Custom GitHub-style components
const components: Components = { const components: Components = {
// Headings with anchor links // Headings with anchor links
h1: (({ children, ...props }: any) => { h1: ({ children, ...props }: PropsWithChildren<object>) => {
const id = generateHeadingId(children?.toString() || '') const id = generateHeadingId(typeof children === 'string' ? children : '')
return ( return (
<h1 <h1
id={id} id={id}
@@ -50,9 +49,9 @@ const components: Components = {
</a> </a>
</h1> </h1>
) )
}) as any, },
h2: (({ children, ...props }: any) => { h2: ({ children, ...props }: PropsWithChildren<object>) => {
const id = generateHeadingId(children?.toString() || '') const id = generateHeadingId(typeof children === 'string' ? children : '')
return ( return (
<h2 <h2
id={id} id={id}
@@ -67,9 +66,9 @@ const components: Components = {
</a> </a>
</h2> </h2>
) )
}) as any, },
h3: (({ children, ...props }: any) => { h3: ({ children, ...props }: PropsWithChildren<object>) => {
const id = generateHeadingId(children?.toString() || '') const id = generateHeadingId(typeof children === 'string' ? children : '')
return ( return (
<h3 id={id} className="mb-3 mt-6 text-lg font-semibold scroll-mt-20" {...props}> <h3 id={id} className="mb-3 mt-6 text-lg font-semibold scroll-mt-20" {...props}>
<a href={`#${id}`} className="group"> <a href={`#${id}`} className="group">
@@ -80,7 +79,7 @@ const components: Components = {
</a> </a>
</h3> </h3>
) )
}) as any, },
h4: ({ children, ...props }) => ( h4: ({ children, ...props }) => (
<h4 className="mb-2 mt-4 text-base font-semibold" {...props}> <h4 className="mb-2 mt-4 text-base font-semibold" {...props}>
{children} {children}
@@ -256,7 +255,7 @@ export function MarkdownContent({ content, className = '' }: MarkdownContentProp
<article className={`markdown-body text-gray-900 dark:text-gray-100 ${className}`}> <article className={`markdown-body text-gray-900 dark:text-gray-100 ${className}`}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]} rehypePlugins={[rehypeSanitize]}
components={components} components={components}
> >
{content} {content}
+39 -7
View File
@@ -1,4 +1,25 @@
import { prisma } from '@/lib/prisma' 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<ProjectWithTagsAndLinks, 'tags'> & {
tags: Prisma.TagGetPayload<{}>[]
}
// 定义标签计数类型
export type TagWithProjectCount = Prisma.TagGetPayload<{
include: {
_count: { select: { projects: true } }
}
}>
export async function getProjects(options?: { export async function getProjects(options?: {
search?: string search?: string
@@ -6,7 +27,15 @@ export async function getProjects(options?: {
status?: 'ACTIVE' | 'ARCHIVED' status?: 'ACTIVE' | 'ARCHIVED'
page?: number page?: number
limit?: number limit?: number
}) { }): Promise<{
projects: ProjectWithFlatTags[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}> {
const { const {
search, search,
tag, tag,
@@ -15,11 +44,12 @@ export async function getProjects(options?: {
limit = 20, limit = 20,
} = options || {} } = options || {}
const where: any = { const where: Prisma.ProjectWhereInput = {
status, status,
} }
if (search) { // 添加搜索字符串长度验证
if (search && search.length >= 2 && search.length <= 100) {
where.OR = [ where.OR = [
{ name: { contains: search, mode: 'insensitive' } }, { name: { contains: search, mode: 'insensitive' } },
{ nameEn: { contains: search, mode: 'insensitive' } }, { nameEn: { contains: search, mode: 'insensitive' } },
@@ -31,7 +61,9 @@ export async function getProjects(options?: {
if (tag) { if (tag) {
where.tags = { where.tags = {
some: { 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<ProjectWithFlatTags | null> {
const project = await prisma.project.findUnique({ const project = await prisma.project.findUnique({
where: { slug }, where: { slug },
include: { include: {
@@ -97,7 +129,7 @@ export async function getProjectBySlug(slug: string) {
} }
} }
export async function getAllTags() { export async function getAllTags(): Promise<TagWithProjectCount[]> {
return prisma.tag.findMany({ return prisma.tag.findMany({
include: { include: {
_count: { _count: {
@@ -110,7 +142,7 @@ export async function getAllTags() {
}) })
} }
export async function getTagsWithProjectCounts() { export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]> {
const tags = await prisma.tag.findMany({ const tags = await prisma.tag.findMany({
include: { include: {
_count: { _count: {
+39
View File
@@ -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'
}
+12 -2
View File
@@ -13,7 +13,17 @@ export const LinkTypeEnum = z.enum(['WEBSITE', 'GITHUB', 'HUGGINGFACE', 'PAPER']
export const ExternalLinkSchema = z.object({ export const ExternalLinkSchema = z.object({
type: LinkTypeEnum, 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() title: z.string().max(200).optional()
}) })
@@ -59,7 +69,7 @@ export const WebhookPayloadSchema = WebhookAuthSchema.extend({
// ================================ // ================================
export const ProjectQuerySchema = z.object({ 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(), tags: z.array(z.string()).optional(),
status: ProjectStatusEnum.optional(), status: ProjectStatusEnum.optional(),
page: z.coerce.number().int().positive().default(1), page: z.coerce.number().int().positive().default(1),