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")

2
backend/pytest.ini Normal file
View File

@@ -0,0 +1,2 @@
[pytest]
testpaths = tests

4
backend/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
httpx==0.28.1
pytest==8.3.4

View File

@@ -0,0 +1,85 @@
import importlib
import os
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path):
os.environ["DB_PATH"] = str(tmp_path / "test.db")
os.environ["SYNC_TOKEN"] = "geheim"
from app import config, db, main
importlib.reload(config)
importlib.reload(db)
importlib.reload(main)
with TestClient(main.app) as c:
yield c
AUTH = {"Authorization": "Bearer geheim"}
def sync(client, since=0, **collections):
body = {"since": since, **collections}
r = client.post("/api/sync", json=body, headers=AUTH)
assert r.status_code == 200, r.text
return r.json()
def test_health_ohne_token(client):
assert client.get("/api/health").json() == {"ok": True}
def test_falscher_token(client):
assert client.post("/api/sync", json={}).status_code == 401
assert client.post("/api/sync", json={}, headers={"Authorization": "Bearer falsch"}).status_code == 401
assert client.get("/api/events").status_code == 401
def test_push_und_pull(client):
a = sync(client, events=[{"id": "e1", "updated": "2026-09-19T10:00:00.000Z", "title": "Zahnarzt"}])
assert a["cursor"] == 1
assert [e["id"] for e in a["events"]] == ["e1"]
# Anderes Gerät ohne Stand bekommt alles
b = sync(client)
assert [e["title"] for e in b["events"]] == ["Zahnarzt"]
assert b["cursor"] == 1
# Mit aktuellem Stand kommt nichts Neues
c = sync(client, since=1)
assert c["events"] == [] and c["cursor"] == 1
def test_neuester_gewinnt(client):
sync(client, todos=[{"id": "t1", "updated": "2026-09-19T10:00:00.000Z", "title": "alt"}])
sync(client, todos=[{"id": "t1", "updated": "2026-09-19T09:00:00.000Z", "title": "älter"}])
assert client.get("/api/todos", headers=AUTH).json()[0]["title"] == "alt"
r = sync(client, since=0, todos=[{"id": "t1", "updated": "2026-09-19T11:00:00.000Z", "title": "neu"}])
assert r["todos"][0]["title"] == "neu"
assert r["cursor"] == 2
def test_abgelehnte_aenderung_bekommt_serverstand(client):
sync(client, todos=[{"id": "t1", "updated": "2026-09-19T10:00:00.000Z", "title": "server"}])
r = sync(client, since=1, todos=[{"id": "t1", "updated": "2026-09-19T09:00:00.000Z", "title": "client"}])
assert [t["title"] for t in r["todos"]] == ["server"]
assert r["cursor"] == 1
def test_tombstone(client):
sync(client, notes=[{"id": "Plan.md", "updated": "2026-09-19T10:00:00.000Z", "content": "x"}])
r = sync(client, since=1, notes=[{"id": "Plan.md", "updated": "2026-09-19T10:05:00.000Z", "deleted": True}])
assert r["notes"][0]["deleted"] is True
assert client.get("/api/notes", headers=AUTH).json() == []
# Ein verspäteter älterer Stand belebt die Notiz nicht wieder
sync(client, notes=[{"id": "Plan.md", "updated": "2026-09-19T10:01:00.000Z", "content": "y"}])
assert client.get("/api/notes", headers=AUTH).json() == []
def test_seit_cursor_liefert_nur_neues(client):
sync(client, events=[{"id": "e1", "updated": "2026-09-19T10:00:00.000Z", "title": "a"}])
sync(client, events=[{"id": "e2", "updated": "2026-09-19T10:00:00.000Z", "title": "b"}])
r = sync(client, since=1)
assert [e["id"] for e in r["events"]] == ["e2"]