From 28cb5456852496b86f77ca57c271f963d91a8db0 Mon Sep 17 00:00:00 2001 From: Johnny Date: Sun, 13 Sep 2026 21:23:19 +0200 Subject: [PATCH] =?UTF-8?q?Un=20jeton=20par=20appareil,=20r=C3=A9vocable?= =?UTF-8?q?=20seul=20(2.2.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tous les appareils partageaient le jeton du serveur : perdre un téléphone obligeait à changer ce jeton, donc à réappairer tout le monde. Chaque appairage émet désormais le sien. Révoquer devient une opération locale, vérifiée en appairant deux appareils puis en coupant le premier : 401 pour lui, 200 pour l'autre, jeton maître intact. L'interface du serveur les liste avec leur modèle, leur date d'appairage et leur dernière synchronisation — sans jamais afficher les jetons eux-mêmes, ce qui reviendrait à les distribuer. La table s'ajoute sans toucher aux données existantes et le jeton maître reste accepté : rien ne casse pour un appareil appairé avant cette version. --- CHANGELOG.md | 26 ++++++++++++ README.md | 3 +- VERSION | 2 +- .../fr/tuxgyver/noxely/sync/SyncClient.kt | 6 ++- server/noxely_server/api.py | 33 +++++++++++++-- server/noxely_server/storage.py | 41 +++++++++++++++++++ server/noxely_server/web/index.html | 27 +++++++++++- 7 files changed, 130 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d662cc..ef76847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ Toutes les évolutions notables de Noxely sont documentées ici. Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le versionnage [SemVer](https://semver.org/lang/fr/). +## [2.2.0] — 2026-09-13 + +### Ajouté + +- **Un jeton par appareil.** Chaque appairage émet désormais sa propre valeur, au lieu de + distribuer le jeton maître. Révoquer un téléphone perdu n'atteint plus les autres : vérifié en + appairant deux appareils, en révoquant le premier, et en constatant que le second continue de + se synchroniser (401 pour l'un, 200 pour l'autre, jeton maître intact). +- **Onglet « Appareils »** dans l'interface du serveur : nom du modèle, date d'appairage, + dernière synchronisation — ou « jamais synchronisé » —, et révocation d'un clic. +- Le téléphone s'annonce par son modèle lors de l'appairage : un appareil qu'on ne sait pas + nommer ne se révoque pas. + +### Sécurité + +- Les jetons d'appareil ne sont **jamais renvoyés** par la route qui les liste : les afficher + reviendrait à les distribuer. +- La date de dernière utilisation est enregistrée à chaque appel authentifié, ce qui permet de + repérer un appareil oublié. + +### Compatibilité + +- La table s'ajoute sans toucher aux données existantes, et le jeton maître reste accepté : un + appareil appairé avant cette version garde l'accès. Le réappairer lui donnera un jeton propre, + et donc une révocation individuelle. + ## [2.1.0] — 2026-09-13 ### Ajouté diff --git a/README.md b/README.md index 29dcb1f..798e2c7 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org) [![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose) [![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3) -[![Version](https://img.shields.io/badge/Version-2.1.0-6B4EFF)](VERSION) +[![Version](https://img.shields.io/badge/Version-2.2.0-6B4EFF)](VERSION) [![Licence](https://img.shields.io/badge/Licence-GPL%20v3-3FD8C0)](LICENSE) [![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche) @@ -60,6 +60,7 @@ synchronisations : c'est là qu'est l'économie. |---|---| | 🔄 **Synchronisation sobre** | Toutes les 6 h, en Wi-Fi non facturé et batterie suffisante | | 📱 **Appairage** | Un bouton côté serveur, un bouton côté téléphone : aucun jeton à recopier | +| 🔑 **Un jeton par appareil** | Chacun le sien, révocable seul depuis l'onglet Appareils | | 🎵 **Lecture** | Audio et vidéo, arrière-plan, casque, écran verrouillé, ±15 s, stop | | 📚 **Bibliothèque** | Médias reçus du serveur et contenu déjà présent sur l'appareil | | 📋 **Playlists** | Listes nommées, appui long pour y ajouter un titre | diff --git a/VERSION b/VERSION index 7ec1d6d..ccbccc3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.0 +2.2.0 diff --git a/app/src/main/java/fr/tuxgyver/noxely/sync/SyncClient.kt b/app/src/main/java/fr/tuxgyver/noxely/sync/SyncClient.kt index 2c95a58..54230ca 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/sync/SyncClient.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/sync/SyncClient.kt @@ -13,6 +13,7 @@ package fr.tuxgyver.noxely.sync import android.content.Context +import android.os.Build import fr.tuxgyver.noxely.library.ImportedStore import fr.tuxgyver.noxely.model.MediaEntry import fr.tuxgyver.noxely.model.MediaKind @@ -69,7 +70,10 @@ class SyncClient( val connection = openConnection("$base/api/pair", readTimeoutMs = 20_000).apply { requestMethod = "POST" doOutput = true - outputStream.close() + setRequestProperty("Content-Type", "application/json") + // Le nom du modèle rend l'appareil reconnaissable dans la liste du serveur. + val name = (Build.MANUFACTURER + " " + Build.MODEL).trim() + outputStream.use { it.write(("{" + "\"name\":\"" + name + "\"}").toByteArray()) } } val body = try { when (connection.responseCode) { diff --git a/server/noxely_server/api.py b/server/noxely_server/api.py index c1be835..20b2703 100644 --- a/server/noxely_server/api.py +++ b/server/noxely_server/api.py @@ -18,6 +18,7 @@ from __future__ import annotations import asyncio import ipaddress +import secrets import time from contextlib import asynccontextmanager from pathlib import Path @@ -41,9 +42,18 @@ watcher = Watcher(config, storage) def authorised(x_noxely_token: str = Header(default="")) -> None: - """Sans jeton valide, le serveur ne répond rien : il expose des fichiers.""" - if x_noxely_token != config.token: + """Sans jeton valide, le serveur ne répond rien : il expose des fichiers. + + Deux formes valent : le jeton maître, qui pilote le serveur depuis le navigateur, et celui + d'un appareil appairé. Les distinguer est ce qui permet de révoquer un téléphone perdu sans + déranger les autres. + """ + if x_noxely_token and x_noxely_token == config.token: + return + device = storage.device_by_token(x_noxely_token) if x_noxely_token else None + if not device: raise HTTPException(status_code=401, detail="Jeton invalide") + storage.touch_device(device["id"]) @asynccontextmanager @@ -221,8 +231,19 @@ def pairing_state() -> dict: return {"open": remaining > 0, "seconds": max(0, remaining)} +@app.get("/api/devices", dependencies=[Depends(authorised)]) +def devices() -> dict: + return {"devices": storage.devices()} + + +@app.delete("/api/devices/{device_id}", dependencies=[Depends(authorised)]) +def revoke_device(device_id: str) -> dict: + """Révoque un appareil : son jeton cesse immédiatement d'être accepté.""" + return {"revoked": storage.delete_device(device_id)} + + @app.post("/api/pair") -def pair(request: Request) -> dict: +def pair(request: Request, payload: dict = Body(default={})) -> dict: """Livre le jeton à un appareil du réseau local, pendant la fenêtre d'appairage. La fenêtre se referme dès qu'un appareil s'est servi : une ouverture, un appairage. @@ -237,7 +258,11 @@ def pair(request: Request) -> dict: if not _is_private(client): raise HTTPException(status_code=403, detail="Appairage réservé au réseau local") _pairing_until = None - return {"token": config.token, "name": "Noxely", "version": VERSION} + # Chaque appairage reçoit son propre jeton : révoquer l'un n'atteint pas les autres. + token = secrets.token_urlsafe(24) + name = str(payload.get("name") or "Appareil").strip()[:60] + storage.add_device(name, token) + return {"token": token, "name": "Noxely", "version": VERSION, "device": name} @app.get("/api/bootstrap") diff --git a/server/noxely_server/storage.py b/server/noxely_server/storage.py index a9d5a85..9befe8e 100644 --- a/server/noxely_server/storage.py +++ b/server/noxely_server/storage.py @@ -42,6 +42,15 @@ CREATE TABLE IF NOT EXISTS media ( ); CREATE INDEX IF NOT EXISTS media_updated ON media(updated_at); +CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + last_seen INTEGER +); +CREATE INDEX IF NOT EXISTS devices_token ON devices(token); + CREATE TABLE IF NOT EXISTS watches ( id TEXT PRIMARY KEY, query TEXT NOT NULL, @@ -150,6 +159,38 @@ class Storage: self._db.commit() return item + # --- Appareils ------------------------------------------------------ + + def add_device(self, name: str, token: str) -> str: + device_id = uuid.uuid4().hex + self._db.execute( + "INSERT INTO devices (id,name,token,created_at) VALUES (?,?,?,?)", + (device_id, name.strip() or "Appareil", token, now()), + ) + self._db.commit() + return device_id + + def device_by_token(self, token: str) -> dict | None: + row = self._db.execute("SELECT * FROM devices WHERE token = ?", (token,)).fetchone() + return dict(row) if row else None + + def touch_device(self, device_id: str) -> None: + """Dernière utilisation : c'est ce qui permet de reconnaître un appareil oublié.""" + self._db.execute("UPDATE devices SET last_seen = ? WHERE id = ?", (now(), device_id)) + self._db.commit() + + def devices(self) -> list[dict]: + return [ + # Le jeton n'est jamais renvoyé : le lister reviendrait à le distribuer. + {k: v for k, v in dict(row).items() if k != "token"} + for row in self._db.execute("SELECT * FROM devices ORDER BY created_at DESC") + ] + + def delete_device(self, device_id: str) -> bool: + cursor = self._db.execute("DELETE FROM devices WHERE id = ?", (device_id,)) + self._db.commit() + return cursor.rowcount > 0 + # --- Guets ---------------------------------------------------------- def add_watch(self, query: str) -> str: diff --git a/server/noxely_server/web/index.html b/server/noxely_server/web/index.html index 8d119d5..2ae8cc1 100644 --- a/server/noxely_server/web/index.html +++ b/server/noxely_server/web/index.html @@ -65,6 +65,7 @@ +