astrapi-sync 26.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. astrapi_sync-26.8.1/.github/copilot-instructions.md +89 -0
  2. astrapi_sync-26.8.1/.github/workflows/publish.yml +31 -0
  3. astrapi_sync-26.8.1/.gitignore +7 -0
  4. astrapi_sync-26.8.1/PKG-INFO +15 -0
  5. astrapi_sync-26.8.1/README.md +66 -0
  6. astrapi_sync-26.8.1/astrapi_sync/__init__.py +0 -0
  7. astrapi_sync-26.8.1/astrapi_sync/_app.py +89 -0
  8. astrapi_sync-26.8.1/astrapi_sync/_cli.py +15 -0
  9. astrapi_sync-26.8.1/astrapi_sync/_paths.py +51 -0
  10. astrapi_sync-26.8.1/astrapi_sync/api/__init__.py +0 -0
  11. astrapi_sync-26.8.1/astrapi_sync/api/auth.py +71 -0
  12. astrapi_sync-26.8.1/astrapi_sync/api/block_hash.py +73 -0
  13. astrapi_sync-26.8.1/astrapi_sync/api/fastapi_app.py +28 -0
  14. astrapi_sync-26.8.1/astrapi_sync/api/sync.py +297 -0
  15. astrapi_sync-26.8.1/astrapi_sync/api/ws_manager.py +44 -0
  16. astrapi_sync-26.8.1/astrapi_sync/app.yaml +3 -0
  17. astrapi_sync-26.8.1/astrapi_sync/modules/devices/__init__.py +48 -0
  18. astrapi_sync-26.8.1/astrapi_sync/modules/devices/config/modul.yaml +7 -0
  19. astrapi_sync-26.8.1/astrapi_sync/modules/devices/config/schema.yaml +30 -0
  20. astrapi_sync-26.8.1/astrapi_sync/modules/devices/dialogs/pair/modal.html +41 -0
  21. astrapi_sync-26.8.1/astrapi_sync/modules/devices/pairing_store.py +48 -0
  22. astrapi_sync-26.8.1/astrapi_sync/modules/devices/ui/__init__.py +1 -0
  23. astrapi_sync-26.8.1/astrapi_sync/modules/devices/ui/crud.py +33 -0
  24. astrapi_sync-26.8.1/astrapi_sync/modules/devices/ui/pairing.py +49 -0
  25. astrapi_sync-26.8.1/astrapi_sync/modules/folders/__init__.py +49 -0
  26. astrapi_sync-26.8.1/astrapi_sync/modules/folders/config/modul.yaml +7 -0
  27. astrapi_sync-26.8.1/astrapi_sync/modules/folders/config/schema.yaml +25 -0
  28. astrapi_sync-26.8.1/astrapi_sync/modules/folders/dialogs/files/browse.html +142 -0
  29. astrapi_sync-26.8.1/astrapi_sync/modules/folders/dialogs/files/modal.html +31 -0
  30. astrapi_sync-26.8.1/astrapi_sync/modules/folders/ui/__init__.py +1 -0
  31. astrapi_sync-26.8.1/astrapi_sync/modules/folders/ui/crud.py +118 -0
  32. astrapi_sync-26.8.1/astrapi_sync/modules/folders/ui/files.py +122 -0
  33. astrapi_sync-26.8.1/astrapi_sync/navigation.yaml +7 -0
  34. astrapi_sync-26.8.1/astrapi_sync.egg-info/PKG-INFO +15 -0
  35. astrapi_sync-26.8.1/astrapi_sync.egg-info/SOURCES.txt +42 -0
  36. astrapi_sync-26.8.1/astrapi_sync.egg-info/dependency_links.txt +1 -0
  37. astrapi_sync-26.8.1/astrapi_sync.egg-info/entry_points.txt +2 -0
  38. astrapi_sync-26.8.1/astrapi_sync.egg-info/requires.txt +10 -0
  39. astrapi_sync-26.8.1/astrapi_sync.egg-info/scm_file_list.json +38 -0
  40. astrapi_sync-26.8.1/astrapi_sync.egg-info/scm_version.json +8 -0
  41. astrapi_sync-26.8.1/astrapi_sync.egg-info/top_level.txt +1 -0
  42. astrapi_sync-26.8.1/pyproject.toml +55 -0
  43. astrapi_sync-26.8.1/requirements.txt +37 -0
  44. astrapi_sync-26.8.1/setup.cfg +4 -0
