update
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
12
backend/app/config.py
Normal file
12
backend/app/config.py
Normal 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
82
backend/app/db.py
Normal 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
91
backend/app/main.py
Normal 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
2
backend/pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
4
backend/requirements.txt
Normal file
4
backend/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
httpx==0.28.1
|
||||
pytest==8.3.4
|
||||
85
backend/tests/test_sync.py
Normal file
85
backend/tests/test_sync.py
Normal 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"]
|
||||
Reference in New Issue
Block a user