feat: implement negative caching for favicon requests to improve performance
This commit is contained in:
@@ -10,7 +10,14 @@ interface CacheEntry {
|
|||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NegativeCacheEntry {
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
const cache = new Map<string, CacheEntry>();
|
const cache = new Map<string, CacheEntry>();
|
||||||
|
const negativeCache = new Map<string, NegativeCacheEntry>();
|
||||||
|
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
|
||||||
|
const NEGATIVE_CACHE_MAX_SIZE = 2000;
|
||||||
|
|
||||||
// Strict domain validation to prevent SSRF
|
// Strict domain validation to prevent SSRF
|
||||||
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
||||||
@@ -49,11 +56,23 @@ export async function GET(request: NextRequest) {
|
|||||||
const domain = request.nextUrl.searchParams.get('domain');
|
const domain = request.nextUrl.searchParams.get('domain');
|
||||||
|
|
||||||
if (!domain || !isValidDomain(domain)) {
|
if (!domain || !isValidDomain(domain)) {
|
||||||
return new NextResponse(null, { status: 400 });
|
return new NextResponse(null, {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Cache-Control': 'public, max-age=86400' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedDomain = domain.toLowerCase();
|
const normalizedDomain = domain.toLowerCase();
|
||||||
|
|
||||||
|
// Check negative cache (domains known to have no favicon)
|
||||||
|
const neg = negativeCache.get(normalizedDomain);
|
||||||
|
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Cache-Control': 'public, max-age=86400' }, // 1 day
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
const cached = cache.get(normalizedDomain);
|
const cached = cache.get(normalizedDomain);
|
||||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||||
@@ -72,7 +91,12 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
return new NextResponse(null, { status: 404 });
|
evictNegativeOldest();
|
||||||
|
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Cache-Control': 'public, max-age=86400' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
|
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
|
||||||
@@ -80,7 +104,12 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// Don't cache empty/tiny responses (likely no real favicon)
|
// Don't cache empty/tiny responses (likely no real favicon)
|
||||||
if (data.byteLength < 10) {
|
if (data.byteLength < 10) {
|
||||||
return new NextResponse(null, { status: 404 });
|
evictNegativeOldest();
|
||||||
|
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Cache-Control': 'public, max-age=86400' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
@@ -94,6 +123,22 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return new NextResponse(null, { status: 502 });
|
return new NextResponse(null, {
|
||||||
|
status: 502,
|
||||||
|
headers: { 'Cache-Control': 'public, max-age=300' }, // 5 min
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function evictNegativeOldest() {
|
||||||
|
if (negativeCache.size < NEGATIVE_CACHE_MAX_SIZE) return;
|
||||||
|
let oldestKey: string | null = null;
|
||||||
|
let oldestTime = Infinity;
|
||||||
|
for (const [key, entry] of negativeCache) {
|
||||||
|
if (entry.fetchedAt < oldestTime) {
|
||||||
|
oldestTime = entry.fetchedAt;
|
||||||
|
oldestKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldestKey) negativeCache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
const IS_DEV = process.env.NODE_ENV !== "production";
|
const IS_DEV = process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
|
// Module-level cache of domains whose favicons failed to load.
|
||||||
|
// Shared across all Avatar instances to avoid re-requesting known-bad domains.
|
||||||
|
const failedFaviconDomains = new Set<string>();
|
||||||
|
|
||||||
// Personal email domains where the favicon is the mail provider logo, not the sender
|
// Personal email domains where the favicon is the mail provider logo, not the sender
|
||||||
const PERSONAL_DOMAINS = new Set([
|
const PERSONAL_DOMAINS = new Set([
|
||||||
"gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com",
|
"gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com",
|
||||||
@@ -86,6 +90,8 @@ interface AvatarProps {
|
|||||||
export function Avatar({ name, email, size = "md", className }: AvatarProps) {
|
export function Avatar({ name, email, size = "md", className }: AvatarProps) {
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
|
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
|
||||||
|
const domain = email?.split("@")[1]?.toLowerCase();
|
||||||
|
const domainFailed = domain ? failedFaviconDomains.has(domain) : false;
|
||||||
|
|
||||||
const getInitials = () => {
|
const getInitials = () => {
|
||||||
if (name) {
|
if (name) {
|
||||||
@@ -117,16 +123,23 @@ export function Avatar({ name, email, size = "md", className }: AvatarProps) {
|
|||||||
lg: "w-12 h-12 text-base",
|
lg: "w-12 h-12 text-base",
|
||||||
};
|
};
|
||||||
|
|
||||||
const domain = email?.split("@")[1]?.toLowerCase();
|
|
||||||
const profilePic = email && domain ? getProfilePictureUrl(email, domain, name) : null;
|
const profilePic = email && domain ? getProfilePictureUrl(email, domain, name) : null;
|
||||||
const showFavicon =
|
const showFavicon =
|
||||||
senderFavicons && domain && !PERSONAL_DOMAINS.has(domain) && !imgError;
|
senderFavicons && domain && !PERSONAL_DOMAINS.has(domain) && !imgError && !domainFailed;
|
||||||
|
|
||||||
// Priority: custom avatar > profile picture > company favicon > initials
|
// Priority: custom avatar > profile picture > company favicon > initials
|
||||||
const customAvatar = email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
const customAvatar = email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||||
const imgSrc = !imgError
|
const imgSrc = !imgError && !domainFailed
|
||||||
? customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(domain!)}` : null)
|
? customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(domain!)}` : null)
|
||||||
: null;
|
: (customAvatar || profilePic || null);
|
||||||
|
|
||||||
|
const handleImgError = useCallback(() => {
|
||||||
|
setImgError(true);
|
||||||
|
// If this was a favicon URL (not a custom avatar or profile pic), remember the domain
|
||||||
|
if (domain && !customAvatar && !profilePic) {
|
||||||
|
failedFaviconDomains.add(domain);
|
||||||
|
}
|
||||||
|
}, [domain, customAvatar, profilePic]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -143,7 +156,7 @@ export function Avatar({ name, email, size = "md", className }: AvatarProps) {
|
|||||||
src={imgSrc}
|
src={imgSrc}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
onError={() => setImgError(true)}
|
onError={handleImgError}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
getInitials()
|
getInitials()
|
||||||
|
|||||||
Reference in New Issue
Block a user