refactor: 统一API鉴权并提升代码健壮性

This commit is contained in:
2026-02-22 11:50:48 +08:00
parent 2e2edac6af
commit 93c616793d
20 changed files with 210 additions and 139 deletions
@@ -1,7 +1,7 @@
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 { CheckTaskDuplicatesSchema } from '@/lib/validations' import { CheckTaskDuplicatesSchema } from '@/lib/validations'
import { isValidApiKey } from '@/lib/auth'
/** /**
* 检查 URL 是否应该创建新任务 * 检查 URL 是否应该创建新任务
@@ -194,14 +194,7 @@ export async function POST(request: NextRequest) {
const { apiKey, urls, sourceType } = validationResult.data const { apiKey, urls, sourceType } = validationResult.data
// 验证 API Key // 验证 API Key
const validApiKey = process.env.WEBHOOK_API_KEY if (!isValidApiKey(apiKey)) {
if (
!validApiKey ||
!crypto.timingSafeEqual(
Buffer.from(apiKey),
Buffer.from(validApiKey)
)
) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
@@ -1,11 +1,11 @@
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 { generateSlug } from '@/lib/slug' import { generateSlug } from '@/lib/slug'
import { import {
ProjectInputSchema, ProjectInputSchema,
type ProjectInput, type ProjectInput,
} from '@/lib/validations' } from '@/lib/validations'
import { isValidApiKey } from '@/lib/auth'
import { import {
findExistingProject, findExistingProject,
resolveFixedProjectTypeTag, resolveFixedProjectTypeTag,
@@ -28,11 +28,7 @@ export async function POST(
const { apiKey, explorationData } = body const { apiKey, explorationData } = body
// 验证API密钥 // 验证API密钥
const validApiKey = process.env.WEBHOOK_API_KEY if (!isValidApiKey(apiKey)) {
if (
!validApiKey ||
!crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey))
) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Unauthorized' }, { success: false, error: 'Unauthorized' },
{ status: 401 } { status: 401 }
+5 -7
View File
@@ -1,8 +1,8 @@
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 { UpdateDiscoveryTaskSchema, TaskStatus } from '@/lib/validations' import { UpdateDiscoveryTaskSchema, TaskStatus } from '@/lib/validations'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import { isValidApiKey } from '@/lib/auth'
/** /**
* 有效的任务状态转换规则 * 有效的任务状态转换规则
@@ -87,11 +87,7 @@ export async function PATCH(
validation.data validation.data
// 验证API密钥 // 验证API密钥
const validApiKey = process.env.WEBHOOK_API_KEY if (!isValidApiKey(apiKey)) {
if (
!validApiKey ||
!crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey))
) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Unauthorized' }, { success: false, error: 'Unauthorized' },
{ status: 401 } { status: 401 }
@@ -143,7 +139,9 @@ export async function PATCH(
updateData.completedAt = new Date() updateData.completedAt = new Date()
} }
if (explorationData !== undefined) updateData.explorationData = explorationData as Prisma.InputJsonObject if (explorationData !== undefined) {
updateData.explorationData = explorationData as Prisma.InputJsonObject
}
if (explorationSummary !== undefined) updateData.explorationSummary = explorationSummary if (explorationSummary !== undefined) updateData.explorationSummary = explorationSummary
if (errorMessage !== undefined) updateData.errorMessage = errorMessage if (errorMessage !== undefined) updateData.errorMessage = errorMessage
@@ -1,7 +1,7 @@
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 { BatchResetTasksSchema } from '@/lib/validations' import { BatchResetTasksSchema } from '@/lib/validations'
import { isValidApiKey } from '@/lib/auth'
/** /**
* POST /api/discovery/tasks/batch-reset * POST /api/discovery/tasks/batch-reset
@@ -31,11 +31,7 @@ export async function POST(request: NextRequest) {
const { apiKey, taskIds, statuses } = validation.data const { apiKey, taskIds, statuses } = validation.data
// 验证API密钥 // 验证API密钥
const validApiKey = process.env.WEBHOOK_API_KEY if (!isValidApiKey(apiKey)) {
if (
!validApiKey ||
!crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey))
) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Unauthorized' }, { success: false, error: 'Unauthorized' },
{ status: 401 } { status: 401 }
+3 -12
View File
@@ -1,10 +1,10 @@
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 { import {
CreateDiscoveryTaskSchema, CreateDiscoveryTaskSchema,
GetDiscoveryTasksQuerySchema, GetDiscoveryTasksQuerySchema,
} from '@/lib/validations' } from '@/lib/validations'
import { isValidApiKey } from '@/lib/auth'
/** /**
* POST /api/discovery/tasks * POST /api/discovery/tasks
@@ -29,11 +29,7 @@ export async function POST(request: NextRequest) {
const { apiKey, tasks } = validation.data const { apiKey, tasks } = validation.data
// 验证API密钥 // 验证API密钥
const validApiKey = process.env.WEBHOOK_API_KEY if (!isValidApiKey(apiKey)) {
if (
!validApiKey ||
!crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey))
) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Unauthorized' }, { success: false, error: 'Unauthorized' },
{ status: 401 } { status: 401 }
@@ -101,12 +97,7 @@ export async function GET(request: NextRequest) {
// 验证 API Key(只读权限) // 验证 API Key(只读权限)
// 支持两种方式:1. 请求头 x-api-key 2. 查询参数 apiKey // 支持两种方式:1. 请求头 x-api-key 2. 查询参数 apiKey
const apiKey = request.headers.get('x-api-key') || searchParams.get('apiKey') const apiKey = request.headers.get('x-api-key') || searchParams.get('apiKey')
const validApiKey = process.env.WEBHOOK_API_KEY if (!isValidApiKey(apiKey)) {
if (
!validApiKey ||
!apiKey ||
!crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey))
) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Unauthorized' }, { success: false, error: 'Unauthorized' },
{ status: 401 } { status: 401 }
+3 -4
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { isValidApiKey } from '@/lib/auth'
/** /**
* DELETE /api/projects/[slug] * DELETE /api/projects/[slug]
@@ -21,10 +22,8 @@ export async function DELETE(
const { slug } = await params const { slug } = await params
// Verify API Key // Verify API Key
const apiKey = request.headers.get('x-api-key') || process.env.WEBHOOK_API_KEY const apiKey = request.headers.get('x-api-key')
const validApiKey = process.env.WEBHOOK_API_KEY if (!isValidApiKey(apiKey)) {
if (apiKey !== validApiKey) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
+2 -9
View File
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { TagMaintenanceRequestSchema } from "@/lib/validations"; import { TagMaintenanceRequestSchema } from "@/lib/validations";
import { isValidApiKey } from "@/lib/auth";
import { executeTagMaintenance, TagMaintenanceApiError } from "./service"; import { executeTagMaintenance, TagMaintenanceApiError } from "./service";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -26,14 +26,7 @@ export async function POST(request: NextRequest) {
const { apiKey, updates, merges } = validation.data; const { apiKey, updates, merges } = validation.data;
// 3. Authenticate with timing-safe comparison // 3. Authenticate with timing-safe comparison
const expectedApiKey = process.env.WEBHOOK_API_KEY; if (!isValidApiKey(apiKey)) {
const providedBuf = Buffer.from(apiKey);
const expectedBuf = Buffer.from(expectedApiKey || "");
if (
!expectedApiKey ||
providedBuf.length !== expectedBuf.length ||
!crypto.timingSafeEqual(providedBuf, expectedBuf)
) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { isValidApiKey } from '@/lib/auth'
import { z } from 'zod' import { z } from 'zod'
/** /**
@@ -179,8 +180,7 @@ export async function POST(request: NextRequest) {
const payload = validationResult.data const payload = validationResult.data
// Verify API Key // Verify API Key
const apiKey = process.env.WEBHOOK_API_KEY if (!isValidApiKey(payload.apiKey)) {
if (payload.apiKey !== apiKey) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
+37 -42
View File
@@ -1,7 +1,7 @@
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 type { ProjectStatus, LinkType } from '@prisma/client'
import { isValidApiKey } from '@/lib/auth'
import { import {
WebhookPayloadSchema, WebhookPayloadSchema,
ProjectInputSchema, ProjectInputSchema,
@@ -38,14 +38,7 @@ export async function POST(request: NextRequest) {
const payload = validationResult.data as WebhookPayload const payload = validationResult.data as WebhookPayload
// Verify API Key using timing-safe comparison to prevent timing attacks // Verify API Key using timing-safe comparison to prevent timing attacks
const apiKey = process.env.WEBHOOK_API_KEY if (!isValidApiKey(payload.apiKey)) {
if (
!apiKey ||
!crypto.timingSafeEqual(
Buffer.from(payload.apiKey),
Buffer.from(apiKey)
)
) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
@@ -66,7 +59,7 @@ export async function POST(request: NextRequest) {
index: number index: number
field: string field: string
message: string message: string
value: any value: unknown
}>, }>,
} }
@@ -123,42 +116,44 @@ export async function POST(request: NextRequest) {
`[Webhook] Updating project "${validProject.name}" (matched by ${matchMethod}, id: ${existingProject.id})` `[Webhook] Updating project "${validProject.name}" (matched by ${matchMethod}, id: ${existingProject.id})`
) )
// Update tags (delete old ones, create new ones) await prisma.$transaction(async (tx) => {
await prisma.projectTag.deleteMany({ // Update tags (delete old ones, create new ones)
where: { projectId: existingProject.id }, await tx.projectTag.deleteMany({
}) where: { projectId: existingProject.id },
})
await prisma.project.update({ await tx.project.update({
where: { id: existingProject.id }, where: { id: existingProject.id },
data: { data: {
name: validProject.name, name: validProject.name,
nameEn: validProject.nameEn || null, nameEn: validProject.nameEn || null,
description: validProject.description, description: validProject.description,
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 ProjectStatus, status: validProject.status as ProjectStatus,
source: validProject.source || null, source: validProject.source || null,
tags: { tags: {
create: tagConnections.map((t) => ({ create: tagConnections.map((t) => ({
tag: { connect: { id: t.id } }, tag: { connect: { id: t.id } },
})), })),
},
}, },
}, })
})
// Update links (delete old ones, create new ones) // Update links (delete old ones, create new ones)
await prisma.externalLink.deleteMany({ await tx.externalLink.deleteMany({
where: { projectId: existingProject.id }, where: { projectId: existingProject.id },
}) })
await prisma.externalLink.createMany({ await tx.externalLink.createMany({
data: validProject.links.map((link) => ({ data: validProject.links.map((link) => ({
type: link.type as LinkType, 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,
})), })),
})
}) })
results.updated++ results.updated++
+1
View File
@@ -1,4 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap');
@import url('https://fonts.googleapis.com/icon?family=Material+Icons');
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" fill="#FFD700"/>
<rect x="6" y="6" width="52" height="52" fill="none" stroke="#000" stroke-width="4"/>
<text
x="32"
y="40"
text-anchor="middle"
font-size="24"
font-family="Inter, Arial, sans-serif"
font-weight="700"
fill="#000"
>
AP
</text>
</svg>

After

Width:  |  Height:  |  Size: 380 B

-6
View File
@@ -13,12 +13,6 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="zh" suppressHydrationWarning> <html lang="zh" suppressHydrationWarning>
<head>
{/* Load fonts via CSS */}
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet"/>
{/* Material Icons */}
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"/>
</head>
<body className="font-sans antialiased"> <body className="font-sans antialiased">
{children} {children}
</body> </body>
+27 -16
View File
@@ -9,6 +9,23 @@ interface MarkdownContentProps {
className?: string className?: string
} }
const BLOCK_LEVEL_TAGS = new Set([
'pre',
'table',
'blockquote',
'ul',
'ol',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'div',
'img',
'hr',
])
// Generate heading ID from text // Generate heading ID from text
function generateHeadingId(text: string): string { function generateHeadingId(text: string): string {
return text return text
@@ -22,14 +39,6 @@ function generateHeadingId(text: string): string {
.replace(/-+$/, '') // Trim - from end .replace(/-+$/, '') // Trim - from end
} }
// Helper function to escape HTML
function escapeHtml(code: string): string {
return code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
// Custom GitHub-style components // Custom GitHub-style components
const components: Components = { const components: Components = {
// Headings with anchor links // Headings with anchor links
@@ -87,13 +96,13 @@ const components: Components = {
), ),
// Paragraphs - skip wrapping if contains block-level elements like pre // Paragraphs - skip wrapping if contains block-level elements like pre
p: ({ children, node, ...props }: any) => { p: ({ children, node, ...props }) => {
// Check if the paragraph node contains block-level elements in its children // Check if the paragraph node contains block-level elements in its children
// This uses the AST node data from react-markdown // This uses the AST node data from react-markdown
const hasBlockElement = node?.children?.some((child: any) => { const hasBlockElement = node?.children?.some((child) => {
const tagName = child?.tagName const tagName = 'tagName' in child ? child.tagName : undefined
// Check if any direct child is a block-level element // Check if any direct child is a block-level element
return ['pre', 'table', 'blockquote', 'ul', 'ol', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'img', 'hr'].includes(tagName) return typeof tagName === 'string' && BLOCK_LEVEL_TAGS.has(tagName)
}) })
if (hasBlockElement) { if (hasBlockElement) {
@@ -131,7 +140,7 @@ const components: Components = {
), ),
// Code blocks (pre element wrapper) // Code blocks (pre element wrapper)
pre: ({ children, ...props }: any) => { pre: ({ children, ...props }) => {
return ( return (
<pre className="bg-gray-900 dark:bg-gray-950 text-gray-100 p-4 rounded-lg overflow-x-auto my-4 border border-gray-700" {...props}> <pre className="bg-gray-900 dark:bg-gray-950 text-gray-100 p-4 rounded-lg overflow-x-auto my-4 border border-gray-700" {...props}>
{children} {children}
@@ -140,12 +149,12 @@ const components: Components = {
}, },
// Code elements (both inline and in code blocks) // Code elements (both inline and in code blocks)
code: ({ inline, className, children, ...props }: any) => { code: ({ className, children, ...props }) => {
// If inline is explicitly true, or if there's no language class and no newlines, treat as inline // If there's no language class and no newlines, treat as inline code.
const hasLanguageClass = className && typeof className === 'string' && className.startsWith('language-') const hasLanguageClass = className && typeof className === 'string' && className.startsWith('language-')
const childStr = String(children) const childStr = String(children)
const hasNewlines = childStr.includes('\n') const hasNewlines = childStr.includes('\n')
const isInline = inline === true || (!hasLanguageClass && !hasNewlines) const isInline = !hasLanguageClass && !hasNewlines
if (isInline) { if (isInline) {
return ( return (
@@ -217,6 +226,8 @@ const components: Components = {
img: ({ src, alt, ...props }) => ( img: ({ src, alt, ...props }) => (
<div className="my-4 flex justify-center"> <div className="my-4 flex justify-center">
<div className="max-w-md w-full"> <div className="max-w-md w-full">
{/* External markdown images use arbitrary remote hosts; Next/Image is not suitable here. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={src} src={src}
alt={alt} alt={alt}
+3 -3
View File
@@ -1,6 +1,5 @@
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import { GitHubStatsCompact } from './GitHubBadges'
import { getGitHubBadgesFromLinks } from '@/lib/github/badges' import { getGitHubBadgesFromLinks } from '@/lib/github/badges'
interface ProjectCardProps { interface ProjectCardProps {
@@ -53,7 +52,7 @@ export function ProjectCard({ project, locale, featured = false, translations }:
const displayDescription = locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description const displayDescription = locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
// Generate GitHub badge URLs // Generate GitHub badge URLs
const badges = project.links ? getGitHubBadgesFromLinks(project.links as any) : { stars: null, forks: null } const badges = project.links ? getGitHubBadgesFromLinks(project.links) : { stars: null }
// Helper to get display name for tag based on locale // Helper to get display name for tag based on locale
const getTagName = (tag: { name: string; nameEn?: string | null }) => { const getTagName = (tag: { name: string; nameEn?: string | null }) => {
@@ -105,7 +104,7 @@ export function ProjectCard({ project, locale, featured = false, translations }:
{/* GitHub Stars Badge */} {/* GitHub Stars Badge */}
{badges.stars && ( {badges.stars && (
<div className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400"> <div className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"> <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/> <path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/>
</svg> </svg>
<Image <Image
@@ -115,6 +114,7 @@ export function ProjectCard({ project, locale, featured = false, translations }:
height={20} height={20}
unoptimized unoptimized
className="rounded" className="rounded"
style={{ width: '70px', height: '20px' }}
/> />
</div> </div>
)} )}
-7
View File
@@ -1,9 +1,5 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server' import { getTranslations } from 'next-intl/server'
import { MarkdownContent } from './MarkdownContent' import { MarkdownContent } from './MarkdownContent'
import { ShareButtons } from './ShareButtons'
import { GitHubBadges } from './GitHubBadges'
import { getGitHubBadgesFromLinks } from '@/lib/github/badges'
import { isFixedProjectTypeSlug } from '@/lib/tag-taxonomy' import { isFixedProjectTypeSlug } from '@/lib/tag-taxonomy'
interface ProjectDetailProps { interface ProjectDetailProps {
@@ -57,9 +53,6 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
const displayContent = locale === 'en' && project.contentEn ? project.contentEn : project.content const displayContent = locale === 'en' && project.contentEn ? project.contentEn : project.content
// Generate GitHub badge URLs
const badges = getGitHubBadgesFromLinks(project.links as any)
const fixedTypeTag = project.tags.find((tag) => isFixedProjectTypeSlug(tag.slug)) const fixedTypeTag = project.tags.find((tag) => isFixedProjectTypeSlug(tag.slug))
const category = fixedTypeTag?.name || project.tags[0]?.name || 'AI Agent' const category = fixedTypeTag?.name || project.tags[0]?.name || 'AI Agent'
const categoryEn = const categoryEn =
+23 -7
View File
@@ -92,6 +92,9 @@ export type FixedProjectTypeFilter = {
export const PROJECT_SORT_OPTIONS = ['latest', 'stars_desc', 'stars_asc'] as const export const PROJECT_SORT_OPTIONS = ['latest', 'stars_desc', 'stars_asc'] as const
export type ProjectSortOption = (typeof PROJECT_SORT_OPTIONS)[number] export type ProjectSortOption = (typeof PROJECT_SORT_OPTIONS)[number]
const DEFAULT_PAGE = 1
const DEFAULT_LIMIT = 20
const MAX_LIMIT = 100
export function normalizeProjectSort(value?: string): ProjectSortOption { export function normalizeProjectSort(value?: string): ProjectSortOption {
const candidate = String(value || '').trim() const candidate = String(value || '').trim()
@@ -101,6 +104,16 @@ export function normalizeProjectSort(value?: string): ProjectSortOption {
return 'latest' return 'latest'
} }
function normalizePositiveInteger(
value: number | undefined,
fallback: number,
max?: number
): number {
const normalized = Number.isFinite(value) ? Math.trunc(value as number) : fallback
const bounded = normalized > 0 ? normalized : fallback
return typeof max === 'number' ? Math.min(bounded, max) : bounded
}
export async function getProjects(options?: { export async function getProjects(options?: {
search?: string search?: string
tag?: string tag?: string
@@ -130,10 +143,13 @@ export async function getProjects(options?: {
projectType, projectType,
sort = 'latest', sort = 'latest',
status = 'ACTIVE', status = 'ACTIVE',
page = 1, page = DEFAULT_PAGE,
limit = 20, limit = DEFAULT_LIMIT,
} = options || {} } = options || {}
const safePage = normalizePositiveInteger(page, DEFAULT_PAGE)
const safeLimit = normalizePositiveInteger(limit, DEFAULT_LIMIT, MAX_LIMIT)
const where: Prisma.ProjectWhereInput = { const where: Prisma.ProjectWhereInput = {
status, status,
} }
@@ -251,8 +267,8 @@ export async function getProjects(options?: {
links: true, links: true,
}, },
orderBy, orderBy,
skip: (page - 1) * limit, skip: (safePage - 1) * safeLimit,
take: limit, take: safeLimit,
}), }),
prisma.project.count({ where }), prisma.project.count({ where }),
]) ])
@@ -273,10 +289,10 @@ export async function getProjects(options?: {
return { return {
projects: transformedProjects, projects: transformedProjects,
pagination: { pagination: {
page, page: safePage,
limit, limit: safeLimit,
total, total,
totalPages: Math.ceil(total / limit), totalPages: Math.ceil(total / safeLimit),
}, },
} }
} }
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { isValidApiKey } from './auth'
describe('isValidApiKey', () => {
it('returns false when provided key is missing', () => {
expect(isValidApiKey(undefined, 'a'.repeat(32))).toBe(false)
expect(isValidApiKey(null, 'a'.repeat(32))).toBe(false)
})
it('returns false when expected key is missing', () => {
expect(isValidApiKey('a'.repeat(32), undefined)).toBe(false)
})
it('returns false when key lengths differ', () => {
expect(isValidApiKey('a'.repeat(31), 'a'.repeat(32))).toBe(false)
})
it('returns false when keys have same length but different value', () => {
expect(isValidApiKey('b'.repeat(32), 'a'.repeat(32))).toBe(false)
})
it('returns true when keys match exactly', () => {
expect(isValidApiKey('a'.repeat(32), 'a'.repeat(32))).toBe(true)
})
})
+23
View File
@@ -0,0 +1,23 @@
import crypto from 'crypto'
/**
* Compare API keys with constant-time semantics.
* Returns false when either key is missing or lengths differ.
*/
export function isValidApiKey(
providedApiKey: string | null | undefined,
expectedApiKey: string | undefined = process.env.WEBHOOK_API_KEY
): boolean {
if (!providedApiKey || !expectedApiKey) {
return false
}
const providedBuffer = Buffer.from(providedApiKey)
const expectedBuffer = Buffer.from(expectedApiKey)
if (providedBuffer.length !== expectedBuffer.length) {
return false
}
return crypto.timingSafeEqual(providedBuffer, expectedBuffer)
}
+5 -2
View File
@@ -1,4 +1,7 @@
import { ExternalLink, LinkType } from '@prisma/client' type LinkLike = {
type: string
url: string
}
/** /**
* 从 GitHub URL 提取 owner 和 repo * 从 GitHub URL 提取 owner 和 repo
@@ -60,7 +63,7 @@ export function getAllGitHubBadgeUrls(owner: string, repo: string): {
* @returns stars 徽章 URL 或 null * @returns stars 徽章 URL 或 null
*/ */
export function getGitHubBadgesFromLinks( export function getGitHubBadgesFromLinks(
links: ExternalLink[] links: ReadonlyArray<LinkLike>
): { stars: string | null } { ): { stars: string | null } {
const githubLink = links.find(link => link.type === 'GITHUB') const githubLink = links.find(link => link.type === 'GITHUB')
+30 -1
View File
@@ -71,6 +71,19 @@ export const WebhookPayloadSchema = WebhookAuthSchema.extend({
export const TaskStatusEnum = z.enum(["PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"]); export const TaskStatusEnum = z.enum(["PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"]);
const JsonValueSchema: z.ZodType<unknown> = z.lazy(() =>
z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.array(JsonValueSchema),
z.record(JsonValueSchema),
])
);
export const JsonObjectSchema = z.record(JsonValueSchema);
export const CreateDiscoveryTaskSchema = WebhookAuthSchema.extend({ export const CreateDiscoveryTaskSchema = WebhookAuthSchema.extend({
tasks: z tasks: z
.array( .array(
@@ -85,7 +98,7 @@ export const CreateDiscoveryTaskSchema = WebhookAuthSchema.extend({
export const UpdateDiscoveryTaskSchema = z.object({ export const UpdateDiscoveryTaskSchema = z.object({
apiKey: z.string().min(32), apiKey: z.string().min(32),
status: TaskStatusEnum, status: TaskStatusEnum,
explorationData: z.record(z.unknown()).optional(), explorationData: JsonObjectSchema.optional(),
explorationSummary: z.string().max(1000).optional(), explorationSummary: z.string().max(1000).optional(),
errorMessage: z.string().max(2000).optional(), errorMessage: z.string().max(2000).optional(),
}); });
@@ -118,6 +131,20 @@ export const CheckTaskDuplicatesSchema = WebhookAuthSchema.extend({
sourceType: z.string().max(50).optional(), sourceType: z.string().max(50).optional(),
}); });
// ================================
// AI Timeline Schemas
// ================================
export const AIEventInputSchema = z.object({
title: z.string().min(1).max(200),
titleEn: z.string().max(200).optional(),
eventDate: z.string().datetime({ offset: true }),
description: z.string().min(10).max(500),
descriptionEn: z.string().max(500).optional(),
imageUrl: z.string().url().max(2000),
sourceUrl: z.string().url().max(2000).optional(),
});
// ================================ // ================================
// Query Schemas // Query Schemas
// ================================ // ================================
@@ -146,6 +173,8 @@ export type CreateDiscoveryTask = z.infer<typeof CreateDiscoveryTaskSchema>;
export type UpdateDiscoveryTask = z.infer<typeof UpdateDiscoveryTaskSchema>; export type UpdateDiscoveryTask = z.infer<typeof UpdateDiscoveryTaskSchema>;
export type GetDiscoveryTasksQuery = z.infer<typeof GetDiscoveryTasksQuerySchema>; export type GetDiscoveryTasksQuery = z.infer<typeof GetDiscoveryTasksQuerySchema>;
export type BatchResetTasks = z.infer<typeof BatchResetTasksSchema>; export type BatchResetTasks = z.infer<typeof BatchResetTasksSchema>;
export type AIEventInput = z.infer<typeof AIEventInputSchema>;
export type JsonObject = z.infer<typeof JsonObjectSchema>;
// ================================ // ================================
// Tags API Schemas // Tags API Schemas