refactor: 统一API鉴权并提升代码健壮性
This commit is contained in:
@@ -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 }
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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 |
@@ -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>
|
||||||
|
|||||||
@@ -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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user