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 crypto from 'crypto'
import { prisma } from '@/lib/prisma'
import { CheckTaskDuplicatesSchema } from '@/lib/validations'
import { isValidApiKey } from '@/lib/auth'
/**
* 检查 URL 是否应该创建新任务
@@ -194,14 +194,7 @@ export async function POST(request: NextRequest) {
const { apiKey, urls, sourceType } = validationResult.data
// 验证 API Key
const validApiKey = process.env.WEBHOOK_API_KEY
if (
!validApiKey ||
!crypto.timingSafeEqual(
Buffer.from(apiKey),
Buffer.from(validApiKey)
)
) {
if (!isValidApiKey(apiKey)) {
return NextResponse.json(
{
success: false,
@@ -1,11 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
import { prisma } from '@/lib/prisma'
import { generateSlug } from '@/lib/slug'
import {
ProjectInputSchema,
type ProjectInput,
} from '@/lib/validations'
import { isValidApiKey } from '@/lib/auth'
import {
findExistingProject,
resolveFixedProjectTypeTag,
@@ -28,11 +28,7 @@ export async function POST(
const { apiKey, explorationData } = body
// 验证API密钥
const validApiKey = process.env.WEBHOOK_API_KEY
if (
!validApiKey ||
!crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey))
) {
if (!isValidApiKey(apiKey)) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
+5 -7
View File
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
import { prisma } from '@/lib/prisma'
import { UpdateDiscoveryTaskSchema, TaskStatus } from '@/lib/validations'
import type { Prisma } from '@prisma/client'
import { isValidApiKey } from '@/lib/auth'
/**
* 有效的任务状态转换规则
@@ -87,11 +87,7 @@ export async function PATCH(
validation.data
// 验证API密钥
const validApiKey = process.env.WEBHOOK_API_KEY
if (
!validApiKey ||
!crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey))
) {
if (!isValidApiKey(apiKey)) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
@@ -143,7 +139,9 @@ export async function PATCH(
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 (errorMessage !== undefined) updateData.errorMessage = errorMessage
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
import { prisma } from '@/lib/prisma'
import { BatchResetTasksSchema } from '@/lib/validations'
import { isValidApiKey } from '@/lib/auth'
/**
* POST /api/discovery/tasks/batch-reset
@@ -31,11 +31,7 @@ export async function POST(request: NextRequest) {
const { apiKey, taskIds, statuses } = validation.data
// 验证API密钥
const validApiKey = process.env.WEBHOOK_API_KEY
if (
!validApiKey ||
!crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey))
) {
if (!isValidApiKey(apiKey)) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
+3 -12
View File
@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
import { prisma } from '@/lib/prisma'
import {
CreateDiscoveryTaskSchema,
GetDiscoveryTasksQuerySchema,
} from '@/lib/validations'
import { isValidApiKey } from '@/lib/auth'
/**
* POST /api/discovery/tasks
@@ -29,11 +29,7 @@ export async function POST(request: NextRequest) {
const { apiKey, tasks } = validation.data
// 验证API密钥
const validApiKey = process.env.WEBHOOK_API_KEY
if (
!validApiKey ||
!crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey))
) {
if (!isValidApiKey(apiKey)) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
@@ -101,12 +97,7 @@ export async function GET(request: NextRequest) {
// 验证 API Key(只读权限)
// 支持两种方式:1. 请求头 x-api-key 2. 查询参数 apiKey
const apiKey = request.headers.get('x-api-key') || searchParams.get('apiKey')
const validApiKey = process.env.WEBHOOK_API_KEY
if (
!validApiKey ||
!apiKey ||
!crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey))
) {
if (!isValidApiKey(apiKey)) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
+3 -4
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { isValidApiKey } from '@/lib/auth'
/**
* DELETE /api/projects/[slug]
@@ -21,10 +22,8 @@ export async function DELETE(
const { slug } = await params
// Verify API Key
const apiKey = request.headers.get('x-api-key') || process.env.WEBHOOK_API_KEY
const validApiKey = process.env.WEBHOOK_API_KEY
if (apiKey !== validApiKey) {
const apiKey = request.headers.get('x-api-key')
if (!isValidApiKey(apiKey)) {
return NextResponse.json(
{
success: false,
+2 -9
View File
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { TagMaintenanceRequestSchema } from "@/lib/validations";
import { isValidApiKey } from "@/lib/auth";
import { executeTagMaintenance, TagMaintenanceApiError } from "./service";
export async function POST(request: NextRequest) {
@@ -26,14 +26,7 @@ export async function POST(request: NextRequest) {
const { apiKey, updates, merges } = validation.data;
// 3. Authenticate with timing-safe comparison
const expectedApiKey = process.env.WEBHOOK_API_KEY;
const providedBuf = Buffer.from(apiKey);
const expectedBuf = Buffer.from(expectedApiKey || "");
if (
!expectedApiKey ||
providedBuf.length !== expectedBuf.length ||
!crypto.timingSafeEqual(providedBuf, expectedBuf)
) {
if (!isValidApiKey(apiKey)) {
return NextResponse.json(
{
success: false,
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { isValidApiKey } from '@/lib/auth'
import { z } from 'zod'
/**
@@ -179,8 +180,7 @@ export async function POST(request: NextRequest) {
const payload = validationResult.data
// Verify API Key
const apiKey = process.env.WEBHOOK_API_KEY
if (payload.apiKey !== apiKey) {
if (!isValidApiKey(payload.apiKey)) {
return NextResponse.json(
{
success: false,
+9 -14
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
import { prisma } from '@/lib/prisma'
import type { ProjectStatus, LinkType } from '@prisma/client'
import { isValidApiKey } from '@/lib/auth'
import {
WebhookPayloadSchema,
ProjectInputSchema,
@@ -38,14 +38,7 @@ export async function POST(request: NextRequest) {
const payload = validationResult.data as WebhookPayload
// Verify API Key using timing-safe comparison to prevent timing attacks
const apiKey = process.env.WEBHOOK_API_KEY
if (
!apiKey ||
!crypto.timingSafeEqual(
Buffer.from(payload.apiKey),
Buffer.from(apiKey)
)
) {
if (!isValidApiKey(payload.apiKey)) {
return NextResponse.json(
{
success: false,
@@ -66,7 +59,7 @@ export async function POST(request: NextRequest) {
index: number
field: string
message: string
value: any
value: unknown
}>,
}
@@ -123,12 +116,13 @@ export async function POST(request: NextRequest) {
`[Webhook] Updating project "${validProject.name}" (matched by ${matchMethod}, id: ${existingProject.id})`
)
await prisma.$transaction(async (tx) => {
// Update tags (delete old ones, create new ones)
await prisma.projectTag.deleteMany({
await tx.projectTag.deleteMany({
where: { projectId: existingProject.id },
})
await prisma.project.update({
await tx.project.update({
where: { id: existingProject.id },
data: {
name: validProject.name,
@@ -148,11 +142,11 @@ export async function POST(request: NextRequest) {
})
// Update links (delete old ones, create new ones)
await prisma.externalLink.deleteMany({
await tx.externalLink.deleteMany({
where: { projectId: existingProject.id },
})
await prisma.externalLink.createMany({
await tx.externalLink.createMany({
data: validProject.links.map((link) => ({
type: link.type as LinkType,
url: link.url,
@@ -160,6 +154,7 @@ export async function POST(request: NextRequest) {
projectId: existingProject.id,
})),
})
})
results.updated++
} else {
+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/icon?family=Material+Icons');
@tailwind base;
@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 (
<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">
{children}
</body>
+27 -16
View File
@@ -9,6 +9,23 @@ interface MarkdownContentProps {
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
function generateHeadingId(text: string): string {
return text
@@ -22,14 +39,6 @@ function generateHeadingId(text: string): string {
.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
const components: Components = {
// Headings with anchor links
@@ -87,13 +96,13 @@ const components: Components = {
),
// 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
// This uses the AST node data from react-markdown
const hasBlockElement = node?.children?.some((child: any) => {
const tagName = child?.tagName
const hasBlockElement = node?.children?.some((child) => {
const tagName = 'tagName' in child ? child.tagName : undefined
// 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) {
@@ -131,7 +140,7 @@ const components: Components = {
),
// Code blocks (pre element wrapper)
pre: ({ children, ...props }: any) => {
pre: ({ children, ...props }) => {
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}>
{children}
@@ -140,12 +149,12 @@ const components: Components = {
},
// Code elements (both inline and in code blocks)
code: ({ inline, className, children, ...props }: any) => {
// If inline is explicitly true, or if there's no language class and no newlines, treat as inline
code: ({ className, children, ...props }) => {
// If there's no language class and no newlines, treat as inline code.
const hasLanguageClass = className && typeof className === 'string' && className.startsWith('language-')
const childStr = String(children)
const hasNewlines = childStr.includes('\n')
const isInline = inline === true || (!hasLanguageClass && !hasNewlines)
const isInline = !hasLanguageClass && !hasNewlines
if (isInline) {
return (
@@ -217,6 +226,8 @@ const components: Components = {
img: ({ src, alt, ...props }) => (
<div className="my-4 flex justify-center">
<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
src={src}
alt={alt}
+3 -3
View File
@@ -1,6 +1,5 @@
import Link from 'next/link'
import Image from 'next/image'
import { GitHubStatsCompact } from './GitHubBadges'
import { getGitHubBadgesFromLinks } from '@/lib/github/badges'
interface ProjectCardProps {
@@ -53,7 +52,7 @@ export function ProjectCard({ project, locale, featured = false, translations }:
const displayDescription = locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
// 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
const getTagName = (tag: { name: string; nameEn?: string | null }) => {
@@ -105,7 +104,7 @@ export function ProjectCard({ project, locale, featured = false, translations }:
{/* GitHub Stars Badge */}
{badges.stars && (
<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"/>
</svg>
<Image
@@ -115,6 +114,7 @@ export function ProjectCard({ project, locale, featured = false, translations }:
height={20}
unoptimized
className="rounded"
style={{ width: '70px', height: '20px' }}
/>
</div>
)}
-7
View File
@@ -1,9 +1,5 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
import { MarkdownContent } from './MarkdownContent'
import { ShareButtons } from './ShareButtons'
import { GitHubBadges } from './GitHubBadges'
import { getGitHubBadgesFromLinks } from '@/lib/github/badges'
import { isFixedProjectTypeSlug } from '@/lib/tag-taxonomy'
interface ProjectDetailProps {
@@ -57,9 +53,6 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
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 category = fixedTypeTag?.name || project.tags[0]?.name || 'AI Agent'
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 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 {
const candidate = String(value || '').trim()
@@ -101,6 +104,16 @@ export function normalizeProjectSort(value?: string): ProjectSortOption {
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?: {
search?: string
tag?: string
@@ -130,10 +143,13 @@ export async function getProjects(options?: {
projectType,
sort = 'latest',
status = 'ACTIVE',
page = 1,
limit = 20,
page = DEFAULT_PAGE,
limit = DEFAULT_LIMIT,
} = options || {}
const safePage = normalizePositiveInteger(page, DEFAULT_PAGE)
const safeLimit = normalizePositiveInteger(limit, DEFAULT_LIMIT, MAX_LIMIT)
const where: Prisma.ProjectWhereInput = {
status,
}
@@ -251,8 +267,8 @@ export async function getProjects(options?: {
links: true,
},
orderBy,
skip: (page - 1) * limit,
take: limit,
skip: (safePage - 1) * safeLimit,
take: safeLimit,
}),
prisma.project.count({ where }),
])
@@ -273,10 +289,10 @@ export async function getProjects(options?: {
return {
projects: transformedProjects,
pagination: {
page,
limit,
page: safePage,
limit: safeLimit,
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
@@ -60,7 +63,7 @@ export function getAllGitHubBadgeUrls(owner: string, repo: string): {
* @returns stars 徽章 URL 或 null
*/
export function getGitHubBadgesFromLinks(
links: ExternalLink[]
links: ReadonlyArray<LinkLike>
): { stars: string | null } {
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"]);
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({
tasks: z
.array(
@@ -85,7 +98,7 @@ export const CreateDiscoveryTaskSchema = WebhookAuthSchema.extend({
export const UpdateDiscoveryTaskSchema = z.object({
apiKey: z.string().min(32),
status: TaskStatusEnum,
explorationData: z.record(z.unknown()).optional(),
explorationData: JsonObjectSchema.optional(),
explorationSummary: z.string().max(1000).optional(),
errorMessage: z.string().max(2000).optional(),
});
@@ -118,6 +131,20 @@ export const CheckTaskDuplicatesSchema = WebhookAuthSchema.extend({
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
// ================================
@@ -146,6 +173,8 @@ export type CreateDiscoveryTask = z.infer<typeof CreateDiscoveryTaskSchema>;
export type UpdateDiscoveryTask = z.infer<typeof UpdateDiscoveryTaskSchema>;
export type GetDiscoveryTasksQuery = z.infer<typeof GetDiscoveryTasksQuerySchema>;
export type BatchResetTasks = z.infer<typeof BatchResetTasksSchema>;
export type AIEventInput = z.infer<typeof AIEventInputSchema>;
export type JsonObject = z.infer<typeof JsonObjectSchema>;
// ================================
// Tags API Schemas