Add auto favicon fetch via URL with manual upload fallback
This commit is contained in:
parent
c64ddd41a2
commit
68a9e44363
4 changed files with 247 additions and 7 deletions
113
backend/main.py
113
backend/main.py
|
|
@ -8,6 +8,9 @@ from typing import Optional, List
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
import shutil
|
import shutil
|
||||||
import os
|
import os
|
||||||
|
import re as re_mod
|
||||||
|
import ipaddress
|
||||||
|
import httpx
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
@ -403,3 +406,113 @@ async def import_html(file: UploadFile = File(...)):
|
||||||
"links": imported_links
|
"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}
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@ fastapi==0.111.0
|
||||||
uvicorn==0.29.0
|
uvicorn==0.29.0
|
||||||
aiosqlite==0.20.0
|
aiosqlite==0.20.0
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
|
httpx==0.27.0
|
||||||
|
|
|
||||||
107
frontend/app.js
107
frontend/app.js
|
|
@ -330,9 +330,17 @@ function openAddLinkModal() {
|
||||||
<label>URL</label>
|
<label>URL</label>
|
||||||
<input type="text" id="f-url" placeholder="https://..." />
|
<input type="text" id="f-url" placeholder="https://..." />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field favicon-field">
|
||||||
<label>Favicon filename (optional)</label>
|
<label>Favicon</label>
|
||||||
<input type="text" id="f-favicon" placeholder="forgejo.png" />
|
<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>
|
</div>
|
||||||
`, async () => {
|
`, async () => {
|
||||||
const label = document.getElementById('f-label').value.trim();
|
const label = document.getElementById('f-label').value.trim();
|
||||||
|
|
@ -349,6 +357,7 @@ function openAddLinkModal() {
|
||||||
closeModal();
|
closeModal();
|
||||||
await selectSubcategory(state.activeSubcategoryId);
|
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) {
|
function openEditLinkModal(link, e) {
|
||||||
|
|
@ -363,9 +372,17 @@ function openEditLinkModal(link, e) {
|
||||||
<label>URL</label>
|
<label>URL</label>
|
||||||
<input type="text" id="f-url" value="${link.url}" />
|
<input type="text" id="f-url" value="${link.url}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field favicon-field">
|
||||||
<label>Favicon filename</label>
|
<label>Favicon</label>
|
||||||
<input type="text" id="f-favicon" value="${link.favicon || ''}" />
|
<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>
|
||||||
<div class="modal-actions" style="justify-content:flex-start; margin-top:0">
|
<div class="modal-actions" style="justify-content:flex-start; margin-top:0">
|
||||||
<button class="btn-secondary" style="color:#e8722a;border-color:#e8722a"
|
<button class="btn-secondary" style="color:#e8722a;border-color:#e8722a"
|
||||||
|
|
@ -386,6 +403,7 @@ function openEditLinkModal(link, e) {
|
||||||
closeModal();
|
closeModal();
|
||||||
await selectSubcategory(state.activeSubcategoryId);
|
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) {
|
async function deleteCategory(id) {
|
||||||
|
|
@ -466,3 +484,80 @@ document.getElementById('import-file-input').addEventListener('change', async (e
|
||||||
}
|
}
|
||||||
e.target.value = '';
|
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 = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -457,3 +457,34 @@ body {
|
||||||
background: var(--surface2);
|
background: var(--surface2);
|
||||||
color: var(--accent);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue