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