diff --git a/app/(chat)/api/files/direct-upload/route.ts b/app/(chat)/api/files/direct-upload/route.ts index b83c19a10..a8146f664 100644 --- a/app/(chat)/api/files/direct-upload/route.ts +++ b/app/(chat)/api/files/direct-upload/route.ts @@ -194,6 +194,7 @@ export async function POST(request: Request) { Bucket: bucket, Key: key, ContentType: contentType, + CacheControl: "public, max-age=31536000, immutable", ACL: "public-read", }); const url = await getSignedUrl(client, command, { @@ -206,6 +207,7 @@ export async function POST(request: Request) { url, headers: { "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", "x-amz-acl": "public-read", }, expiresIn: PRESIGNED_URL_EXPIRES_SECONDS, @@ -217,6 +219,7 @@ export async function POST(request: Request) { Bucket: bucket, Key: key, ContentType: contentType, + CacheControl: "public, max-age=31536000, immutable", ACL: "public-read", }) ); diff --git a/app/api/image-proxy/route.ts b/app/api/image-proxy/route.ts index 087c06fb4..698bfb1ef 100644 --- a/app/api/image-proxy/route.ts +++ b/app/api/image-proxy/route.ts @@ -9,8 +9,19 @@ const MIN_WIDTH = 64; const MIN_QUALITY = 40; const MAX_QUALITY = 100; const FETCH_TIMEOUT_MS = 15000; +const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024; +const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256; type OutputFormat = "webp" | "avif" | "jpeg" | "png"; +type CachedImageProxyResponse = { + body: Buffer; + contentType: string; + etag: string; + vary?: string; +}; + +const memoryCache = new Map(); +let memoryCacheBytes = 0; function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); @@ -22,6 +33,25 @@ function parsePositiveInt(value: string | null): number | null { return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } +function getEnvPositiveInt(name: string, fallback: number) { + const parsed = Number.parseInt(process.env[name] || "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function getMemoryCacheMaxBytes() { + return getEnvPositiveInt( + "IMAGE_PROXY_MEMORY_CACHE_MAX_BYTES", + DEFAULT_MEMORY_CACHE_MAX_BYTES + ); +} + +function getMemoryCacheMaxItems() { + return getEnvPositiveInt( + "IMAGE_PROXY_MEMORY_CACHE_MAX_ITEMS", + DEFAULT_MEMORY_CACHE_MAX_ITEMS + ); +} + function isPrivateIpv4(hostname: string): boolean { const parts = hostname.split(".").map((part) => Number.parseInt(part, 10)); if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) { @@ -90,6 +120,68 @@ function buildEtag(input: string): string { return `"${crypto.createHash("sha1").update(input).digest("hex")}"`; } +function buildImageResponse( + body: Buffer, + params: { + contentType: string; + etag: string; + vary?: string; + cacheStatus?: "HIT" | "MISS"; + } +) { + const headers: Record = { + "Content-Type": params.contentType, + "Content-Length": String(body.length), + ETag: params.etag, + "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400", + }; + if (params.vary) { + headers.Vary = params.vary; + } + if (params.cacheStatus) { + headers["X-Image-Proxy-Cache"] = params.cacheStatus; + } + return new NextResponse(body, { + status: 200, + headers, + }); +} + +function getCachedImageResponse(cacheKey: string) { + const cached = memoryCache.get(cacheKey); + if (!cached) return null; + memoryCache.delete(cacheKey); + memoryCache.set(cacheKey, cached); + return cached; +} + +function setCachedImageResponse(cacheKey: string, cached: CachedImageProxyResponse) { + const maxBytes = getMemoryCacheMaxBytes(); + const maxItems = getMemoryCacheMaxItems(); + if (maxBytes <= 0 || maxItems <= 0 || cached.body.length > maxBytes) { + return; + } + + const existing = memoryCache.get(cacheKey); + if (existing) { + memoryCacheBytes -= existing.body.length; + memoryCache.delete(cacheKey); + } + + memoryCache.set(cacheKey, cached); + memoryCacheBytes += cached.body.length; + + while (memoryCache.size > maxItems || memoryCacheBytes > maxBytes) { + const firstKey = memoryCache.keys().next().value; + if (!firstKey) break; + const removed = memoryCache.get(firstKey); + memoryCache.delete(firstKey); + if (removed) { + memoryCacheBytes -= removed.body.length; + } + } +} + function isBrowserImageRequest(request: NextRequest): boolean { return request.headers.get("sec-fetch-dest") === "image"; } @@ -220,6 +312,16 @@ export async function GET(request: NextRequest) { }); } + const cached = getCachedImageResponse(cacheKey); + if (cached) { + return buildImageResponse(cached.body, { + contentType: cached.contentType, + etag: cached.etag, + vary: cached.vary, + cacheStatus: "HIT", + }); + } + let upstreamResponse: Response; try { upstreamResponse = await fetch(target.toString(), { @@ -250,26 +352,28 @@ export async function GET(request: NextRequest) { const isGif = contentType.toLowerCase().includes("image/gif"); if ((isSvg || isGif) && !width && !requestedFormat) { - return new NextResponse(originalBuffer, { - status: 200, - headers: { - "Content-Type": contentType, - "Content-Length": String(originalBuffer.length), - ETag: etag, - "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400", - }, + setCachedImageResponse(cacheKey, { + body: originalBuffer, + contentType, + etag, + }); + return buildImageResponse(originalBuffer, { + contentType, + etag, + cacheStatus: "MISS", }); } if (isSvg || isGif) { - return new NextResponse(originalBuffer, { - status: 200, - headers: { - "Content-Type": contentType, - "Content-Length": String(originalBuffer.length), - ETag: etag, - "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400", - }, + setCachedImageResponse(cacheKey, { + body: originalBuffer, + contentType, + etag, + }); + return buildImageResponse(originalBuffer, { + contentType, + etag, + cacheStatus: "MISS", }); } @@ -303,26 +407,29 @@ export async function GET(request: NextRequest) { outputContentType = "image/jpeg"; } - return new NextResponse(outputBuffer, { - status: 200, - headers: { - "Content-Type": outputContentType, - "Content-Length": String(outputBuffer.length), - ETag: etag, - Vary: "Accept", - "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400", - }, + setCachedImageResponse(cacheKey, { + body: outputBuffer, + contentType: outputContentType, + etag, + vary: "Accept", + }); + return buildImageResponse(outputBuffer, { + contentType: outputContentType, + etag, + vary: "Accept", + cacheStatus: "MISS", }); } catch (error) { console.error("[ImageProxy] Transform failed:", error); - return new NextResponse(originalBuffer, { - status: 200, - headers: { - "Content-Type": contentType, - "Content-Length": String(originalBuffer.length), - ETag: etag, - "Cache-Control": "public, max-age=31536000, immutable, stale-while-revalidate=86400", - }, + setCachedImageResponse(cacheKey, { + body: originalBuffer, + contentType, + etag, + }); + return buildImageResponse(originalBuffer, { + contentType, + etag, + cacheStatus: "MISS", }); } } diff --git a/lib/storage/service.ts b/lib/storage/service.ts index be918c550..e8e9ce895 100644 --- a/lib/storage/service.ts +++ b/lib/storage/service.ts @@ -219,6 +219,7 @@ async function uploadToS3( Key: key, Body: Buffer.from(buffer), ContentType: contentType, + CacheControl: "public, max-age=31536000, immutable", // 设置为公开可读(如果您的 MinIO 桶支持) ACL: "public-read", });