Fix: no more 404 console spam for missing sender favicons
/api/favicon returned 404 in three paths (negative cache hit, non-200 upstream, sub-10-byte body), and since the avatar loads it as <img src>, the browser logged a red 404 for every sender domain without a public favicon - dozens per inbox view. Now it returns HTTP 200 with a 1x1 transparent PNG and an X-Bulwark-Favicon: missing header. Avatar.tsx detects the sentinel via naturalWidth <= 1 in onLoad and falls back to initials, so behaviour is visually identical without the console noise.
This commit is contained in:
+17
-12
@@ -19,6 +19,20 @@ const negativeCache = new Map<string, NegativeCacheEntry>();
|
|||||||
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
|
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
|
||||||
const NEGATIVE_CACHE_MAX_SIZE = 2000;
|
const NEGATIVE_CACHE_MAX_SIZE = 2000;
|
||||||
|
|
||||||
|
// 1x1 transparent PNG. Returned with HTTP 200 (instead of 404) when no
|
||||||
|
// favicon exists for a domain, so the browser's <img> tag loads it cleanly
|
||||||
|
// without spamming the DevTools console with red 404 errors. Avatar.tsx
|
||||||
|
// checks `naturalWidth <= 1` in onLoad and falls back to initials.
|
||||||
|
const TRANSPARENT_PNG = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
const MISSING_FAVICON_HEADERS = {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Cache-Control': 'public, max-age=86400', // 1 day
|
||||||
|
'X-Bulwark-Favicon': 'missing',
|
||||||
|
};
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
||||||
@@ -434,10 +448,7 @@ export async function GET(request: NextRequest) {
|
|||||||
// Check negative cache (domains known to have no favicon)
|
// Check negative cache (domains known to have no favicon)
|
||||||
const neg = negativeCache.get(normalizedDomain);
|
const neg = negativeCache.get(normalizedDomain);
|
||||||
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
|
if (neg && Date.now() - neg.fetchedAt < NEGATIVE_CACHE_TTL_MS) {
|
||||||
return new NextResponse(null, {
|
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||||
status: 404,
|
|
||||||
headers: { 'Cache-Control': 'public, max-age=86400' }, // 1 day
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
@@ -460,10 +471,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
evictNegativeOldest();
|
evictNegativeOldest();
|
||||||
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||||
return new NextResponse(null, {
|
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||||
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';
|
||||||
@@ -473,10 +481,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (data.byteLength < 10) {
|
if (data.byteLength < 10) {
|
||||||
evictNegativeOldest();
|
evictNegativeOldest();
|
||||||
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
negativeCache.set(normalizedDomain, { fetchedAt: Date.now() });
|
||||||
return new NextResponse(null, {
|
return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS });
|
||||||
status: 404,
|
|
||||||
headers: { 'Cache-Control': 'public, max-age=86400' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
|
|||||||
@@ -270,6 +270,15 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
|||||||
alt=""
|
alt=""
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
onError={handleImgError}
|
onError={handleImgError}
|
||||||
|
// /api/favicon returns a 1x1 transparent PNG (HTTP 200) when no real
|
||||||
|
// favicon exists, to avoid spamming the DevTools console with 404s.
|
||||||
|
// Detect that sentinel by naturalWidth and fall back to initials.
|
||||||
|
onLoad={(e) => {
|
||||||
|
const img = e.currentTarget;
|
||||||
|
if (isFavicon && img.naturalWidth <= 1) {
|
||||||
|
handleImgError();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
getInitials()
|
getInitials()
|
||||||
|
|||||||
Reference in New Issue
Block a user