mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 22:46:39 +02:00
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:
parent
0a52606012
commit
0f9b3d4f36
4 changed files with 295 additions and 61 deletions
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
/** 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 {
|
||||
const parsed = new URL(url);
|
||||
return `${parsed.origin}/favicon.ico`;
|
||||
} catch {
|
||||
return null;
|
||||
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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue