diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 58d6e7bcd..93c477fec 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -99,6 +99,7 @@ import { CANVAS_SKILL_ICON_OPTIONS, getCanvasSkillIconComponent, } from "@/components/canvas/canvas-skill-icons"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; const fetcher = (url: string) => fetch(url).then((res) => res.json()); const DEFAULT_RUNWARE_REALTIME_MODEL = "runware:101@1"; @@ -1209,10 +1210,7 @@ export function AdminSettingsClient() { const formData = new FormData(); formData.append("file", file); - const response = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const response = await fetchUploadWithRetry(formData); const result = await response.json(); if (!response.ok || !result?.url) { @@ -2521,10 +2519,7 @@ export function AdminSettingsClient() { const formData = new FormData(); formData.append("file", resizedFile); - const response = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const response = await fetchUploadWithRetry(formData); const result = await response.json(); if (!response.ok || !result?.url) { @@ -2551,10 +2546,7 @@ export function AdminSettingsClient() { const formData = new FormData(); formData.append("file", file); - const response = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const response = await fetchUploadWithRetry(formData); const result = await response.json(); if (!response.ok || !result?.url) { @@ -5581,10 +5573,7 @@ export function AdminSettingsClient() { const formData = new FormData(); formData.append("file", file); - const response = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const response = await fetchUploadWithRetry(formData); const result = await response.json(); if (result.url) { diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 222fd5b61..8154fd7e6 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -40,6 +40,7 @@ import { toast } from "sonner"; import { useCanvasStore } from "@/store/canvas"; import { cn } from "@/lib/utils"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { extractImageUrlsFromText, extractOriginalImageUrlFromAlt, @@ -4004,10 +4005,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const formData = new FormData(); formData.append("file", file); - const response = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const response = await fetchUploadWithRetry(formData); if (!response.ok) { throw new Error("上传失败"); @@ -4174,10 +4172,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const formData = new FormData(); formData.append("file", file); - const response = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const response = await fetchUploadWithRetry(formData); if (!response.ok) { throw new Error("上传失败"); @@ -6100,10 +6095,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const formData = new FormData(); formData.append('file', file); - const response = await fetch('/api/files/upload', { - method: 'POST', - body: formData, - }); + const response = await fetchUploadWithRetry(formData); if (!response.ok) { throw new Error('上传失败'); @@ -6258,10 +6250,7 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF const formData = new FormData(); formData.append('file', file); - const response = await fetch('/api/files/upload', { - method: 'POST', - body: formData, - }); + const response = await fetchUploadWithRetry(formData); if (!response.ok) { throw new Error('上传失败'); diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 7c68d71dc..735b668b0 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -30,6 +30,7 @@ import type { CanvasNode, ImageNodeData, ImageTextEditItem } from "@/types/canva import { cn } from "@/lib/utils"; import { inferOriginalImageUrl } from "@/lib/image-url"; import { buildImageProxyUrl } from "@/lib/image-delivery"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { AngleEditDialog } from "@/components/angle-edit-dialog"; import { SceneFusionDialog } from "@/components/scene-fusion-dialog"; import { ImageAnnotator } from "@/components/image-annotator"; @@ -1385,10 +1386,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const blob = await response.blob(); const formData = new FormData(); formData.append("file", blob, `annotated-${Date.now()}.png`); - const uploadResponse = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const uploadResponse = await fetchUploadWithRetry(formData); if (uploadResponse.ok) { const result = await uploadResponse.json(); finalUrl = result.url; @@ -1425,10 +1423,7 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) { const formData = new FormData(); formData.append("file", blob, `canvas-resize-${Date.now()}.png`); - const uploadResponse = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const uploadResponse = await fetchUploadWithRetry(formData); if (!uploadResponse.ok) { throw new Error("上传失败"); diff --git a/components/chat.tsx b/components/chat.tsx index f8049f21e..782d38f46 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -27,6 +27,7 @@ import { ChatSDKError } from "@/lib/errors"; import type { Attachment, ChatMessage } from "@/lib/types"; import type { AppUsage } from "@/lib/usage"; import { requestLongTaskResult } from "@/lib/client/long-task"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; import { Artifact } from "@/components/artifact"; import { useDataStream } from "@/components/data-stream-provider"; @@ -1233,10 +1234,7 @@ export function Chat({ const formData = new FormData(); formData.append("file", blob, fileName); - const uploadResponse = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const uploadResponse = await fetchUploadWithRetry(formData); if (!uploadResponse.ok) { throw new Error("上传失败"); diff --git a/components/home/lovart-home.tsx b/components/home/lovart-home.tsx index 31b46329c..c8c39d69e 100644 --- a/components/home/lovart-home.tsx +++ b/components/home/lovart-home.tsx @@ -51,6 +51,7 @@ import { } from "@/lib/canvas-launch"; import type { MembershipConfig } from "@/lib/membership/config"; import { ImageAnnotator } from "@/components/image-annotator"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; type HomeUser = { id: string; @@ -833,10 +834,7 @@ export function LovartHome({ const formData = new FormData(); formData.append("file", item.file); - const response = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const response = await fetchUploadWithRetry(formData); const data = await response.json(); if (!response.ok || !data?.url) { @@ -929,10 +927,7 @@ export function LovartHome({ const blob = await response.blob(); const formData = new FormData(); formData.append("file", blob, `edited-${Date.now()}.png`); - const uploadResponse = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const uploadResponse = await fetchUploadWithRetry(formData); if (!uploadResponse.ok) { toast.error("上传失败"); diff --git a/components/image-annotator.tsx b/components/image-annotator.tsx index 92def9bf2..eee22d402 100644 --- a/components/image-annotator.tsx +++ b/components/image-annotator.tsx @@ -26,6 +26,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { requestLongTaskResult } from "@/lib/client/long-task"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; type Tool = "pen" | "arrow" | "circle" | "text"; @@ -119,10 +120,7 @@ export function ImageAnnotator({ const uploadImageFile = async (file: File): Promise => { const formData = new FormData(); formData.append("file", file); - const response = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const response = await fetchUploadWithRetry(formData); if (!response.ok) { throw new Error("上传失败"); } @@ -1306,10 +1304,7 @@ export function ImageAnnotator({ const formData = new FormData(); formData.append("file", blob, filename); - const uploadResponse = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const uploadResponse = await fetchUploadWithRetry(formData); if (!uploadResponse.ok) { throw new Error("上传失败"); diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index 602256395..616b7c74e 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -92,6 +92,7 @@ import { toast } from "sonner"; import useSWR from "swr"; import { useLocalStorage, useWindowSize } from "usehooks-ts"; import { saveChatModelAsCookie } from "@/app/(chat)/actions"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { SelectItem } from "@/components/ui/select"; import { AlertDialog, @@ -602,10 +603,7 @@ function PureMultimodalInput({ const formData = new FormData(); formData.append("file", processedFile); - const response = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const response = await fetchUploadWithRetry(formData); if (response.ok) { const data = await response.json(); @@ -1166,10 +1164,7 @@ function PureMultimodalInput({ const blob = await response.blob(); const formData = new FormData(); formData.append("file", blob, fileName); - const uploadResponse = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const uploadResponse = await fetchUploadWithRetry(formData); if (uploadResponse.ok) { const result = await uploadResponse.json(); finalUrl = result.url; diff --git a/components/top-navbar.tsx b/components/top-navbar.tsx index 4bb693751..059222a54 100644 --- a/components/top-navbar.tsx +++ b/components/top-navbar.tsx @@ -16,6 +16,7 @@ import { import { ImageAnnotator } from "@/components/image-annotator"; import { VideoPlayer } from "@/components/video-player"; import { toast } from "sonner"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; type RankingItem = { rank: number; @@ -162,10 +163,7 @@ export function TopNavbar({ const blob = await response.blob(); const formData = new FormData(); formData.append("file", blob, `edited-${Date.now()}.png`); - const uploadResponse = await fetch("/api/files/upload", { - method: "POST", - body: formData, - }); + const uploadResponse = await fetchUploadWithRetry(formData); if (uploadResponse.ok) { const result = await uploadResponse.json(); finalUrl = result.url; diff --git a/lib/client/upload-with-retry.ts b/lib/client/upload-with-retry.ts new file mode 100644 index 000000000..482aa170d --- /dev/null +++ b/lib/client/upload-with-retry.ts @@ -0,0 +1,60 @@ +const DEFAULT_MAX_RETRIES = 3; +const MAX_BACKOFF_MS = 8000; + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseRetryAfterMs(value: string | null): number | null { + if (!value) return null; + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds > 0) { + return Math.round(seconds * 1000); + } + return null; +} + +function computeBackoffMs(attempt: number, retryAfterMs: number | null) { + if (retryAfterMs && retryAfterMs > 0) { + return Math.min(retryAfterMs, MAX_BACKOFF_MS); + } + const exp = Math.min(MAX_BACKOFF_MS, 500 * 2 ** attempt); + const jitter = Math.floor(Math.random() * 300); + return exp + jitter; +} + +export async function fetchUploadWithRetry( + formData: FormData, + options?: { + maxRetries?: number; + signal?: AbortSignal; + } +) { + const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES; + + for (let attempt = 0; ; attempt++) { + const response = await fetch("/api/files/upload", { + method: "POST", + body: formData, + signal: options?.signal, + }); + + if (response.status !== 429 || attempt >= maxRetries) { + return response; + } + + let retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After")); + if (!retryAfterMs) { + try { + const payload = await response.clone().json(); + const retryAfterSec = Number(payload?.retryAfter || 0); + if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) { + retryAfterMs = Math.round(retryAfterSec * 1000); + } + } catch {} + } + + const delayMs = computeBackoffMs(attempt, retryAfterMs); + await sleep(delayMs); + } +} diff --git a/lib/security/rate-limiter.ts b/lib/security/rate-limiter.ts index b557447e0..1eef66b13 100644 --- a/lib/security/rate-limiter.ts +++ b/lib/security/rate-limiter.ts @@ -77,6 +77,9 @@ export const rateLimiters = { // 通用API限制:每分钟100次请求 api: new RateLimiter(60000, 100), + // 文件上传接口:每分钟60次请求(独立于通用API限流) + upload: new RateLimiter(60000, 60), + // 高频只读接口(首页/画布轮询):每分钟1200次请求 uiPolling: new RateLimiter(60000, 1200), @@ -129,3 +132,10 @@ export function checkRateLimit( const identifier = getClientIdentifier(request); return limiter.check(identifier); } + +export function checkRateLimitByIdentifier( + identifier: string, + limiter: RateLimiter = rateLimiters.api +): { allowed: boolean; remaining: number; resetTime: number } { + return limiter.check(identifier); +} diff --git a/proxy.ts b/proxy.ts index cf4a535b2..550865bf3 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,7 +1,11 @@ import { type NextRequest, NextResponse } from "next/server"; import { getToken } from "next-auth/jwt"; import { isBlockedIP, getClientIP } from "@/lib/security/ip-blocklist"; -import { checkRateLimit, rateLimiters } from "@/lib/security/rate-limiter"; +import { + checkRateLimit, + checkRateLimitByIdentifier, + rateLimiters, +} from "@/lib/security/rate-limiter"; import { isSuspiciousRequest, logSuspiciousRequest } from "@/lib/security/request-monitor"; function isHighFrequencyReadApi(pathname: string, method: string) { @@ -24,6 +28,21 @@ function isHighFrequencyReadApi(pathname: string, method: string) { export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; + const isSecure = + process.env.AUTH_URL?.startsWith("https://") || + request.nextUrl.protocol === "https:"; + let cachedToken: Awaited> | null | undefined; + const resolveToken = async () => { + if (cachedToken !== undefined) { + return cachedToken; + } + cachedToken = await getToken({ + req: request, + secret: process.env.AUTH_SECRET, + secureCookie: isSecure, + }); + return cachedToken; + }; // 🛡️ 安全检查1:阻止黑名单IP const clientIP = getClientIP(request); @@ -58,6 +77,29 @@ export async function proxy(request: NextRequest) { { status: 429 } ); } + } else if (pathname === "/api/files/upload" && request.method === "POST") { + const token = await resolveToken(); + const userId = + typeof token?.sub === "string" && token.sub.length > 0 + ? token.sub + : "anonymous"; + const uploadRateLimit = checkRateLimitByIdentifier( + `${userId}:${request.method}:${pathname}`, + rateLimiters.upload + ); + if (!uploadRateLimit.allowed) { + const retryAfter = Math.max(1, Math.ceil((uploadRateLimit.resetTime - Date.now()) / 1000)); + console.warn(`[Security] Upload rate limit exceeded for IP: ${clientIP}`, { + pathname, + method: request.method, + userId, + resetTime: new Date(uploadRateLimit.resetTime).toISOString(), + }); + return NextResponse.json( + { error: "Too many upload requests", retryAfter }, + { status: 429, headers: { "Retry-After": String(retryAfter) } } + ); + } } else if (pathname.startsWith("/api/canvas/") && request.method === "PUT") { const canvasRateLimit = checkRateLimit(request, rateLimiters.canvas); if (!canvasRateLimit.allowed) { @@ -171,15 +213,7 @@ export async function proxy(request: NextRequest) { return NextResponse.next(); } - // 检测是否为 HTTPS(通过 AUTH_URL 或请求协议判断) - const isSecure = process.env.AUTH_URL?.startsWith("https://") || - request.nextUrl.protocol === "https:"; - - const token = await getToken({ - req: request, - secret: process.env.AUTH_SECRET, - secureCookie: isSecure, - }); + const token = await resolveToken(); const externalBase = process.env.AUTH_URL ??