From 481d566eb6e5604cc6e6dfee730a1d614ced7fd8 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Mon, 9 Mar 2026 18:55:44 +0100 Subject: [PATCH] feat: sender favicon avatars --- app/api/favicon/route.ts | 99 +++++++++++++++++++++++ components/settings/advanced-settings.tsx | 7 +- components/ui/avatar.tsx | 34 +++++++- locales/de/common.json | 4 + locales/en/common.json | 4 + locales/es/common.json | 4 + locales/fr/common.json | 4 + locales/it/common.json | 4 + locales/ja/common.json | 4 + locales/nl/common.json | 4 + locales/pt/common.json | 4 + stores/settings-store.ts | 7 ++ 12 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 app/api/favicon/route.ts diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts new file mode 100644 index 00000000..095fc793 --- /dev/null +++ b/app/api/favicon/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from 'next/server'; + +// In-memory LRU cache: domain -> { data, contentType, fetchedAt } +const CACHE_MAX_SIZE = 1000; +const CACHE_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 2 weeks + +interface CacheEntry { + data: ArrayBuffer; + contentType: string; + fetchedAt: number; +} + +const cache = new Map(); + +// 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; + +function isValidDomain(domain: string): boolean { + if (domain.length > 253) return false; + if (!DOMAIN_RE.test(domain)) return false; + // Block internal/private hostnames + const lower = domain.toLowerCase(); + if ( + lower === 'localhost' || + lower.endsWith('.local') || + lower.endsWith('.internal') || + lower.endsWith('.arpa') + ) { + return false; + } + return true; +} + +function evictOldest() { + if (cache.size < CACHE_MAX_SIZE) return; + // Evict the oldest entry + let oldestKey: string | null = null; + let oldestTime = Infinity; + for (const [key, entry] of cache) { + if (entry.fetchedAt < oldestTime) { + oldestTime = entry.fetchedAt; + oldestKey = key; + } + } + if (oldestKey) cache.delete(oldestKey); +} + +export async function GET(request: NextRequest) { + const domain = request.nextUrl.searchParams.get('domain'); + + if (!domain || !isValidDomain(domain)) { + return new NextResponse(null, { status: 400 }); + } + + const normalizedDomain = domain.toLowerCase(); + + // Check cache + const cached = cache.get(normalizedDomain); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return new NextResponse(cached.data, { + headers: { + 'Content-Type': cached.contentType, + 'Cache-Control': 'public, max-age=1209600', // 2 weeks + }, + }); + } + + try { + const upstream = await fetch( + `https://icons.duckduckgo.com/ip3/${encodeURIComponent(normalizedDomain)}.ico`, + { signal: AbortSignal.timeout(5000) } + ); + + if (!upstream.ok) { + return new NextResponse(null, { status: 404 }); + } + + const contentType = upstream.headers.get('content-type') || 'image/x-icon'; + const data = await upstream.arrayBuffer(); + + // Don't cache empty/tiny responses (likely no real favicon) + if (data.byteLength < 10) { + return new NextResponse(null, { status: 404 }); + } + + // Cache the result + evictOldest(); + cache.set(normalizedDomain, { data, contentType, fetchedAt: Date.now() }); + + return new NextResponse(data, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=1209600', + }, + }); + } catch { + return new NextResponse(null, { status: 502 }); + } +} diff --git a/components/settings/advanced-settings.tsx b/components/settings/advanced-settings.tsx index 56edf60e..ab02881a 100644 --- a/components/settings/advanced-settings.tsx +++ b/components/settings/advanced-settings.tsx @@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button'; export function AdvancedSettings() { const t = useTranslations('settings.advanced'); const tCommon = useTranslations('common'); - const { debugMode, updateSetting, resetToDefaults, exportSettings, importSettings } = + const { debugMode, senderFavicons, updateSetting, resetToDefaults, exportSettings, importSettings } = useSettingsStore(); const [showResetConfirm, setShowResetConfirm] = useState(false); const fileInputRef = useRef(null); @@ -66,6 +66,11 @@ export function AdvancedSettings() { updateSetting('debugMode', checked)} /> + {/* Sender Favicons (Experimental) */} + + updateSetting('senderFavicons', checked)} /> + + {/* Export Settings */}