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:
2026-09-27 10:06:08 +02:00
co-authored by Claude Opus 5.5
commit 1e63e4fe80
12 changed files with 1001 additions and 0 deletions
Executable
+362
View File
@@ -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
View File
@@ -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