From 0f9b3d4f3626862a21f964671f7d996acddc0079 Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Tue, 10 Mar 2026 11:29:19 +0100 Subject: [PATCH] feat: backend favicon proxy with HTML parsing, collapsible sidebar sections - Add GET /settings/favicon?url= endpoint that parses HTML for tags - Falls back to /favicon.ico if no icon link found in HTML - Caches favicon URLs in Redis (24h TTL) - Frontend uses backend proxy for reliable favicon loading (fixes Atlassian etc.) - Anwendungen section in sidebar is now collapsible with chevron toggle Co-Authored-By: Claude Opus 4.6 --- .../src/core/settings/settings.controller.ts | 149 ++++++++++++++++++ .../src/admin/AdminExternalLinksPage.tsx | 42 +++-- .../frontend/src/shell/AppLayout.module.css | 31 ++++ packages/frontend/src/shell/AppLayout.tsx | 134 ++++++++++------ 4 files changed, 295 insertions(+), 61 deletions(-) diff --git a/packages/core-service/src/core/settings/settings.controller.ts b/packages/core-service/src/core/settings/settings.controller.ts index f762637..07700d7 100644 --- a/packages/core-service/src/core/settings/settings.controller.ts +++ b/packages/core-service/src/core/settings/settings.controller.ts @@ -3,6 +3,7 @@ import { Get, Post, Body, + Query, Logger, UseGuards, BadRequestException, @@ -92,4 +93,152 @@ export class SettingsController { return { success: true, count: sorted.length }; } + + /** + * GET /api/v1/settings/favicon?url=https://example.com + * Favicon-URL fuer eine beliebige Webseite ermitteln. + * Parst die HTML-Seite nach Tags und cached das Ergebnis. + */ + @Get('favicon') + @ApiOperation({ summary: 'Favicon-URL fuer eine Webseite ermitteln' }) + async getFavicon( + @Query('url') url: string, + ): Promise<{ faviconUrl: string | null }> { + if (!url) { + throw new BadRequestException('url Parameter fehlt'); + } + + // Cache pruefen (24h) + const cacheKey = `favicon:${url}`; + const cached = await this.redis.get(cacheKey); + if (cached !== null) { + return { faviconUrl: cached || null }; + } + + const faviconUrl = await this.discoverFavicon(url); + + // In Redis cachen (24h), auch leeres Ergebnis cachen + await this.redis.set(cacheKey, faviconUrl || '', 86400); + + return { faviconUrl }; + } + + /** + * Versucht das Favicon einer Webseite zu finden: + * 1. HTML der Seite laden und parsen + * 2. Fallback: /favicon.ico pruefen + */ + private async discoverFavicon(urlStr: string): Promise { + try { + const parsed = new URL(urlStr); + const origin = parsed.origin; + + // 1. HTML laden und suchen + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + const res = await fetch(urlStr, { + signal: controller.signal, + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; INSIGHT/1.0)', + Accept: 'text/html', + }, + redirect: 'follow', + }); + clearTimeout(timeout); + + if (res.ok) { + const html = await res.text(); + const iconUrl = this.parseFaviconFromHtml(html, origin); + if (iconUrl) { + return iconUrl; + } + } + } catch (e) { + this.logger.debug(`HTML fetch fehlgeschlagen fuer ${urlStr}: ${e}`); + } + + // 2. Fallback: /favicon.ico direkt pruefen + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + + const icoUrl = `${origin}/favicon.ico`; + const res = await fetch(icoUrl, { + method: 'HEAD', + signal: controller.signal, + redirect: 'follow', + }); + clearTimeout(timeout); + + if (res.ok) { + const contentType = res.headers.get('content-type') || ''; + if ( + contentType.includes('image') || + contentType.includes('icon') || + contentType.includes('octet-stream') + ) { + return icoUrl; + } + } + } catch (e) { + this.logger.debug(`favicon.ico check fehlgeschlagen fuer ${origin}: ${e}`); + } + + return null; + } catch { + return null; + } + } + + /** + * Parst HTML nach oder Tags. + */ + private parseFaviconFromHtml(html: string, origin: string): string | null { + // Nur den Bereich betrachten (Performance) + const headMatch = html.match(/]([\s\S]*?)<\/head>/i); + const headHtml = headMatch ? headMatch[1] : html.slice(0, 10000); + + // Alle Tags mit rel="icon" oder rel="shortcut icon" finden + const linkRegex = + /]*rel\s*=\s*["'](?:shortcut\s+)?icon["'][^>]*>/gi; + const matches = headHtml.match(linkRegex); + if (!matches || matches.length === 0) return null; + + // href aus dem besten Match extrahieren + // Bevorzuge groessere Icons (sizes Attribut) + let bestHref: string | null = null; + let bestSize = 0; + + for (const tag of matches) { + const hrefMatch = tag.match(/href\s*=\s*["']([^"']+)["']/i); + if (!hrefMatch) continue; + + const href = hrefMatch[1]; + + // Groesse parsen + const sizeMatch = tag.match(/sizes\s*=\s*["'](\d+)x\d+["']/i); + const size = sizeMatch ? parseInt(sizeMatch[1], 10) : 16; + + if (!bestHref || size > bestSize) { + bestHref = href; + bestSize = size; + } + } + + if (!bestHref) return null; + + // Relative URLs aufloesen + if (bestHref.startsWith('//')) { + return `https:${bestHref}`; + } + if (bestHref.startsWith('/')) { + return `${origin}${bestHref}`; + } + if (bestHref.startsWith('http')) { + return bestHref; + } + return `${origin}/${bestHref}`; + } } diff --git a/packages/frontend/src/admin/AdminExternalLinksPage.tsx b/packages/frontend/src/admin/AdminExternalLinksPage.tsx index 2b1f846..8e1f49a 100644 --- a/packages/frontend/src/admin/AdminExternalLinksPage.tsx +++ b/packages/frontend/src/admin/AdminExternalLinksPage.tsx @@ -1,21 +1,41 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import api from '../api/client'; +/** Hook: Favicon-URL ueber Backend-Proxy laden */ +function useFavicon(url: string): string | null { + const [faviconUrl, setFaviconUrl] = useState(null); + + const fetchFavicon = useCallback(async () => { + if (!url || url.length < 8) { + setFaviconUrl(null); + return; + } + try { + new URL(url); // Validierung + const res = await api.get<{ faviconUrl: string | null }>( + `/settings/favicon?url=${encodeURIComponent(url)}`, + ); + setFaviconUrl(res.data.faviconUrl); + } catch { + setFaviconUrl(null); + } + }, [url]); + + useEffect(() => { + // Debounce: nur ausfuehren wenn URL sich 500ms nicht geaendert hat + const timer = setTimeout(fetchFavicon, 500); + return () => clearTimeout(timer); + }, [fetchFavicon]); + + return faviconUrl; +} + /** Einfache ID-Generierung (crypto.randomUUID ist nur ueber HTTPS verfuegbar) */ function generateId(): string { return Date.now().toString(36) + Math.random().toString(36).slice(2, 10); } -/** Favicon-URL direkt von der Webseite laden */ -function getFaviconUrl(url: string): string | null { - try { - const parsed = new URL(url); - return `${parsed.origin}/favicon.ico`; - } catch { - return null; - } -} interface ExternalLink { id: string; @@ -93,7 +113,7 @@ function LinkRow({ isFirst: boolean; isLast: boolean; }) { - const faviconUrl = getFaviconUrl(link.url); + const faviconUrl = useFavicon(link.url); return (
(null); + const [failed, setFailed] = useState(false); + + const fetchFavicon = useCallback(async () => { + try { + new URL(url); + const res = await api.get<{ faviconUrl: string | null }>( + `/settings/favicon?url=${encodeURIComponent(url)}`, + ); + if (res.data.faviconUrl) { + setFaviconUrl(res.data.faviconUrl); + setFailed(false); + } else { + setFailed(true); + } + } catch { + setFailed(true); + } + }, [url]); + + useEffect(() => { + fetchFavicon(); + }, [fetchFavicon]); + + if (failed || !faviconUrl) { + return ( + + {label.charAt(0).toUpperCase()} + + ); } + + return ( + setFailed(true)} + /> + ); } export function AppLayout() { const { user, logout } = useAuth(); const navigate = useNavigate(); + const [appsOpen, setAppsOpen] = useState(true); const { data: externalLinks } = useQuery({ queryKey: ['settings', 'external-links'], @@ -72,13 +127,28 @@ export function AppLayout() { Dashboard - {/* Externe Links */} + {/* Externe Links (aufklappbar) */} {externalLinks && externalLinks.length > 0 && ( <> -
Anwendungen
- {externalLinks.map((link) => { - const favicon = getFaviconUrl(link.url); - return ( + + {appsOpen && + externalLinks.map((link) => ( - { - // Fallback: erstes Zeichen des Labels als Text-Icon - const el = e.target as HTMLImageElement; - el.style.display = 'none'; - const fallback = el.nextElementSibling; - if (fallback) - (fallback as HTMLElement).style.display = - 'flex'; - }} - /> - - {link.label.charAt(0).toUpperCase()} - + {link.label} - ); - })} + ))} )}