Bibliothèque vidéo partagée par URL signées
Extrait de li.k3nny.fr et rendu indépendant du serveur : toute la configuration propre à l'hôte passe par li.env (URL publique, chemins, expiration, répertoires « HTML seulement », commande nginx). Écarts avec la version en production : - li-user del retire aussi la page .html, pas seulement le .xspf ; - li-gen ignore les répertoires cachés, pas seulement les fichiers ; - textes de la page HTML accentués. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
# Les secrets ne vivent jamais dans le depot.
|
||||||
|
users.json
|
||||||
|
users.conf
|
||||||
|
*.env
|
||||||
|
!li.env.example
|
||||||
|
playlists/
|
||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 k3nny
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# li — une bibliothèque vidéo partagée par URL signées
|
||||||
|
|
||||||
|
Partager une bibliothèque de films et de séries avec quelques proches, **lisible directement dans
|
||||||
|
VLC**, sans compte, sans portail d'authentification et sans secret partagé.
|
||||||
|
|
||||||
|
Chaque personne reçoit **deux liens personnels** :
|
||||||
|
|
||||||
|
| Document | Pour quoi faire |
|
||||||
|
|---|---|
|
||||||
|
| `<nom>.xspf` | Une playlist VLC contenant toute la bibliothèque (*Média → Ouvrir un flux réseau*) |
|
||||||
|
| `<nom>.html` | Une page autonome : l'arborescence, les tailles, un filtre par titre, un clic pour télécharger |
|
||||||
|
|
||||||
|
Les deux portent **exactement les mêmes URL de téléchargement**, puisque la signature ne dépend que
|
||||||
|
du chemin et du secret de la personne, jamais du document qui la transporte.
|
||||||
|
|
||||||
|
## Le principe : la révocation, c'est la table
|
||||||
|
|
||||||
|
Chaque ayant droit possède un identifiant et un secret de 24 caractères, qui **ne quitte jamais le
|
||||||
|
serveur**. Une URL porte le nom, une expiration et un `md5(expiration + chemin + secret)`, que
|
||||||
|
nginx vérifie lui-même avec [`secure_link`](https://nginx.org/en/docs/http/ngx_http_secure_link_module.html) :
|
||||||
|
|
||||||
|
```
|
||||||
|
https://li.example.org/library/movies/Film.mkv?u=alice&e=2145916800&s=O8H1HbrY_SGVLnl8KP-AxA
|
||||||
|
```
|
||||||
|
|
||||||
|
| Levier | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `li-user del alice` | **La** révocation : tous les liens de la personne meurent dans la seconde |
|
||||||
|
| L'expiration `e=` | Un filet, rien de plus — fixée à 2038 par défaut |
|
||||||
|
|
||||||
|
Si l'expiration est aussi lointaine, c'est parce que VLC indexe la reprise de lecture et
|
||||||
|
l'historique **par URL** : une signature renouvelée chaque nuit les effacerait chaque nuit. Ici,
|
||||||
|
les URL ne changent jamais, et la régénération nocturne ne fait qu'ajouter les nouveautés.
|
||||||
|
|
||||||
|
Ce choix permet aussi de se passer d'un portail d'authentification (SSO, `auth_request`). VLC ne
|
||||||
|
suit pas une redirection vers une page de connexion, et une sous-requête d'authentification
|
||||||
|
répétée à chaque requête de plage (chaque avance rapide) coûte cher. Un jeton signé se vérifie sur
|
||||||
|
place, en quelques microsecondes.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
AMI(["Ami · VLC ou navigateur"])
|
||||||
|
NGX["<b>nginx</b> · façade<br/><i>443, TLS, X-Real-IP</i>"]
|
||||||
|
subgraph NET["réseau Docker « li » · internal"]
|
||||||
|
LI["<b>li</b> — nginx dédié<br/><i>vérifie la signature</i>"]
|
||||||
|
end
|
||||||
|
MAP[("users.conf<br/><i>map $arg_u → secret</i>")]
|
||||||
|
LIB[("bibliothèque<br/><b>lecture seule</b>")]
|
||||||
|
|
||||||
|
AMI -->|"https · URL signée"| NGX
|
||||||
|
NGX -->|"proxy_pass<br/>proxy_buffering off"| LI
|
||||||
|
LI --> MAP
|
||||||
|
LI --> LIB
|
||||||
|
```
|
||||||
|
|
||||||
|
Le nginx de façade ne monte pas la bibliothèque : c'est le processus le plus exposé de la machine,
|
||||||
|
et lui donner accès à des centaines de gigaoctets élargirait son rayon d'explosion pour rien. Le
|
||||||
|
conteneur `li` ne publie aucun port, ne joint personne (réseau `internal`) et voit la bibliothèque
|
||||||
|
en lecture seule.
|
||||||
|
|
||||||
|
## Contenu du dépôt
|
||||||
|
|
||||||
|
| Fichier | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `bin/li-user` | Ajoute, révoque et liste les ayants droit, et affiche leurs liens (sh, `jq`, `openssl`) |
|
||||||
|
| `bin/li-gen` | Engendre la playlist et la page de chaque ayant droit (Python ≥ 3.8, sans dépendance) |
|
||||||
|
| `nginx/li.conf` | Vhost du conteneur `li` : signature, limites, en-têtes |
|
||||||
|
| `nginx/facade.conf.example` | Vhost de la façade TLS, à adapter |
|
||||||
|
| `compose.yaml` | Le conteneur `li` et son réseau |
|
||||||
|
| `systemd/li-gen.*` | Régénération nocturne |
|
||||||
|
| `li.env.example` | **Toute** la configuration propre au serveur |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Configuration
|
||||||
|
sudo install -d /etc/li
|
||||||
|
sudo install -m 644 li.env.example /etc/li/li.env
|
||||||
|
sudoedit /etc/li/li.env # LI_BASE_URL, LI_LIBRARY, LI_DATA…
|
||||||
|
|
||||||
|
# 2. Scripts et minuteur
|
||||||
|
sudo install -m 755 bin/li-gen bin/li-user /usr/local/sbin/
|
||||||
|
sudo install -m 644 systemd/li-gen.service systemd/li-gen.timer /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload && sudo systemctl enable --now li-gen.timer
|
||||||
|
|
||||||
|
# 3. Répertoires de données, avec une map vide pour que nginx démarre
|
||||||
|
. /etc/li/li.env
|
||||||
|
sudo install -d "$LI_DATA/conf" "$LI_DATA/playlists"
|
||||||
|
echo 'map $arg_u $li_secret { default ""; }' | sudo tee "$LI_DATA/conf/users.conf"
|
||||||
|
|
||||||
|
# 4. Le conteneur
|
||||||
|
sudo docker compose --env-file /etc/li/li.env up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Côté façade, adaptez `nginx/facade.conf.example` (nom, certificat) et raccordez la façade au
|
||||||
|
réseau `li` (`networks: li: {external: true}` dans son `compose`).
|
||||||
|
|
||||||
|
Hors Docker, `nginx/li.conf` s'utilise tel quel une fois ses chemins adaptés (`root`, `include`),
|
||||||
|
avec `LI_NGINX=nginx` dans `li.env`.
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
li-user add alice # crée, engendre la map, recharge nginx, affiche les deux liens
|
||||||
|
li-user lien alice # retrouve les deux liens, sans rien changer
|
||||||
|
li-user list
|
||||||
|
li-user del alice # tue tous ses liens dans la seconde
|
||||||
|
li-gen # régénère à la main (le minuteur le fait chaque nuit)
|
||||||
|
```
|
||||||
|
|
||||||
|
Les liens **sont** les clés : transmettez-les par un canal privé (un envoi à usage unique d'un
|
||||||
|
gestionnaire de mots de passe convient), pas dans une discussion de groupe.
|
||||||
|
|
||||||
|
### Ce qui va dans la playlist
|
||||||
|
|
||||||
|
* Partout : les fichiers dont l'extension figure dans `LI_VIDEO_EXT`.
|
||||||
|
* Dans la page HTML seulement : **tous** les fichiers des répertoires de premier niveau listés dans
|
||||||
|
`LI_HTML_ONLY` (`software` par défaut), car un `.iso` n'a rien à faire dans une playlist VLC.
|
||||||
|
Chacun de ces répertoires doit avoir son `location` avec `Content-Disposition: attachment` dans
|
||||||
|
`nginx/li.conf`, pour qu'un `.html` ou un `.svg` déposé là soit téléchargé et non rendu.
|
||||||
|
* Les fichiers et répertoires cachés (`.Trash-1000`…) sont ignorés.
|
||||||
|
|
||||||
|
### Limites côté lecteur
|
||||||
|
|
||||||
|
| Limite | Pourquoi |
|
||||||
|
|---|---|
|
||||||
|
| Fichiers lus **tels quels**, sans transcodage | Un 4K HEVC ne passera pas sur un vieux téléphone |
|
||||||
|
| Les sous-titres intégrés au `.mkv` fonctionnent, les `.srt` voisins non | VLC ne cherche les sous-titres voisins que pour les fichiers locaux |
|
||||||
|
| Trois flux simultanés par personne | `limit_conn` par identifiant, et non par adresse |
|
||||||
|
| 12 Mo/s par flux, les 16 premiers Mo à pleine vitesse | Démarrage et avance rapide restent francs |
|
||||||
|
|
||||||
|
## Pièges connus
|
||||||
|
|
||||||
|
**Monter le répertoire, pas le fichier.** `li-user` publie la map par un `mv` atomique, qui
|
||||||
|
remplace l'inode. Un bind-mount *de fichier* épingle l'inode d'origine : le conteneur resterait sur
|
||||||
|
l'ancienne version, et un ayant droit ajouté n'existerait jamais pour nginx, sans la moindre
|
||||||
|
erreur.
|
||||||
|
|
||||||
|
**Signer le chemin décodé.** `secure_link_md5` porte sur `$uri`, que nginx a déjà décodé. On signe
|
||||||
|
donc le chemin brut et on n'encode qu'au moment d'écrire l'URL. Dans l'autre ordre, on obtient un
|
||||||
|
`403` sur tout titre qui contient une espace ou un accent. De même, les `&` doivent devenir `&`
|
||||||
|
dans le XML, sans quoi VLC refuse le `.xspf`.
|
||||||
|
|
||||||
|
**Ne pas journaliser la signature.** Le format `combined` enregistre la ligne de requête entière,
|
||||||
|
et donc le laissez-passer. Les deux nginx (façade et `li`) utilisent un format qui ne retient que
|
||||||
|
`$uri`. Seul le journal d'erreurs porte encore la requête complète, dans un cas étroit (signature
|
||||||
|
valide mais fichier disparu).
|
||||||
|
|
||||||
|
**L'adresse du client.** Sans `realip`, le conteneur attribuerait toutes les requêtes à l'adresse
|
||||||
|
de la façade. Il fait confiance à `X-Real-IP` depuis les plages privées, et non depuis un
|
||||||
|
sous-réseau figé que Docker peut réattribuer. En contrepartie, la façade doit **écraser** cet
|
||||||
|
en-tête.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
[MIT](LICENSE).
|
||||||
Executable
+362
@@ -0,0 +1,362 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Engendre, pour chaque ayant droit, sa playlist .xspf et sa page .html.
|
||||||
|
|
||||||
|
Les deux portent EXACTEMENT les memes URL : la signature ne depend que du
|
||||||
|
chemin et du secret, jamais du document qui la transporte.
|
||||||
|
|
||||||
|
Idempotent : relance-le autant que tu veux, il reecrit les memes URL. C est
|
||||||
|
voulu — une URL stable, c est VLC qui retrouve la reprise de lecture et
|
||||||
|
l historique, tous deux indexes par URL.
|
||||||
|
|
||||||
|
Configuration : /etc/li/li.env (ou le fichier nomme par LI_CONF), que les
|
||||||
|
variables d environnement surchargent. Voir li.env.example.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
from xml.sax.saxutils import escape
|
||||||
|
|
||||||
|
# Ces deux prefixes sont ceux du vhost nginx/li.conf : les changer ici impose
|
||||||
|
# de les changer la-bas.
|
||||||
|
PREFIXE = "/library"
|
||||||
|
PLAYLISTS = "/playlists"
|
||||||
|
|
||||||
|
VIDEOS_DEFAUT = ".mkv .mp4 .m4v .avi .mov .webm .mpg .mpeg .ts"
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- configuration
|
||||||
|
|
||||||
|
def lis_env(chemin: Path) -> dict:
|
||||||
|
"""Lit un fichier « CLE=valeur » compatible sh — celui que li-user source."""
|
||||||
|
valeurs = {}
|
||||||
|
if not chemin.exists():
|
||||||
|
return valeurs
|
||||||
|
for ligne in chemin.read_text(encoding="utf-8").splitlines():
|
||||||
|
ligne = ligne.strip()
|
||||||
|
if not ligne or ligne.startswith("#") or "=" not in ligne:
|
||||||
|
continue
|
||||||
|
cle, valeur = ligne.split("=", 1)
|
||||||
|
mots = shlex.split(valeur, comments=True)
|
||||||
|
valeurs[cle.strip()] = " ".join(mots)
|
||||||
|
return valeurs
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
def __init__(self, env: dict):
|
||||||
|
base = env.get("LI_BASE_URL", "").rstrip("/")
|
||||||
|
if not base:
|
||||||
|
raise SystemExit("LI_BASE_URL n est pas defini (voir li.env.example).")
|
||||||
|
self.base = base
|
||||||
|
self.racine = Path(env.get("LI_LIBRARY", "/srv/media/library"))
|
||||||
|
donnees = Path(env.get("LI_DATA", "/srv/appdata/li"))
|
||||||
|
self.ayants = donnees / "users.json"
|
||||||
|
self.sortie = donnees / "playlists"
|
||||||
|
# La revocation, c est la table des ayants droit — pas l expiration.
|
||||||
|
# Une expiration courte obligerait a resigner regulierement, donc a
|
||||||
|
# changer les URL, donc a faire perdre a VLC la reprise de lecture de
|
||||||
|
# chaque film. D ou 2038 par defaut.
|
||||||
|
self.expire = int(env.get("LI_EXPIRES", "2145916800"))
|
||||||
|
self.titre = env.get("LI_TITLE", "Bibliothèque")
|
||||||
|
self.videos = {e.lower() for e in env.get("LI_VIDEO_EXT", VIDEOS_DEFAUT).split()}
|
||||||
|
# Repertoires de premier niveau dont le contenu ne figure QUE dans la
|
||||||
|
# page HTML, jamais dans la playlist — et dont TOUS les fichiers sont
|
||||||
|
# pris, quelle que soit leur extension.
|
||||||
|
#
|
||||||
|
# Une playlist VLC n a rien a faire d un .iso ou d un .exe : elle ne
|
||||||
|
# saurait pas les lire, et les mettre dedans donnerait une liste ou un
|
||||||
|
# titre sur deux echoue a l ouverture. La page HTML, elle, ne fait que
|
||||||
|
# proposer des telechargements — tout y a sa place.
|
||||||
|
#
|
||||||
|
# Le vhost nginx/li.conf force « Content-Disposition: attachment » sur
|
||||||
|
# ces memes chemins. Les deux doivent rester d accord.
|
||||||
|
self.html_seulement = set(env.get("LI_HTML_ONLY", "software").split())
|
||||||
|
|
||||||
|
|
||||||
|
def charge_config() -> Config:
|
||||||
|
env = lis_env(Path(os.environ.get("LI_CONF", "/etc/li/li.env")))
|
||||||
|
env.update({k: v for k, v in os.environ.items() if k.startswith("LI_")})
|
||||||
|
return Config(env)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- signature
|
||||||
|
|
||||||
|
def signe(chemin: str, expire: int, secret: str) -> str:
|
||||||
|
"""md5(expiration + chemin + secret), en base64url sans remplissage.
|
||||||
|
|
||||||
|
C est exactement ce que calcule « secure_link_md5 » de nginx. Le secret
|
||||||
|
est en FIN de chaine : c est l ordre de la documentation nginx, et le seul
|
||||||
|
qui ne prete pas le flanc a une extension de longueur.
|
||||||
|
"""
|
||||||
|
brut = f"{expire}{chemin}{secret}".encode("utf-8")
|
||||||
|
return base64.urlsafe_b64encode(hashlib.md5(brut).digest()).decode().rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def url(cfg: Config, chemin: str, nom: str, secret: str) -> str:
|
||||||
|
# On signe le chemin DECODE — c est ce que nginx met dans $uri — et on
|
||||||
|
# n encode qu ici, pour l ecriture. Inverser les deux donne un 403 sur
|
||||||
|
# tout titre contenant une espace ou un accent.
|
||||||
|
return (f"{cfg.base}{quote(chemin)}"
|
||||||
|
f"?u={quote(nom)}&e={cfg.expire}&s={signe(chemin, cfg.expire, secret)}")
|
||||||
|
|
||||||
|
|
||||||
|
def url_media(cfg: Config, fichier: Path, nom: str, secret: str) -> str:
|
||||||
|
return url(cfg, f"{PREFIXE}/{fichier.relative_to(cfg.racine).as_posix()}", nom, secret)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ outils
|
||||||
|
|
||||||
|
def taille_lisible(octets: int) -> str:
|
||||||
|
for unite, seuil in (("To", 1 << 40), ("Go", 1 << 30), ("Mo", 1 << 20)):
|
||||||
|
if octets >= seuil:
|
||||||
|
return f"{octets / seuil:.1f} {unite}".replace(".", ",")
|
||||||
|
return f"{octets // 1024} ko"
|
||||||
|
|
||||||
|
|
||||||
|
def html_seulement(cfg: Config, chemin: Path) -> bool:
|
||||||
|
parties = chemin.relative_to(cfg.racine).parts
|
||||||
|
return len(parties) > 1 and parties[0] in cfg.html_seulement
|
||||||
|
|
||||||
|
|
||||||
|
def collecte(cfg: Config):
|
||||||
|
"""(medias, autres) — les premiers vont partout, les seconds en HTML seul."""
|
||||||
|
medias, autres = [], []
|
||||||
|
for f in cfg.racine.rglob("*"):
|
||||||
|
if not f.is_file():
|
||||||
|
continue
|
||||||
|
# Fichiers ET repertoires caches : .@__thumb, .Trash-1000 et consorts.
|
||||||
|
if any(p.startswith(".") for p in f.relative_to(cfg.racine).parts):
|
||||||
|
continue
|
||||||
|
if html_seulement(cfg, f):
|
||||||
|
autres.append(f)
|
||||||
|
elif f.suffix.lower() in cfg.videos:
|
||||||
|
medias.append(f)
|
||||||
|
ordre = lambda p: p.relative_to(cfg.racine).as_posix().lower()
|
||||||
|
return sorted(medias, key=ordre), sorted(autres, key=ordre)
|
||||||
|
|
||||||
|
|
||||||
|
def arbre(cfg: Config, fichiers):
|
||||||
|
"""Reconstruit l arborescence : {dossier: sous-arbre}, fichiers a plat."""
|
||||||
|
racine = {"dossiers": {}, "fichiers": []}
|
||||||
|
for f in fichiers:
|
||||||
|
parties = f.relative_to(cfg.racine).parts
|
||||||
|
noeud = racine
|
||||||
|
for dossier in parties[:-1]:
|
||||||
|
noeud = noeud["dossiers"].setdefault(
|
||||||
|
dossier, {"dossiers": {}, "fichiers": []})
|
||||||
|
noeud["fichiers"].append(f)
|
||||||
|
return racine
|
||||||
|
|
||||||
|
|
||||||
|
def compte(noeud):
|
||||||
|
"""(nombre de fichiers, octets) d un noeud et de toute sa descendance."""
|
||||||
|
n = len(noeud["fichiers"])
|
||||||
|
o = sum(f.stat().st_size for f in noeud["fichiers"])
|
||||||
|
for sous in noeud["dossiers"].values():
|
||||||
|
sn, so = compte(sous)
|
||||||
|
n += sn
|
||||||
|
o += so
|
||||||
|
return n, o
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- XSPF
|
||||||
|
|
||||||
|
def xspf(cfg: Config, fichiers, nom, secret, horodatage):
|
||||||
|
pistes = []
|
||||||
|
for f in fichiers:
|
||||||
|
rel = f.relative_to(cfg.racine)
|
||||||
|
pistes.append(
|
||||||
|
" <track>\n"
|
||||||
|
f" <location>{escape(url_media(cfg, f, nom, secret))}</location>\n"
|
||||||
|
f" <title>{escape(rel.stem)}</title>\n"
|
||||||
|
f" <album>{escape(rel.parent.as_posix().replace('/', ' · '))}</album>\n"
|
||||||
|
" </track>")
|
||||||
|
return ('<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
'<playlist version="1" xmlns="http://xspf.org/ns/0/">\n'
|
||||||
|
f' <title>{escape(nom)} — {len(fichiers)} titres ({horodatage})</title>\n'
|
||||||
|
' <trackList>\n' + "\n".join(pistes) + '\n </trackList>\n'
|
||||||
|
'</playlist>\n')
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- HTML
|
||||||
|
|
||||||
|
STYLE = """
|
||||||
|
:root{--fond:#fbfaf8;--carte:#fff;--texte:#1b1b1b;--doux:#6b6b6b;
|
||||||
|
--trait:#e4e0d9;--accent:#2d6a4f;--survol:#f2efe9}
|
||||||
|
@media(prefers-color-scheme:dark){:root{--fond:#16181a;--carte:#1e2124;
|
||||||
|
--texte:#e8e6e3;--doux:#9a9793;--trait:#2e3236;--accent:#6ba583;--survol:#25292d}}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;padding:0 16px 64px;background:var(--fond);color:var(--texte);
|
||||||
|
font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||||
|
.enveloppe{max-width:920px;margin:0 auto}
|
||||||
|
header{padding:28px 0 16px;border-bottom:1px solid var(--trait);margin-bottom:18px}
|
||||||
|
h1{margin:0 0 6px;font-size:1.45rem;font-weight:650;letter-spacing:-.01em}
|
||||||
|
.sous{color:var(--doux);font-size:.875rem}
|
||||||
|
.actions{margin-top:14px;display:flex;gap:10px;flex-wrap:wrap}
|
||||||
|
.bouton{display:inline-block;padding:7px 13px;border:1px solid var(--trait);
|
||||||
|
border-radius:7px;background:var(--carte);color:var(--texte);text-decoration:none;
|
||||||
|
font-size:.85rem}
|
||||||
|
.bouton:hover{background:var(--survol)}
|
||||||
|
.bouton.fort{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||||
|
#filtre{width:100%;margin:16px 0 20px;padding:10px 13px;border:1px solid var(--trait);
|
||||||
|
border-radius:8px;background:var(--carte);color:var(--texte);font:inherit;font-size:.9rem}
|
||||||
|
#filtre:focus{outline:2px solid var(--accent);outline-offset:-1px}
|
||||||
|
details{margin:0}
|
||||||
|
summary{cursor:pointer;padding:7px 8px;border-radius:6px;list-style:none;
|
||||||
|
display:flex;align-items:baseline;gap:9px;font-weight:550}
|
||||||
|
summary:hover{background:var(--survol)}
|
||||||
|
summary::-webkit-details-marker{display:none}
|
||||||
|
summary::before{content:"▸";color:var(--doux);font-size:.8em;flex:none;
|
||||||
|
transition:transform .12s;display:inline-block}
|
||||||
|
details[open]>summary::before{transform:rotate(90deg)}
|
||||||
|
.meta{color:var(--doux);font-weight:400;font-size:.8rem;margin-left:auto;
|
||||||
|
flex:none;white-space:nowrap}
|
||||||
|
.niveau{margin-left:15px;padding-left:9px;border-left:1px solid var(--trait)}
|
||||||
|
a.f{display:flex;align-items:baseline;gap:9px;padding:6px 8px;border-radius:6px;
|
||||||
|
color:var(--texte);text-decoration:none;font-size:.9rem}
|
||||||
|
a.f:hover{background:var(--survol)}
|
||||||
|
a.f::before{content:"↓";color:var(--accent);font-size:.85em;flex:none}
|
||||||
|
a.f .t{color:var(--doux);font-size:.8rem;margin-left:auto;flex:none;
|
||||||
|
white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||||
|
.vide{display:none!important}
|
||||||
|
footer{margin-top:36px;padding-top:16px;border-top:1px solid var(--trait);
|
||||||
|
color:var(--doux);font-size:.8rem}
|
||||||
|
"""
|
||||||
|
|
||||||
|
SCRIPT = """
|
||||||
|
const f=document.getElementById('filtre');
|
||||||
|
f.addEventListener('input',()=>{
|
||||||
|
const q=f.value.trim().toLowerCase();
|
||||||
|
document.querySelectorAll('a.f').forEach(a=>{
|
||||||
|
a.classList.toggle('vide', q!=='' && !a.dataset.n.includes(q));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('details').forEach(d=>{
|
||||||
|
const vu=d.querySelector('a.f:not(.vide)')!==null;
|
||||||
|
d.classList.toggle('vide', q!=='' && !vu);
|
||||||
|
if(q!=='') d.open=true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def rendre(cfg: Config, noeud, nom, secret, profondeur=0):
|
||||||
|
morceaux = []
|
||||||
|
for dossier in sorted(noeud["dossiers"], key=str.lower):
|
||||||
|
sous = noeud["dossiers"][dossier]
|
||||||
|
n, o = compte(sous)
|
||||||
|
ouvert = " open" if profondeur == 0 else ""
|
||||||
|
morceaux.append(
|
||||||
|
f'<details{ouvert}><summary>{html.escape(dossier)}'
|
||||||
|
f'<span class="meta">{n} · {taille_lisible(o)}</span></summary>'
|
||||||
|
f'<div class="niveau">{rendre(cfg, sous, nom, secret, profondeur + 1)}</div>'
|
||||||
|
'</details>')
|
||||||
|
for f in sorted(noeud["fichiers"], key=lambda p: p.name.lower()):
|
||||||
|
# Pour un film, l extension n apprend rien — le nom de release dit
|
||||||
|
# deja tout. Pour un fichier « HTML seulement », elle est au contraire
|
||||||
|
# l information principale : un .iso ne se telecharge pas comme un
|
||||||
|
# .exe. D ou le nom entier dans un cas, le radical dans l autre.
|
||||||
|
affiche = f.name if html_seulement(cfg, f) else f.stem
|
||||||
|
morceaux.append(
|
||||||
|
f'<a class="f" href="{html.escape(url_media(cfg, f, nom, secret), quote=True)}" '
|
||||||
|
f'download="{html.escape(f.name, quote=True)}" '
|
||||||
|
f'data-n="{html.escape(affiche.lower(), quote=True)}">'
|
||||||
|
f'<span>{html.escape(affiche)}</span>'
|
||||||
|
f'<span class="t">{taille_lisible(f.stat().st_size)}</span></a>')
|
||||||
|
return "".join(morceaux)
|
||||||
|
|
||||||
|
|
||||||
|
def page(cfg: Config, racine, medias, autres, nom, secret, horodatage):
|
||||||
|
fichiers = medias + autres
|
||||||
|
total = sum(f.stat().st_size for f in fichiers)
|
||||||
|
decompte = f"{len(medias)} titres"
|
||||||
|
if autres:
|
||||||
|
decompte += f" et {len(autres)} fichiers"
|
||||||
|
lien_xspf = url(cfg, f"{PLAYLISTS}/{nom}.xspf", nom, secret)
|
||||||
|
titre = html.escape(cfg.titre)
|
||||||
|
return f"""<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<!-- Aucune ressource externe, et aucun referent : les URL de cette page sont
|
||||||
|
des laissez-passer, elles n ont a fuir vers aucun tiers. -->
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<meta name="robots" content="noindex,nofollow,noarchive">
|
||||||
|
<title>{titre} — {html.escape(nom)}</title>
|
||||||
|
<style>{STYLE}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="enveloppe">
|
||||||
|
<header>
|
||||||
|
<h1>{titre}</h1>
|
||||||
|
<div class="sous">Accès personnel de <b>{html.escape(nom)}</b> ·
|
||||||
|
{decompte} · {taille_lisible(total)} · mise à jour le {horodatage}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="bouton fort" href="{html.escape(lien_xspf, quote=True)}">Ouvrir dans VLC</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<input id="filtre" type="search" placeholder="Filtrer par titre…" autocomplete="off">
|
||||||
|
{rendre(cfg, racine, nom, secret)}
|
||||||
|
<footer>
|
||||||
|
Chaque lien de cette page t’identifie et n’est valable que pour toi.
|
||||||
|
Ne la transmets pas — demande plutôt qu’on ajoute la personne.
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script>{SCRIPT}</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- corps
|
||||||
|
|
||||||
|
def publie(chemin: Path, contenu: str):
|
||||||
|
tmp = chemin.with_name("." + chemin.name + ".tmp")
|
||||||
|
tmp.write_text(contenu, encoding="utf-8")
|
||||||
|
os.chmod(tmp, 0o644)
|
||||||
|
# Publication atomique : personne ne lit jamais un document a moitie
|
||||||
|
# ecrit, meme si la generation tombe en plein milieu.
|
||||||
|
os.replace(tmp, chemin)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
cfg = charge_config()
|
||||||
|
if not cfg.ayants.exists():
|
||||||
|
print(f"{cfg.ayants} absent — rien a engendrer.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not cfg.racine.is_dir():
|
||||||
|
print(f"{cfg.racine} n est pas un repertoire.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
ayants = json.loads(cfg.ayants.read_text(encoding="utf-8"))
|
||||||
|
medias, autres = collecte(cfg)
|
||||||
|
# L arbre de la page porte les deux ; la playlist ne verra que « medias ».
|
||||||
|
racine = arbre(cfg, medias + autres)
|
||||||
|
cfg.sortie.mkdir(parents=True, exist_ok=True)
|
||||||
|
horodatage = time.strftime("%d/%m/%Y à %Hh%M")
|
||||||
|
|
||||||
|
for nom, secret in sorted(ayants.items()):
|
||||||
|
publie(cfg.sortie / f"{nom}.xspf", xspf(cfg, medias, nom, secret, horodatage))
|
||||||
|
publie(cfg.sortie / f"{nom}.html",
|
||||||
|
page(cfg, racine, medias, autres, nom, secret, horodatage))
|
||||||
|
print(f"{nom} : {len(medias)} pistes, {len(autres)} fichiers hors playlist")
|
||||||
|
|
||||||
|
# Les documents des personnes revoquees ne doivent pas survivre au retrait
|
||||||
|
# de leur secret : sans signature valide ils seraient refuses, mais un
|
||||||
|
# fichier qui traine est un fichier qu on oublie.
|
||||||
|
for orphelin in list(cfg.sortie.glob("*.xspf")) + list(cfg.sortie.glob("*.html")):
|
||||||
|
if orphelin.stem not in ayants:
|
||||||
|
orphelin.unlink()
|
||||||
|
print(f"{orphelin.name} : retire (plus d ayant droit)")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+132
@@ -0,0 +1,132 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Gestion des ayants droit de la bibliotheque.
|
||||||
|
#
|
||||||
|
# users.json est la SEULE source de verite ; la map nginx en est deduite et
|
||||||
|
# se reecrit entierement a chaque geste. Retirer quelqu un de ce fichier et
|
||||||
|
# recharger, c est tuer tous ses liens dans la seconde : c est la revocation.
|
||||||
|
# L expiration inscrite dans les URL n est qu un filet.
|
||||||
|
#
|
||||||
|
# Configuration : /etc/li/li.env (ou le fichier nomme par LI_CONF), partage
|
||||||
|
# avec li-gen. Voir li.env.example.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
LI_CONF=${LI_CONF:-/etc/li/li.env}
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
[ -f "$LI_CONF" ] && . "$LI_CONF"
|
||||||
|
|
||||||
|
: "${LI_BASE_URL:?LI_BASE_URL n est pas defini (voir li.env.example)}"
|
||||||
|
LI_DATA=${LI_DATA:-/srv/appdata/li}
|
||||||
|
LI_EXPIRES=${LI_EXPIRES:-2145916800} # 2038 — doit valoir celui de li-gen
|
||||||
|
LI_NGINX=${LI_NGINX:-docker exec li nginx}
|
||||||
|
# li-gen lit les memes variables : on les lui transmet telles que chargees.
|
||||||
|
export LI_CONF LI_BASE_URL LI_DATA LI_EXPIRES
|
||||||
|
|
||||||
|
BASE=${LI_BASE_URL%/}
|
||||||
|
AYANTS=$LI_DATA/users.json
|
||||||
|
MAP=$LI_DATA/conf/users.conf
|
||||||
|
LI_GEN=${LI_GEN:-$(dirname "$0")/li-gen}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: li-user add|del|lien|list [nom]" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
verifie_nom() {
|
||||||
|
# Le nom voyage dans l URL et devient un nom de fichier : on le tient
|
||||||
|
# court et sans surprise plutot que d avoir a l echapper partout.
|
||||||
|
case "$1" in
|
||||||
|
*[!A-Za-z0-9_-]*|"") echo "Nom invalide : lettres, chiffres, - et _ seulement." >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
rendre_map() {
|
||||||
|
mkdir -p "$(dirname "$MAP")"
|
||||||
|
umask 077
|
||||||
|
{
|
||||||
|
echo "# Engendre par li-user — ne pas editer a la main."
|
||||||
|
echo "# Un identifiant absent d ici obtient un secret VIDE, et le vhost"
|
||||||
|
echo "# refuse avant meme de calculer la moindre signature."
|
||||||
|
echo 'map $arg_u $li_secret {'
|
||||||
|
echo ' default "";'
|
||||||
|
jq -r 'to_entries | sort_by(.key)[] | " \"\(.key)\" \"\(.value)\";"' "$AYANTS"
|
||||||
|
echo '}'
|
||||||
|
} > "$MAP.tmp"
|
||||||
|
mv "$MAP.tmp" "$MAP"
|
||||||
|
chmod 644 "$MAP" # lu par le conteneur, qui le monte en lecture seule
|
||||||
|
|
||||||
|
# shellcheck disable=SC2086 # LI_NGINX est une commande, mots compris
|
||||||
|
$LI_NGINX -t >/dev/null 2>&1 || {
|
||||||
|
echo "La configuration nginx ne passe plus le controle — rien recharge." >&2
|
||||||
|
$LI_NGINX -t
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
$LI_NGINX -s reload
|
||||||
|
}
|
||||||
|
|
||||||
|
signe_uri() { # $1 = uri, $2 = secret
|
||||||
|
printf '%s' "$LI_EXPIRES$1$2" \
|
||||||
|
| openssl md5 -binary | openssl base64 | tr '+/' '-_' | tr -d '='
|
||||||
|
}
|
||||||
|
|
||||||
|
lien() {
|
||||||
|
secret=$(jq -er --arg n "$1" '.[$n]' "$AYANTS") || {
|
||||||
|
echo "$1 : inconnu." >&2; exit 1
|
||||||
|
}
|
||||||
|
# Les deux documents portent les MEMES liens de telechargement : la
|
||||||
|
# signature ne depend que du chemin et du secret, jamais du document
|
||||||
|
# qui la transporte.
|
||||||
|
for ext in html xspf; do
|
||||||
|
uri="/playlists/$1.$ext"
|
||||||
|
printf ' %-5s %s\n' "$ext" "$BASE$uri?u=$1&e=$LI_EXPIRES&s=$(signe_uri "$uri" "$secret")"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
[ -f "$AYANTS" ] || { mkdir -p "$LI_DATA"; umask 077; echo '{}' > "$AYANTS"; }
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
add)
|
||||||
|
[ $# -eq 2 ] || usage
|
||||||
|
verifie_nom "$2"
|
||||||
|
if jq -e --arg n "$2" 'has($n)' "$AYANTS" >/dev/null; then
|
||||||
|
echo "$2 existe deja — « li-user lien $2 » pour retrouver son URL." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
secret=$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 24)
|
||||||
|
tmp=$(mktemp)
|
||||||
|
jq --arg n "$2" --arg s "$secret" '.[$n] = $s' "$AYANTS" > "$tmp"
|
||||||
|
mv "$tmp" "$AYANTS"
|
||||||
|
chmod 600 "$AYANTS"
|
||||||
|
rendre_map
|
||||||
|
"$LI_GEN"
|
||||||
|
echo
|
||||||
|
echo "Liens a transmettre a $2 :"
|
||||||
|
lien "$2"
|
||||||
|
;;
|
||||||
|
del)
|
||||||
|
[ $# -eq 2 ] || usage
|
||||||
|
jq -e --arg n "$2" 'has($n)' "$AYANTS" >/dev/null || {
|
||||||
|
echo "$2 : inconnu." >&2; exit 1
|
||||||
|
}
|
||||||
|
tmp=$(mktemp)
|
||||||
|
jq --arg n "$2" 'del(.[$n])' "$AYANTS" > "$tmp"
|
||||||
|
mv "$tmp" "$AYANTS"
|
||||||
|
chmod 600 "$AYANTS"
|
||||||
|
# Les documents partent AVANT le rechargement : un worker en cours de
|
||||||
|
# vidange a encore l ancienne map, et servirait un 404 la ou la
|
||||||
|
# reponse juste est 403.
|
||||||
|
rm -f "$LI_DATA/playlists/$2.xspf" "$LI_DATA/playlists/$2.html"
|
||||||
|
rendre_map
|
||||||
|
echo "$2 revoque — tous ses liens sont morts."
|
||||||
|
;;
|
||||||
|
lien)
|
||||||
|
[ $# -eq 2 ] || usage
|
||||||
|
lien "$2"
|
||||||
|
;;
|
||||||
|
list)
|
||||||
|
jq -r 'to_entries | sort_by(.key)[] | .key' "$AYANTS"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Bibliotheque en lecture seule pour des tiers — un nginx DEDIE, qui ne fait
|
||||||
|
# que servir des fichiers signes.
|
||||||
|
#
|
||||||
|
# docker compose --env-file /etc/li/li.env up -d
|
||||||
|
#
|
||||||
|
# POURQUOI UN SECOND NGINX. Le proxy de facade est le processus le plus expose
|
||||||
|
# de la machine ; lui monter toute la bibliotheque elargirait son rayon
|
||||||
|
# d explosion pour rien. Ici, le conteneur qui tient les fichiers ne publie
|
||||||
|
# aucun port et ne joint personne : il vit sur un reseau « internal » ou la
|
||||||
|
# facade est son seul voisin.
|
||||||
|
services:
|
||||||
|
li:
|
||||||
|
image: nginx:1.29-alpine
|
||||||
|
container_name: li
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./nginx/li.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
# La table des ayants droit contient leurs secrets : elle vit hors du
|
||||||
|
# depot, dans LI_DATA.
|
||||||
|
#
|
||||||
|
# C est le REPERTOIRE qui est monte, et non le fichier. Un bind-mount de
|
||||||
|
# fichier epingle l INODE : li-user publie sa map par un « mv » atomique,
|
||||||
|
# qui remplace l inode, et le conteneur resterait indefiniment sur
|
||||||
|
# l ancienne version — un ayant droit ajoute n existerait jamais pour
|
||||||
|
# nginx, sans la moindre erreur.
|
||||||
|
- ${LI_DATA:?}/conf:/etc/nginx/li:ro
|
||||||
|
- ${LI_DATA:?}/playlists:/srv/li/playlists:ro
|
||||||
|
# La bibliotheque, en LECTURE SEULE. Le conteneur ne peut rien ecrire
|
||||||
|
# dans la bibliotheque, meme si nginx tombait.
|
||||||
|
- ${LI_LIBRARY:?}:/srv/li/library:ro
|
||||||
|
networks: [li]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
# La facade rejoint ce reseau (« external: true » de son cote). Aucune
|
||||||
|
# sortie : « internal » interdit au conteneur de joindre quoi que ce soit.
|
||||||
|
li:
|
||||||
|
name: li
|
||||||
|
internal: true
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Configuration de li — a copier en /etc/li/li.env.
|
||||||
|
#
|
||||||
|
# Lue par li-user (source par sh), par li-gen, et par docker compose
|
||||||
|
# (--env-file). D ou la syntaxe la plus simple : CLE=valeur, guillemets
|
||||||
|
# doubles si la valeur contient une espace.
|
||||||
|
|
||||||
|
# Adresse publique, sans barre finale. Obligatoire.
|
||||||
|
LI_BASE_URL=https://li.example.org
|
||||||
|
|
||||||
|
# La bibliotheque a partager (montee en lecture seule dans le conteneur).
|
||||||
|
LI_LIBRARY=/srv/media/library
|
||||||
|
|
||||||
|
# users.json (les secrets), conf/users.conf (la map nginx), playlists/.
|
||||||
|
LI_DATA=/srv/appdata/li
|
||||||
|
|
||||||
|
# Expiration inscrite dans les URL, en secondes depuis l epoque. 2038 : les
|
||||||
|
# URL ne bougent jamais, et VLC garde sa reprise de lecture. La revocation
|
||||||
|
# passe par « li-user del », pas par l expiration.
|
||||||
|
LI_EXPIRES=2145916800
|
||||||
|
|
||||||
|
# Repertoires de premier niveau publies EN ENTIER, mais seulement dans la page
|
||||||
|
# HTML (jamais dans la playlist). Separes par des espaces. Chacun doit avoir
|
||||||
|
# son « location » avec Content-Disposition dans nginx/li.conf.
|
||||||
|
LI_HTML_ONLY=software
|
||||||
|
|
||||||
|
# Extensions retenues ailleurs — dans la playlist et dans la page.
|
||||||
|
LI_VIDEO_EXT=".mkv .mp4 .m4v .avi .mov .webm .mpg .mpeg .ts"
|
||||||
|
|
||||||
|
# Titre de la page HTML.
|
||||||
|
LI_TITLE="Bibliothèque"
|
||||||
|
|
||||||
|
# Commande nginx utilisee par li-user pour controler et recharger (« -t »,
|
||||||
|
# « -s reload » lui sont ajoutes). « nginx » tout court hors Docker.
|
||||||
|
LI_NGINX="docker exec li nginx"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Vhost de la FACADE — le nginx en frontal, qui porte le TLS et relaie vers le
|
||||||
|
# conteneur « li ». A adapter : nom, chemins du certificat, et le resolveur
|
||||||
|
# si la facade ne tourne pas dans Docker.
|
||||||
|
#
|
||||||
|
# Aucune authentification ici : le laissez-passer voyage dans l URL, et il est
|
||||||
|
# verifie par le conteneur « li », seul a connaitre les secrets. Un portail
|
||||||
|
# d authentification (auth_request, SSO…) serait de toute facon inutilisable :
|
||||||
|
# VLC ne suit pas une redirection vers une page de connexion.
|
||||||
|
|
||||||
|
# Le format « combined » par defaut journalise $request, donc la ligne de
|
||||||
|
# requete ENTIERE, donc la signature. Ce vhost a donc son propre format : la
|
||||||
|
# facade ne doit pas consigner ce que le conteneur « li » prend soin de taire.
|
||||||
|
# Un journal qui porte le laissez-passer est un journal rejouable.
|
||||||
|
log_format li_edge '$remote_addr "$arg_u" "$uri" $status $body_bytes_sent $request_time';
|
||||||
|
|
||||||
|
# Resolveur interne de Docker : indispensable des qu un proxy_pass porte une
|
||||||
|
# variable. Sans lui, nginx resout l amont UNE SEULE FOIS au demarrage, et un
|
||||||
|
# conteneur qui redemarre avec une autre adresse produit un 502 permanent.
|
||||||
|
# A retirer si ce resolveur est deja declare ailleurs dans le contexte http.
|
||||||
|
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name li.example.org;
|
||||||
|
location / { return 301 https://$host$request_uri; }
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name li.example.org;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/li.example.org/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/li.example.org/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_session_tickets off;
|
||||||
|
|
||||||
|
access_log /dev/stdout li_edge;
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
# ECRASE, jamais complete : c est ce qui rend X-Real-IP digne de la
|
||||||
|
# confiance que lui accorde le conteneur « li ».
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Sans « proxy_buffering off », nginx accumulerait le .mkv avant d en
|
||||||
|
# relayer le premier octet. La lecture ne demarrerait jamais.
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 600s;
|
||||||
|
send_timeout 600s;
|
||||||
|
|
||||||
|
set $upstream http://li:80;
|
||||||
|
proxy_pass $upstream;
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
# Vhost du conteneur « li » — un nginx DEDIE, qui ne fait que verifier des
|
||||||
|
# signatures et servir des fichiers. Il est derriere une facade (voir
|
||||||
|
# facade.conf.example) qui porte le TLS.
|
||||||
|
#
|
||||||
|
# Chemins vus DANS le conteneur (voir compose.yaml) :
|
||||||
|
# /etc/nginx/li/users.conf la map des ayants droit, engendree par li-user
|
||||||
|
# /srv/li/library/ la bibliotheque, en lecture seule
|
||||||
|
# /srv/li/playlists/ les .xspf et .html engendres par li-gen
|
||||||
|
|
||||||
|
# La table des ayants droit : « map $arg_u $li_secret ». Engendree par
|
||||||
|
# li-user, jamais editee a la main.
|
||||||
|
#
|
||||||
|
# Son « default "" » est le coeur du dispositif : un identifiant inconnu ou
|
||||||
|
# revoque n obtient pas un secret par defaut devinable, il n obtient RIEN, et
|
||||||
|
# la requete est refusee avant meme le calcul de signature.
|
||||||
|
include /etc/nginx/li/users.conf;
|
||||||
|
|
||||||
|
# L ADRESSE REELLE DU CLIENT, et non celle du proxy de facade.
|
||||||
|
#
|
||||||
|
# Sans ceci, $remote_addr vaut l adresse de la facade sur le reseau Docker :
|
||||||
|
# toutes les lectures, tous les 403 et toute la limitation de debit seraient
|
||||||
|
# imputes a un seul et meme voisin.
|
||||||
|
#
|
||||||
|
# La confiance porte sur les plages privees et non sur le sous-reseau du
|
||||||
|
# jour : il est ATTRIBUE par Docker, et un reseau recree peut en changer. Une
|
||||||
|
# plage figee ferait alors silencieusement reapparaitre l adresse du proxy
|
||||||
|
# dans le journal — exactement le genre de panne qui ne se voit pas. Le risque
|
||||||
|
# est nul tant que le reseau est « internal » et que la facade y est le seul
|
||||||
|
# voisin de ce conteneur.
|
||||||
|
#
|
||||||
|
# X-Real-IP n est pas falsifiable : la facade l ECRASE a chaque requete avec
|
||||||
|
# le $remote_addr qu elle constate, et c est elle qui est en frontal.
|
||||||
|
set_real_ip_from 10.0.0.0/8;
|
||||||
|
set_real_ip_from 172.16.0.0/12;
|
||||||
|
set_real_ip_from 192.168.0.0/16;
|
||||||
|
real_ip_header X-Real-IP;
|
||||||
|
|
||||||
|
# Une zone par ayant droit, et non par adresse : trois flux simultanes depuis
|
||||||
|
# trois pays, c est du partage de lien, et cela se voit.
|
||||||
|
limit_conn_zone $arg_u zone=li_ayants:1m;
|
||||||
|
|
||||||
|
# Le journal d ACCES ne contient jamais $request_uri. La signature EST le
|
||||||
|
# laissez-passer : un journal qui la porte est un journal rejouable, et il est
|
||||||
|
# lu par plus de monde que la table des secrets. $uri est le chemin decode,
|
||||||
|
# sans les arguments. nginx echappe lui-meme les caracteres speciaux de $arg_u.
|
||||||
|
#
|
||||||
|
# Le journal d ERREUR, lui, porte la ligne de requete entiere — donc la
|
||||||
|
# signature — et nginx ne sait pas l en priver. Le cas est etroit : une
|
||||||
|
# signature VALIDE dont le fichier a disparu. Il sort sur stderr, donc dans
|
||||||
|
# les journaux Docker, lisibles du seul root de l hote.
|
||||||
|
log_format li '$remote_addr "$arg_u" "$uri" $status $body_bytes_sent $request_time';
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /srv/li;
|
||||||
|
autoindex off;
|
||||||
|
access_log /dev/stdout li;
|
||||||
|
|
||||||
|
# « hash,expiration » lus dans les arguments de l URL. La signature porte
|
||||||
|
# sur $uri, c est-a-dire le chemin DECODE : le generateur signe donc le
|
||||||
|
# chemin brut et n encode qu au moment d ecrire l URL. Une signature
|
||||||
|
# calculee sur la forme encodee donne un 403 sur tout titre accentue.
|
||||||
|
secure_link $arg_s,$arg_e;
|
||||||
|
secure_link_md5 "$secure_link_expires$uri$li_secret";
|
||||||
|
|
||||||
|
# Ces trois controles sont au niveau server et non dans un location : la
|
||||||
|
# phase de reecriture s execute AVANT le choix du location, ils couvrent
|
||||||
|
# donc les deux arborescences sans etre ecrits deux fois. « if » suivi de
|
||||||
|
# « return » est la seule forme que la documentation nginx ne deconseille
|
||||||
|
# pas.
|
||||||
|
if ($li_secret = "") { return 403; } # inconnu, ou revoque
|
||||||
|
if ($secure_link = "") { return 403; } # signature fausse ou absente
|
||||||
|
if ($secure_link = "0") { return 410; } # lien perime
|
||||||
|
|
||||||
|
limit_conn li_ayants 3;
|
||||||
|
limit_rate_after 16m; # les premiers 16 Mo a pleine vitesse : demarrage
|
||||||
|
limit_rate 12m; # et avance rapide restent francs
|
||||||
|
sendfile_max_chunk 512k;
|
||||||
|
|
||||||
|
add_header Cache-Control "private, no-store" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
|
||||||
|
location /library/ { }
|
||||||
|
|
||||||
|
# Le sous-arbre « software » se TELECHARGE, il ne s affiche pas.
|
||||||
|
#
|
||||||
|
# On y depose du .txt, du .html, du .svg — que le navigateur rendrait
|
||||||
|
# volontiers DANS la page, sur l origine de la bibliotheque. Ce serveur ne
|
||||||
|
# pose aucun cookie et n a pas de session a voler. Mais un depot de
|
||||||
|
# fichiers qui execute ce qu on y met est une surprise qu on s epargne
|
||||||
|
# pour trois lignes.
|
||||||
|
#
|
||||||
|
# Ce chemin doit rester d accord avec LI_HTML_ONLY (li.env) : un
|
||||||
|
# repertoire de plus la-bas, c est un location de plus ici.
|
||||||
|
location /library/software/ {
|
||||||
|
# « add_header » dans un location REMPLACE ceux du bloc parent au lieu
|
||||||
|
# de s y ajouter. Les deux en-tetes du niveau server sont donc repris
|
||||||
|
# ici : sans cela, ce sous-arbre perdrait son « no-store ».
|
||||||
|
add_header Cache-Control "private, no-store" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Content-Disposition "attachment" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ce repertoire contient DEUX documents par ayant droit : sa playlist
|
||||||
|
# <nom>.xspf et sa page <nom>.html. Ils portent exactement les memes URL
|
||||||
|
# signees — la signature ne depend que du chemin et du secret, jamais du
|
||||||
|
# document qui la transporte.
|
||||||
|
#
|
||||||
|
# Et ils se typent tout seuls, chacun par une voie differente :
|
||||||
|
# .html — connu de mime.types, donc servi en text/html sans rien faire ;
|
||||||
|
# .xspf — inconnu de mime.types, donc rattrape par le default_type.
|
||||||
|
#
|
||||||
|
# D ou « default_type » et NON un bloc « types » : « types » REMPLACE la
|
||||||
|
# table heritee de mime.types au lieu de s y ajouter. Un bloc « types » ici
|
||||||
|
# ferait perdre au .html son type — et aux .mkv le leur.
|
||||||
|
location /playlists/ { default_type application/xspf+xml; }
|
||||||
|
|
||||||
|
location / { return 404; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Regeneration des playlists de la bibliotheque li
|
||||||
|
After=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/li-gen
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Les nouveautes apparaissent dans les playlists sans intervention. Les URL,
|
||||||
|
# elles, ne bougent pas : la signature ne depend que du chemin et du secret.
|
||||||
|
[Unit]
|
||||||
|
Description=Regeneration nocturne des playlists de la bibliotheque li
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 05:20:00
|
||||||
|
Persistent=true
|
||||||
|
RandomizedDelaySec=300
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""python3 -m unittest discover tests"""
|
||||||
|
import importlib.machinery
|
||||||
|
import importlib.util
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_chargeur = importlib.machinery.SourceFileLoader(
|
||||||
|
"li_gen", str(Path(__file__).resolve().parent.parent / "bin" / "li-gen"))
|
||||||
|
_spec = importlib.util.spec_from_loader("li_gen", _chargeur)
|
||||||
|
li_gen = importlib.util.module_from_spec(_spec)
|
||||||
|
_chargeur.exec_module(li_gen)
|
||||||
|
|
||||||
|
|
||||||
|
class Signature(unittest.TestCase):
|
||||||
|
def test_identique_a_openssl(self):
|
||||||
|
# Ce que calcule li-user, donc ce que verifie secure_link_md5.
|
||||||
|
chemin, secret = "/library/movies/Été à Paris/The Janitor’s Boy.mkv", "abc123"
|
||||||
|
brut = f"2145916800{chemin}{secret}"
|
||||||
|
attendu = subprocess.run(
|
||||||
|
["sh", "-c", "printf '%s' \"$1\" | openssl md5 -binary | openssl base64 "
|
||||||
|
"| tr '+/' '-_' | tr -d '='", "sh", brut],
|
||||||
|
check=True, capture_output=True, text=True).stdout.strip()
|
||||||
|
self.assertEqual(li_gen.signe(chemin, 2145916800, secret), attendu)
|
||||||
|
|
||||||
|
def test_signe_le_chemin_decode(self):
|
||||||
|
cfg = li_gen.Config({"LI_BASE_URL": "https://li.example.org/"})
|
||||||
|
u = li_gen.url(cfg, "/library/a b’c.mkv", "alice", "s3cret")
|
||||||
|
self.assertTrue(u.startswith("https://li.example.org/library/a%20b%E2%80%99c.mkv?"))
|
||||||
|
self.assertTrue(u.endswith("&s=" + li_gen.signe("/library/a b’c.mkv", 2145916800, "s3cret")))
|
||||||
|
|
||||||
|
|
||||||
|
class Configuration(unittest.TestCase):
|
||||||
|
def test_base_obligatoire(self):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
li_gen.Config({})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user