This commit is contained in:
team3
2026-10-05 21:15:41 +02:00
parent 24efa2f4ee
commit 16630f0368
10 changed files with 364 additions and 127 deletions

View File

@@ -13,5 +13,5 @@ SYNC_TOKEN = os.getenv("SYNC_TOKEN", "")
COLLECTIONS = ("events", "todos", "notes", "folders", "files")
BASE_COLLECTIONS = COLLECTIONS[:-1]
# Dateien kommen höchstens in Paketen dieser Größe zurück, der Client holt mit "more" weiter
FILES_PAGE_BYTES = int(os.getenv("FILES_PAGE_BYTES", 16 * 1024 * 1024))
# Inhalt der Dateien (Claude-Sitzungen) liegt auf der Platte, in SQLite nur der Steckbrief
FILES_DIR = Path(os.getenv("FILES_DIR", DATA_DIR / "files"))

View File

@@ -25,11 +25,14 @@ CREATE INDEX IF NOT EXISTS items_seq ON items(seq);
# Große Datei-Uploads halten die Datenbank sekundenlang. WAL lässt Leser derweil durch,
# Schreiber warten bis zu 60 s statt mit "database is locked" abzubrechen.
@contextmanager
def connect():
def connect(write=False):
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH, timeout=60)
conn.row_factory = sqlite3.Row
try:
# Schreiber nacheinander: so greifen zwei Anhänge an dieselbe Datei nie gleichzeitig zu
if write:
conn.execute("BEGIN IMMEDIATE")
yield conn
conn.commit()
finally:
@@ -78,21 +81,6 @@ def since(conn, collection, seq):
return [json.loads(r["data"]) for r in rows]
def since_paged(conn, collection, seq, max_bytes):
"""Wie since, aber höchstens max_bytes Daten. Gibt (Einträge, letzter seq, mehr da) zurück."""
rows = conn.execute(
"SELECT data, seq FROM items WHERE collection = ? AND seq > ? ORDER BY seq", (collection, seq)
)
items, size, last = [], 0, seq
for r in rows:
if items and size + len(r["data"]) > max_bytes:
return items, last, True
items.append(json.loads(r["data"]))
size += len(r["data"])
last = r["seq"]
return items, last, False
def alive(conn, collection):
rows = conn.execute(
"SELECT data FROM items WHERE collection = ? AND deleted = 0 ORDER BY id", (collection,)

111
backend/app/files.py Normal file
View File

@@ -0,0 +1,111 @@
import hashlib
import json
from pathlib import Path
from .config import FILES_DIR
# Dateien der PCs (Claude-Code-Sitzungen und Memory) liegen auf der Platte unter FILES_DIR,
# in SQLite steht nur noch der Steckbrief: id, updated, size, deleted.
# Sitzungen wachsen nur am Ende. Deshalb schickt der Client meist nur den neuen Rest ("append")
# und sagt dazu, welchen Stand er beim Server vermutet (base: size und Hash der letzten 64 KB).
# Stimmt das, wird angehängt. Sonst bekommt er den Server-Stand zurück und schickt beim
# nächsten Mal die ganze Datei oder holt sich die neuere.
TAIL = 64 * 1024
def path(file_id):
return FILES_DIR / file_id
def tail_hash(data: bytes) -> str:
return hashlib.sha256(data[-TAIL:]).hexdigest()
# Hash der letzten 64 KB vor Position size, ohne die ganze Datei zu lesen
def tail_hash_at(file_id, size) -> str:
with open(path(file_id), "rb") as f:
start = max(0, size - TAIL)
f.seek(start)
return hashlib.sha256(f.read(size - start)).hexdigest()
def meta(conn, file_id):
row = conn.execute("SELECT data FROM items WHERE collection = 'files' AND id = ?", (file_id,)).fetchone()
return json.loads(row["data"]) if row else None
def _store(conn, item):
from .db import cursor
conn.execute(
"INSERT OR REPLACE INTO items (collection, id, updated, deleted, data, seq) VALUES ('files', ?, ?, ?, ?, ?)",
(item["id"], item["updated"], 1 if item.get("deleted") else 0, json.dumps(item, ensure_ascii=False), cursor(conn) + 1),
)
# Server-Stand für den Client: Steckbrief plus Hash, damit er den Anschluss findet
def state(conn, file_id):
m = meta(conn, file_id)
if m and not m.get("deleted") and path(file_id).exists():
m = {**m, "hash": tail_hash_at(file_id, m["size"])}
return m
def merge(conn, item):
"""Nimmt eine Datei an: ganz (content), als Anhang (base + append) oder als Tombstone.
Gibt (True, None) zurück, wenn übernommen, sonst (False, Server-Stand).
"""
file_id = item["id"]
current = meta(conn, file_id)
if current and current["updated"] >= item["updated"]:
return False, state(conn, file_id)
target = path(file_id)
if item.get("deleted"):
target.unlink(missing_ok=True)
_store(conn, {"id": file_id, "updated": item["updated"], "deleted": True})
return True, None
if "append" in item:
base = item.get("base") or {}
if not current or current.get("deleted") or not target.exists() or current["size"] != base.get("size") \
or tail_hash_at(file_id, current["size"]) != base.get("hash"):
return False, state(conn, file_id)
data = item["append"].encode()
with open(target, "ab") as f:
f.write(data)
_store(conn, {"id": file_id, "updated": item["updated"], "size": current["size"] + len(data)})
return True, None
data = item["content"].encode()
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
_store(conn, {"id": file_id, "updated": item["updated"], "size": len(data)})
return True, None
# Inhalt ab Position: passt der Anfang des Clients (size, hash), kommt nur der Rest, sonst alles.
def read_from(file_id, size, hash_) -> tuple[bytes, int]:
target = path(file_id)
total = target.stat().st_size
start = size if 0 < size <= total and tail_hash_at(file_id, size) == hash_ else 0
with open(target, "rb") as f:
f.seek(start)
return f.read(), start
# Ältere Stände hielten den Inhalt in der Datenbank. Einmal auf die Platte schreiben.
def migrate(conn):
rows = conn.execute("SELECT id, data FROM items WHERE collection = 'files' AND deleted = 0").fetchall()
moved = 0
for row in rows:
item = json.loads(row["data"])
if "content" not in item:
continue
data = item.pop("content").encode()
target = path(row["id"])
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
item["size"] = len(data)
conn.execute("UPDATE items SET data = ? WHERE collection = 'files' AND id = ?", (json.dumps(item, ensure_ascii=False), row["id"]))
moved += 1
return moved

View File

@@ -1,11 +1,12 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Header, HTTPException
from fastapi import FastAPI, Header, HTTPException, Query
from fastapi.responses import Response
from pydantic import BaseModel
from . import db
from .config import BASE_COLLECTIONS, COLLECTIONS, FILES_PAGE_BYTES, SYNC_TOKEN
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")
@@ -16,6 +17,10 @@ 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
@@ -57,30 +62,23 @@ def sync(body: SyncIn, authorization: str = Header(default="")):
erkennt sie am gleichen updated und überspringt sie.
"""
check_token(authorization)
with db.connect() as conn:
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 = db.merge(conn, collection, item)
ok, stored = merge(conn, item)
if ok:
accepted += 1
else:
elif stored:
rejected[collection].append(stored)
out = {}
cursor = db.cursor(conn)
for collection in body.requested:
if collection == "files":
# Dateien sind groß: nur ein Paket, der Stand bleibt beim letzten gelieferten Eintrag
changed, last, more = db.since_paged(conn, collection, body.since, FILES_PAGE_BYTES)
if more:
out["more"] = True
cursor = min(cursor, last)
else:
changed = db.since(conn, collection, body.since)
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"] = cursor
out["cursor"] = db.cursor(conn)
if accepted:
log.info("sync: %d Einträge übernommen", accepted)
return out
@@ -106,3 +104,16 @@ 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))})