diff --git a/fluxtuner/core/remote_subscription_store.py b/fluxtuner/core/remote_subscription_store.py index 048aed8..7176685 100644 --- a/fluxtuner/core/remote_subscription_store.py +++ b/fluxtuner/core/remote_subscription_store.py @@ -93,7 +93,7 @@ def list_subscriptions( dict(row) for row in conn.execute( """ - SELECT sub.* + SELECT sub.*, src.display_name FROM remote_catalog_subscriptions AS sub JOIN station_sources AS src ON src.id = sub.source_id WHERE sub.profile_id = ? AND src.profile_id = ? diff --git a/fluxtuner/web/routes/subscriptions.py b/fluxtuner/web/routes/subscriptions.py index a199344..655a20a 100644 --- a/fluxtuner/web/routes/subscriptions.py +++ b/fluxtuner/web/routes/subscriptions.py @@ -4,9 +4,11 @@ from __future__ import annotations import asyncio +import json import sqlite3 from contextlib import closing from typing import Any +from urllib.parse import urlsplit from fastapi import APIRouter, HTTPException, Query, Request @@ -27,7 +29,6 @@ from fluxtuner.web import remote_subscription_service as service from fluxtuner.web.m3u_import import _profile_id from fluxtuner.web.payloads import station_payload -from fluxtuner.web.routes.imports import _read_remote_url from fluxtuner.web.security import SESSION_COOKIE_NAME router = APIRouter(prefix="/api/stations/subscriptions") @@ -49,12 +50,34 @@ def _session(request: Request) -> str: return token +def _catalog_label(display_name: str | None, url: str, source_id: int) -> str: + """Build a safe, concise label without exposing URL credentials or query tokens.""" + if display_name and display_name.strip(): + return display_name.strip() + parsed = urlsplit(url) + host = parsed.hostname or "" + # Only expose the well-known owner/repository prefix, never branch, file, + # path, userinfo, or query fragments that could contain secret material. + if host == "raw.githubusercontent.com": + pieces = parsed.path.strip("/").split("/") + if len(pieces) >= 4 and all( + piece and all(char.isascii() and (char.isalnum() or char in "._-") for char in piece) + for piece in pieces[:2] + ): + return f"{pieces[0]}/{pieces[1]}" + return host or f"Catalog #{source_id}" + + def _list_owned(user_id: int, profile: str | None) -> list[dict[str, Any]]: with closing(db.connect()) as conn: profile_id = _profile_id(conn, user_id=user_id, profile_name=profile) return [ { "source_id": item["source_id"], + "display_name": item.get("display_name"), + "label": _catalog_label( + item.get("display_name"), str(item["catalog_url"]), int(item["source_id"]) + ), "revision": item["revision"], "refreshed_at": item["refreshed_at"], } @@ -183,6 +206,33 @@ async def subscriptions_list( raise HTTPException(status_code=404, detail=str(exc)) from None +async def _read_subscription_input(request: Request) -> tuple[str, str | None]: + """Parse a bounded URL + optional name request; keep remote import contracts unchanged.""" + content_type = request.headers.get("content-type", "").split(";", 1)[0].lower() + if content_type != "application/json": + raise HTTPException(status_code=415, detail="Expected a JSON URL request.") + data = bytearray() + async for chunk in request.stream(): + if len(data) + len(chunk) > 4096: + raise HTTPException(status_code=413, detail="URL request exceeds 4 KiB.") + data.extend(chunk) + try: + payload = json.loads(data) + except (ValueError, UnicodeDecodeError): + raise HTTPException(status_code=400, detail="Invalid JSON URL request.") from None + if not isinstance(payload, dict) or not isinstance(payload.get("url"), str): + raise HTTPException(status_code=400, detail="A collection URL is required.") + url = payload["url"] + if not url or len(url) > 2048: + raise HTTPException(status_code=400, detail="Invalid collection URL length.") + name = payload.get("display_name") + if name is not None: + if not isinstance(name, str) or len(name) > 120: + raise HTTPException(status_code=400, detail="Invalid catalog name.") + name = name.strip() or None + return url, name + + @router.post("") async def subscriptions_create( request: Request, @@ -190,7 +240,7 @@ async def subscriptions_create( ) -> dict[str, Any]: user_id = _authorize(request) _session(request) - url = await _read_remote_url(request) + url, display_name = await _read_subscription_input(request) try: await asyncio.to_thread( _check_creation_access, @@ -216,6 +266,7 @@ async def subscriptions_create( user_id=user_id, profile_name=profile, catalog_url=url, + display_name=display_name, etag=result.etag, last_modified=result.last_modified, ) @@ -239,8 +290,19 @@ def _catalog_stations( with closing(db.connect()) as conn: profile_id = _profile_id(conn, user_id=user_id, profile_name=profile) entries = get_entries(conn, profile_id=profile_id, source_id=source_id) + subscription = get_subscription(conn, profile_id=profile_id, source_id=source_id) + label = _catalog_label(None, str(subscription["catalog_url"]), source_id) + source = conn.execute( + "SELECT display_name FROM station_sources WHERE id = ? AND profile_id = ?", + (source_id, profile_id), + ).fetchone() + if source is not None: + label = _catalog_label( + source["display_name"], str(subscription["catalog_url"]), source_id + ) stations = [station_payload({"name": entry.name, "url": entry.url}) for entry in entries] return { + "label": label, "source_id": source_id, "count": len(stations), "stations": stations, diff --git a/fluxtuner/web/static/dialogs.css b/fluxtuner/web/static/dialogs.css index 89d4e56..5410256 100644 --- a/fluxtuner/web/static/dialogs.css +++ b/fluxtuner/web/static/dialogs.css @@ -456,3 +456,21 @@ dialog.station-import-dialog { [data-recovery-codes-regenerate-dialog] { z-index: 100; } + +/* N3: keep catalog identity separate from its controls. */ +[data-subscriptions-list] .subscription-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem 1rem; + align-items: center; + padding: 0.85rem 0; + border-bottom: 1px solid var(--border-soft); +} +[data-subscriptions-list] .subscription-info { min-width: 0; display: grid; gap: 0.3rem; overflow-wrap: anywhere; } +[data-subscriptions-list] .subscription-info small { color: var(--muted); } +[data-subscriptions-list] .subscription-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; } +@media (max-width: 42rem) { + [data-subscriptions-list] .subscription-row { grid-template-columns: minmax(0, 1fr); } + [data-subscriptions-list] .subscription-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } + [data-subscriptions-list] .subscription-actions button { min-width: 0; padding-inline: 0.5rem; } +} diff --git a/fluxtuner/web/static/js/app-elements.js b/fluxtuner/web/static/js/app-elements.js index 285ea52..f5d88e1 100644 --- a/fluxtuner/web/static/js/app-elements.js +++ b/fluxtuner/web/static/js/app-elements.js @@ -9,6 +9,7 @@ export function createAppElements(root = document) { dialog: root.querySelector("[data-subscriptions-dialog]"), form: root.querySelector("[data-subscriptions-form]"), urlInput: root.querySelector("[data-subscriptions-url]"), + nameInput: root.querySelector("[data-subscriptions-name]"), listNode: root.querySelector("[data-subscriptions-list]"), previewNode: root.querySelector("[data-subscriptions-preview]"), messageNode: root.querySelector("[data-subscriptions-message]"), diff --git a/fluxtuner/web/static/js/remote-subscriptions.js b/fluxtuner/web/static/js/remote-subscriptions.js index bd983bf..becb2dd 100644 --- a/fluxtuner/web/static/js/remote-subscriptions.js +++ b/fluxtuner/web/static/js/remote-subscriptions.js @@ -96,15 +96,22 @@ export function createRemoteSubscriptionController({ for (const item of items) { const container = e.listNode.ownerDocument.createElement("div"); - container.className = "station-actions"; - const name = e.listNode.ownerDocument.createElement("span"); - // Never render an untrusted remote URL as HTML. - name.textContent = `Catalog #${item.source_id} · revision ${item.revision}`; - container.append( - name, + container.className = "subscription-row"; + const info = e.listNode.ownerDocument.createElement("div"); + info.className = "subscription-info"; + const name = e.listNode.ownerDocument.createElement("strong"); + // Never render catalog names or untrusted remote strings as HTML. + name.textContent = item.label || `Catalog #${item.source_id}`; + const meta = e.listNode.ownerDocument.createElement("small"); + meta.textContent = `Catalog #${item.source_id} · revision ${item.revision}`; + info.append(name, meta); + const actions = e.listNode.ownerDocument.createElement("div"); + actions.className = "subscription-actions"; + actions.append( button("Check updates", () => preview(item.source_id)), button("Unsubscribe", () => remove(item.source_id)), ); + container.append(info, actions); e.listNode.append(container); } } @@ -130,14 +137,17 @@ export function createRemoteSubscriptionController({ event.preventDefault(); await operate(async (mark) => { const url = e.urlInput.value.trim(); + const displayName = e.nameInput?.value?.trim() || ""; if (!url) throw new Error("Collection URL required."); + if (displayName.length > 120) throw new Error("Catalog name is too long."); await request(BASE, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url }), + body: JSON.stringify({ url, display_name: displayName || null }), }); if (!current(mark)) return; e.urlInput.value = ""; + if (e.nameInput) e.nameInput.value = ""; clearPreview(); if (current(mark)) { onChanged({ type: "added" }); diff --git a/fluxtuner/web/static/js/unified-library.js b/fluxtuner/web/static/js/unified-library.js index 53d421b..dc3e320 100644 --- a/fluxtuner/web/static/js/unified-library.js +++ b/fluxtuner/web/static/js/unified-library.js @@ -83,12 +83,15 @@ export function createUnifiedLibraryController({ const id = Number(catalog.source_id); if (!Number.isSafeInteger(id) || id <= 0) return ""; + const name = String(catalog.label || `Catalog #${id}`); + const escapedName = name.replaceAll("&", "&").replaceAll("<", "<") + .replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); return `
-

Catalog #${id}

+

${escapedName}

- Revision ${Number(catalog.revision) || 1} + Catalog #${id} · Revision ${Number(catalog.revision) || 1}
@@ -153,7 +156,13 @@ export function createUnifiedLibraryController({ const payload = await fetchJson( `/api/stations/subscriptions/${id}/stations`, ); - if (current()) renderResults(payload); + if (current()) { + setResultsHeader( + "Remote catalog", + `${String(payload.label || `Catalog #${id}`)} · Catalog #${id}`, + ); + renderResults(payload); + } } catch (error) { if (current()) renderSearchError(error); } diff --git a/fluxtuner/web/static/shell.css b/fluxtuner/web/static/shell.css index 6b2a8ee..ce558d2 100644 --- a/fluxtuner/web/static/shell.css +++ b/fluxtuner/web/static/shell.css @@ -743,3 +743,31 @@ html[data-theme="light"] .theme-toggle span::before { outline-offset: 2px; } } + +/* N3: viewport-anchored mobile controls, independent of page scroll. */ +@media (max-width: 58rem) { + .shell { padding-top: 5.5rem; } + .shell > .app-header { + position: fixed; + top: 0.5rem; + left: var(--page-inline); + right: var(--page-inline); + z-index: 70; + } + .shell > .app-sidebar-region { + position: fixed; + top: 5.5rem; + right: var(--page-inline); + left: auto; + z-index: 75; + max-height: calc(100dvh - 6rem); + } + .shell > .app-sidebar-region .app-menu { + max-height: calc(100dvh - 6rem); + } +} +@media (max-width: 30rem) { + .shell { padding-top: 8.5rem; } + .shell > .app-sidebar-region { top: 8.5rem; max-height: calc(100dvh - 9rem); } + .shell > .app-sidebar-region .app-menu { max-height: calc(100dvh - 9rem); } +} diff --git a/fluxtuner/web/templates/index.html b/fluxtuner/web/templates/index.html index 78c363d..0720061 100644 --- a/fluxtuner/web/templates/index.html +++ b/fluxtuner/web/templates/index.html @@ -942,6 +942,11 @@

Remote catalogs

autocomplete="off" placeholder="https://example.org/stations.m3u" data-subscriptions-url> +
diff --git a/tests/test_web_catalog_identification.py b/tests/test_web_catalog_identification.py new file mode 100644 index 0000000..4c82492 --- /dev/null +++ b/tests/test_web_catalog_identification.py @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: LicenseRef-FluxTuner-Web-NC +"""Catalog labels exclude sensitive URL parts and respect display names.""" + +from fluxtuner.web.routes.subscriptions import _catalog_label + + +def test_explicit_catalog_name_takes_precedence() -> None: + assert ( + _catalog_label(" Jazz Collection ", "https://example.org/feed?secret=yes", 7) + == "Jazz Collection" + ) + + +def test_fallback_never_exposes_remote_url_tokens() -> None: + label = _catalog_label(None, "https://example.org/private/secret.m3u?token=abcdef", 3) + assert label == "example.org" + assert "abcdef" not in label + assert "secret" not in label + + +def test_missing_url_uses_id_fallback() -> None: + assert _catalog_label(None, "", 4) == "Catalog #4" + + +def test_list_owned_includes_safe_labels(monkeypatch) -> None: + from unittest.mock import MagicMock + + from fluxtuner.web.routes import subscriptions as routes + + conn = MagicMock() + monkeypatch.setattr(routes.db, "connect", lambda: conn) + monkeypatch.setattr(routes, "_profile_id", lambda *args, **kwargs: 12) + monkeypatch.setattr( + routes, + "list_subscriptions", + lambda *args, **kwargs: [ + { + "source_id": 7, + "display_name": "Jazz Collection", + "catalog_url": "https://example.org/jazz?token=private", + "revision": 2, + "refreshed_at": None, + }, + { + "source_id": 8, + "display_name": None, + "catalog_url": "https://example.org/rock?token=secret", + "revision": 1, + "refreshed_at": None, + }, + ], + ) + + result = routes._list_owned(4, None) + assert [item["label"] for item in result] == [ + "Jazz Collection", + "example.org", + ] + assert [item["source_id"] for item in result] == [7, 8] + assert [item["revision"] for item in result] == [2, 1] + assert all("catalog_url" not in item for item in result) + assert "private" not in str(result) + assert "secret" not in str(result) + conn.close.assert_called_once() + + +def test_github_raw_catalog_label_is_repository_scoped() -> None: + assert ( + _catalog_label( + None, + "https://raw.githubusercontent.com/user/repository/main/folder/list.m3u?token=private", + 7, + ) + == "user/repository" + ) + assert ( + _catalog_label(None, "https://raw.githubusercontent.com/?token=private", 8) + == "raw.githubusercontent.com" + ) diff --git a/tests/test_web_remote_subscription_http.py b/tests/test_web_remote_subscription_http.py index bacb741..ff1ee57 100644 --- a/tests/test_web_remote_subscription_http.py +++ b/tests/test_web_remote_subscription_http.py @@ -43,11 +43,19 @@ async def download(url, **_kwargs): listed = client.get(ENDPOINT) assert listed.status_code == 200, listed.text assert len(listed.json()["subscriptions"]) == 1 - assert set(listed.json()["subscriptions"][0]) == { + item = listed.json()["subscriptions"][0] + assert set(item) == { "source_id", + "display_name", + "label", "revision", "refreshed_at", } + assert item["source_id"] == source + assert item["display_name"] is None + assert item["label"] == URL.split("/")[2] + assert item["revision"] == 1 + assert "catalog_url" not in item assert URL not in listed.text assert "catalog_url" not in listed.text @@ -445,3 +453,28 @@ async def download(_url, **_kwargs): (source,), ).fetchone() assert tuple(stored) == (2, '"new"') + + +def test_http_subscription_optional_display_name(web_library, monkeypatch): + client, _users = web_library + headers = login(client) + + async def download(_url, **_kwargs): + return ConditionalCollectionResult(200, catalog("Named", "named")) + + monkeypatch.setattr(routes, "_download_catalog", download) + created = client.post( + ENDPOINT, + json={"url": URL, "display_name": " My radio collection "}, + headers=headers, + ) + assert created.status_code == 200, created.text + listed = client.get(ENDPOINT) + assert listed.status_code == 200, listed.text + item = listed.json()["subscriptions"][0] + assert item["display_name"] == "My radio collection" + assert item["label"] == "My radio collection" + assert "catalog_url" not in item + detail = client.get(f"{ENDPOINT}/{item['source_id']}/stations") + assert detail.status_code == 200, detail.text + assert detail.json()["label"] == "My radio collection" diff --git a/tests/web_remote_subscription_state.mjs b/tests/web_remote_subscription_state.mjs index 47c8069..9da22fd 100644 --- a/tests/web_remote_subscription_state.mjs +++ b/tests/web_remote_subscription_state.mjs @@ -66,6 +66,7 @@ function setup() { closeButton: node(), form: node(), urlInput: node(), + nameInput: node(), listNode: node(), messageNode: node(), previewNode: node(), @@ -152,11 +153,13 @@ function setup() { function actionButtons(elements) { return elements.listNode.children.flatMap( - (container) => container.children.filter( - (child) => + (container) => container.children + .filter((child) => child.className === "subscription-actions") + .flatMap((actions) => actions.children) + .filter((child) => child.textContent === "Check updates" || child.textContent === "Unsubscribe" - ), + ), ); } @@ -172,11 +175,12 @@ function actionButtons(elements) { // A successful create must restore both action buttons. { - const { controller, elements } = setup(); + const { controller, elements, calls } = setup(); await controller.openDialog(); elements.urlInput.value = "https://example.org/test.m3u"; + elements.nameInput.value = "My radio collection"; await elements.form.listeners.submit({ preventDefault() {}, @@ -188,6 +192,17 @@ function actionButtons(elements) { ); assert.equal(elements.urlInput.value, ""); + assert.equal(elements.nameInput.value, ""); + + const creation = calls.find(({ path, options }) => + path === "/api/stations/subscriptions" && + options.method === "POST" + ); + assert.ok(creation, "Subscription creation request missing"); + assert.deepEqual(JSON.parse(creation.options.body), { + url: "https://example.org/test.m3u", + display_name: "My radio collection", + }); const buttons = actionButtons(elements); assert.equal(buttons.length, 2);