This commit is contained in:
team3
2026-09-19 20:59:47 +02:00
parent 45a6ecf654
commit 4548178a7c
20 changed files with 832 additions and 23 deletions

0
backend/app/__init__.py Normal file
View File

12
backend/app/config.py Normal file
View File

@@ -0,0 +1,12 @@
import os
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
DATA_DIR = Path(os.getenv("DATA_DIR", ROOT / "data"))
DB_PATH = Path(os.getenv("DB_PATH", DATA_DIR / "desktop.db"))
# Jeder Client schickt diesen Token als "Authorization: Bearer <Token>"
SYNC_TOKEN = os.getenv("SYNC_TOKEN", "")
COLLECTIONS = ("events", "todos", "notes")

82
backend/app/db.py Normal file
View File

@@ -0,0 +1,82 @@
import json
import sqlite3
from contextlib import contextmanager
from .config import DB_PATH
# Ein Eintrag pro Termin, Todo oder Notiz. Gelöschte bleiben als Tombstone erhalten,
# sonst würde ein anderes Gerät sie wieder hochladen.
# updated: Zeitstempel des Geräts, entscheidet bei Konflikten (neuester gewinnt).
# seq: Server-Zähler, damit Clients "alles seit meinem letzten Stand" abholen können.
SCHEMA = """
CREATE TABLE IF NOT EXISTS items (
collection TEXT NOT NULL,
id TEXT NOT NULL,
updated TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0,
data TEXT NOT NULL,
seq INTEGER NOT NULL,
PRIMARY KEY (collection, id)
);
CREATE INDEX IF NOT EXISTS items_seq ON items(seq);
"""
@contextmanager
def connect():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
finally:
conn.close()
def init():
with connect() as conn:
conn.executescript(SCHEMA)
def cursor(conn):
return conn.execute("SELECT COALESCE(MAX(seq), 0) FROM items").fetchone()[0]
def merge(conn, collection, item):
"""Übernimmt den Eintrag, wenn er neuer ist als der gespeicherte.
Gibt (True, None) zurück, wenn er übernommen wurde, sonst (False, gespeicherter Eintrag),
damit der Client den gültigen Stand bekommt.
"""
row = conn.execute(
"SELECT updated, data FROM items WHERE collection = ? AND id = ?", (collection, item["id"])
).fetchone()
if row and row["updated"] >= item["updated"]:
return False, json.loads(row["data"])
conn.execute(
"INSERT OR REPLACE INTO items (collection, id, updated, deleted, data, seq) VALUES (?, ?, ?, ?, ?, ?)",
(
collection,
item["id"],
item["updated"],
1 if item.get("deleted") else 0,
json.dumps(item, ensure_ascii=False),
cursor(conn) + 1,
),
)
return True, None
def since(conn, collection, seq):
rows = conn.execute(
"SELECT data FROM items WHERE collection = ? AND seq > ? ORDER BY seq", (collection, seq)
)
return [json.loads(r["data"]) for r in rows]
def alive(conn, collection):
rows = conn.execute(
"SELECT data FROM items WHERE collection = ? AND deleted = 0 ORDER BY id", (collection,)
)
return [json.loads(r["data"]) for r in rows]

91
backend/app/main.py Normal file
View File

@@ -0,0 +1,91 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
from . import db
from .config import 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()
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] = []
todos: list[dict] = []
notes: list[dict] = []
@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() as conn:
accepted = 0
rejected = {collection: [] for collection in COLLECTIONS}
for collection in COLLECTIONS:
for item in getattr(body, collection):
ok, stored = db.merge(conn, collection, item)
if ok:
accepted += 1
else:
rejected[collection].append(stored)
out = {}
for collection in COLLECTIONS:
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")