fix(upload): isolate rate limit and add 429 retry backoff
This commit is contained in:
parent
242c8fdb01
commit
60349a3bb0
@ -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) {
|
||||
|
||||
@ -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('上传失败');
|
||||
|
||||
@ -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("上传失败");
|
||||
|
||||
@ -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("上传失败");
|
||||
|
||||
@ -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("上传失败");
|
||||
|
||||
@ -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("上传失败");
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
60
lib/client/upload-with-retry.ts
Normal file
60
lib/client/upload-with-retry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
54
proxy.ts
54
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<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 ??
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user