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:
@@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user