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
28cb545685
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/)
|
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
|
||||||
et le versionnage [SemVer](https://semver.org/lang/fr/).
|
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
|
## [2.1.0] — 2026-09-13
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[](https://kotlinlang.org)
|
[](https://kotlinlang.org)
|
||||||
[](https://developer.android.com/jetpack/compose)
|
[](https://developer.android.com/jetpack/compose)
|
||||||
[](https://developer.android.com/media/media3)
|
[](https://developer.android.com/media/media3)
|
||||||
[](VERSION)
|
[](VERSION)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](#-comment-ça-marche)
|
[](#-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 |
|
| 🔄 **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 |
|
| 📱 **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 |
|
| 🎵 **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 |
|
| 📚 **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 |
|
| 📋 **Playlists** | Listes nommées, appui long pour y ajouter un titre |
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
package fr.tuxgyver.noxely.sync
|
package fr.tuxgyver.noxely.sync
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
import fr.tuxgyver.noxely.library.ImportedStore
|
import fr.tuxgyver.noxely.library.ImportedStore
|
||||||
import fr.tuxgyver.noxely.model.MediaEntry
|
import fr.tuxgyver.noxely.model.MediaEntry
|
||||||
import fr.tuxgyver.noxely.model.MediaKind
|
import fr.tuxgyver.noxely.model.MediaKind
|
||||||
@@ -69,7 +70,10 @@ class SyncClient(
|
|||||||
val connection = openConnection("$base/api/pair", readTimeoutMs = 20_000).apply {
|
val connection = openConnection("$base/api/pair", readTimeoutMs = 20_000).apply {
|
||||||
requestMethod = "POST"
|
requestMethod = "POST"
|
||||||
doOutput = true
|
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 {
|
val body = try {
|
||||||
when (connection.responseCode) {
|
when (connection.responseCode) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import secrets
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -41,9 +42,18 @@ watcher = Watcher(config, storage)
|
|||||||
|
|
||||||
|
|
||||||
def authorised(x_noxely_token: str = Header(default="")) -> None:
|
def authorised(x_noxely_token: str = Header(default="")) -> None:
|
||||||
"""Sans jeton valide, le serveur ne répond rien : il expose des fichiers."""
|
"""Sans jeton valide, le serveur ne répond rien : il expose des fichiers.
|
||||||
if x_noxely_token != config.token:
|
|
||||||
|
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")
|
raise HTTPException(status_code=401, detail="Jeton invalide")
|
||||||
|
storage.touch_device(device["id"])
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -221,8 +231,19 @@ def pairing_state() -> dict:
|
|||||||
return {"open": remaining > 0, "seconds": max(0, remaining)}
|
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")
|
@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.
|
"""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.
|
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):
|
if not _is_private(client):
|
||||||
raise HTTPException(status_code=403, detail="Appairage réservé au réseau local")
|
raise HTTPException(status_code=403, detail="Appairage réservé au réseau local")
|
||||||
_pairing_until = None
|
_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")
|
@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 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 (
|
CREATE TABLE IF NOT EXISTS watches (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
query TEXT NOT NULL,
|
query TEXT NOT NULL,
|
||||||
@@ -150,6 +159,38 @@ class Storage:
|
|||||||
self._db.commit()
|
self._db.commit()
|
||||||
return item
|
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 ----------------------------------------------------------
|
# --- Guets ----------------------------------------------------------
|
||||||
|
|
||||||
def add_watch(self, query: str) -> str:
|
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('watch')" aria-selected="false" id="t-watch">📡 Guets</button>
|
||||||
<button onclick="show('queue')" aria-selected="false" id="t-queue">⏳ File</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('library')" aria-selected="false" id="t-library">📚 Bibliothèque</button>
|
||||||
|
<button onclick="show('devices')" aria-selected="false" id="t-devices">📱 Appareils</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section id="p-search">
|
<section id="p-search">
|
||||||
@@ -88,6 +89,10 @@
|
|||||||
|
|
||||||
<section id="p-queue" hidden><div id="queue"></div></section>
|
<section id="p-queue" hidden><div id="queue"></div></section>
|
||||||
<section id="p-library" hidden><div id="library"></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>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -120,13 +125,14 @@ async function api(path, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function show(name) {
|
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;
|
$('p-' + tab).hidden = tab !== name;
|
||||||
$('t-' + tab).setAttribute('aria-selected', String(tab === name));
|
$('t-' + tab).setAttribute('aria-selected', String(tab === name));
|
||||||
}
|
}
|
||||||
if (name === 'queue') loadQueue();
|
if (name === 'queue') loadQueue();
|
||||||
if (name === 'library') loadLibrary();
|
if (name === 'library') loadLibrary();
|
||||||
if (name === 'watch') loadWatches();
|
if (name === 'watch') loadWatches();
|
||||||
|
if (name === 'devices') loadDevices();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
@@ -257,6 +263,25 @@ async function sweep() {
|
|||||||
loadWatches();
|
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() {
|
async function openPairing() {
|
||||||
try {
|
try {
|
||||||
const { seconds } = await api('/pair/open', { method: 'POST' });
|
const { seconds } = await api('/pair/open', { method: 'POST' });
|
||||||
|
|||||||
Reference in new issue
Block a user