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.
- astrapi_sync-26.8.1/.github/copilot-instructions.md +89 -0
- astrapi_sync-26.8.1/.github/workflows/publish.yml +31 -0
- astrapi_sync-26.8.1/.gitignore +7 -0
- astrapi_sync-26.8.1/PKG-INFO +15 -0
- astrapi_sync-26.8.1/README.md +66 -0
- astrapi_sync-26.8.1/astrapi_sync/__init__.py +0 -0
- astrapi_sync-26.8.1/astrapi_sync/_app.py +89 -0
- astrapi_sync-26.8.1/astrapi_sync/_cli.py +15 -0
- astrapi_sync-26.8.1/astrapi_sync/_paths.py +51 -0
- astrapi_sync-26.8.1/astrapi_sync/api/__init__.py +0 -0
- astrapi_sync-26.8.1/astrapi_sync/api/auth.py +71 -0
- astrapi_sync-26.8.1/astrapi_sync/api/block_hash.py +73 -0
- astrapi_sync-26.8.1/astrapi_sync/api/fastapi_app.py +28 -0
- astrapi_sync-26.8.1/astrapi_sync/api/sync.py +297 -0
- astrapi_sync-26.8.1/astrapi_sync/api/ws_manager.py +44 -0
- astrapi_sync-26.8.1/astrapi_sync/app.yaml +3 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/devices/__init__.py +48 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/devices/config/modul.yaml +7 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/devices/config/schema.yaml +30 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/devices/dialogs/pair/modal.html +41 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/devices/pairing_store.py +48 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/devices/ui/__init__.py +1 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/devices/ui/crud.py +33 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/devices/ui/pairing.py +49 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/folders/__init__.py +49 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/folders/config/modul.yaml +7 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/folders/config/schema.yaml +25 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/folders/dialogs/files/browse.html +142 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/folders/dialogs/files/modal.html +31 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/folders/ui/__init__.py +1 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/folders/ui/crud.py +118 -0
- astrapi_sync-26.8.1/astrapi_sync/modules/folders/ui/files.py +122 -0
- astrapi_sync-26.8.1/astrapi_sync/navigation.yaml +7 -0
- astrapi_sync-26.8.1/astrapi_sync.egg-info/PKG-INFO +15 -0
- astrapi_sync-26.8.1/astrapi_sync.egg-info/SOURCES.txt +42 -0
- astrapi_sync-26.8.1/astrapi_sync.egg-info/dependency_links.txt +1 -0
- astrapi_sync-26.8.1/astrapi_sync.egg-info/entry_points.txt +2 -0
- astrapi_sync-26.8.1/astrapi_sync.egg-info/requires.txt +10 -0
- astrapi_sync-26.8.1/astrapi_sync.egg-info/scm_file_list.json +38 -0
- astrapi_sync-26.8.1/astrapi_sync.egg-info/scm_version.json +8 -0
- astrapi_sync-26.8.1/astrapi_sync.egg-info/top_level.txt +1 -0
- astrapi_sync-26.8.1/pyproject.toml +55 -0
- astrapi_sync-26.8.1/requirements.txt +37 -0
- 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,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
|