@@ -0,0 +1,89 @@
1
+ # astrapi-sync – Projektkontext für GitHub Copilot
2
+
3
+ Wird im Repo versioniert und von VS Code Copilot automatisch geladen.
4
+
5
+ ---
6
+
7
+ ## Was ist astrapi-sync?
8
+
9
+ Persönlicher Datei-Synchronisationsdienst nach dem Vorbild von Syncthing/Nextcloud
10
+ (Server + CLI-Client, später GTK4/Android). Nur Michaels eigene Geräte (Auth über
11
+ Geräte-Token statt Accounts), Block-/Delta-Sync (Syncthing-Stil, fixe Blockgröße,
12
+ SHA256 je Block), Änderungs-Push per WebSocket statt Polling.
13
+ Basiert auf **astrapi-core** (FastAPI + HTMX + Jinja2).
14
+ PyPI-Paketname: `astrapi-sync`, Python-Paket: `astrapi_sync`.
15
+
16
+ Zugehöriges Client-Repo: [astrapi-sync-client](https://github.com/astrapi/astrapi-sync-client)
17
+ (geteilte Sync-Engine + CLI, kein astrapi-core-Abhängigkeit).
18
+
19
+ ---
20
+
21
+ ## Stack
22
+
23
+ | Komponente | Details |
24
+ |---|---|
25
+ | Framework | astrapi-core (FastAPI + HTMX) |
26
+ | API | FastAPI (`/api/...`) |
27
+ | UI | FastAPI + HTMX + Jinja2 (`/ui/...`) |
28
+ | Persistenz | SQLite (über astrapi-core) |
29
+ | Auth (Sync-API) | Bearer-Geräte-Token, SHA256-gehasht gespeichert |
30
+ | Python | ≥ 3.11 |
31
+
32
+ ---
33
+
34
+ ## Verzeichnisstruktur
35
+
36
+ ```
37
+ astrapi_sync/
38
+ ├── _app.py # ASGI-App-Factory (uvicorn-Einstiegspunkt)
39
+ ├── _cli.py # Console-Script: astrapi-sync
40
+ ├── _paths.py # package_dir(), work_dir(), folder_path(), extra_disk_options()
41
+ ├── app.yaml # name: astrapi-sync, display_name: Sync
42
+ ├── navigation.yaml # App-Navigation
43
+ ├── api/
44
+ │ ├── fastapi_app.py # FastAPI-Factory, Router-Registrierung
45
+ │ ├── auth.py # Geräte-Token-Auth (require_device, require_device_only)
46
+ │ ├── block_hash.py # Block-Hashing, Datei-/Verzeichnis-Index
47
+ │ ├── sync.py # Pairing, Datei-Index, Upload/Download/Delete, WebSocket-Push
48
+ │ └── ws_manager.py # In-Process-WebSocket-Verbindungsmanager je Ordner
49
+ └── modules/
50
+ ├── folders/ # Sync-Ordner (Admin-CRUD, Speicherort, Dateibrowser-Modal)
51
+ └── devices/ # Gepairte Geräte, Pairing-Flow, Reconnect
52
+ ```
53
+
54
+ ---
55
+
56
+ ## App-Start
57
+
58
+ ```
59
+ astrapi-sync --work-dir /opt/astrapi-sync --port 5004
60
+ ```
61
+
62
+ - `_configure_paths("astrapi-sync")` setzt Runtime-Pfade
63
+ - `configure_updater(_pkg)` registriert `astrapi-sync` + `astrapi-core` im Updater
64
+ - Sync-Ordner liegen standardmäßig unter `<work-dir>/folders/<id>/`, oder auf einem
65
+ konfigurierten Zusatzspeicher (`extra_disks`-Systemeinstellung) unter
66
+ `<speicherort>/astrapi-sync/<id>/` — siehe `_paths.py::folder_path()`.
67
+
68
+ ---
69
+
70
+ ## Sync-Protokoll (Kurzüberblick)
71
+
72
+ - `POST /api/sync/pair` — Pairing-Token → langlebiges Geräte-Token (Bearer).
73
+ - `GET /api/sync/folders/{id}/index` — Datei- und Verzeichnis-Index
74
+ (`build_index()`/`build_dir_index()` in `block_hash.py`).
75
+ - `POST/GET /api/sync/folders/{id}/files/{path}` — Block-Delta-Upload/Download.
76
+ - `DELETE /api/sync/folders/{id}/files/{path}` — Löschung propagieren.
77
+ - `POST/DELETE /api/sync/folders/{id}/dirs/{path}` — leere Verzeichnisse anlegen/entfernen
78
+ (nicht-leere entstehen implizit über Datei-Pfade).
79
+ - `WS /api/sync/folders/{id}/events` — Push bei Änderungen an alle verbundenen Geräte.
80
+
81
+ Kein rsync-Rolling-Hash, kein Blob-Store: reale Verzeichnisbäume auf Platte,
82
+ Block-Hashes nur als Übertragungstechnik.
83
+
84
+ ---
85
+
86
+ ## Versionsschema
87
+
88
+ CalVer: `YY.MM.patch.devN` – gesteuert via setuptools-scm + Git-Tags.
89
+ Release über `astrapi-tools/release.sh sync` (Tag `v*` triggert `publish.yml` → PyPI).
@@ -0,0 +1,31 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ id-token: write
13
+
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ with:
17
+ fetch-depth: 0 # nötig für setuptools-scm
18
+
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.12"
22
+
23
+ - name: Build
24
+ run: |
25
+ pip install --quiet build
26
+ python -m build --wheel --sdist
27
+
28
+ - name: Publish to PyPI
29
+ uses: pypa/gh-action-pypi-publish@release/v1
30
+ with:
31
+ password: ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .venv/
5
+ var/
6
+ app.db
7
+ pip_*.txt
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: astrapi-sync
3
+ Version: 26.8.1
4
+ Summary: astrapi-sync – Ordner-Synchronisation über mehrere Geräte
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: fastapi==0.141.1
7
+ Requires-Dist: uvicorn[standard]==0.52.4
8
+ Requires-Dist: jinja2==3.1.6
9
+ Requires-Dist: APScheduler==3.11.3
10
+ Requires-Dist: pyyaml==6.0.3
11
+ Requires-Dist: python-multipart==0.0.32
12
+ Requires-Dist: cryptography==50.0.0
13
+ Requires-Dist: psutil==7.2.2
14
+ Requires-Dist: apispec==6.10.0
15
+ Requires-Dist: astrapi-core>=26.8.21
@@ -0,0 +1,66 @@
1
+ # astrapi-sync
2
+
3
+ Ordner-Synchronisation über mehrere Geräte (Server-Komponente) — nach dem
4
+ Vorbild von Syncthing/Nextcloud/Ubuntu One, auf `astrapi-core` aufgebaut.
5
+
6
+ Ein Ordner wird auf dem Server angelegt (Modul „Ordner", inkl. Wahl des
7
+ Speicherorts und Dateibrowser); Geräte (CLI-Client — GTK4-/Android-App
8
+ folgen) werden per Pairing-Code gekoppelt (Modul „Geräte") und verbinden
9
+ sich dann mit einem lokalen Ordner.
10
+
11
+ Block-/Delta-Sync (Syncthing-Stil, fixe Blockgröße, SHA256 je Block),
12
+ Änderungs-Push per WebSocket statt Polling, reale Verzeichnisbäume auf
13
+ Platte statt Blob-Store. Nur Michaels eigene Geräte — Auth über
14
+ Geräte-Token statt Accounts.
15
+
16
+ Zugehöriges Client-Repo: [astrapi-sync-client](https://github.com/astrapi/astrapi-sync-client).
17
+
18
+ ## Stack
19
+
20
+ | Komponente | Details |
21
+ |---|---|
22
+ | Framework | astrapi-core (FastAPI + HTMX) |
23
+ | Persistenz | SQLite |
24
+ | Auth (Sync-API) | Bearer-Geräte-Token, SHA256-gehasht gespeichert |
25
+ | Python | ≥ 3.11 |
26
+
27
+ ## Setup (Entwicklung)
28
+
29
+ ```bash
30
+ python -m venv .venv
31
+ source .venv/bin/activate
32
+ pip install -e ../astrapi-core # Schwesterprojekt
33
+ pip install -e .
34
+ ```
35
+
36
+ ## Starten
37
+
38
+ ```bash
39
+ astrapi-sync --work-dir ./data --port 5004 --debug
40
+ ```
41
+
42
+ | Parameter | Standard | Beschreibung |
43
+ |---|---|---|
44
+ | `--work-dir` | (Pflicht) | Datenpfad für SQLite-DB, Sync-Ordner und Laufzeitdaten |
45
+ | `--port` | `5004` | HTTP-Port |
46
+ | `--host` | `0.0.0.0` | Bind-Adresse |
47
+ | `--debug` / `--reload` | – | Auto-Reload bei Dateiänderungen |
48
+
49
+ Die Web-Oberfläche ist danach erreichbar unter: `http://localhost:5004`
50
+
51
+ ## Projektstruktur
52
+
53
+ ```
54
+ astrapi_sync/
55
+ ├── _cli.py # Einstiegspunkt (CLI)
56
+ ├── _app.py # ASGI-App-Factory
57
+ ├── _paths.py # Pfad-Utilities (Speicherort je Ordner)
58
+ ├── api/
59
+ │ ├── auth.py # Geräte-Token-Auth
60
+ │ ├── block_hash.py # Block-Hashing, Datei-/Verzeichnis-Index
61
+ │ ├── sync.py # Pairing, Index, Upload/Download/Delete, WebSocket
62
+ │ └── ws_manager.py # Verbindungsmanager je Ordner
63
+ └── modules/
64
+ ├── folders/ # Sync-Ordner (Admin-CRUD, Speicherort, Dateibrowser)
65
+ └── devices/ # Gepairte Geräte, Pairing-Flow
66
+ ```
File without changes
@@ -0,0 +1,89 @@
1
+ """astrapi_sync._app – ASGI-App-Factory.
2
+
3
+ Start:
4
+ uvicorn astrapi_sync._app:app
5
+ astrapi-sync --work-dir /opt/astrapi-sync --port 5004
6
+ """
7
+
8
+ import time
9
+
10
+ from astrapi_core.system.paths import configure as _configure_paths
11
+
12
+ _configure_paths("astrapi-sync")
13
+
14
+ from astrapi_core.modules.settings.engine import configure as configure_settings
15
+ from astrapi_core.modules.system.engine import configure_updater
16
+ from astrapi_core.system.health import register_health
17
+ from astrapi_core.system.systemd import sd_notify, start_watchdog
18
+ from astrapi_core.system.version import get_display_name
19
+ from astrapi_core.ui import create as create_ui
20
+ from astrapi_core.ui.module_registry import load_modules
21
+ from astrapi_core.ui.settings_registry import init as settings_init
22
+ from fastapi import FastAPI
23
+ from fastapi.staticfiles import StaticFiles
24
+
25
+ from astrapi_sync._paths import db_path, package_dir, work_dir
26
+ from astrapi_sync.api.fastapi_app import create as create_api
27
+
28
+ _START_TIME = time.time()
29
+
30
+
31
+ def _db_check() -> tuple[bool, dict]:
32
+ from astrapi_core.system.db import _conn
33
+
34
+ try:
35
+ _conn().execute("SELECT 1").fetchone()
36
+ return True, {"db": True}
37
+ except Exception:
38
+ return False, {"db": False}
39
+
40
+
41
+ def _migrate_folders_storage_location() -> None:
42
+ """register_table()'s DDL ist CREATE TABLE IF NOT EXISTS -- legt bei
43
+ bereits bestehender Tabelle keine neuen Spalten nach. storage_location
44
+ kam nachträglich dazu (T-202-SYNC), hier per ALTER TABLE ergänzt."""
45
+ from astrapi_core.system.db import _conn
46
+
47
+ con = _conn()
48
+ try:
49
+ cols = [r[1] for r in con.execute("PRAGMA table_info(folders)")]
50
+ if "storage_location" not in cols:
51
+ con.execute("ALTER TABLE folders ADD COLUMN storage_location TEXT NOT NULL DEFAULT ''")
52
+ con.commit()
53
+ except Exception:
54
+ pass
55
+
56
+
57
+ def create_app() -> FastAPI:
58
+ _pkg = package_dir()
59
+ configure_settings(health_fn=_db_check, app_name=get_display_name(_pkg))
60
+ configure_updater(_pkg)
61
+
62
+ from astrapi_core.system.db import configure as _configure_db
63
+ from astrapi_core.system.db import create_all_registered_tables
64
+
65
+ _configure_db(db_path())
66
+ create_all_registered_tables()
67
+ _migrate_folders_storage_location()
68
+
69
+ settings_init(work_dir())
70
+
71
+ modules, _ = load_modules(_pkg)
72
+ api = create_api(modules=modules)
73
+
74
+ from pathlib import Path
75
+
76
+ import astrapi_core.ui
77
+
78
+ core_static = Path(astrapi_core.ui.__file__).parent / "static"
79
+ api.mount("/static", StaticFiles(directory=str(core_static)), name="static")
80
+
81
+ create_ui(api, app_root=_pkg, modules=modules)
82
+
83
+ register_health(api, check_fn=_db_check, start_time=_START_TIME)
84
+ start_watchdog(check_fn=lambda: _db_check()[0])
85
+ sd_notify("READY=1")
86
+ return api
87
+
88
+
89
+ app = create_app()
@@ -0,0 +1,15 @@
1
+ """astrapi_sync._cli – Console-Script-Einstiegspunkt.
2
+
3
+ Start:
4
+ astrapi-sync --work-dir /opt/astrapi-sync --port 5004
5
+ astrapi-sync --work-dir /opt/astrapi-sync --port 5004 --debug
6
+ """
7
+ from astrapi_core.system.paths import run_app
8
+
9
+
10
+ def main() -> None:
11
+ run_app("astrapi_sync._app:app", "astrapi-sync", default_port=5004)
12
+
13
+
14
+ if __name__ == "__main__":
15
+ main()
@@ -0,0 +1,51 @@
1
+ # astrapi_sync/_paths.py
2
+ from pathlib import Path
3
+
4
+ from astrapi_core.system.paths import db_path, log_dir, work_dir # noqa: F401 – re-export
5
+
6
+
7
+ def package_dir() -> Path:
8
+ """Pfad zum installierten Package – für app.yaml, Templates, Modul-YAMLs."""
9
+ return Path(__file__).resolve().parent
10
+
11
+
12
+ def extra_disk_options() -> list[str]:
13
+ """Alle konfigurierten Zusatzspeicher-Pfade (Einstellungen -> System ->
14
+ "Zusätzlicher Speicher", core-weites Setting system.extra_disks).
15
+
16
+ get_module() liefert für type:list-Settings eine echte Python-Liste
17
+ zurück (astrapi_mirror._paths::_extra_disk() geht von einem
18
+ Komma-String aus und würde bei einer echten Liste mit AttributeError
19
+ abstürzen -- hier bewusst robust gegen beide Formen).
20
+ """
21
+ from astrapi_core.ui.settings_registry import get_module
22
+
23
+ raw = get_module("system", "extra_disks", default=[]) or []
24
+ if isinstance(raw, str):
25
+ raw = raw.split(",")
26
+ return [p.strip() for p in raw if p and p.strip()]
27
+
28
+
29
+ def folder_base(storage_location: str) -> Path:
30
+ """Wurzelverzeichnis für einen gegebenen Speicherort ("" = Standard,
31
+ sonst einer der konfigurierten Zusatzspeicher-Pfade)."""
32
+ if storage_location:
33
+ return Path(storage_location).resolve() / "astrapi-sync"
34
+ return work_dir().resolve() / "folders"
35
+
36
+
37
+ def folder_path(folder_id) -> Path:
38
+ """Plattenpfad eines einzelnen Sync-Ordners -- abhängig von dessen
39
+ eigenem storage_location-Feld (nicht global), legt ihn bei Bedarf an.
40
+
41
+ Ordner werden nach ihrer numerischen ID benannt (nicht nach dem
42
+ änderbaren Namen) -- stabil auch wenn der Anzeigename später geändert
43
+ wird.
44
+ """
45
+ from astrapi_sync.modules.folders.ui.crud import store as folders_store
46
+
47
+ folder = folders_store.get(str(folder_id)) or {}
48
+ location = folder.get("storage_location") or ""
49
+ p = folder_base(location) / str(folder_id)
50
+ p.mkdir(parents=True, exist_ok=True)
51
+ return p
File without changes
@@ -0,0 +1,71 @@
1
+ # astrapi_sync/api/auth.py
2
+ """Geräte-Authentifizierung für die Sync-API.
3
+
4
+ astrapi-core kennt keinerlei Auth-Konzept (kein Login, keine Sessions,
5
+ keine API-Keys) -- komplett neu für dieses Projekt gebaut. Ein Gerät
6
+ weist sich mit einem Bearer-Token aus (`Authorization: Bearer <token>`),
7
+ das beim Pairing einmalig im Klartext ausgegeben und seither nur noch als
8
+ SHA256-Hash gespeichert wird (wie ein Passwort).
9
+ """
10
+ import hashlib
11
+ import time
12
+
13
+ from fastapi import Header, HTTPException, Path
14
+
15
+
16
+ def hash_token(token: str) -> str:
17
+ return hashlib.sha256(token.encode()).hexdigest()
18
+
19
+
20
+ def get_device_by_token(token: str) -> tuple[str, dict] | None:
21
+ from astrapi_sync.modules.devices.ui.crud import store as devices_store
22
+
23
+ token_hash = hash_token(token)
24
+ for device_id, device in devices_store.list().items():
25
+ if device.get("token_hash") == token_hash:
26
+ return device_id, device
27
+ return None
28
+
29
+
30
+ def authenticate(token: str) -> tuple[str, dict]:
31
+ """Prüft ein rohes Token (ohne "Bearer "-Präfix), aktualisiert last_seen.
32
+
33
+ Gibt (device_id, device_dict) zurück oder wirft HTTPException.
34
+ """
35
+ from astrapi_sync.modules.devices.ui.crud import store as devices_store
36
+
37
+ found = get_device_by_token(token)
38
+ if found is None:
39
+ raise HTTPException(401, "Ungültiges Geräte-Token")
40
+ device_id, device = found
41
+ if not device.get("enabled", True):
42
+ raise HTTPException(403, "Gerät ist deaktiviert")
43
+
44
+ devices_store.update(device_id, {"last_seen": time.strftime("%Y-%m-%d %H:%M:%S")})
45
+ return device_id, device
46
+
47
+
48
+ def require_device_only(authorization: str = Header(default="")) -> tuple[str, dict]:
49
+ """Dependency für Endpunkte ohne folder_id (z.B. GET /api/sync/folders)."""
50
+ if not authorization.startswith("Bearer "):
51
+ raise HTTPException(401, "Kein Geräte-Token angegeben")
52
+ return authenticate(authorization.removeprefix("Bearer ").strip())
53
+
54
+
55
+ def require_device(
56
+ folder_id: str = Path(...),
57
+ authorization: str = Header(default=""),
58
+ ) -> tuple[str, dict]:
59
+ """Dependency für Sync-Endpunkte unter /api/sync/folders/{folder_id}/...
60
+
61
+ Gibt (device_id, device_dict) zurück, wenn das Token gültig ist, das
62
+ Gerät aktiviert ist und Zugriff auf diesen Ordner hat.
63
+ """
64
+ if not authorization.startswith("Bearer "):
65
+ raise HTTPException(401, "Kein Geräte-Token angegeben")
66
+ token = authorization.removeprefix("Bearer ").strip()
67
+
68
+ device_id, device = authenticate(token)
69
+ if folder_id not in (device.get("folder_ids") or []):
70
+ raise HTTPException(403, "Gerät hat keinen Zugriff auf diesen Ordner")
71
+ return device_id, device
@@ -0,0 +1,73 @@
1
+ # astrapi_sync/api/block_hash.py
2
+ """Block-Hashing für Delta-Sync.
3
+
4
+ Syncthing-Stil: feste Blockgröße, SHA256 je Block, Vergleich per Position
5
+ -- kein rsync-Rolling-Hash (der auch Byte-Verschiebungen mitten in der
6
+ Datei erkennen würde, dafür deutlich komplexer ist). Bewusste
7
+ Vereinfachung, siehe Architektur-Abschnitt im Plan.
8
+
9
+ Kein persistenter Hash-Cache in dieser Phase -- Hashes werden bei jeder
10
+ Index-Anfrage neu berechnet. Für sehr große, selten geänderte Dateien
11
+ später ggf. über (Pfad, mtime, Größe) cachen.
12
+ """
13
+ import hashlib
14
+ from pathlib import Path
15
+
16
+ DEFAULT_BLOCK_SIZE = 1 << 20 # 1 MiB
17
+
18
+
19
+ def hash_blocks(path: Path, block_size: int = DEFAULT_BLOCK_SIZE) -> list[str]:
20
+ """Liest eine Datei blockweise, gibt die SHA256-Hashes je Block zurück."""
21
+ hashes = []
22
+ with open(path, "rb") as f:
23
+ while True:
24
+ chunk = f.read(block_size)
25
+ if not chunk:
26
+ break
27
+ hashes.append(hashlib.sha256(chunk).hexdigest())
28
+ return hashes
29
+
30
+
31
+ def whole_file_hash(path: Path) -> str:
32
+ h = hashlib.sha256()
33
+ with open(path, "rb") as f:
34
+ for chunk in iter(lambda: f.read(1 << 20), b""):
35
+ h.update(chunk)
36
+ return h.hexdigest()
37
+
38
+
39
+ def file_entry(path: Path, rel_path: str, block_size: int = DEFAULT_BLOCK_SIZE) -> dict:
40
+ stat = path.stat()
41
+ return {
42
+ "path": rel_path,
43
+ "size": stat.st_size,
44
+ "mtime": stat.st_mtime,
45
+ "block_size": block_size,
46
+ "sha256": whole_file_hash(path),
47
+ "blocks": hash_blocks(path, block_size),
48
+ }
49
+
50
+
51
+ def build_index(root: Path, block_size: int = DEFAULT_BLOCK_SIZE) -> list[dict]:
52
+ """Läuft den Ordnerbaum ab, gibt eine Liste von file_entry()-Dicts zurück."""
53
+ entries = []
54
+ for p in sorted(root.rglob("*")):
55
+ if not p.is_file():
56
+ continue
57
+ entries.append(file_entry(p, p.relative_to(root).as_posix(), block_size))
58
+ return entries
59
+
60
+
61
+ def build_dir_index(root: Path) -> list[str]:
62
+ """Relative Pfade aller (rekursiv) leeren Verzeichnisse.
63
+
64
+ Ein Verzeichnis, das irgendwo in seinem Baum eine Datei enthält, wird
65
+ schon implizit durch deren Pfad mitsynchronisiert (upload_file() legt
66
+ fehlende Elternverzeichnisse an) -- nur komplett leere Verzeichnisse
67
+ tauchen sonst nirgends im Index auf und würden nie propagiert.
68
+ """
69
+ dirs = []
70
+ for p in sorted(root.rglob("*")):
71
+ if p.is_dir() and not any(f.is_file() for f in p.rglob("*")):
72
+ dirs.append(p.relative_to(root).as_posix())
73
+ return dirs
@@ -0,0 +1,28 @@
1
+ """astrapi_sync.api.fastapi_app – FastAPI-Factory."""
2
+ from fastapi import FastAPI
3
+ from astrapi_core.system.version import get_app_version
4
+
5
+ from astrapi_sync._paths import package_dir
6
+
7
+ APP_ROOT = package_dir()
8
+
9
+
10
+ def create(modules: list | None = None) -> FastAPI:
11
+ _version = get_app_version(APP_ROOT, default="0.0.1")
12
+ app = FastAPI(
13
+ title="astrapi-sync API",
14
+ version=_version,
15
+ docs_url="/api/docs",
16
+ redoc_url="/api/redoc",
17
+ openapi_url="/api/openapi.json",
18
+ )
19
+
20
+ from astrapi_core.ui.module_registry import load_modules, register_fastapi_modules
21
+ if modules is None:
22
+ modules, _ = load_modules(APP_ROOT)
23
+ register_fastapi_modules(app, modules)
24
+
25
+ from astrapi_sync.api.sync import router as sync_router
26
+ app.include_router(sync_router)
27
+
28
+ return app