from fastapi import FastAPI, UploadFile, File, HTTPException from html.parser import HTMLParser from fastapi.responses import JSONResponse, HTMLResponse import json as json_lib from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Optional, List import aiosqlite import shutil import os import re as re_mod import ipaddress import httpx app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) DB_PATH = "/data/startpage.db" FAVICON_DIR = "/data/favicons" os.makedirs(FAVICON_DIR, exist_ok=True) class CategoryIn(BaseModel): name: str icon: Optional[str] = None position: Optional[int] = 0 class SubcategoryIn(BaseModel): category_id: int name: str position: Optional[int] = 0 class LinkIn(BaseModel): subcategory_id: int label: str url: str favicon: Optional[str] = None position: Optional[int] = 0 class ReorderIn(BaseModel): type: str # "categories", "subcategories", "links" ids: List[int] async def init_db(): async with aiosqlite.connect(DB_PATH) as db: await db.execute(""" CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, icon TEXT, position INTEGER DEFAULT 0 ) """) await db.execute(""" CREATE TABLE IF NOT EXISTS subcategories ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, name TEXT NOT NULL, position INTEGER DEFAULT 0, FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE ) """) await db.execute(""" CREATE TABLE IF NOT EXISTS links ( id INTEGER PRIMARY KEY AUTOINCREMENT, subcategory_id INTEGER NOT NULL, label TEXT NOT NULL, url TEXT NOT NULL, favicon TEXT, position INTEGER DEFAULT 0, FOREIGN KEY (subcategory_id) REFERENCES subcategories(id) ON DELETE CASCADE ) """) await db.execute("PRAGMA foreign_keys = ON") await db.commit() @app.on_event("startup") async def startup(): await init_db() @app.get("/categories") async def get_categories(): async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute("SELECT * FROM categories ORDER BY position") as cursor: rows = await cursor.fetchall() return [dict(row) for row in rows] @app.post("/categories") async def create_category(cat: CategoryIn): async with aiosqlite.connect(DB_PATH) as db: cursor = await db.execute( "INSERT INTO categories (name, icon, position) VALUES (?, ?, ?)", (cat.name, cat.icon, cat.position) ) await db.commit() return {"id": cursor.lastrowid, **cat.dict()} @app.put("/categories/{cat_id}") async def update_category(cat_id: int, cat: CategoryIn): async with aiosqlite.connect(DB_PATH) as db: await db.execute( "UPDATE categories SET name=?, icon=?, position=? WHERE id=?", (cat.name, cat.icon, cat.position, cat_id) ) await db.commit() return {"id": cat_id, **cat.dict()} @app.delete("/categories/{cat_id}") async def delete_category(cat_id: int): async with aiosqlite.connect(DB_PATH) as db: await db.execute("PRAGMA foreign_keys = ON") await db.execute("DELETE FROM categories WHERE id=?", (cat_id,)) await db.commit() return {"deleted": cat_id} @app.get("/subcategories/{cat_id}") async def get_subcategories(cat_id: int): async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute( "SELECT * FROM subcategories WHERE category_id=? ORDER BY position", (cat_id,) ) as cursor: rows = await cursor.fetchall() return [dict(row) for row in rows] @app.post("/subcategories") async def create_subcategory(sub: SubcategoryIn): async with aiosqlite.connect(DB_PATH) as db: cursor = await db.execute( "INSERT INTO subcategories (category_id, name, position) VALUES (?, ?, ?)", (sub.category_id, sub.name, sub.position) ) await db.commit() return {"id": cursor.lastrowid, **sub.dict()} @app.put("/subcategories/{sub_id}") async def update_subcategory(sub_id: int, sub: SubcategoryIn): async with aiosqlite.connect(DB_PATH) as db: await db.execute( "UPDATE subcategories SET name=?, position=? WHERE id=?", (sub.name, sub.position, sub_id) ) await db.commit() return {"id": sub_id, **sub.dict()} @app.delete("/subcategories/{sub_id}") async def delete_subcategory(sub_id: int): async with aiosqlite.connect(DB_PATH) as db: await db.execute("PRAGMA foreign_keys = ON") await db.execute("DELETE FROM subcategories WHERE id=?", (sub_id,)) await db.commit() return {"deleted": sub_id} @app.get("/links/{sub_id}") async def get_links(sub_id: int): async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute( "SELECT * FROM links WHERE subcategory_id=? ORDER BY position", (sub_id,) ) as cursor: rows = await cursor.fetchall() return [dict(row) for row in rows] @app.post("/links") async def create_link(link: LinkIn): async with aiosqlite.connect(DB_PATH) as db: cursor = await db.execute( "INSERT INTO links (subcategory_id, label, url, favicon, position) VALUES (?, ?, ?, ?, ?)", (link.subcategory_id, link.label, link.url, link.favicon, link.position) ) await db.commit() return {"id": cursor.lastrowid, **link.dict()} @app.put("/links/{link_id}") async def update_link(link_id: int, link: LinkIn): async with aiosqlite.connect(DB_PATH) as db: await db.execute( "UPDATE links SET subcategory_id=?, label=?, url=?, favicon=?, position=? WHERE id=?", (link.subcategory_id, link.label, link.url, link.favicon, link.position, link_id) ) await db.commit() return {"id": link_id, **link.dict()} @app.delete("/links/{link_id}") async def delete_link(link_id: int): async with aiosqlite.connect(DB_PATH) as db: await db.execute("DELETE FROM links WHERE id=?", (link_id,)) await db.commit() return {"deleted": link_id} @app.put("/reorder") async def reorder(data: ReorderIn): table_map = { "categories": "categories", "subcategories": "subcategories", "links": "links", } if data.type not in table_map: raise HTTPException(status_code=400, detail="Invalid type") table = table_map[data.type] async with aiosqlite.connect(DB_PATH) as db: for position, item_id in enumerate(data.ids): await db.execute( f"UPDATE {table} SET position=? WHERE id=?", (position, item_id) ) await db.commit() return {"reordered": data.type, "count": len(data.ids)} @app.post("/favicons/upload") async def upload_favicon(file: UploadFile = File(...)): filename = file.filename.replace(" ", "_").lower() dest = os.path.join(FAVICON_DIR, filename) with open(dest, "wb") as f: shutil.copyfileobj(file.file, f) return {"favicon": filename} @app.get("/export/json") async def export_json(): async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute("SELECT * FROM categories ORDER BY position") as cur: categories = [dict(r) for r in await cur.fetchall()] for cat in categories: async with db.execute( "SELECT * FROM subcategories WHERE category_id=? ORDER BY position", (cat["id"],) ) as cur: subs = [dict(r) for r in await cur.fetchall()] for sub in subs: async with db.execute( "SELECT * FROM links WHERE subcategory_id=? ORDER BY position", (sub["id"],) ) as cur: sub["links"] = [dict(r) for r in await cur.fetchall()] cat["subcategories"] = subs headers = {"Content-Disposition": "attachment; filename=startpage-bookmarks.json"} return JSONResponse(content=categories, headers=headers) @app.get("/export/html") async def export_html(): async with aiosqlite.connect(DB_PATH) as db: db.row_factory = aiosqlite.Row async with db.execute("SELECT * FROM categories ORDER BY position") as cur: categories = [dict(r) for r in await cur.fetchall()] for cat in categories: async with db.execute( "SELECT * FROM subcategories WHERE category_id=? ORDER BY position", (cat["id"],) ) as cur: subs = [dict(r) for r in await cur.fetchall()] for sub in subs: async with db.execute( "SELECT * FROM links WHERE subcategory_id=? ORDER BY position", (sub["id"],) ) as cur: sub["links"] = [dict(r) for r in await cur.fetchall()] cat["subcategories"] = subs lines = [ "", "", "", "Bookmarks", "

