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/.woodpecker/ci.yml b/.woodpecker/ci.yml index 64ad7d11..5f683f78 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -18,6 +18,10 @@ steps: image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim environment: UV_LINK_MODE: copy + # Version is git-derived (hatch-vcs). CI's clone is shallow/tagless, which + # makes setuptools_scm raise, so pin a placeholder for the build — CI only + # lints/tests and never publishes. #169 + SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0" commands: - uv sync --frozen --all-extras - uv run ruff check app/ tests/ @@ -28,6 +32,10 @@ steps: image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim environment: UV_LINK_MODE: copy + # Version is git-derived (hatch-vcs). CI's clone is shallow/tagless, which + # makes setuptools_scm raise, so pin a placeholder for the build — CI only + # lints/tests and never publishes. #169 + SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0" commands: - apt-get update && apt-get install -y --no-install-recommends ffmpeg - uv sync --frozen --all-extras @@ -42,6 +50,10 @@ steps: image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim environment: UV_LINK_MODE: copy + # Version is git-derived (hatch-vcs). CI's clone is shallow/tagless, which + # makes setuptools_scm raise, so pin a placeholder for the build — CI only + # lints/tests and never publishes. #169 + SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0" commands: - uv tool install bandit - uv tool run bandit -r app/ -ll # fail on medium+ severity @@ -50,6 +62,10 @@ steps: image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim environment: UV_LINK_MODE: copy + # Version is git-derived (hatch-vcs). CI's clone is shallow/tagless, which + # makes setuptools_scm raise, so pin a placeholder for the build — CI only + # lints/tests and never publishes. #169 + SETUPTOOLS_SCM_PRETEND_VERSION: "0.0.0" commands: - uv tool install pip-audit - uv pip compile pyproject.toml -o /tmp/requirements.txt 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..1ae36c27 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,16 +55,29 @@ 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" +# Used when the tag can't be reached (e.g. CI's shallow/tagless clone) so the +# build never hard-fails. Real builds derive from the tag or the pinned +# SETUPTOOLS_SCM_PRETEND_VERSION. (#169) +fallback-version = "0.0.0" + +[tool.hatch.build.hooks.vcs] +version-file = "app/_version.py" + [tool.hatch.build.targets.wheel] packages = ["app"] [tool.ruff] target-version = "py310" line-length = 100 -exclude = ["jobs", ".run", "static/vendor"] +# app/_version.py is generated by hatch-vcs at build/sync time — don't lint it. +exclude = ["jobs", ".run", "static/vendor", "app/_version.py"] [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "SIM"] 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/css/daw.css b/static/css/daw.css index cfa5aad3..d0f5a775 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -726,6 +726,8 @@ input, textarea { font-family: inherit; } .folder-editor-name:focus { border-color: rgba(244,183,64,0.5); box-shadow: 0 0 0 2px rgba(244,183,64,0.12); } .folder-editor-colors { display: flex; align-items: center; gap: 9px; } .folder-editor-colors .folder-color-dot { width: 22px; height: 22px; } +.folder-editor-msg { color: var(--danger); font-size: 11px; margin-top: 8px; } +.folder-editor-msg:empty { display: none; } .folder-editor-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; } .folder-editor-actions button { min-height: 30px; border-radius: 7px; font-family: var(--font-mono); font-size: 11px; cursor: pointer; padding: 0 11px; } .folder-editor-cancel { border: 1px solid var(--border); background: rgba(21,31,39,0.52); color: var(--muted); } diff --git a/static/js/catalog.js b/static/js/catalog.js index c2593bc2..53c7b381 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -601,6 +601,13 @@ function closeFolderEditor() { folderEditor = null; } +// Folder names accept letters (any language), digits, spaces, and a small safe +// 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) { const folder = folders.find((f) => f.id === folderId); if (!folder || folder.id === TRASH_ID) return; @@ -621,7 +628,7 @@ function openFolderEditor(folderId) {
Color @@ -629,6 +636,7 @@ function openFolderEditor(folderId) { ${folderColorButtonsHtml(selectedColor)}
+
@@ -659,9 +667,27 @@ 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 (!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 = "Use letters, numbers, spaces, or - _ ' & ( ) . ,"; + input.focus(); + return; // don't save or close until the name is valid + } + folder.name = name; folder.color = selectedColor; saveState(); closeFolderEditor(); @@ -985,7 +1011,7 @@ function renderFolder(folder) { `} ${folderIcon} - ${folder.name} + ${esc(folder.name)} ${folder.items.length} ${isTrash ? "" : `