startpage/backend/main.py

218 lines
7.3 KiB
Python

from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional, List
import aiosqlite
import shutil
import os
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 label=?, url=?, favicon=?, position=? WHERE 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}