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:
1 parent
51b60cda70
commit
7712f241fd
7 files changed
+130
-8
No files matched your search
@@ -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é
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in new issue
Block a user