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 @@
[](https://kotlinlang.org)
[](https://developer.android.com/jetpack/compose)
[](https://developer.android.com/media/media3)
-[](VERSION)
+[](VERSION)
[](LICENSE)
[](#-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 @@
+
@@ -88,6 +89,10 @@
+
+
+
+