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