Bookmarks

", "

", ] for cat in categories: lines.append("

" + cat["name"] + "

") lines.append("

") for sub in cat["subcategories"]: lines.append("

" + sub["name"] + "

") lines.append("

") for link in sub["links"]: lines.append("

" + link["label"] + "") lines.append("

") lines.append("

") lines.append("

") body = "\n".join(lines) headers = {"Content-Disposition": "attachment; filename=startpage-bookmarks.html"} return HTMLResponse(content=body, headers=headers) class BookmarkParser(HTMLParser): def __init__(self): super().__init__() self.categories = [] self._cat = None self._sub = None self._depth = 0 self._in_h3 = False self._in_a = False self._current_href = None self._dl_depth = 0 self._cat_dl_depth = None self._sub_dl_depth = None def handle_starttag(self, tag, attrs): attrs = dict(attrs) if tag == 'dl': self._dl_depth += 1 elif tag == 'h3': self._in_h3 = True elif tag == 'a': self._in_a = True self._current_href = attrs.get('href', '') def handle_endtag(self, tag): if tag == 'dl': if self._cat_dl_depth is not None and self._dl_depth == self._cat_dl_depth: self._cat = None self._cat_dl_depth = None self._sub_dl_depth = None elif self._sub_dl_depth is not None and self._dl_depth == self._sub_dl_depth: self._sub = None self._sub_dl_depth = None self._dl_depth -= 1 elif tag == 'h3': self._in_h3 = False elif tag == 'a': self._in_a = False self._current_href = None def handle_data(self, data): data = data.strip() if not data: return if self._in_h3: if self._cat is None: self._cat = {"name": data, "subcategories": []} self.categories.append(self._cat) self._cat_dl_depth = self._dl_depth + 1 else: self._sub = {"name": data, "links": []} self._cat["subcategories"].append(self._sub) self._sub_dl_depth = self._dl_depth + 1 elif self._in_a and self._current_href: if self._sub is None: if not self._cat["subcategories"] or self._cat["subcategories"][-1]["name"] != "General": self._sub = {"name": "General", "links": []} self._cat["subcategories"].append(self._sub) else: self._sub = self._cat["subcategories"][-1] self._sub["links"].append({"label": data, "url": self._current_href}) @app.post("/import/html") async def import_html(file: UploadFile = File(...)): content = (await file.read()).decode("utf-8", errors="replace") parser = BookmarkParser() parser.feed(content) imported_cats = 0 imported_subs = 0 imported_links = 0 async with aiosqlite.connect(DB_PATH) as db: await db.execute("PRAGMA foreign_keys = ON") async with db.execute("SELECT COALESCE(MAX(position), -1) FROM categories") as cur: row = await cur.fetchone() cat_pos = (row[0] or -1) + 1 for cat_data in parser.categories: cursor = await db.execute( "INSERT INTO categories (name, icon, position) VALUES (?, ?, ?)", (cat_data["name"], "ti-folder", cat_pos) ) cat_id = cursor.lastrowid cat_pos += 1 imported_cats += 1 sub_pos = 0 for sub_data in cat_data["subcategories"]: cursor = await db.execute( "INSERT INTO subcategories (category_id, name, position) VALUES (?, ?, ?)", (cat_id, sub_data["name"], sub_pos) ) sub_id = cursor.lastrowid sub_pos += 1 imported_subs += 1 for link_pos, link_data in enumerate(sub_data["links"]): await db.execute( "INSERT INTO links (subcategory_id, label, url, favicon, position) VALUES (?, ?, ?, ?, ?)", (sub_id, link_data["label"], link_data["url"], None, link_pos) ) imported_links += 1 await db.commit() return { "imported": { "categories": imported_cats, "subcategories": imported_subs, "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}