From 08b3416c3d73d0a4c4a24fc7925b8f2d5ba010e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 16:51:51 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20NAS=20WebDAV=20sync=20=E2=80=94=20pe?= =?UTF-8?q?rsist=20config,=20push=20notes=20without=20notebook,=20MKCOL=20?= =?UTF-8?q?405?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs prevented notes from syncing to the NAS: 1. MKCOL returned 405 (already exists) on second sync → treated as failure. Now 405 is accepted as success (WebDAV standard for existing resource). 2. Notes not assigned to a notebook were silently skipped during push. They now sync into a 'sans-carnet' folder on the remote server. 3. Sync credentials were in-memory only — lost on every backend restart. Now persisted to data_dir/sync_config.json and reloaded on startup. Also added bilingual (EN/FR) module docstrings per project convention. https://claude.ai/code/session_01DrMeeXsyuHucgvtcwtJoLN --- nexanote/api/routes.py | 49 ++++++++++++++++++++++++++++++++--------- nexanote/sync/client.py | 35 +++++++++++++++-------------- 2 files changed, 57 insertions(+), 27 deletions(-) diff --git a/nexanote/api/routes.py b/nexanote/api/routes.py index 5ec0d3c..c6bfb3c 100644 --- a/nexanote/api/routes.py +++ b/nexanote/api/routes.py @@ -1,13 +1,18 @@ """ -NexaNote — API REST (FastAPI) -Interface HTTP que l'app Flutter consomme pour toutes les opérations. - -Routes : - GET /health → statut du serveur - GET /notebooks → liste des carnets - POST /notebooks → créer un carnet - GET /notebooks/{id} → détails d'un carnet - PUT /notebooks/{id} → modifier un carnet +REST API / Interface REST FastAPI + +EN: HTTP interface consumed by the Flutter app for all data operations. + Runs on port 8766 alongside the WebDAV server (port 8765). + +FR: Interface HTTP consommée par l'app Flutter pour toutes les opérations. + Tourne sur le port 8766 en parallèle du serveur WebDAV (port 8765). + +Routes: + GET /health → server status / statut du serveur + GET /notebooks → list notebooks / liste des carnets + POST /notebooks → create notebook / créer un carnet + GET /notebooks/{id} → notebook detail / détails d'un carnet + PUT /notebooks/{id} → update notebook / modifier un carnet DELETE /notebooks/{id} → supprimer un carnet GET /notes → liste des notes (filtrable) @@ -255,9 +260,21 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) - # État sync + # EN: Sync state — config persisted to data_dir/sync_config.json so it + # survives backend restarts (no need to re-enter credentials each time). + # FR: État de sync — la config est persistée dans sync_config.json pour + # survivre aux redémarrages du backend. _last_sync_report: dict = {} _sync_config: dict = {} + _sync_config_path = db.db_path.parent / "sync_config.json" + + if _sync_config_path.exists(): + try: + import json as _json + _sync_config.update(_json.loads(_sync_config_path.read_text())) + logger.info(f"Sync config loaded from {_sync_config_path}") + except Exception as exc: + logger.warning(f"Could not load sync config: {exc}") # ------------------------------------------------------------------ # Health @@ -497,8 +514,18 @@ def update_text(note_id: str, page_num: int, data: TextUpdateSchema): @app.post("/sync/configure") def configure_sync(config: SyncConfigSchema): - """Configure les paramètres de connexion WebDAV.""" + """ + EN: Save WebDAV connection settings and persist them to disk. + FR: Sauvegarde les paramètres WebDAV et les persiste sur disque. + """ _sync_config.update(config.model_dump()) + # EN: Persist to disk so credentials survive a backend restart. + # FR: Persiste sur disque pour survivre aux redémarrages du backend. + try: + import json as _json + _sync_config_path.write_text(_json.dumps(_sync_config, indent=2)) + except Exception as exc: + logger.warning(f"Could not persist sync config: {exc}") return {"status": "configured", "server_url": config.server_url} @app.post("/sync/trigger", response_model=SyncReportSchema) diff --git a/nexanote/sync/client.py b/nexanote/sync/client.py index 5fd0153..730a9c3 100644 --- a/nexanote/sync/client.py +++ b/nexanote/sync/client.py @@ -1,14 +1,13 @@ """ -NexaNote — Client de synchronisation WebDAV -Tourne sur l'appareil (Android/Linux/Windows), compare les notes -locales avec le serveur et synchronise intelligemment. - -Flux de sync : - 1. PULL — récupérer les changements du serveur - 2. DIFF — comparer avec la version locale - 3. RESOLVE — résoudre les conflits - 4. PUSH — envoyer les notes locales modifiées - 5. COMMIT — marquer tout comme SYNCED +WebDAV Sync Client / Client de synchronisation WebDAV + +EN: Runs on the device (Linux/Android/Windows). Compares local notes with a + remote WebDAV server and synchronises them intelligently. + Sync flow: PULL → DIFF → RESOLVE CONFLICTS → PUSH → COMMIT (mark SYNCED) + +FR: Tourne sur l'appareil. Compare les notes locales avec un serveur WebDAV + distant et les synchronise intelligemment. + Flux : PULL → DIFF → RÉSOUDRE LES CONFLITS → PUSH → COMMIT (marquer SYNCED) """ from __future__ import annotations @@ -212,7 +211,8 @@ def create_notebook_dir(self, notebook_slug: str) -> bool: resp = self.session.request( "MKCOL", url, timeout=self.config.timeout_seconds ) - return resp.status_code in (200, 201) + # 200/201 = créé, 405 = la ressource existe déjà (WebDAV standard) + return resp.status_code in (200, 201, 405) except requests.RequestException as e: logger.error(f"MKCOL échoué ({url}): {e}") return False @@ -224,7 +224,8 @@ def create_note_dir(self, notebook_slug: str, note_slug: str) -> bool: resp = self.session.request( "MKCOL", url, timeout=self.config.timeout_seconds ) - return resp.status_code in (200, 201) + # 200/201 = créé, 405 = la ressource existe déjà (WebDAV standard) + return resp.status_code in (200, 201, 405) except requests.RequestException as e: logger.error(f"MKCOL note échoué ({url}): {e}") return False @@ -594,11 +595,13 @@ def _push_note(self, note: Note, report: SyncReport) -> None: if note.notebook_id: notebook = self.db.get_notebook(note.notebook_id) - if not notebook: - logger.warning(f"Note {note.title} sans carnet — skip push") - return + if notebook: + nb_slug = _notebook_to_slug(notebook) + else: + # Notes sans carnet → dossier "sans-carnet" sur le serveur + nb_slug = "sans-carnet" + logger.debug(f"Note {note.title!r} sans carnet → dossier '{nb_slug}'") - nb_slug = _notebook_to_slug(notebook) note_slug = _note_to_slug(note) # Créer le dossier carnet sur le serveur si nécessaire From c2b73d7352672d2fb22bb55f86ebbec6f11a0617 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 17:01:57 +0000 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20security,=20exceptions,=20DRY,=20constant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security (sync_config.json): - Password is no longer persisted to disk (only kept in memory) - Only non-sensitive fields are written: server_url, username, conflict_strategy - File permissions set to 0o600 (owner read/write only) - Behavior clearly documented in docstrings (EN + FR) Exception handling: - 'except Exception' narrowed to (OSError, json.JSONDecodeError, ValueError) when loading sync config from disk - Write errors narrowed to OSError DRY — MKCOL status: - Extracted WebDAVClient._is_mkcol_success(status_code) static method - Both create_notebook_dir() and create_note_dir() now use the helper - RFC 4918 behaviour documented in the method docstring (EN + FR) Constant: - Hardcoded "sans-carnet" replaced by module-level DEFAULT_NOTEBOOK_SLUG = "uncategorized" https://claude.ai/code/session_01DrMeeXsyuHucgvtcwtJoLN --- nexanote/api/routes.py | 47 +++++++++++++++++++++++++++-------------- nexanote/sync/client.py | 47 ++++++++++++++++++++++++++--------------- 2 files changed, 61 insertions(+), 33 deletions(-) diff --git a/nexanote/api/routes.py b/nexanote/api/routes.py index c6bfb3c..8041ebd 100644 --- a/nexanote/api/routes.py +++ b/nexanote/api/routes.py @@ -36,7 +36,9 @@ from __future__ import annotations +import json import logging +import os from contextlib import asynccontextmanager from datetime import datetime, timezone from pathlib import Path @@ -260,20 +262,37 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) - # EN: Sync state — config persisted to data_dir/sync_config.json so it - # survives backend restarts (no need to re-enter credentials each time). - # FR: État de sync — la config est persistée dans sync_config.json pour - # survivre aux redémarrages du backend. + # EN: Non-sensitive sync fields that are safe to write to disk. + # Password is intentionally excluded — never persisted in plain text. + # FR: Champs de sync non-sensibles sûrs à écrire sur disque. + # Le mot de passe est exclu intentionnellement — jamais écrit en clair. + _PERSIST_FIELDS = {"server_url", "username", "conflict_strategy"} + _last_sync_report: dict = {} _sync_config: dict = {} _sync_config_path = db.db_path.parent / "sync_config.json" + def _save_sync_config_to_disk() -> None: + """ + EN: Persist non-sensitive sync fields to disk. + File permissions are set to 0o600 (owner read/write only). + Password is never written — user must re-enter it after a restart. + FR: Persiste les champs non-sensibles sur disque. + Permissions du fichier : 0o600 (lecture/écriture propriétaire uniquement). + Le mot de passe n'est jamais écrit — l'utilisateur doit le re-saisir après redémarrage. + """ + safe = {k: v for k, v in _sync_config.items() if k in _PERSIST_FIELDS} + try: + _sync_config_path.write_text(json.dumps(safe, indent=2)) + os.chmod(_sync_config_path, 0o600) + except OSError as exc: + logger.warning(f"Could not persist sync config: {exc}") + if _sync_config_path.exists(): try: - import json as _json - _sync_config.update(_json.loads(_sync_config_path.read_text())) + _sync_config.update(json.loads(_sync_config_path.read_text())) logger.info(f"Sync config loaded from {_sync_config_path}") - except Exception as exc: + except (OSError, json.JSONDecodeError, ValueError) as exc: logger.warning(f"Could not load sync config: {exc}") # ------------------------------------------------------------------ @@ -515,17 +534,13 @@ def update_text(note_id: str, page_num: int, data: TextUpdateSchema): @app.post("/sync/configure") def configure_sync(config: SyncConfigSchema): """ - EN: Save WebDAV connection settings and persist them to disk. - FR: Sauvegarde les paramètres WebDAV et les persiste sur disque. + EN: Save WebDAV connection settings in memory and persist safe fields to disk. + The password is kept in memory only and is never written to disk. + FR: Sauvegarde les paramètres WebDAV en mémoire et persiste les champs + sûrs sur disque. Le mot de passe reste en mémoire uniquement. """ _sync_config.update(config.model_dump()) - # EN: Persist to disk so credentials survive a backend restart. - # FR: Persiste sur disque pour survivre aux redémarrages du backend. - try: - import json as _json - _sync_config_path.write_text(_json.dumps(_sync_config, indent=2)) - except Exception as exc: - logger.warning(f"Could not persist sync config: {exc}") + _save_sync_config_to_disk() return {"status": "configured", "server_url": config.server_url} @app.post("/sync/trigger", response_model=SyncReportSchema) diff --git a/nexanote/sync/client.py b/nexanote/sync/client.py index 730a9c3..5e12901 100644 --- a/nexanote/sync/client.py +++ b/nexanote/sync/client.py @@ -30,6 +30,10 @@ logger = logging.getLogger("nexanote.sync.client") +# EN: Fallback folder name on the remote server for notes not assigned to any notebook. +# FR: Nom du dossier de repli sur le serveur distant pour les notes sans carnet. +DEFAULT_NOTEBOOK_SLUG = "uncategorized" + # --------------------------------------------------------------------------- # Config @@ -122,6 +126,20 @@ def _url(self, *parts: str) -> str: path = "/".join(quote(p, safe="") for p in parts if p) return urljoin(self.base_url, path) + @staticmethod + def _is_mkcol_success(status_code: int) -> bool: + """ + EN: Returns True when a MKCOL response means the collection was created + or already exists. Per the WebDAV spec (RFC 4918): + 201 = Created + 405 = Method Not Allowed → the resource already exists (success for us) + FR: Retourne True si la réponse MKCOL signifie que la collection a été + créée ou existe déjà. Selon la spec WebDAV (RFC 4918) : + 201 = Créé + 405 = Method Not Allowed → la ressource existe déjà (succès pour nous) + """ + return status_code in (200, 201, 405) + def ping(self) -> bool: """Vérifie que le serveur est accessible.""" try: @@ -205,29 +223,23 @@ def put_ink_page( return False def create_notebook_dir(self, notebook_slug: str) -> bool: - """MKCOL /{notebook} — crée un dossier carnet sur le serveur.""" + """MKCOL /{notebook} — creates a notebook folder on the remote server.""" url = self._url(notebook_slug) try: - resp = self.session.request( - "MKCOL", url, timeout=self.config.timeout_seconds - ) - # 200/201 = créé, 405 = la ressource existe déjà (WebDAV standard) - return resp.status_code in (200, 201, 405) + resp = self.session.request("MKCOL", url, timeout=self.config.timeout_seconds) + return self._is_mkcol_success(resp.status_code) except requests.RequestException as e: - logger.error(f"MKCOL échoué ({url}): {e}") + logger.error(f"MKCOL failed ({url}): {e}") return False def create_note_dir(self, notebook_slug: str, note_slug: str) -> bool: - """MKCOL /{notebook}/{note}""" + """MKCOL /{notebook}/{note} — creates a note folder on the remote server.""" url = self._url(notebook_slug, note_slug) try: - resp = self.session.request( - "MKCOL", url, timeout=self.config.timeout_seconds - ) - # 200/201 = créé, 405 = la ressource existe déjà (WebDAV standard) - return resp.status_code in (200, 201, 405) + resp = self.session.request("MKCOL", url, timeout=self.config.timeout_seconds) + return self._is_mkcol_success(resp.status_code) except requests.RequestException as e: - logger.error(f"MKCOL note échoué ({url}): {e}") + logger.error(f"MKCOL note failed ({url}): {e}") return False def _propfind(self, url: str, depth: int = 1) -> list[dict]: @@ -598,9 +610,10 @@ def _push_note(self, note: Note, report: SyncReport) -> None: if notebook: nb_slug = _notebook_to_slug(notebook) else: - # Notes sans carnet → dossier "sans-carnet" sur le serveur - nb_slug = "sans-carnet" - logger.debug(f"Note {note.title!r} sans carnet → dossier '{nb_slug}'") + # EN: Notes without a notebook go into the default fallback folder. + # FR: Les notes sans carnet sont placées dans le dossier de repli par défaut. + nb_slug = DEFAULT_NOTEBOOK_SLUG + logger.debug(f"Note {note.title!r} has no notebook → using '{nb_slug}' folder") note_slug = _note_to_slug(note)