Un jeton par appareil, révocable seul (2.2.0)

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.
This commit is contained in:
tuxgyver committed 2026-09-13 21:23:19 +02:00
1 parent 51b60cda70
commit 7712f241fd
7 files changed
+130 -8

No files matched your search

+26
View File
@@ -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é
+2 -1
View File
@@ -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 |
+1 -1
View File
@@ -1 +1 @@
2.1.0
2.2.0
@@ -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) {
+29 -4
View File
@@ -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")
+41
View File
@@ -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:
+26 -1
View File
@@ -65,6 +65,7 @@
<button onclick="show('watch')" aria-selected="false" id="t-watch">📡 Guets</button>
<button onclick="show('queue')" aria-selected="false" id="t-queue">⏳ File</button>
<button onclick="show('library')" aria-selected="false" id="t-library">📚 Bibliothèque</button>
<button onclick="show('devices')" aria-selected="false" id="t-devices">📱 Appareils</button>
</div>
<section id="p-search">
@@ -88,6 +89,10 @@
<section id="p-queue" hidden><div id="queue"></div></section>
<section id="p-library" hidden><div id="library"></div></section>
<section id="p-devices" hidden>
<div class="row"><button class="go" onclick="openPairing()">Appairer un appareil</button></div>
<div id="devices"></div>
</section>
</main>
<script>
@@ -120,13 +125,14 @@ async function api(path, options = {}) {
}
function show(name) {
for (const tab of ['search', 'watch', 'queue', 'library']) {
for (const tab of ['search', 'watch', 'queue', 'library', 'devices']) {
$('p-' + tab).hidden = tab !== name;
$('t-' + tab).setAttribute('aria-selected', String(tab === name));
}
if (name === 'queue') loadQueue();
if (name === 'library') loadLibrary();
if (name === 'watch') loadWatches();
if (name === 'devices') loadDevices();
}
async function refreshAll() {
@@ -257,6 +263,25 @@ async function sweep() {
loadWatches();
}
async function loadDevices() {
try {
const { devices } = await api('/devices');
$('devices').innerHTML = devices.length ? devices.map((d) => `
<div class="card"><div class="grow"><b>${esc(d.name)}</b><small>appairé le ${
new Date(d.created_at).toLocaleString('fr-FR')}${
d.last_seen ? ' · vu le ' + new Date(d.last_seen).toLocaleString('fr-FR')
: ' · jamais synchronisé'}</small></div>
<button class="ghost" onclick="revokeDevice('${d.id}','${esc(d.name)}')">Révoquer</button></div>`).join('')
: '<div class="empty">Aucun appareil appairé</div>';
} catch (e) { $('devices').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
}
async function revokeDevice(id, name) {
if (!confirm(`Révoquer « ${name} » ? Il ne pourra plus se synchroniser tant qu'il n'est pas réappairé.`)) return;
await api('/devices/' + id, { method: 'DELETE' });
loadDevices();
}
async function openPairing() {
try {
const { seconds } = await api('/pair/open', { method: 'POST' });