From 5a434e9e168d27426f375ca721039e8391cc95d5 Mon Sep 17 00:00:00 2001 From: Doalo <83450601+Doalou@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:35:54 +0200 Subject: [PATCH] release: publish Toolbox Everything 2.0.1 --- .dockerignore | 2 + .github/workflows/codeql.yml | 38 ----- .github/workflows/docker-publish.yml | 2 +- .gitignore | 2 + CHANGELOG.md | 39 +++++ Dockerfile | 41 +++-- Makefile | 2 +- README.md | 105 ++++++++++-- VERSION | 2 +- app/__init__.py | 2 +- app/services/downloader/routes.py | 103 ++++++++++-- app/services/main.py | 16 ++ app/static/css/style.css | 235 ++++++++++++++++++++++++++- app/templates/base.html | 14 +- app/templates/essentials/qr.html | 2 +- app/templates/legal/privacy.html | 166 +++++++++++++++++++ app/templates/legal/terms.html | 144 ++++++++++++++++ compose.yml | 9 +- env.example | 17 +- requirements.txt | 2 + tests/test_downloader_options.py | 113 +++++++++++++ tests/test_smoke.py | 35 +++- 22 files changed, 997 insertions(+), 94 deletions(-) delete mode 100644 .github/workflows/codeql.yml create mode 100644 app/templates/legal/privacy.html create mode 100644 app/templates/legal/terms.html create mode 100644 tests/test_downloader_options.py diff --git a/.dockerignore b/.dockerignore index 8026fb8..c4b6b6d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -35,6 +35,8 @@ Thumbs.db logs/ uploads/ downloads/ +secrets/ +youtube-cookies.txt bin/ build/ dist/ diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 17d92ea..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: "CodeQL" - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - schedule: - - cron: '0 1 * * 0' - -permissions: - actions: read - contents: read - security-events: write - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - strategy: - fail-fast: false - matrix: - language: [ 'python' ] - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - - name: Autobuild - uses: github/codeql-action/autobuild@v4 - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 22f0bb7..9f5898e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -54,7 +54,7 @@ jobs: echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" echo "minor=${MAJOR}.${MINOR}" >> "${GITHUB_OUTPUT}" - # `release=true` => on publie les tags de version IMMUABLES (X.Y.Z, X.Y). + # `release=true` => on publie X.Y.Z (immuable) et X.Y (canal mineur mobile). # Vrai uniquement sur un tag Git, ou sur un push main qui modifie VERSION. # Ainsi le cron nocturne et les commits sans bump ne réécrivent que `latest`. RELEASE=false diff --git a/.gitignore b/.gitignore index 06a39fb..9990777 100644 --- a/.gitignore +++ b/.gitignore @@ -176,6 +176,8 @@ cython_debug/ uploads/ downloads/ logs/ +/secrets/ +youtube-cookies.txt # Tailwind — CSS généré (rebuild via Dockerfile ou `make tailwind-build`) app/static/css/tailwind.css diff --git a/CHANGELOG.md b/CHANGELOG.md index f3fc297..ca65a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,45 @@ et ce projet adhère au [Versionnage Sémantique](https://semver.org/lang/fr/). --- +## [2.0.1] - 2026-08-08 + +### Informations légales + +- Ajout d'une politique de confidentialité commune au site Web et à + l'application mobile, accessible sur `/confidentialite`. +- Ajout des conditions d'utilisation sur `/conditions-utilisation`, avec un + encadrement explicite des téléchargements et contenus tiers. +- Ajout d'alias stables `/privacy` et `/terms`, des liens dans le pied de page + et de tests de disponibilité. +- Documentation des traitements locaux, fichiers temporaires, journaux, + limitations de débit, services réseau tiers et droits RGPD. +- Identification de l'association Nerieum comme éditrice et exploitante du + service dans les pages légales, le pied de page et les métadonnées de l'image. + +### Téléchargeur YouTube + +- Ajout de Deno 2.9.5 et `yt-dlp-ejs` dans l'image pour résoudre les challenges + JavaScript récents de YouTube. +- Prise en charge optionnelle d'un export Netscape de cookies via + `YTDLP_COOKIES_FILE`, conservé hors du dépôt et monté en lecture seule. +- Cookie jar inscriptible conservé dans un volume Docker, initialisé depuis le + secret en lecture seule et partagé sous verrou entre les workers Gunicorn. +- Réinitialisation automatique du jar persistant lorsque l'empreinte SHA-256 de + l'export source change. +- Message explicite et statut `503` lorsque YouTube exige une session serveur. +- Correction du répertoire personnel de l'utilisateur non-root et désactivation + du control socket Gunicorn incompatible avec les permissions du conteneur. + +### Distribution et documentation + +- Références GitHub et GHCR alignées sur le nouvel emplacement + `Nerieum/ToolboxEverything` du dépôt. +- Publication de l'URL de l'instance officielle et documentation de la procédure + d'export, de montage et de renouvellement des cookies YouTube. +- Suppression du workflow CodeQL redondant avec l'analyse par défaut de GitHub. + +--- + ## [2.0.0] - 2026-07-19 Refonte de fond en comble sur trois fronts (design, backend, outillage), à diff --git a/Dockerfile b/Dockerfile index c307b4c..b26d883 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,21 @@ # ===================================================== # Toolbox Everything — image Docker (multi-stage) # -# 1. py-builder : installe les deps Python dans /opt/venv -# 2. css-builder : compile Tailwind (binaire Go standalone, pas de Node) -# 3. runtime : image finale, zéro outil de build +# 1. deno-bin : fournit le runtime JS recommandé par yt-dlp +# 2. py-builder : installe les deps Python dans /opt/venv +# 3. css-builder : compile Tailwind (binaire Go standalone, pas de Node) +# 4. runtime : image finale, zéro outil de build # ===================================================== ARG TAILWIND_VERSION=4.3.3 +ARG DENO_VERSION=2.9.5 # ----------------------------------------------------- -# 1) Build des deps Python +# 1) Runtime JavaScript pour les challenges YouTube EJS +# ----------------------------------------------------- +FROM denoland/deno:bin-${DENO_VERSION} AS deno-bin + +# ----------------------------------------------------- +# 2) Build des deps Python # ----------------------------------------------------- FROM python:3.12-slim AS py-builder @@ -19,7 +26,7 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # ----------------------------------------------------- -# 2) Build du CSS Tailwind +# 3) Build du CSS Tailwind # Même pipeline que le dev local : script Python + CLI standalone. # Le binaire est téléchargé dans ce stage, puis jeté. # ----------------------------------------------------- @@ -40,23 +47,26 @@ RUN python scripts/tailwind.py build && \ python scripts/tailwind.py check # ----------------------------------------------------- -# 3) Image finale +# 4) Image finale # ----------------------------------------------------- FROM python:3.12-slim -LABEL maintainer="toolbox-everything" +LABEL maintainer="Association Nerieum" LABEL description="Toolbox Everything - Une boite a outils web complete" -LABEL org.opencontainers.image.source="https://github.com/doalou/toolbox_everything" -LABEL org.opencontainers.image.documentation="https://github.com/doalou/toolbox_everything/README.md" +LABEL org.opencontainers.image.authors="Association Nerieum" +LABEL org.opencontainers.image.vendor="Association Nerieum" +LABEL org.opencontainers.image.source="https://github.com/Nerieum/ToolboxEverything" +LABEL org.opencontainers.image.documentation="https://github.com/Nerieum/ToolboxEverything/blob/main/README.md" -# FFmpeg est la seule dépendance système du runtime. Le healthcheck utilise -# urllib (stdlib), donc curl n'est plus nécessaire dans l'image. +# FFmpeg est la seule dépendance installée via APT. Le healthcheck utilise +# urllib (stdlib), et Deno est copié depuis son image binaire officielle. RUN apt-get update && apt-get install -y --no-install-recommends \ ffmpeg \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* COPY --from=py-builder /opt/venv /opt/venv +COPY --from=deno-bin /deno /usr/local/bin/deno ENV PATH="/opt/venv/bin:$PATH" WORKDIR /app @@ -71,16 +81,17 @@ ENV PYTHONUNBUFFERED=1 ENV FLASK_APP=run.py ENV FLASK_ENV=production ENV DOCKER_ENV=1 +ENV HOME=/home/toolbox EXPOSE 8000 RUN groupadd -r toolbox && \ - useradd -r -g toolbox toolbox && \ - mkdir -p uploads/temp logs && \ - chown -R toolbox:toolbox /app + useradd -r -m -d /home/toolbox -g toolbox toolbox && \ + mkdir -p uploads/temp logs /home/toolbox/.cache/deno /var/lib/toolbox/yt-dlp && \ + chown -R toolbox:toolbox /app /home/toolbox /var/lib/toolbox USER toolbox # Une clé éphémère unique est créée au démarrage du conteneur si aucune clé # persistante n'est fournie. Elle n'est ainsi jamais figée dans une couche. CMD export SECRET_KEY="${SECRET_KEY:-$(python -c 'import secrets; print(secrets.token_hex(32))')}" && \ exec gunicorn --bind 0.0.0.0:8000 --workers 4 --threads 2 --timeout 900 \ - --access-logfile - --error-logfile - run:app + --no-control-socket --access-logfile - --error-logfile - run:app diff --git a/Makefile b/Makefile index 5afd13c..36e2d74 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ PIP := pip FLASK_APP := run.py PORT := 8000 VERSION := $(shell if [ -f VERSION ]; then tr -d '\r\n' < VERSION; else echo "0.0.0"; fi) -GHCR_IMAGE ?= ghcr.io/doalou/toolbox_everything +GHCR_IMAGE ?= ghcr.io/nerieum/toolboxeverything TAILWIND_VERSION := 4.3.3 TAILWIND := $(PYTHON) scripts/tailwind.py diff --git a/README.md b/README.md index 1310fdc..8b7bbb5 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,16 @@ # Toolbox Everything > Une boîte à outils web, modulaire et sans prise de tête : télécharger une vidéo (YouTube, Vimeo, Dailymotion, TikTok), convertir un média, bidouiller un QR code ou un hash, et manipuler des PDF sans quitter son navigateur. -# Le projet est actuellement en cours de refonte, la description ci-dessous correspond à l'ancienne version et une nouvelle version sera bientôt proposée. Stack : **Flask + Tailwind**, tout en Docker, prêt à être posé derrière un reverse proxy. -Interface **sobre et utilitaire** (v2.0.0) : couche de tokens CSS, thème clair/sombre +Interface **sobre et utilitaire** (v2.0.1) : couche de tokens CSS, thème clair/sombre piloté par variables, Fraunces self-hébergée et zéro dépendance front tierce au runtime. -![version](https://img.shields.io/badge/version-2.0.0-blue) +Site public : + +Toolbox Everything est édité et exploité par l'**association Nerieum**. + +![version](https://img.shields.io/badge/version-2.0.1-blue) ![python](https://img.shields.io/badge/python-3.12-3776AB?logo=python&logoColor=white) ![flask](https://img.shields.io/badge/flask-3.1-000000?logo=flask) ![license](https://img.shields.io/badge/license-MIT-green) @@ -33,7 +36,7 @@ piloté par variables, Fraunces self-hébergée et zéro dépendance front tierc Un **unique** `compose.yml` couvre les deux modes de déploiement, choix via `TOOLBOX_IMAGE` : ```bash -git clone https://github.com/doalou/toolbox_everything.git +git clone https://github.com/Nerieum/ToolboxEverything.git cd toolbox_everything cp env.example .env # édite au moins SECRET_KEY docker compose up -d --build # build local (dev / CI) @@ -42,7 +45,7 @@ docker compose up -d --build # build local (dev / CI) Ou en prod avec l'image publique GHCR : ```bash -export TOOLBOX_IMAGE=ghcr.io/doalou/toolbox_everything:2.0.0 +export TOOLBOX_IMAGE=ghcr.io/nerieum/toolboxeverything:2.0.1 docker compose pull && docker compose up -d ``` @@ -90,19 +93,92 @@ Tout se passe dans `.env` (copié depuis `env.example`) : | `FLASK_ENV` | `development` ou `production` | `production` | | `MAX_CONTENT_LENGTH` | Taille max des uploads (octets) | `536870912` (512 MB) | | `FFMPEG_PATH` | Chemin explicite vers FFmpeg | auto-détecté (`shutil.which`) | +| `YTDLP_COOKIES_FILE` | Fichier Netscape de cookies pour yt-dlp | désactivé | +| `YTDLP_COOKIES_STATE_DIR` | Cookie jar inscriptible et persistant | `/var/lib/toolbox/yt-dlp` | +| `YTDLP_USER_AGENT` | User-Agent associé aux cookies yt-dlp | défaut yt-dlp | +| `YTDLP_DENO_PATH` | Chemin du runtime Deno | auto-détecté (`shutil.which`) | | `STIRLING_PDF_URL` | URL **interne** de Stirling PDF (healthcheck serveur) | `http://stirling-pdf:8080` | | `STIRLING_PDF_PUBLIC_URL` | URL **publique** utilisée par l'iframe (navigateur) | `http://localhost:8080` | | `LIBRESPEED_URL` | URL **interne** de LibreSpeed (healthcheck serveur) | `http://librespeed` | | `LIBRESPEED_PUBLIC_URL` | URL **publique** utilisée par l'iframe (navigateur) | `http://localhost:8081` | | `RATELIMIT_STORAGE_URI` | Backend du rate limiter (Redis en prod) | `redis://redis:6379/0` | -| `TOOLBOX_IMAGE` | Image Docker à tirer depuis GHCR | `ghcr.io/doalou/toolbox_everything:2.0.0` | +| `TOOLBOX_IMAGE` | Image Docker à tirer depuis GHCR | `ghcr.io/nerieum/toolboxeverything:2.0.1` | | `TOOLBOX_PORT` / `STIRLING_PORT` / `LIBRESPEED_PORT` | Ports hôte exposés | `8000` / `8080` / `8081` | +### YouTube : Deno et authentification + +L'image Docker contient déjà Deno et `yt-dlp-ejs`. Ils permettent à yt-dlp de +résoudre les challenges JavaScript de YouTube et ne demandent aucune +configuration. + +Un fichier de cookies est uniquement nécessaire lorsque YouTube répond +« Sign in to confirm you're not a bot ». Le `.env` contient alors le chemin du +fichier, jamais les cookies eux-mêmes ni un token. + +#### Préparer le fichier de cookies + +1. Ouvrir une fenêtre privée, puis se connecter avec un compte YouTube dédié. +2. Dans le même onglet, ouvrir `https://www.youtube.com/robots.txt`. +3. Exporter les cookies `youtube.com` au format Netscape avec + **Get cookies.txt LOCALLY** (Chrome/Chromium) ou **cookies.txt** (Firefox). +4. Fermer la fenêtre privée et ne plus utiliser cette session dans le navigateur. + +Les liens des extensions et les précautions à suivre sont maintenus dans la +[documentation officielle yt-dlp](https://github.com/yt-dlp/yt-dlp/wiki/Extractors#exporting-youtube-cookies). +Ne jamais utiliser l'ancienne extension Chrome **Get cookies.txt** sans +« LOCALLY », signalée comme malveillante par yt-dlp. + +Déposer ensuite le fichier à la racine du projet : + +```bash +install -d -m 700 secrets +install -m 644 /path/to/youtube-cookies.txt secrets/youtube-cookies.txt +``` + +Le dossier privé protège le fichier sur l'hôte. Le mode `644` permet au +processus non-root du conteneur de le lire ; le montage Docker reste en lecture +seule. Pour laisser yt-dlp actualiser son cookie jar sans modifier le secret, +l'application initialise une copie inscriptible dans le volume Docker +`toolbox_ytdlp_state`, puis conserve les mises à jour reçues de YouTube. Les +workers partagent ce jar sous verrou. Le remplacement du fichier source +réinitialise automatiquement la copie grâce à son empreinte SHA-256. Le dossier +`secrets/` est exclu de Git et du contexte de build. + +#### Activer les cookies + +Dans `.env` : + +```dotenv +YTDLP_COOKIES_FILE=/run/secrets/youtube-cookies.txt +YTDLP_COOKIES_STATE_DIR=/var/lib/toolbox/yt-dlp +``` + +Dans le service `toolbox` de `compose.yml` : + +```yaml +volumes: + - ./secrets/youtube-cookies.txt:/run/secrets/youtube-cookies.txt:ro +``` + +Recréer le service puis tester la session : + +```bash +docker compose up -d --force-recreate toolbox +docker compose exec toolbox yt-dlp \ + --cookies /run/secrets/youtube-cookies.txt \ + --simulate --print title 'https://www.youtube.com/watch?v=BaW_jenozKc' +``` + +Si YouTube redemande une connexion, refaire l'export et remplacer le fichier. +Un PO Token est un mécanisme différent et n'est pas configuré par ce projet ; +le [guide yt-dlp](https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide) ne devient +pertinent que si l'erreur mentionne explicitement un PO Token. + --- ## Versioning et images GHCR -La version de référence est `VERSION`. Pour la v2.0.0, elle alimente : +La version de référence est `VERSION`. Pour la v2.0.1, elle alimente : - La version affichée dans le footer et `/health`. - Les tags locaux générés par `make docker-build`. @@ -111,9 +187,9 @@ La version de référence est `VERSION`. Pour la v2.0.0, elle alimente : Images publiées : ```bash -ghcr.io/doalou/toolbox_everything:2.0.0 -ghcr.io/doalou/toolbox_everything:2.0 -ghcr.io/doalou/toolbox_everything:latest +ghcr.io/nerieum/toolboxeverything:2.0.1 +ghcr.io/nerieum/toolboxeverything:2.0 +ghcr.io/nerieum/toolboxeverything:latest ``` Règle de release : @@ -122,16 +198,17 @@ Règle de release : 2. Reporter la version dans le badge README, les exemples GHCR et `CHANGELOG.md`. 3. Merger sur `main`. 4. Le workflow Docker détecte le bump de `VERSION` sur ce push et publie les tags - **immuables** `X.Y.Z` et `X.Y`, plus `latest`. Les commits sans bump (et le cron - nocturne) ne réécrivent que `latest`. + `X.Y.Z` (immuable), `X.Y` (dernière correction de la branche mineure) et + `latest`. Les commits sans bump, ainsi que le cron nocturne, ne réécrivent que + `latest`. 5. Le workflow `Release tag` crée en parallèle le tag Git `vX.Y.Z` (marqueur d'historique). Exemple pour publier une nouvelle version : ```bash -echo 2.0.1 > VERSION +echo 2.0.2 > VERSION git add VERSION CHANGELOG.md README.md -git commit -m "Release 2.0.1" +git commit -m "Release 2.0.2" git push origin main ``` diff --git a/VERSION b/VERSION index 359a5b9..38f77a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.0 \ No newline at end of file +2.0.1 diff --git a/app/__init__.py b/app/__init__.py index 8334efc..140d5e0 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -20,7 +20,7 @@ def _read_version() -> str: version_file = Path(__file__).resolve().parent.parent / "VERSION" if version_file.exists(): return version_file.read_text(encoding="utf-8").strip() - return "2.0.0" + return "2.0.1" __version__ = _read_version() diff --git a/app/services/downloader/routes.py b/app/services/downloader/routes.py index ef317f1..245e942 100644 --- a/app/services/downloader/routes.py +++ b/app/services/downloader/routes.py @@ -16,9 +16,13 @@ import os import shutil import tempfile +from collections.abc import Iterator +from contextlib import contextmanager, suppress +from pathlib import Path from typing import Any from urllib.parse import urlparse +from filelock import FileLock from flask import ( Blueprint, after_this_request, @@ -91,25 +95,103 @@ def _get_format_string(quality: str) -> str: def _common_ydl_opts() -> dict[str, Any]: - return { + opts: dict[str, Any] = { "quiet": True, "no_warnings": True, "noplaylist": True, "retries": 3, "fragment_retries": 3, - "http_headers": { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/126.0.0.0 Safari/537.36" - ) - }, } + deno_path = os.getenv("YTDLP_DENO_PATH", "").strip() or shutil.which("deno") + if deno_path: + opts["js_runtimes"] = {"deno": {"path": deno_path}} + + cookie_file = os.getenv("YTDLP_COOKIES_FILE", "").strip() + if cookie_file: + opts["cookiefile"] = cookie_file + + user_agent = os.getenv("YTDLP_USER_AGENT", "").strip() + if user_agent: + opts["http_headers"] = {"User-Agent": user_agent} + + return opts + + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(64 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _prepare_cookie_jar(source_file: str, state_dir: Path) -> str: + """Initialise le cookie jar persistant et le rafraîchit si la source change.""" + source = Path(source_file) + state_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + cookie_jar = state_dir / "youtube-cookies.txt" + source_marker = state_dir / "youtube-cookies.source.sha256" + source_digest = _file_sha256(source) + + try: + previous_digest = source_marker.read_text(encoding="ascii").strip() + except OSError: + previous_digest = "" + + if not cookie_jar.is_file() or previous_digest != source_digest: + cookie_fd, temporary_name = tempfile.mkstemp( + prefix=".youtube-cookies-", suffix=".tmp", dir=state_dir + ) + os.close(cookie_fd) + temporary_cookie_jar = Path(temporary_name) + try: + shutil.copyfile(source, temporary_cookie_jar) + temporary_cookie_jar.chmod(0o600) + os.replace(temporary_cookie_jar, cookie_jar) + source_marker.write_text(source_digest, encoding="ascii") + source_marker.chmod(0o600) + finally: + with suppress(FileNotFoundError): + temporary_cookie_jar.unlink() + + return str(cookie_jar) + + +@contextmanager +def _youtube_dl(extra_opts: dict[str, Any] | None = None) -> Iterator[YoutubeDL]: + """Crée une instance yt-dlp avec un cookie jar persistant et sérialisé.""" + opts = {**_common_ydl_opts(), **(extra_opts or {})} + source_cookie_file = opts.pop("cookiefile", None) + + if not source_cookie_file: + with YoutubeDL(opts) as ydl: + yield ydl + return + + configured_state_dir = os.getenv("YTDLP_COOKIES_STATE_DIR", "").strip() + state_dir = ( + Path(configured_state_dir) + if configured_state_dir + else Path(tempfile.gettempdir()) / "toolbox-ytdlp" + ) + state_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + + with FileLock(state_dir / "youtube-cookies.lock", timeout=900): + opts["cookiefile"] = _prepare_cookie_jar(source_cookie_file, state_dir) + with YoutubeDL(opts) as ydl: + yield ydl + def _classify_yt_error(message: str) -> tuple[int, str]: """Transforme une erreur yt-dlp en couple (status, message humain).""" msg_lower = message.lower() + if "not a bot" in msg_lower: + return ( + 503, + "YouTube bloque les requêtes anonymes de cette instance. " + "Une session serveur valide est nécessaire.", + ) if "video unavailable" in msg_lower or "private video" in msg_lower: return 400, "Cette vidéo n'est pas accessible (privée, supprimée ou géo-restreinte)." if "sign in to confirm your age" in msg_lower: @@ -147,7 +229,7 @@ def get_video_info(): return jsonify(_REJECTION_PAYLOAD), 400 try: - with YoutubeDL({**_common_ydl_opts(), "extract_flat": False}) as ydl: + with _youtube_dl({"extract_flat": False}) as ydl: info = ydl.extract_info(url, download=False) if info is None: return ( @@ -214,7 +296,6 @@ def _cleanup(response): try: base_opts = { - **_common_ydl_opts(), "outtmpl": os.path.join(temp_dir, "%(title)s.%(ext)s"), "ffmpeg_location": ffmpeg_path, } @@ -239,7 +320,7 @@ def _cleanup(response): "concurrent_fragment_downloads": 4, } - with YoutubeDL(ydl_opts) as ydl: + with _youtube_dl(ydl_opts) as ydl: info = ydl.extract_info(url, download=True) if not info: return jsonify({"error": "Impossible de télécharger la vidéo."}), 400 diff --git a/app/services/main.py b/app/services/main.py index 6da02e2..39b4f08 100644 --- a/app/services/main.py +++ b/app/services/main.py @@ -294,6 +294,22 @@ def create_app(config_class: object | None = None) -> Flask: def index(): return render_template("index.html") + @app.route("/confidentialite") + def privacy_policy(): + return render_template("legal/privacy.html") + + @app.route("/conditions-utilisation") + def terms_of_use(): + return render_template("legal/terms.html") + + @app.route("/privacy") + def privacy_redirect(): + return redirect(url_for("privacy_policy"), code=308) + + @app.route("/terms") + def terms_redirect(): + return redirect(url_for("terms_of_use"), code=308) + @app.route("/downloader") def downloader_redirect(): return redirect(url_for("downloader.index")) diff --git a/app/static/css/style.css b/app/static/css/style.css index 7bc211f..3b2ee13 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -2592,8 +2592,241 @@ input, select, textarea { color: var(--ink); } +.site-footer__links { + display: flex; + align-items: center; + gap: 1rem; +} + +.site-footer__github { + display: inline-flex; +} + +@media (max-width: 640px) { + .site-footer__row { + flex-wrap: wrap; + gap: 0.75rem 1rem; + } + + .site-footer__motto { + display: none; + } + + .site-footer__links { + gap: 0.75rem; + font-size: 0.78rem; + } +} + +/* ------------------------------------------------------------------ */ +/* 14. Pages légales */ +/* ------------------------------------------------------------------ */ +.legal-page { + width: min(100%, 70rem); + margin: 0 auto; + padding: 1.5rem 1rem 4rem; +} + +.legal-hero { + position: relative; + padding: clamp(2rem, 6vw, 4.5rem) clamp(1.25rem, 5vw, 4rem); + overflow: hidden; + border: 1px solid var(--line-strong); + background: var(--surface); +} + +.legal-hero::after { + position: absolute; + top: -3.5rem; + right: -3.5rem; + width: 9rem; + height: 9rem; + border: 1.25rem solid var(--brand-blue-soft); + transform: rotate(45deg); + content: ""; +} + +.legal-hero--terms::after { + border-color: var(--brand-pink-soft); +} + +.legal-hero__eyebrow, +.legal-hero__updated, +.legal-toc > span { + color: var(--faint); + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.legal-hero h1 { + max-width: 48rem; + margin-top: 0.8rem; + color: var(--ink); + font-family: var(--font-display); + font-size: clamp(2.6rem, 7vw, 5.2rem); + font-weight: 580; + letter-spacing: -0.045em; + line-height: 0.96; +} + +.legal-hero__lede { + max-width: 42rem; + margin-top: 1.4rem; + color: var(--ink-soft); + font-size: clamp(1rem, 2vw, 1.15rem); + line-height: 1.65; +} + +.legal-hero__updated { + margin-top: 1.5rem; +} + +.legal-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + margin-top: 1rem; + border: 1px solid var(--line-strong); + background: var(--line-strong); +} + +.legal-summary > div { + display: flex; + min-height: 8rem; + flex-direction: column; + gap: 0.55rem; + padding: 1.25rem; + background: var(--surface); +} + +.legal-summary strong { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; +} + +.legal-summary span { + color: var(--muted); + font-size: 0.84rem; + line-height: 1.55; +} + +.legal-layout { + display: grid; + grid-template-columns: 12rem minmax(0, 1fr); + gap: clamp(2rem, 6vw, 5rem); + margin-top: clamp(2.5rem, 7vw, 5rem); +} + +.legal-toc { + position: sticky; + top: 6.5rem; + display: flex; + height: max-content; + flex-direction: column; + gap: 0.7rem; + padding-left: 1rem; + border-left: 1px solid var(--line-strong); +} + +.legal-toc > span { + margin-bottom: 0.25rem; +} + +.legal-toc a { + color: var(--muted); + font-size: 0.82rem; + text-decoration: none; + transition: color var(--dur-fast) ease; +} + +.legal-toc a:hover { + color: var(--accent-ink); +} + +.legal-copy { + min-width: 0; +} + +.legal-copy section { + padding-bottom: 2.5rem; + scroll-margin-top: 6.5rem; +} + +.legal-copy section + section { + padding-top: 2.5rem; + border-top: 1px solid var(--line); +} + +.legal-copy h2 { + color: var(--ink); + font-family: var(--font-display); + font-size: clamp(1.6rem, 4vw, 2.15rem); + font-weight: 600; + letter-spacing: -0.025em; +} + +.legal-copy h3 { + margin-top: 1.5rem; + color: var(--ink-soft); + font-size: 0.95rem; + font-weight: 700; +} + +.legal-copy p, +.legal-copy li { + color: var(--muted); + font-size: 0.93rem; + line-height: 1.75; +} + +.legal-copy p { + margin-top: 0.9rem; +} + +.legal-copy ul { + display: grid; + gap: 0.55rem; + margin: 1rem 0 0; + padding-left: 1.25rem; + list-style: square; +} + +.legal-copy a { + color: var(--accent-ink); + font-weight: 600; + text-underline-offset: 0.2em; +} + +@media (max-width: 760px) { + .legal-summary { + grid-template-columns: 1fr; + } + + .legal-summary > div { + min-height: 0; + } + + .legal-layout { + grid-template-columns: 1fr; + } + + .legal-toc { + position: static; + display: none; + } + + .legal-hero::after { + width: 6rem; + height: 6rem; + } +} + /* ------------------------------------------------------------------ */ -/* 14. Pages d'erreur */ +/* 15. Pages d'erreur */ /* ------------------------------------------------------------------ */ .error-page { min-height: 55vh; diff --git a/app/templates/base.html b/app/templates/base.html index e48c338..0ffdfda 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -223,12 +223,16 @@ diff --git a/app/templates/essentials/qr.html b/app/templates/essentials/qr.html index 0915e57..45113a2 100644 --- a/app/templates/essentials/qr.html +++ b/app/templates/essentials/qr.html @@ -3,7 +3,7 @@ {% block tool_input %}
- +
diff --git a/app/templates/legal/privacy.html b/app/templates/legal/privacy.html new file mode 100644 index 0000000..c946be8 --- /dev/null +++ b/app/templates/legal/privacy.html @@ -0,0 +1,166 @@ +{% extends 'base.html' %} + +{% block title %}Politique de confidentialité · Toolbox Everything{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/app/templates/legal/terms.html b/app/templates/legal/terms.html new file mode 100644 index 0000000..1c3e820 --- /dev/null +++ b/app/templates/legal/terms.html @@ -0,0 +1,144 @@ +{% extends 'base.html' %} + +{% block title %}Conditions d'utilisation · Toolbox Everything{% endblock %} + +{% block content %} + +{% endblock %} diff --git a/compose.yml b/compose.yml index e382c53..4e06bed 100644 --- a/compose.yml +++ b/compose.yml @@ -7,7 +7,7 @@ # docker compose up -d --build # # 2) IMAGE GHCR (prod) : -# export TOOLBOX_IMAGE=ghcr.io/doalou/toolbox_everything:2.0.0 +# export TOOLBOX_IMAGE=ghcr.io/nerieum/toolboxeverything:2.0.1 # docker compose pull # docker compose up -d # @@ -28,6 +28,7 @@ services: - toolbox_uploads:/app/uploads - toolbox_downloads:/app/downloads - toolbox_logs:/app/logs + - toolbox_ytdlp_state:/var/lib/toolbox/yt-dlp environment: - FLASK_APP=run.py - FLASK_ENV=${FLASK_ENV:-production} @@ -41,6 +42,11 @@ services: - LIBRESPEED_PUBLIC_URL=${LIBRESPEED_PUBLIC_URL:-http://localhost:8081} # Rate limiter : Redis partagé entre les workers gunicorn - RATELIMIT_STORAGE_URI=${RATELIMIT_STORAGE_URI:-redis://redis:6379/0} + # Authentification yt-dlp optionnelle (le fichier doit être monté dans le conteneur) + - YTDLP_COOKIES_FILE=${YTDLP_COOKIES_FILE:-} + - YTDLP_COOKIES_STATE_DIR=${YTDLP_COOKIES_STATE_DIR:-/var/lib/toolbox/yt-dlp} + - YTDLP_USER_AGENT=${YTDLP_USER_AGENT:-} + - YTDLP_DENO_PATH=${YTDLP_DENO_PATH:-} depends_on: stirling-pdf: condition: service_started @@ -233,3 +239,4 @@ volumes: toolbox_uploads: toolbox_downloads: toolbox_logs: + toolbox_ytdlp_state: diff --git a/env.example b/env.example index 6a3d9bf..4724e89 100644 --- a/env.example +++ b/env.example @@ -9,7 +9,7 @@ # --- Docker Compose -------------------------------------------------- # Laisser vide pour BUILD LOCAL (`docker compose up -d --build`). # Définir pour utiliser une image pré-construite : -# TOOLBOX_IMAGE=ghcr.io/doalou/toolbox_everything:2.0.0 +# TOOLBOX_IMAGE=ghcr.io/nerieum/toolboxeverything:2.0.1 #TOOLBOX_IMAGE= # Ports exposés côté hôte (défauts : 8000 Toolbox, 8080 Stirling, 8081 LibreSpeed) @@ -48,3 +48,18 @@ RATELIMIT_STORAGE_URI=redis://redis:6379/0 # Chemin explicite vers le binaire ffmpeg. Par défaut, auto-détecté # via PATH, puis `/usr/bin/ffmpeg`, `/usr/local/bin/ffmpeg`, `bin/ffmpeg(.exe)`. #FFMPEG_PATH= + +# --- Téléchargeur yt-dlp --------------------------------------------- +# Fichier Netscape cookies.txt optionnel, monté en lecture seule dans le +# conteneur. Ne jamais versionner ce fichier : il contient une session. +#YTDLP_COOKIES_FILE=/run/secrets/youtube-cookies.txt + +# Répertoire inscriptible du cookie jar actualisé par yt-dlp. Le compose fourni +# le conserve dans un volume Docker dédié. +#YTDLP_COOKIES_STATE_DIR=/var/lib/toolbox/yt-dlp + +# User-Agent du navigateur ayant produit les cookies, si nécessaire. +#YTDLP_USER_AGENT= + +# Chemin Deno explicite. L'image Docker fournit /usr/local/bin/deno. +#YTDLP_DENO_PATH= diff --git a/requirements.txt b/requirements.txt index 48af7e6..e673889 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,8 @@ Pillow==12.3.0 # --- Téléchargement vidéo / audio (multi-sources) --- yt-dlp==2026.7.4 +yt-dlp-ejs==0.8.0 +filelock==3.32.2 # --- HTTP client (healthcheck Stirling PDF) --- requests==2.34.2 diff --git a/tests/test_downloader_options.py b/tests/test_downloader_options.py new file mode 100644 index 0000000..7202e91 --- /dev/null +++ b/tests/test_downloader_options.py @@ -0,0 +1,113 @@ +"""Options partagées du téléchargeur yt-dlp.""" + +from __future__ import annotations + +from pathlib import Path + +from app.services.downloader import routes + + +def test_common_opts_enable_deno_when_available(monkeypatch): + monkeypatch.delenv("YTDLP_DENO_PATH", raising=False) + monkeypatch.setattr(routes.shutil, "which", lambda command: "/usr/local/bin/deno") + + opts = routes._common_ydl_opts() + + assert opts["js_runtimes"] == {"deno": {"path": "/usr/local/bin/deno"}} + + +def test_common_opts_include_optional_authentication(monkeypatch): + monkeypatch.setenv("YTDLP_COOKIES_FILE", "/run/secrets/youtube-cookies.txt") + monkeypatch.setenv("YTDLP_USER_AGENT", "Test Browser/1.0") + monkeypatch.setenv("YTDLP_DENO_PATH", "/opt/deno") + + opts = routes._common_ydl_opts() + + assert opts["cookiefile"] == "/run/secrets/youtube-cookies.txt" + assert opts["http_headers"] == {"User-Agent": "Test Browser/1.0"} + assert opts["js_runtimes"] == {"deno": {"path": "/opt/deno"}} + + +def test_common_opts_leave_authentication_disabled_by_default(monkeypatch): + monkeypatch.delenv("YTDLP_COOKIES_FILE", raising=False) + monkeypatch.delenv("YTDLP_USER_AGENT", raising=False) + monkeypatch.delenv("YTDLP_DENO_PATH", raising=False) + monkeypatch.setattr(routes.shutil, "which", lambda command: None) + + opts = routes._common_ydl_opts() + + assert "cookiefile" not in opts + assert "http_headers" not in opts + assert "js_runtimes" not in opts + + +def test_youtube_dl_persists_cookie_updates_without_modifying_source(monkeypatch, tmp_path): + source = tmp_path / "youtube-cookies.txt" + source.write_text("# Netscape HTTP Cookie File\n", encoding="utf-8") + state_dir = tmp_path / "state" + observed: list[tuple[Path, str]] = [] + + class FakeYoutubeDL: + def __init__(self, opts): + self.cookiefile = Path(opts["cookiefile"]) + observed.append((self.cookiefile, self.cookiefile.read_text(encoding="utf-8"))) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.cookiefile.write_text("updated", encoding="utf-8") + + monkeypatch.setenv("YTDLP_COOKIES_FILE", str(source)) + monkeypatch.setenv("YTDLP_COOKIES_STATE_DIR", str(state_dir)) + monkeypatch.setattr(routes, "YoutubeDL", FakeYoutubeDL) + + with routes._youtube_dl() as ydl: + assert isinstance(ydl, FakeYoutubeDL) + with routes._youtube_dl(): + pass + + assert source.read_text(encoding="utf-8").startswith("# Netscape") + assert observed[0][0] == state_dir / "youtube-cookies.txt" + assert observed[0][1].startswith("# Netscape") + assert observed[1][1] == "updated" + assert (state_dir / "youtube-cookies.txt").read_text(encoding="utf-8") == "updated" + + +def test_youtube_dl_refreshes_cookie_jar_when_source_changes(monkeypatch, tmp_path): + source = tmp_path / "youtube-cookies.txt" + source.write_text("first export", encoding="utf-8") + state_dir = tmp_path / "state" + observed: list[str] = [] + + class FakeYoutubeDL: + def __init__(self, opts): + self.cookiefile = Path(opts["cookiefile"]) + observed.append(self.cookiefile.read_text(encoding="utf-8")) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.cookiefile.write_text("server update", encoding="utf-8") + + monkeypatch.setenv("YTDLP_COOKIES_FILE", str(source)) + monkeypatch.setenv("YTDLP_COOKIES_STATE_DIR", str(state_dir)) + monkeypatch.setattr(routes, "YoutubeDL", FakeYoutubeDL) + + with routes._youtube_dl(): + pass + source.write_text("second export", encoding="utf-8") + with routes._youtube_dl(): + pass + + assert observed == ["first export", "second export"] + + +def test_bot_challenge_is_reported_as_temporary_unavailability(): + status, message = routes._classify_yt_error( + "Sign in to confirm you're not a bot. Use --cookies for authentication" + ) + + assert status == 503 + assert "session serveur" in message.lower() diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 8849eed..075cb35 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -19,13 +19,42 @@ def test_index_renders(client): resp = client.get("/") assert resp.status_code == 200 assert b"Toolbox" in resp.data - assert b"v2.0.0" in resp.data + assert b"v2.0.1" in resp.data assert b"Speedtest" in resp.data assert b"/speedtest/" in resp.data def test_version_file_is_release_source(): - assert Path("VERSION").read_text(encoding="utf-8").strip() == "2.0.0" + assert Path("VERSION").read_text(encoding="utf-8").strip() == "2.0.1" + + +def test_legal_pages_are_public_and_linked(client): + home = client.get("/") + assert b'href="/confidentialite"' in home.data + assert b'href="/conditions-utilisation"' in home.data + + privacy = client.get("/confidentialite") + assert privacy.status_code == 200 + assert "Politique de confidentialité".encode() in privacy.data + assert b"Association Nerieum" in privacy.data + assert b"contact@doalo.fr" in privacy.data + + terms = client.get("/conditions-utilisation") + assert terms.status_code == 200 + assert b"Conditions d'utilisation" in terms.data + assert b"Association Nerieum" in terms.data + assert b"contact@doalo.fr" in terms.data + + assert b"Association Nerieum" in home.data + + +def test_legal_english_aliases_redirect(client): + privacy = client.get("/privacy", follow_redirects=False) + terms = client.get("/terms", follow_redirects=False) + assert privacy.status_code == 308 + assert privacy.headers["Location"].endswith("/confidentialite") + assert terms.status_code == 308 + assert terms.headers["Location"].endswith("/conditions-utilisation") def test_base_template_loads_local_css_assets(client): @@ -270,4 +299,4 @@ def test_editorial_design_system_present(client): assert b"Pas de compte" in resp.data assert b"Pourquoi utiliser nos outils" not in resp.data assert b"js/shell.js" in resp.data - assert b"css/style.css?v=2.0.0-" in resp.data + assert b"css/style.css?v=2.0.1-" in resp.data