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:
dealerweb
2026-05-30 16:58:46 +02:00
committed by Linus Rath
parent 1512b9afc0
commit 7ee329e046
2 changed files with 26 additions and 12 deletions
+17 -12
View File
@@ -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
+9
View File
@@ -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()