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 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'),
}
}
+37 -31
View File
@@ -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,
})
}
}
+11 -12
View File
@@ -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<object>) => {
const id = generateHeadingId(typeof children === 'string' ? children : '')
return (
<h1
id={id}
@@ -50,9 +49,9 @@ const components: Components = {
</a>
</h1>
)
}) as any,
h2: (({ children, ...props }: any) => {
const id = generateHeadingId(children?.toString() || '')
},
h2: ({ children, ...props }: PropsWithChildren<object>) => {
const id = generateHeadingId(typeof children === 'string' ? children : '')
return (
<h2
id={id}
@@ -67,9 +66,9 @@ const components: Components = {
</a>
</h2>
)
}) as any,
h3: (({ children, ...props }: any) => {
const id = generateHeadingId(children?.toString() || '')
},
h3: ({ children, ...props }: PropsWithChildren<object>) => {
const id = generateHeadingId(typeof children === 'string' ? children : '')
return (
<h3 id={id} className="mb-3 mt-6 text-lg font-semibold scroll-mt-20" {...props}>
<a href={`#${id}`} className="group">
@@ -80,7 +79,7 @@ const components: Components = {
</a>
</h3>
)
}) as any,
},
h4: ({ children, ...props }) => (
<h4 className="mb-2 mt-4 text-base font-semibold" {...props}>
{children}
@@ -256,7 +255,7 @@ export function MarkdownContent({ content, className = '' }: MarkdownContentProp
<article className={`markdown-body text-gray-900 dark:text-gray-100 ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
rehypePlugins={[rehypeSanitize]}
components={components}
>
{content}
+39 -7
View File
@@ -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<ProjectWithTagsAndLinks, 'tags'> & {
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<ProjectWithFlatTags | null> {
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<TagWithProjectCount[]> {
return prisma.tag.findMany({
include: {
_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({
include: {
_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({
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),