Add auto favicon fetch via URL with manual upload fallback

This commit is contained in:
Einar 2026-05-11 15:08:32 +02:00
parent c64ddd41a2
commit 68a9e44363
4 changed files with 247 additions and 7 deletions

View file

@ -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 <link rel="icon"> — 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}

View file

@ -2,3 +2,4 @@ fastapi==0.111.0
uvicorn==0.29.0
aiosqlite==0.20.0
python-multipart==0.0.9
httpx==0.27.0

View file

@ -330,9 +330,17 @@ function openAddLinkModal() {
<label>URL</label>
<input type="text" id="f-url" placeholder="https://..." />
</div>
<div class="field">
<label>Favicon filename (optional)</label>
<input type="text" id="f-favicon" placeholder="forgejo.png" />
<div class="field favicon-field">
<label>Favicon</label>
<div class="favicon-preview-row">
<div id="f-favicon-preview" class="favicon-preview-box"></div>
<span id="f-favicon-status" class="favicon-status"></span>
<button type="button" class="btn-ghost favicon-upload-btn" id="f-favicon-upload-btn" style="display:none">
<i class="ti ti-upload"></i> Upload manually
</button>
<input type="file" id="f-favicon-file" accept="image/*" style="display:none" />
<input type="hidden" id="f-favicon" value="" />
</div>
</div>
`, 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) {
<label>URL</label>
<input type="text" id="f-url" value="${link.url}" />
</div>
<div class="field">
<label>Favicon filename</label>
<input type="text" id="f-favicon" value="${link.favicon || ''}" />
<div class="field favicon-field">
<label>Favicon</label>
<div class="favicon-preview-row">
<div id="f-favicon-preview" class="favicon-preview-box"></div>
<span id="f-favicon-status" class="favicon-status"></span>
<button type="button" class="btn-ghost favicon-upload-btn" id="f-favicon-upload-btn" style="display:none">
<i class="ti ti-upload"></i> Upload manually
</button>
<input type="file" id="f-favicon-file" accept="image/*" style="display:none" />
<input type="hidden" id="f-favicon" value="${link.favicon || ''}" />
</div>
</div>
<div class="modal-actions" style="justify-content:flex-start; margin-top:0">
<button class="btn-secondary" style="color:#e8722a;border-color:#e8722a"
@ -386,6 +403,7 @@ function openEditLinkModal(link, e) {
closeModal();
await selectSubcategory(state.activeSubcategoryId);
});
setupFaviconFetch('f-url', 'f-favicon', 'f-favicon-preview', 'f-favicon-status', 'f-favicon-upload-btn', 'f-favicon-file', link.favicon);
}
async function deleteCategory(id) {
@ -466,3 +484,80 @@ document.getElementById('import-file-input').addEventListener('change', async (e
}
e.target.value = '';
});
// Favicon auto-fetch + manual upload helper
function setupFaviconFetch(urlId, faviconId, previewId, statusId, uploadBtnId, fileInputId, existingFavicon) {
const urlEl = document.getElementById(urlId);
const faviconEl = document.getElementById(faviconId);
const previewEl = document.getElementById(previewId);
const statusEl = document.getElementById(statusId);
const uploadBtn = document.getElementById(uploadBtnId);
const fileInput = document.getElementById(fileInputId);
function showPreview(filename) {
if (filename) {
previewEl.innerHTML = '<img src="/favicons/' + filename + '" width="20" height="20" onerror="this.parentNode.innerHTML=\'<span style=opacity:.4>?</span>\'" />';
} else {
previewEl.innerHTML = '';
}
}
// Show existing favicon immediately if editing
if (existingFavicon) {
showPreview(existingFavicon);
statusEl.textContent = existingFavicon;
uploadBtn.style.display = 'inline-flex';
}
async function doFetch(url) {
if (!url || !url.startsWith('http')) return;
statusEl.textContent = 'Fetching…';
uploadBtn.style.display = 'none';
try {
const res = await fetch(API + '/favicons/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const data = await res.json();
if (data.favicon) {
faviconEl.value = data.favicon;
showPreview(data.favicon);
statusEl.textContent = data.favicon;
uploadBtn.style.display = 'inline-flex';
} else {
faviconEl.value = '';
previewEl.innerHTML = '';
statusEl.textContent = 'Not found';
uploadBtn.style.display = 'inline-flex';
}
} catch (err) {
statusEl.textContent = 'Failed';
uploadBtn.style.display = 'inline-flex';
}
}
urlEl.addEventListener('blur', () => doFetch(urlEl.value.trim()));
uploadBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
statusEl.textContent = 'Uploading…';
try {
const res = await fetch(API + '/favicons/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.favicon) {
faviconEl.value = data.favicon;
showPreview(data.favicon);
statusEl.textContent = data.favicon;
}
} catch (err) {
statusEl.textContent = 'Upload failed';
}
e.target.value = '';
});
}

View file

@ -457,3 +457,34 @@ body {
background: var(--surface2);
color: var(--accent);
}
/* Favicon fetch UI */
.favicon-preview-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.favicon-preview-box {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
flex-shrink: 0;
}
.favicon-status {
font-size: 11px;
color: var(--text-muted);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.favicon-upload-btn {
padding: 4px 8px;
font-size: 12px;
}