fix(upload): isolate rate limit and add 429 retry backoff
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-05 16:14:38 +08:00
parent 242c8fdb01
commit 60349a3bb0
11 changed files with 140 additions and 82 deletions

View File

@ -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) {

View File

@ -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('上传失败');

View File

@ -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("上传失败");

View File

@ -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("上传失败");

View File

@ -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("上传失败");

View File

@ -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<string> => {
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("上传失败");

View File

@ -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;

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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<ReturnType<typeof getToken>> | 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 ??