feat: backend favicon proxy with HTML parsing, collapsible sidebar sections

- Add GET /settings/favicon?url= endpoint that parses HTML for <link rel="icon"> 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 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-10 11:29:19 +01:00
parent 0a52606012
commit 0f9b3d4f36
4 changed files with 295 additions and 61 deletions

View file

@ -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 <link rel="icon"> 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 <link rel="icon"> parsen
* 2. Fallback: /favicon.ico pruefen
*/
private async discoverFavicon(urlStr: string): Promise<string | null> {
try {
const parsed = new URL(urlStr);
const origin = parsed.origin;
// 1. HTML laden und <link rel="icon"> 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 <link rel="icon"> oder <link rel="shortcut icon"> Tags.
*/
private parseFaviconFromHtml(html: string, origin: string): string | null {
// Nur den <head> Bereich betrachten (Performance)
const headMatch = html.match(/<head[\s>]([\s\S]*?)<\/head>/i);
const headHtml = headMatch ? headMatch[1] : html.slice(0, 10000);
// Alle <link> Tags mit rel="icon" oder rel="shortcut icon" finden
const linkRegex =
/<link\s[^>]*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}`;
}
}

View file

@ -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<string | null>(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 (
<div

View file

@ -43,6 +43,37 @@
font-weight: 600;
}
.navSectionToggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 1rem 1.5rem 0.5rem;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 1px;
color: rgba(255, 255, 255, 0.4);
font-weight: 600;
background: none;
border: none;
cursor: pointer;
transition: color 0.15s;
}
.navSectionToggle:hover {
color: rgba(255, 255, 255, 0.7);
}
.chevron {
transition: transform 0.2s ease;
transform: rotate(-90deg);
flex-shrink: 0;
}
.chevronOpen {
transform: rotate(0deg);
}
.navLink {
display: flex;
align-items: center;

View file

@ -1,3 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../auth/AuthContext';
@ -12,19 +13,73 @@ interface ExternalLink {
sortOrder: number;
}
/** 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;
/** Favicon ueber Backend-Proxy laden (cached in Redis) */
function FaviconImg({ url, label }: { url: string; label: string }) {
const [faviconUrl, setFaviconUrl] = useState<string | null>(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 (
<span
style={{
display: 'flex',
width: 16,
height: 16,
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.625rem',
fontWeight: 700,
background: 'rgba(255,255,255,0.15)',
borderRadius: 2,
color: 'rgba(255,255,255,0.8)',
flexShrink: 0,
}}
>
{label.charAt(0).toUpperCase()}
</span>
);
}
return (
<img
src={faviconUrl}
alt=""
style={{
width: 16,
height: 16,
objectFit: 'contain',
borderRadius: 2,
}}
onError={() => setFailed(true)}
/>
);
}
export function AppLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [appsOpen, setAppsOpen] = useState(true);
const { data: externalLinks } = useQuery<ExternalLink[]>({
queryKey: ['settings', 'external-links'],
@ -72,13 +127,28 @@ export function AppLayout() {
Dashboard
</NavLink>
{/* Externe Links */}
{/* Externe Links (aufklappbar) */}
{externalLinks && externalLinks.length > 0 && (
<>
<div className={styles.navSection}>Anwendungen</div>
{externalLinks.map((link) => {
const favicon = getFaviconUrl(link.url);
return (
<button
className={styles.navSectionToggle}
onClick={() => setAppsOpen((p) => !p)}
>
<span>Anwendungen</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`${styles.chevron} ${appsOpen ? styles.chevronOpen : ''}`}
>
<path d="M3 4.5l3 3 3-3" />
</svg>
</button>
{appsOpen &&
externalLinks.map((link) => (
<a
key={link.id}
href={link.url}
@ -92,42 +162,7 @@ export function AppLayout() {
}}
className={styles.navLink}
>
<img
src={favicon || ''}
alt=""
style={{
width: 16,
height: 16,
objectFit: 'contain',
borderRadius: 2,
}}
onError={(e) => {
// 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';
}}
/>
<span
style={{
display: 'none',
width: 16,
height: 16,
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.625rem',
fontWeight: 700,
background: 'rgba(255,255,255,0.15)',
borderRadius: 2,
color: 'rgba(255,255,255,0.8)',
flexShrink: 0,
}}
>
{link.label.charAt(0).toUpperCase()}
</span>
<FaviconImg url={link.url} label={link.label} />
{link.label}
<svg
width="10"
@ -142,8 +177,7 @@ export function AppLayout() {
<path d="M4 6l5-5" />
</svg>
</a>
);
})}
))}
</>
)}
</nav>