Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion fluxtuner/core/remote_subscription_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ?
Expand Down
66 changes: 64 additions & 2 deletions fluxtuner/web/routes/subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")
Expand All @@ -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"],
}
Expand Down Expand Up @@ -183,14 +206,41 @@ 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,
profile: str | None = Query(default=None, max_length=80),
) -> 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,
Expand All @@ -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,
)
Expand All @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions fluxtuner/web/static/dialogs.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
1 change: 1 addition & 0 deletions fluxtuner/web/static/js/app-elements.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]"),
Expand Down
24 changes: 17 additions & 7 deletions fluxtuner/web/static/js/remote-subscriptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand All @@ -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" });
Expand Down
15 changes: 12 additions & 3 deletions fluxtuner/web/static/js/unified-library.js
Original file line number Diff line number Diff line change
Expand Up @@ -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("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
return `
<article class="station-card">
<header>
<h3>Catalog #${id}</h3>
<h3>${escapedName}</h3>
<div class="station-meta">
<span>Revision ${Number(catalog.revision) || 1}</span>
<span>Catalog #${id} · Revision ${Number(catalog.revision) || 1}</span>
</div>
</header>
<div class="station-actions">
Expand Down Expand Up @@ -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);
}
Expand Down
28 changes: 28 additions & 0 deletions fluxtuner/web/static/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
}
5 changes: 5 additions & 0 deletions fluxtuner/web/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,11 @@ <h2 id="subscriptions-title">Remote catalogs</h2>
autocomplete="off" placeholder="https://example.org/stations.m3u"
data-subscriptions-url>
</label>
<label>
<span>Catalog name (optional)</span>
<input type="text" maxlength="120" autocomplete="off"
placeholder="My favorite radio collection" data-subscriptions-name>
</label>
<button type="submit" data-subscriptions-add>Add subscription</button>
</form>
<div data-subscriptions-list aria-label="Subscribed catalogs"></div>
Expand Down
79 changes: 79 additions & 0 deletions tests/test_web_catalog_identification.py
Original file line number Diff line number Diff line change
@@ -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"
)
Loading
Loading