From 68a9e44363b050d20ca3327f06c64b8d4c08d164 Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 11 May 2026 15:08:32 +0200 Subject: [PATCH] Add auto favicon fetch via URL with manual upload fallback --- backend/main.py | 113 +++++++++++++++++++++++++++++++++++++++ backend/requirements.txt | 3 +- frontend/app.js | 107 +++++++++++++++++++++++++++++++++--- frontend/style.css | 31 +++++++++++ 4 files changed, 247 insertions(+), 7 deletions(-) diff --git a/backend/main.py b/backend/main.py index eda3e68..6c8cb77 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,6 +8,9 @@ from typing import Optional, List import aiosqlite import shutil import os +import re as re_mod +import ipaddress +import httpx app = FastAPI() @@ -403,3 +406,113 @@ async def import_html(file: UploadFile = File(...)): "links": imported_links } } + + +class IconLinkParser(HTMLParser): + def __init__(self): + super().__init__() + self.icons = [] + + def handle_starttag(self, tag, attrs): + if tag != 'link': + return + attrs = dict(attrs) + rel = attrs.get('rel', '').lower() + if 'icon' in rel: + href = attrs.get('href', '').strip() + sizes = attrs.get('sizes', '') + if href: + self.icons.append({'href': href, 'sizes': sizes, 'rel': rel}) + + +def _is_private_url(url: str) -> bool: + """Return True for Tailscale, .local, or private IP URLs — skip fetch for these.""" + if '.ts.net' in url or '.local' in url: + return True + try: + from urllib.parse import urlparse + host = urlparse(url).hostname or '' + addr = ipaddress.ip_address(host) + return addr.is_private or addr.is_loopback + except Exception: + return False + + +def _pick_best_icon(icons: list, base_url: str) -> str | None: + """Pick highest-res icon, resolve relative URLs, return absolute URL or None.""" + from urllib.parse import urljoin, urlparse + if not icons: + return None + # Prefer explicit sizes, larger first + def size_score(icon): + s = icon.get('sizes', '') + m = re_mod.search(r'(\d+)', s) + return int(m.group(1)) if m else 0 + icons_sorted = sorted(icons, key=size_score, reverse=True) + href = icons_sorted[0]['href'] + if href.startswith('data:'): + return None + return urljoin(base_url, href) + + +def _domain_filename(url: str) -> str: + from urllib.parse import urlparse + host = urlparse(url).hostname or 'unknown' + # strip www. + host = re_mod.sub(r'^www\.', '', host) + return host + '.png' + + +@app.post("/favicons/fetch") +async def fetch_favicon(payload: dict): + from urllib.parse import urlparse, urljoin + url = payload.get('url', '').strip() + if not url: + return {"favicon": None} + if _is_private_url(url): + return {"favicon": None} + + filename = _domain_filename(url) + dest = os.path.join(FAVICON_DIR, filename) + + # If already cached, return immediately + if os.path.exists(dest): + return {"favicon": filename} + + parsed = urlparse(url) + base_url = f"{parsed.scheme}://{parsed.netloc}" + icon_url = None + + headers = {"User-Agent": "Mozilla/5.0 (compatible; favicon-fetcher/1.0)"} + try: + async with httpx.AsyncClient(follow_redirects=True, headers=headers) as client: + # Try to parse the page for — short timeout + try: + resp = await client.get(url, timeout=5) + if resp.status_code == 200: + content_type = resp.headers.get('content-type', '') + if 'html' in content_type: + parser = IconLinkParser() + parser.feed(resp.text[:20000]) + icon_url = _pick_best_icon(parser.icons, base_url) + except Exception: + pass + + # Fall back to /favicon.ico + if not icon_url: + icon_url = base_url + '/favicon.ico' + + # Download the icon — separate timeout + try: + icon_resp = await client.get(icon_url, timeout=8) + if icon_resp.status_code == 200 and len(icon_resp.content) > 0: + with open(dest, 'wb') as f: + f.write(icon_resp.content) + return {"favicon": filename} + except Exception: + pass + + except Exception: + pass + + return {"favicon": None} diff --git a/backend/requirements.txt b/backend/requirements.txt index f120f26..f9226be 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,5 @@ fastapi==0.111.0 uvicorn==0.29.0 aiosqlite==0.20.0 -python-multipart==0.0.9 \ No newline at end of file +python-multipart==0.0.9 +httpx==0.27.0 diff --git a/frontend/app.js b/frontend/app.js index 42a4d46..855a332 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -330,9 +330,17 @@ function openAddLinkModal() { -
- - +
+ +
+
+ + + + +
`, async () => { const label = document.getElementById('f-label').value.trim(); @@ -349,6 +357,7 @@ function openAddLinkModal() { closeModal(); await selectSubcategory(state.activeSubcategoryId); }); + setupFaviconFetch('f-url', 'f-favicon', 'f-favicon-preview', 'f-favicon-status', 'f-favicon-upload-btn', 'f-favicon-file'); } function openEditLinkModal(link, e) { @@ -363,9 +372,17 @@ function openEditLinkModal(link, e) {
-
- - +
+ +
+
+ + + + +