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,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Body,
|
Body,
|
||||||
|
Query,
|
||||||
Logger,
|
Logger,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
|
@ -92,4 +93,152 @@ export class SettingsController {
|
||||||
|
|
||||||
return { success: true, count: sorted.length };
|
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../api/client';
|
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) */
|
/** Einfache ID-Generierung (crypto.randomUUID ist nur ueber HTTPS verfuegbar) */
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
return Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
|
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 {
|
interface ExternalLink {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -93,7 +113,7 @@ function LinkRow({
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
}) {
|
}) {
|
||||||
const faviconUrl = getFaviconUrl(link.url);
|
const faviconUrl = useFavicon(link.url);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,37 @@
|
||||||
font-weight: 600;
|
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 {
|
.navLink {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
@ -12,19 +13,73 @@ interface ExternalLink {
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Favicon-URL direkt von der Webseite laden */
|
/** Favicon ueber Backend-Proxy laden (cached in Redis) */
|
||||||
function getFaviconUrl(url: string): string | null {
|
function FaviconImg({ url, label }: { url: string; label: string }) {
|
||||||
try {
|
const [faviconUrl, setFaviconUrl] = useState<string | null>(null);
|
||||||
const parsed = new URL(url);
|
const [failed, setFailed] = useState(false);
|
||||||
return `${parsed.origin}/favicon.ico`;
|
|
||||||
} catch {
|
const fetchFavicon = useCallback(async () => {
|
||||||
return null;
|
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() {
|
export function AppLayout() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [appsOpen, setAppsOpen] = useState(true);
|
||||||
|
|
||||||
const { data: externalLinks } = useQuery<ExternalLink[]>({
|
const { data: externalLinks } = useQuery<ExternalLink[]>({
|
||||||
queryKey: ['settings', 'external-links'],
|
queryKey: ['settings', 'external-links'],
|
||||||
|
|
@ -72,13 +127,28 @@ export function AppLayout() {
|
||||||
Dashboard
|
Dashboard
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
{/* Externe Links */}
|
{/* Externe Links (aufklappbar) */}
|
||||||
{externalLinks && externalLinks.length > 0 && (
|
{externalLinks && externalLinks.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className={styles.navSection}>Anwendungen</div>
|
<button
|
||||||
{externalLinks.map((link) => {
|
className={styles.navSectionToggle}
|
||||||
const favicon = getFaviconUrl(link.url);
|
onClick={() => setAppsOpen((p) => !p)}
|
||||||
return (
|
>
|
||||||
|
<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
|
<a
|
||||||
key={link.id}
|
key={link.id}
|
||||||
href={link.url}
|
href={link.url}
|
||||||
|
|
@ -92,42 +162,7 @@ export function AppLayout() {
|
||||||
}}
|
}}
|
||||||
className={styles.navLink}
|
className={styles.navLink}
|
||||||
>
|
>
|
||||||
<img
|
<FaviconImg url={link.url} label={link.label} />
|
||||||
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>
|
|
||||||
{link.label}
|
{link.label}
|
||||||
<svg
|
<svg
|
||||||
width="10"
|
width="10"
|
||||||
|
|
@ -142,8 +177,7 @@ export function AppLayout() {
|
||||||
<path d="M4 6l5-5" />
|
<path d="M4 6l5-5" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue