From 33d9d0b4222a326296d004e488ce7eef9d0a66b4 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:15:39 +0100 Subject: [PATCH 1/5] fix: address open issues #169, #170, #173 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #170 — Stored XSS via library folder names: folder.name was interpolated raw into innerHTML in the folder render path. Escape it with the existing esc() helper (catalog.js), matching the track render paths. #173 — SoundCloud SSRF surface: drop the on.soundcloud.com share shortener from the host allowlist (it redirects to arbitrary targets) and add a yt-dlp extractor allowlist (allowed_extractors=[youtube, soundcloud]) so a URL that slips past host validation can't invoke the generic extractor. #169 — Version stuck at 0.6.0-alpha.2 for source/Docker/self-hosted: make the version git-tag-derived via hatch-vcs (pyproject dynamic version, app/_version.py build artifact). app_version() now reads package metadata -> _version.py -> dev placeholder; static/version.json is removed (now a build artifact, gitignored). Install sites pin SETUPTOOLS_SCM_PRETEND_VERSION from the release version so shallow CI clones / Docker (no .git) don't break (Dockerfile, make-runtime-pack.sh, make-portable.ps1). make-app.sh defaults VERSION to `git describe`. Desktop version literals (Cargo.toml, package.json, tauri.conf.json) are now 0.0.0 placeholders stamped from the tag at build. The update-check no longer nags dev/source builds. Tests: 81 passed; ruff clean; app_version derives correctly. --- .gitignore | 4 ++++ app/main.py | 18 +++++++++--------- app/pipeline/download.py | 15 +++++++++++++-- build/Dockerfile | 6 +++++- desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 3 ++- desktop/src-tauri/tauri.conf.json | 2 +- pyproject.toml | 15 +++++++++++++-- scripts/macos/make-app.sh | 6 ++++-- scripts/macos/make-runtime-pack.sh | 5 ++++- scripts/windows/make-portable.ps1 | 5 +++++ static/js/catalog.js | 5 ++++- static/version.json | 1 - tests/test_url_validation.py | 4 +++- uv.lock | 1 - 16 files changed, 69 insertions(+), 25 deletions(-) delete mode 100644 static/version.json diff --git a/.gitignore b/.gitignore index fdf98bf9..507be5c3 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ dist/ *.egg .eggs/ +# Version is git-derived (hatch-vcs); these are generated artifacts, never committed. +app/_version.py +static/version.json + # Desktop dependencies and build outputs desktop/node_modules/ desktop/src-tauri/target/ diff --git a/app/main.py b/app/main.py index aaf3138f..329ae2df 100644 --- a/app/main.py +++ b/app/main.py @@ -2,7 +2,6 @@ import asyncio import ctypes -import json import logging import os import signal @@ -69,17 +68,18 @@ def _process_exists(pid: int) -> bool: def app_version() -> str: - version_file = STATIC_DIR / "version.json" - try: - data = json.loads(version_file.read_text(encoding="utf-8")) - value = str(data.get("version", "")).strip().removeprefix("v") - if value: - return value - except (OSError, json.JSONDecodeError): - pass + # Version is git-tag-derived via hatch-vcs (#169). Prefer installed package + # metadata (set at install/build from the tag); fall back to the generated + # app/_version.py for non-installed runs, then a dev placeholder. try: return package_version("stemdeck") except PackageNotFoundError: + pass + try: + from app._version import __version__ + + return str(__version__) + except Exception: return "0.0.0-dev" diff --git a/app/pipeline/download.py b/app/pipeline/download.py index c536247e..2c759d4c 100644 --- a/app/pipeline/download.py +++ b/app/pipeline/download.py @@ -59,9 +59,17 @@ def _is_retriable(exc: Exception) -> bool: "youtu.be", ) ) -_SOUNDCLOUD_HOSTS = frozenset(("soundcloud.com", "www.soundcloud.com", "on.soundcloud.com")) +# Note: on.soundcloud.com (the share shortener) is intentionally excluded — it +# redirects to arbitrary targets, which is an SSRF vector once handed to yt-dlp +# (#173). Users must paste the full soundcloud.com URL. +_SOUNDCLOUD_HOSTS = frozenset(("soundcloud.com", "www.soundcloud.com")) _ALLOWED_HOSTS = _YOUTUBE_HOSTS | _SOUNDCLOUD_HOSTS +# Restrict yt-dlp to the extractors we actually support. Crucially this excludes +# the "generic" extractor, so even a URL that slips past host validation cannot +# make yt-dlp fetch an arbitrary host/redirect target (#173). +_ALLOWED_EXTRACTORS = ["youtube", "soundcloud"] + class InvalidYouTubeURL(ValueError): """Raised at the API boundary for URLs we won't hand to yt-dlp.""" @@ -147,7 +155,9 @@ def download(job: Job, url: str, job_dir: Path) -> Path: # Fetch metadata first (no download) so we can reject videos that are # too long before wasting bandwidth and disk. - with YoutubeDL({"quiet": True, "noplaylist": True}) as ydl: + with YoutubeDL( + {"quiet": True, "noplaylist": True, "allowed_extractors": _ALLOWED_EXTRACTORS} + ) as ydl: meta = ydl.extract_info(url, download=False) or {} duration = meta.get("duration") or 0 if duration > MAX_DURATION_SEC: @@ -176,6 +186,7 @@ def hook(d: dict) -> None: "quiet": True, "noprogress": True, "noplaylist": True, + "allowed_extractors": _ALLOWED_EXTRACTORS, "progress_hooks": [hook], } info: dict = {} diff --git a/build/Dockerfile b/build/Dockerfile index c54a177f..6b837ccf 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -50,7 +50,11 @@ WORKDIR /app COPY pyproject.toml ./ COPY app/ ./app/ COPY static/ ./static/ -RUN pip install . --timeout 300 +# The version is git-derived (hatch-vcs), but the build context has no .git, so +# pin it explicitly: `docker build --build-arg VERSION=0.7.0-alpha.4 ...` +# (defaults to 0.0.0 for ad-hoc local builds). #169 +ARG VERSION=0.0.0 +RUN SETUPTOOLS_SCM_PRETEND_VERSION="${VERSION#v}" pip install . --timeout 300 # ─── Stage 2: runner ───────────────────────────────────────────── FROM python:3.12-slim AS runner diff --git a/desktop/package.json b/desktop/package.json index f5c783dc..3154a7d9 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "stemdeck-desktop", - "version": "0.6.0-alpha.2", + "version": "0.0.0", "private": true, "type": "module", "scripts": { diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 14a9a47d..0bfa8fa3 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -3194,7 +3194,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stemdeck" -version = "0.6.0-alpha.2" +version = "0.0.0" dependencies = [ "flate2", "libc", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 57f0fe0f..c18b23e2 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "stemdeck" -version = "0.6.0-alpha.2" +# Placeholder — stamped from the git tag at build time (Woodpecker / make-app.sh). #169 +version = "0.0.0" description = "StemDeck desktop launcher" authors = ["StemDeck contributors"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 05a9dee1..6aa7f000 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "StemDeck", - "version": "0.6.0-alpha.2", + "version": "0.0.0", "identifier": "app.stemdeck.desktop", "build": { "frontendDist": "../ui", diff --git a/pyproject.toml b/pyproject.toml index 5ad48575..b28a1dc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,9 @@ [project] name = "stemdeck" -version = "0.6.0-alpha.2" +# Version is derived from the git tag by hatch-vcs (see [tool.hatch.version]), +# so it is never hand-edited — `git tag vX.Y.Z` is the single source of truth. +# Builds between tags report a dev version (e.g. 0.7.0a5.dev3+g). (#169) +dynamic = ["version"] description = "Paste a YouTube URL, get audio stems split into a DAW-style player." requires-python = ">=3.10,<3.14" dependencies = [ @@ -52,9 +55,17 @@ dev = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" +# Derive the version from git tags. Writes app/_version.py at build time as a +# runtime fallback for environments without installed package metadata. +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.hooks.vcs] +version-file = "app/_version.py" + [tool.hatch.build.targets.wheel] packages = ["app"] diff --git a/scripts/macos/make-app.sh b/scripts/macos/make-app.sh index a0401270..61fb0955 100755 --- a/scripts/macos/make-app.sh +++ b/scripts/macos/make-app.sh @@ -2,9 +2,11 @@ set -euo pipefail ARCH="${ARCH:-arm64}" -VERSION="${VERSION:-LOCAL_DEV_TEST}" -VERSION="${VERSION#v}" REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +# Default the version to the current git tag so local builds match the tag with +# no VERSION passed; falls back to a dev label outside a tagged checkout. (#169) +VERSION="${VERSION:-$(git -C "$REPO_ROOT" describe --tags --always 2>/dev/null || echo 0.0.0-dev)}" +VERSION="${VERSION#v}" BUILD_DIR="${REPO_ROOT}/.build" if [[ "$(uname)" != "Darwin" ]]; then diff --git a/scripts/macos/make-runtime-pack.sh b/scripts/macos/make-runtime-pack.sh index 29ab9e51..4aa2f375 100755 --- a/scripts/macos/make-runtime-pack.sh +++ b/scripts/macos/make-runtime-pack.sh @@ -123,7 +123,10 @@ find "$PYTHON_DIR/lib" -name "EXTERNALLY-MANAGED" -delete echo "==> Installing packages into bundled Python" # --system is required because $PYTHON_DIR is not a venv (it's a full Python install). uv pip install --system --python "$PYTHON_DIR/bin/python" pip setuptools wheel -uv pip install --system --python "$PYTHON_DIR/bin/python" "$REPO_ROOT" +# The project version is git-derived (hatch-vcs). Pin it explicitly from $VERSION +# so the install doesn't depend on git tags being present in the build checkout (#169). +SETUPTOOLS_SCM_PRETEND_VERSION="${VERSION#v}" \ + uv pip install --system --python "$PYTHON_DIR/bin/python" "$REPO_ROOT" echo "==> Verifying stdlib and imports" PYTHON_DIR="$PYTHON_DIR" PYTHONHOME="$PYTHON_DIR" "$PYTHON_DIR/bin/python" - <<'PY' diff --git a/scripts/windows/make-portable.ps1 b/scripts/windows/make-portable.ps1 index 3d37e8e5..9e7f0f0b 100644 --- a/scripts/windows/make-portable.ps1 +++ b/scripts/windows/make-portable.ps1 @@ -192,6 +192,11 @@ if (Get-Command "py" -ErrorAction SilentlyContinue) { & $PythonExe -m pip install --upgrade pip +# The project version is git-derived (hatch-vcs). Pin it from $PackageVersion so +# the install doesn't depend on git tags in the build checkout (#169). +if ($PackageVersion) { + $env:SETUPTOOLS_SCM_PRETEND_VERSION = ($PackageVersion -replace '^v', '') +} & $PythonExe -m pip install "$Root" if ($CpuOnly) { diff --git a/static/js/catalog.js b/static/js/catalog.js index c2593bc2..4e01de3f 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -985,7 +985,7 @@ function renderFolder(folder) { `} ${folderIcon} - ${folder.name} + ${esc(folder.name)} ${folder.items.length} ${isTrash ? "" : ` @@ -659,9 +666,19 @@ function openFolderEditor(folderId) { refreshDots(); }); } + const msgEl = overlay.querySelector(".folder-editor-msg"); + input.addEventListener("input", () => { msgEl.textContent = ""; }); form.addEventListener("submit", (e) => { e.preventDefault(); - folder.name = input.value.trim() || folder.name; + const name = input.value.trim(); + if (!isValidFolderName(name)) { + msgEl.textContent = name + ? "Use letters, numbers, spaces, or - _ ' & ( ) . ," + : "Enter a folder name."; + input.focus(); + return; // don't save or close until the name is valid + } + folder.name = name; folder.color = selectedColor; saveState(); closeFolderEditor(); From 1d35bc3ce44cb19eeaea78f019f2c5c8276e7777 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:30:39 +0100 Subject: [PATCH 5/5] feat: raise folder name limit to 100 chars + enforce in validator Bump the editor input maxlength from 48 to 100 and reject over-length names on Save with an inline message (defensive, in case the cap is bypassed). --- static/js/catalog.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/static/js/catalog.js b/static/js/catalog.js index 7342f2f2..53c7b381 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -605,6 +605,7 @@ function closeFolderEditor() { // punctuation set — markup/symbols are rejected so names like the XSS probe or // "±!@£$%^&*" can't be created (#170 follow-up). const FOLDER_NAME_RE = /^[\p{L}\p{M}\p{N} '’&().,_-]+$/u; +const MAX_FOLDER_NAME_LEN = 100; const isValidFolderName = (s) => FOLDER_NAME_RE.test(s); function openFolderEditor(folderId) { @@ -627,7 +628,7 @@ function openFolderEditor(folderId) {
Color @@ -671,10 +672,18 @@ function openFolderEditor(folderId) { form.addEventListener("submit", (e) => { e.preventDefault(); const name = input.value.trim(); + if (!name) { + msgEl.textContent = "Enter a folder name."; + input.focus(); + return; + } + if (name.length > MAX_FOLDER_NAME_LEN) { + msgEl.textContent = `Folder name is too long (max ${MAX_FOLDER_NAME_LEN}).`; + input.focus(); + return; + } if (!isValidFolderName(name)) { - msgEl.textContent = name - ? "Use letters, numbers, spaces, or - _ ' & ( ) . ," - : "Enter a folder name."; + msgEl.textContent = "Use letters, numbers, spaces, or - _ ' & ( ) . ,"; input.focus(); return; // don't save or close until the name is valid }