import logging from contextlib import asynccontextmanager from fastapi import FastAPI, Header, HTTPException, Query from fastapi.responses import Response from pydantic import BaseModel from . import db, files from .config import BASE_COLLECTIONS, COLLECTIONS, SYNC_TOKEN logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("app") @asynccontextmanager async def lifespan(_app): if not SYNC_TOKEN: log.warning("SYNC_TOKEN ist leer, jede Anfrage wird abgelehnt.") db.init() with db.connect(write=True) as conn: moved = files.migrate(conn) if moved: log.info("%d Dateien aus der Datenbank nach %s verschoben", moved, files.FILES_DIR) yield app = FastAPI(title="Desktop Sync", lifespan=lifespan) def check_token(authorization: str): if not SYNC_TOKEN or authorization != f"Bearer {SYNC_TOKEN}": raise HTTPException(status_code=401, detail="Token fehlt oder ist falsch") class SyncIn(BaseModel): since: int = 0 events: list[dict] | None = None todos: list[dict] | None = None notes: list[dict] | None = None folders: list[dict] | None = None files: list[dict] | None = None # Zurück kommen die Sammlungen der Anfrage. Ohne Sammlungen die vier Basis-Sammlungen, # Dateien nur auf ausdrückliche Anfrage, sonst zieht das Handy Sitzungen mit. @property def requested(self): named = [c for c in COLLECTIONS if getattr(self, c) is not None] return named or list(BASE_COLLECTIONS) @app.get("/api/health") def health(): return {"ok": True} @app.post("/api/sync") def sync(body: SyncIn, authorization: str = Header(default="")): """Nimmt Änderungen des Clients an und gibt alles zurück, was sich seit seinem Stand geändert hat. Konflikte: der Eintrag mit dem neueren updated gewinnt. Verliert der Client, bekommt er den Server-Stand zurück. Die Antwort enthält auch die gerade angenommenen Einträge; der Client erkennt sie am gleichen updated und überspringt sie. """ check_token(authorization) with db.connect(write=True) as conn: accepted = 0 rejected = {collection: [] for collection in COLLECTIONS} for collection in COLLECTIONS: merge = files.merge if collection == "files" else lambda c, item: db.merge(c, collection, item) for item in getattr(body, collection) or []: ok, stored = merge(conn, item) if ok: accepted += 1 elif stored: rejected[collection].append(stored) out = {} for collection in body.requested: changed = db.since(conn, collection, body.since) ids = {item["id"] for item in changed} out[collection] = changed + [item for item in rejected[collection] if item["id"] not in ids] out["cursor"] = db.cursor(conn) if accepted: log.info("sync: %d Einträge übernommen", accepted) return out # Lesezugriff für andere Anwendungen @app.get("/api/events") def events(authorization: str = Header(default="")): check_token(authorization) with db.connect() as conn: return db.alive(conn, "events") @app.get("/api/todos") def todos(authorization: str = Header(default="")): check_token(authorization) with db.connect() as conn: return db.alive(conn, "todos") @app.get("/api/notes") def notes(authorization: str = Header(default="")): check_token(authorization) with db.connect() as conn: return db.alive(conn, "notes") # Inhalt einer Datei. Mit from und hash (Länge und Hash der letzten 64 KB des eigenen Stands) # kommt nur der Rest ab dieser Position, sonst die ganze Datei. X-From sagt, ab wo. @app.get("/api/files/{file_id:path}") def file_content(file_id: str, from_: int = Query(0, alias="from"), hash: str = "", authorization: str = Header(default="")): check_token(authorization) with db.connect() as conn: meta = files.meta(conn, file_id) if not meta or meta.get("deleted"): raise HTTPException(status_code=404, detail="Datei unbekannt") data, start = files.read_from(file_id, from_, hash) return Response(content=data, media_type="application/octet-stream", headers={"X-From": str(start), "X-Size": str(start + len(data))})